diff --git a/README.md b/README.md index ca5f98f..aec3d49 100644 --- a/README.md +++ b/README.md @@ -143,11 +143,10 @@ export class TestPrompt extends PromptElement { Please note: - If your prompt does asynchronous work e.g. VS Code extension API calls or additional requests to the Copilot API for chunk reranking, you can precompute this state in an optional async `prepare` method. `prepare` is called before `render` and the prepared state will be passed back to your prompt component's sync `render` method. - Newlines are not preserved in JSX text or between JSX elements when rendered, and must be explicitly declared with the builtin `
` attribute. -- For now, if two prompt messages _with the same priority_ are up for pruning due to exceeding the token budget, it is not possible for a subtree of the prompt message declared before to prune a subtree of the prompt message declared later. -### Managing your budget +### Prioritization -If a rendered prompt has more message tokens than can fit into the available context window, the prompt renderer prunes messages with the lowest priority from the `ChatMessage`s result, preserving the order in which they were declared. +If a rendered prompt has more message tokens than can fit into the available context window, the prompt renderer prunes messages with the lowest priority from the `ChatMessage`s result. In the above example, each message had the same priority, so they would be pruned in the order in which they were declared, but we could control that by passing a priority to element: @@ -159,9 +158,32 @@ In the above example, each message had the same priority, so they would be prune ``` -In this case, a very long `userQuery` would get pruned from the output first if it's too long. +In this case, a very long `userQuery` would get pruned from the output first if it's too long. Priorities are local in the element tree, so for example the tree of nodes... + +```html + + A + B + + + C + D + +``` + +...would be pruned in the order `B->A->D->C`. If two sibling elements share the same priority, the renderer looks ahead at their direct children and picks whichever one has a child with the lowest priority: if the `SystemMessage` and `UserMessage` in the above example did not declare priorities, the pruning order would be `B->D->A->C`. + +Continuous text strings and elements can both be pruned from the tree. If you have a set of elements that you want to either be include all the time or none of the time, you can use the simple `Chunk` utility element: + +```html + + The file I'm editing is: + +``` + +### Flex Behavior -But, this is not ideal. Instead, we'd prefer to include as much of the query as possible. To do this, we can use the `flexGrow` property, which allows an element to use the remainder of its parent's token budget when it's rendered. +Wholesale pruning is not always already. Instead, we'd prefer to include as much of the query as possible. To do this, we can use the `flexGrow` property, which allows an element to use the remainder of its parent's token budget when it's rendered. `prompt-tsx` provides a utility component that supports this use case: `TextChunk`. Given input text, and optionally a delimiting string or regular expression, it'll include as much of the text as possible to fit within its budget: diff --git a/package-lock.json b/package-lock.json index 20b3ee4..14784c1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,11 +14,12 @@ "@types/vscode": "^1.92.0", "@vscode/test-cli": "^0.0.9", "@vscode/test-electron": "^2.4.1", + "cross-env": "^7.0.3", "esbuild": "0.20.2", "mocha": "^10.2.0", "npm-dts": "^1.3.12", "prettier": "^2.8.7", - "tsx": "^4.6.2", + "tsx": "^4.19.1", "typescript": "^5.6.2" } }, @@ -336,6 +337,22 @@ "node": ">=12" } }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", + "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/openbsd-x64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", @@ -1124,6 +1141,24 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", "dev": true }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -1413,9 +1448,9 @@ } }, "node_modules/get-tsconfig": { - "version": "4.7.3", - "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.7.3.tgz", - "integrity": "sha512-ZvkrzoUA0PQZM6fy6+/Hce561s+faD1rsNwhnO5FelNjyy7EMGJ3Rz1AQ8GYDWjhRs/7dBLOEJvhK8MiEJOAFg==", + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.8.1.tgz", + "integrity": "sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==", "dev": true, "dependencies": { "resolve-pkg-maps": "^1.0.0" @@ -3078,13 +3113,13 @@ } }, "node_modules/tsx": { - "version": "4.7.1", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.7.1.tgz", - "integrity": "sha512-8d6VuibXHtlN5E3zFkgY8u4DX7Y3Z27zvvPKVmLon/D4AjuKzarkUBTLDBgj9iTQ0hg5xM7c/mYiRVM+HETf0g==", + "version": "4.19.1", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.1.tgz", + "integrity": "sha512-0flMz1lh74BR4wOvBjuh9olbnwqCPc35OOlfyzHba0Dc+QNUeWX/Gq2YTbnwcWPO3BMd8fkzRVrHcsR+a7z7rA==", "dev": true, "dependencies": { - "esbuild": "~0.19.10", - "get-tsconfig": "^4.7.2" + "esbuild": "~0.23.0", + "get-tsconfig": "^4.7.5" }, "bin": { "tsx": "dist/cli.mjs" @@ -3097,9 +3132,9 @@ } }, "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz", - "integrity": "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", + "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", "cpu": [ "ppc64" ], @@ -3109,13 +3144,13 @@ "aix" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.19.12.tgz", - "integrity": "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", + "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", "cpu": [ "arm" ], @@ -3125,13 +3160,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz", - "integrity": "sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", + "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", "cpu": [ "arm64" ], @@ -3141,13 +3176,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.19.12.tgz", - "integrity": "sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", + "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", "cpu": [ "x64" ], @@ -3157,13 +3192,13 @@ "android" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz", - "integrity": "sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", + "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", "cpu": [ "arm64" ], @@ -3173,13 +3208,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz", - "integrity": "sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz", + "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==", "cpu": [ "x64" ], @@ -3189,13 +3224,13 @@ "darwin" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz", - "integrity": "sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", + "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", "cpu": [ "arm64" ], @@ -3205,13 +3240,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz", - "integrity": "sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", + "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", "cpu": [ "x64" ], @@ -3221,13 +3256,13 @@ "freebsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz", - "integrity": "sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", + "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", "cpu": [ "arm" ], @@ -3237,13 +3272,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz", - "integrity": "sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", + "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", "cpu": [ "arm64" ], @@ -3253,13 +3288,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz", - "integrity": "sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", + "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", "cpu": [ "ia32" ], @@ -3269,13 +3304,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz", - "integrity": "sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", + "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", "cpu": [ "loong64" ], @@ -3285,13 +3320,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz", - "integrity": "sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", + "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", "cpu": [ "mips64el" ], @@ -3301,13 +3336,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz", - "integrity": "sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", + "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", "cpu": [ "ppc64" ], @@ -3317,13 +3352,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz", - "integrity": "sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", + "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", "cpu": [ "riscv64" ], @@ -3333,13 +3368,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz", - "integrity": "sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", + "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", "cpu": [ "s390x" ], @@ -3349,13 +3384,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz", - "integrity": "sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz", + "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==", "cpu": [ "x64" ], @@ -3365,13 +3400,13 @@ "linux" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz", - "integrity": "sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", + "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", "cpu": [ "x64" ], @@ -3381,13 +3416,13 @@ "netbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz", - "integrity": "sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", + "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", "cpu": [ "x64" ], @@ -3397,13 +3432,13 @@ "openbsd" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz", - "integrity": "sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", + "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", "cpu": [ "x64" ], @@ -3413,13 +3448,13 @@ "sunos" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz", - "integrity": "sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", + "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", "cpu": [ "arm64" ], @@ -3429,13 +3464,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz", - "integrity": "sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", + "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", "cpu": [ "ia32" ], @@ -3445,13 +3480,13 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz", - "integrity": "sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", + "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==", "cpu": [ "x64" ], @@ -3461,45 +3496,46 @@ "win32" ], "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/tsx/node_modules/esbuild": { - "version": "0.19.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.12.tgz", - "integrity": "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==", + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz", + "integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==", "dev": true, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" }, "engines": { - "node": ">=12" + "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.19.12", - "@esbuild/android-arm": "0.19.12", - "@esbuild/android-arm64": "0.19.12", - "@esbuild/android-x64": "0.19.12", - "@esbuild/darwin-arm64": "0.19.12", - "@esbuild/darwin-x64": "0.19.12", - "@esbuild/freebsd-arm64": "0.19.12", - "@esbuild/freebsd-x64": "0.19.12", - "@esbuild/linux-arm": "0.19.12", - "@esbuild/linux-arm64": "0.19.12", - "@esbuild/linux-ia32": "0.19.12", - "@esbuild/linux-loong64": "0.19.12", - "@esbuild/linux-mips64el": "0.19.12", - "@esbuild/linux-ppc64": "0.19.12", - "@esbuild/linux-riscv64": "0.19.12", - "@esbuild/linux-s390x": "0.19.12", - "@esbuild/linux-x64": "0.19.12", - "@esbuild/netbsd-x64": "0.19.12", - "@esbuild/openbsd-x64": "0.19.12", - "@esbuild/sunos-x64": "0.19.12", - "@esbuild/win32-arm64": "0.19.12", - "@esbuild/win32-ia32": "0.19.12", - "@esbuild/win32-x64": "0.19.12" + "@esbuild/aix-ppc64": "0.23.1", + "@esbuild/android-arm": "0.23.1", + "@esbuild/android-arm64": "0.23.1", + "@esbuild/android-x64": "0.23.1", + "@esbuild/darwin-arm64": "0.23.1", + "@esbuild/darwin-x64": "0.23.1", + "@esbuild/freebsd-arm64": "0.23.1", + "@esbuild/freebsd-x64": "0.23.1", + "@esbuild/linux-arm": "0.23.1", + "@esbuild/linux-arm64": "0.23.1", + "@esbuild/linux-ia32": "0.23.1", + "@esbuild/linux-loong64": "0.23.1", + "@esbuild/linux-mips64el": "0.23.1", + "@esbuild/linux-ppc64": "0.23.1", + "@esbuild/linux-riscv64": "0.23.1", + "@esbuild/linux-s390x": "0.23.1", + "@esbuild/linux-x64": "0.23.1", + "@esbuild/netbsd-x64": "0.23.1", + "@esbuild/openbsd-arm64": "0.23.1", + "@esbuild/openbsd-x64": "0.23.1", + "@esbuild/sunos-x64": "0.23.1", + "@esbuild/win32-arm64": "0.23.1", + "@esbuild/win32-ia32": "0.23.1", + "@esbuild/win32-x64": "0.23.1" } }, "node_modules/typescript": { diff --git a/package.json b/package.json index 1a5e039..71e385d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@vscode/prompt-tsx", - "version": "0.2.11-alpha", + "version": "0.3.0-alpha.1", "description": "Declare LLM prompts with TSX", "main": "./dist/base/index.js", "types": "./dist/base/index.d.ts", @@ -9,6 +9,7 @@ "compile": "tsc -p tsconfig.json && tsx ./build/postcompile.ts", "watch": "tsc --watch --sourceMap", "test": "vscode-test", + "test:unit": "cross-env IS_OUTSIDE_VSCODE=1 mocha --import=tsx -u tdd \"src/base/test/**/*.test.{ts,tsx}\"", "prettier": "prettier --list-different --write --cache .", "prepare": "tsx ./build/postinstall.ts" }, @@ -21,14 +22,15 @@ "devDependencies": { "@microsoft/tiktokenizer": "^1.0.6", "@types/node": "^20.11.30", + "@types/vscode": "^1.92.0", "@vscode/test-cli": "^0.0.9", "@vscode/test-electron": "^2.4.1", - "@types/vscode": "^1.92.0", + "cross-env": "^7.0.3", "esbuild": "0.20.2", "mocha": "^10.2.0", "npm-dts": "^1.3.12", "prettier": "^2.8.7", - "tsx": "^4.6.2", + "tsx": "^4.19.1", "typescript": "^5.6.2" } } diff --git a/src/base/index.ts b/src/base/index.ts index aa8c76d..6351417 100644 --- a/src/base/index.ts +++ b/src/base/index.ts @@ -20,7 +20,7 @@ export * from './tracer'; export * from './tsx-globals'; export * from './types'; -export { AssistantMessage, FunctionMessage, PrioritizedList, PrioritizedListProps, SystemMessage, TextChunk, TextChunkProps, UserMessage } from './promptElements'; +export { AssistantMessage, FunctionMessage, PrioritizedList, PrioritizedListProps, SystemMessage, TextChunk, TextChunkProps, UserMessage, LegacyPrioritization, Chunk } from './promptElements'; export { PromptElement } from './promptElement'; export { MetadataMap, PromptRenderer, QueueItem, RenderPromptResult } from './promptRenderer'; @@ -82,15 +82,14 @@ export async function renderPrompt

( ? new AnyTokenizer((text, token) => tokenizerMetadata.countTokens(text, token)) : tokenizerMetadata; const renderer = new PromptRenderer(endpoint, ctor, props, tokenizer); - let { messages, tokenCount, references } = await renderer.render(progress, token); - const metadatas = renderer.getAllMeta(); + let { messages, tokenCount, references, metadata } = await renderer.render(progress, token); const usedContext = renderer.getUsedContext(); if (mode === 'vscode') { messages = toVsCodeChatMessages(messages); } - return { messages, tokenCount, metadatas, usedContext, references }; + return { messages, tokenCount, metadatas: metadata, usedContext, references }; } /** diff --git a/src/base/jsonTypes.ts b/src/base/jsonTypes.ts index 7fbfe73..dea3ad0 100644 --- a/src/base/jsonTypes.ts +++ b/src/base/jsonTypes.ts @@ -19,13 +19,6 @@ import { UriComponents } from './util/vs/common/uri'; export const enum PromptNodeType { Piece = 1, Text = 2, - LineBreak = 3 -} - -export interface LineBreakJSON { - type: PromptNodeType.LineBreak; - isExplicit: boolean; - priority: number | undefined; } export interface TextJSON { @@ -33,6 +26,7 @@ export interface TextJSON { text: string; priority: number | undefined; references: PromptReferenceJSON[] | undefined; + lineBreakBefore: boolean | undefined; } /** @@ -55,7 +49,7 @@ export interface PieceJSON { props?: Record; } -export type PromptNodeJSON = PieceJSON | TextJSON | LineBreakJSON; +export type PromptNodeJSON = PieceJSON | TextJSON; export type UriOrLocationJSON = UriComponents | { uri: UriComponents, range: Range }; diff --git a/src/base/materialized.ts b/src/base/materialized.ts new file mode 100644 index 0000000..7cfe4ec --- /dev/null +++ b/src/base/materialized.ts @@ -0,0 +1,372 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation and GitHub. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { once } from './once'; +import { ChatMessage, ChatMessageToolCall, ChatRole } from './openai'; +import { PromptMetadata } from './results'; +import { ITokenizer } from './tokenizer/tokenizer'; + +export interface IMaterializedNode { + /** + * Gets the maximum number of tokens this message can contain. This is + * calculated by summing the token counts of all individual messages, which + * may be larger than the real count due to merging of sibling tokens. + */ + upperBoundTokenCount(tokenizer: ITokenizer): Promise; + + /** + * Gets the precise number of tokens this message contains. + */ + tokenCount(tokenizer: ITokenizer): Promise; +} + +export type MaterializedNode = MaterializedContainer | MaterializedChatMessage | MaterializedChatMessageTextChunk; + +export const enum ContainerFlags { + /** It's a {@link LegacyPrioritization} instance */ + IsLegacyPrioritization = 1 << 0, + /** It's a {@link Chunk} instance */ + IsChunk = 1 << 1, +} + +export class MaterializedContainer implements IMaterializedNode { + + constructor( + public readonly priority: number, + public readonly children: MaterializedNode[], + public readonly metadata: PromptMetadata[], + public readonly flags: number, + ) { } + + public has(flag: ContainerFlags) { + return !!(this.flags & flag); + } + + /** @inheritdoc */ + async tokenCount(tokenizer: ITokenizer): Promise { + let total = 0; + await Promise.all(this.children.map(async (child) => { + // note: this method is not called when the container is inside a chat + // message, because in that case the chat message generates the text + // and counts that. + assertContainerOrChatMessage(child); + + const amt = await child.tokenCount(tokenizer); + total += amt; + })); + return total; + } + + /** @inheritdoc */ + async upperBoundTokenCount(tokenizer: ITokenizer): Promise { + let total = 0; + await Promise.all(this.children.map(async (child) => { + const amt = await child.upperBoundTokenCount(tokenizer); + total += amt; + })); + return total; + } + + /** + * Gets all metadata the container holds. + */ + allMetadata(): Generator { + return allMetadata(this); + } + + /** + * Gets the chat messages the container holds. + */ + *toChatMessages(): Generator { + for (const child of this.children) { + assertContainerOrChatMessage(child); + if (child instanceof MaterializedContainer) { + yield* child.toChatMessages(); + } else if (!child.isEmpty) { + // note: empty messages are already removed during pruning, but the + // consumer might themselves have given us empty messages that we should omit. + yield child.toChatMessage(); + } + } + } + + /** Removes the node in the tree with the lowest priority. */ + removeLowestPriorityChild(): void { + if (this.has(ContainerFlags.IsLegacyPrioritization)) { + removeLowestPriorityLegacy(this); + } else { + removeLowestPriorityChild(this.children); + } + } +} + +export const enum LineBreakBefore { + None, + Always, + IfNotTextSibling, +} + +/** A chunk of text in a {@link MaterializedChatMessage} */ +export class MaterializedChatMessageTextChunk { + constructor( + public readonly text: string, + public readonly priority: number, + public readonly metadata: PromptMetadata[] = [], + public readonly lineBreakBefore: LineBreakBefore, + ) { } + + public upperBoundTokenCount(tokenizer: ITokenizer) { + return this._upperBound(tokenizer); + } + + private readonly _upperBound = once(async (tokenizer: ITokenizer) => { + return await tokenizer.tokenLength(this.text) + (this.lineBreakBefore !== LineBreakBefore.None ? 1 : 0); + }); +} + +export class MaterializedChatMessage implements IMaterializedNode { + + constructor( + public readonly role: ChatRole, + public readonly name: string | undefined, + public readonly toolCalls: ChatMessageToolCall[] | undefined, + public readonly toolCallId: string | undefined, + public readonly priority: number, + private readonly childIndex: number, + public readonly metadata: PromptMetadata[], + public readonly children: MaterializedNode[], + ) { } + + /** @inheritdoc */ + public async tokenCount(tokenizer: ITokenizer): Promise { + return this._tokenCount(tokenizer); + } + + /** @inheritdoc */ + public async upperBoundTokenCount(tokenizer: ITokenizer): Promise { + return this._upperBound(tokenizer); + } + + /** Gets the text this message contains */ + public get text(): string { + return this._text() + } + + /** Gets whether the message is empty */ + public get isEmpty() { + return !/\S/.test(this.text); + } + + /** Remove the lowest priority chunk among this message's children. */ + removeLowestPriorityChild() { + removeLowestPriorityChild(this.children); + this.onChunksChange(); + } + + onChunksChange() { + this._tokenCount.clear(); + this._upperBound.clear(); + this._text.clear(); + } + + private readonly _tokenCount = once(async (tokenizer: ITokenizer) => { + return tokenizer.countMessageTokens(this.toChatMessage()); + }); + + private readonly _upperBound = once(async (tokenizer: ITokenizer) => { + let total = await this._baseMessageTokenCount(tokenizer) + await Promise.all(this.children.map(async (chunk) => { + const amt = await chunk.upperBoundTokenCount(tokenizer); + total += amt; + })); + return total; + }); + + private readonly _baseMessageTokenCount = once((tokenizer: ITokenizer) => { + return tokenizer.countMessageTokens({ ...this.toChatMessage(), content: '' }); + }); + + private readonly _text = once(() => { + let result = ''; + for (const { text, isTextSibling } of textChunks(this)) { + if (text.lineBreakBefore === LineBreakBefore.Always || (text.lineBreakBefore === LineBreakBefore.IfNotTextSibling && !isTextSibling)) { + if (result.length && !result.endsWith('\n')) { + result += '\n'; + } + } + + result += text.text; + } + + return result.trim(); + }); + + public toChatMessage(): ChatMessage { + if (this.role === ChatRole.System) { + return { + role: this.role, + content: this.text, + ...(this.name ? { name: this.name } : {}) + }; + } else if (this.role === ChatRole.Assistant) { + return { + role: this.role, + content: this.text, + ...(this.toolCalls ? { tool_calls: this.toolCalls } : {}), + ...(this.name ? { name: this.name } : {}) + }; + } else if (this.role === ChatRole.User) { + return { + role: this.role, + content: this.text, + ...(this.name ? { name: this.name } : {}) + } + } else if (this.role === ChatRole.Tool) { + return { + role: this.role, + content: this.text, + tool_call_id: this.toolCallId + }; + } else { + return { + role: this.role, + content: this.text, + name: this.name! + }; + } + } + + public static cmp(a: MaterializedChatMessage, b: MaterializedChatMessage): number { + if (a.priority !== b.priority) { + return (b.priority || 0) - (a.priority || 0); + } + return b.childIndex - a.childIndex; + } +} + +function assertContainerOrChatMessage(v: MaterializedNode): asserts v is MaterializedContainer | MaterializedChatMessage { + if (!(v instanceof MaterializedContainer) && !(v instanceof MaterializedChatMessage)) { + throw new Error(`Cannot have a text node outside a ChatMessage. Text: "${v.text}"`); + } +} + + +function* textChunks(node: MaterializedContainer | MaterializedChatMessage, isTextSibling = false): Generator<{ text: MaterializedChatMessageTextChunk; isTextSibling: boolean }> { + for (const child of node.children) { + if (child instanceof MaterializedChatMessageTextChunk) { + yield { text: child, isTextSibling }; + isTextSibling = true; + } else { + yield* textChunks(child, isTextSibling); + isTextSibling = false; + } + } +} + +function removeLowestPriorityLegacy(root: MaterializedNode) { + let lowest: undefined | { + chain: (MaterializedContainer | MaterializedChatMessage)[], + node: MaterializedChatMessageTextChunk; + }; + + function findLowestInTree(node: MaterializedNode, chain: (MaterializedContainer | MaterializedChatMessage)[]) { + if (node instanceof MaterializedChatMessageTextChunk) { + if (!lowest || node.priority < lowest.node.priority) { + lowest = { chain: chain.slice(), node }; + } + } else { + chain.push(node); + for (const child of node.children) { + findLowestInTree(child, chain); + } + chain.pop(); + } + } + + findLowestInTree(root, []); + + if (!lowest) { + throw new Error('No lowest priority node found'); + } + + let needle: MaterializedNode = lowest.node; + let i = lowest.chain.length - 1; + for (; i >= 0; i--) { + const node = lowest.chain[i]; + node.children.splice(node.children.indexOf(needle), 1); + if (node instanceof MaterializedChatMessage) { + node.onChunksChange(); + } + if (node.children.length > 0) { + break; + } + + needle = node; + } + + for (; i >= 0; i--) { + const node = lowest.chain[i]; + if (node instanceof MaterializedChatMessage) { + node.onChunksChange(); + } + } +} + +function removeLowestPriorityChild(children: MaterializedNode[]) { + if (!children.length) { + return; + } + + let lowestIndex = 0; + let lowestNestedChildPriority: number | undefined; + for (let i = 1; i < children.length; i++) { + if (children[i].priority < children[lowestIndex].priority) { + lowestIndex = i; + lowestNestedChildPriority = undefined; + } else if (children[i].priority === children[lowestIndex].priority) { + // Use the lowest priority of any of their nested remaining children as a tiebreaker, + // useful e.g. when dealing with root sibling user vs. system messages + lowestNestedChildPriority ??= getLowestPriorityAmongChildren(children[lowestIndex]); + const lowestNestedPriority = getLowestPriorityAmongChildren(children[i]); + if (lowestNestedPriority < lowestNestedChildPriority) { + lowestIndex = i; + lowestNestedChildPriority = lowestNestedPriority; + } + } + } + + const lowest = children[lowestIndex]; + if (lowest instanceof MaterializedChatMessageTextChunk || (lowest instanceof MaterializedContainer && lowest.has(ContainerFlags.IsChunk))) { + children.splice(lowestIndex, 1); + } else { + lowest.removeLowestPriorityChild(); + if (lowest.children.length === 0) { + children.splice(lowestIndex, 1); + } + } +} + +function getLowestPriorityAmongChildren(node: MaterializedNode): number { + if (node instanceof MaterializedChatMessageTextChunk) { + return -1; + } + + let lowest = Number.MAX_SAFE_INTEGER; + for (const child of node.children) { + lowest = Math.min(lowest, child.priority); + } + + return lowest; +} + +function* allMetadata(node: MaterializedContainer | MaterializedChatMessage): Generator { + yield* node.metadata; + for (const child of node.children) { + if (child instanceof MaterializedChatMessageTextChunk) { + yield* child.metadata; + } else { + yield* allMetadata(child); + } + } +} diff --git a/src/base/once.ts b/src/base/once.ts new file mode 100644 index 0000000..ac79b78 --- /dev/null +++ b/src/base/once.ts @@ -0,0 +1,22 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation and GitHub. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +export function once any>(fn: T): T & { clear: () => void } { + let result: ReturnType; + let called = false; + + const wrappedFunction = ((...args: Parameters): ReturnType => { + if (!called) { + result = fn(...args); + called = true; + } + return result; + }) as T & { clear: () => void }; + + wrappedFunction.clear = () => { + called = false; + }; + + return wrappedFunction; +} diff --git a/src/base/promptElements.tsx b/src/base/promptElements.tsx index 67acf4d..759e966 100644 --- a/src/base/promptElements.tsx +++ b/src/base/promptElements.tsx @@ -4,7 +4,6 @@ import type { CancellationToken } from 'vscode'; import { contentType } from '.'; -import * as JSONT from './jsonTypes'; import { ChatRole } from './openai'; import { PromptElement } from './promptElement'; import { BasePromptElementProps, PromptPiece, PromptSizing } from './types'; @@ -232,12 +231,22 @@ export class PrioritizedList extends PromptElement { return ( <> {children.map((child, i) => { - child.props ??= {}; - child.props.priority = this.props.descending + if (!child) { + return; + } + + const priority = this.props.descending ? // First element in array of children has highest priority this.props.priority - i : // Last element in array of children has highest priority this.props.priority - children.length + i; + + if (typeof child !== 'object') { + return {child}; + } + + child.props ??= {}; + child.props.priority = priority; return child; })} @@ -264,43 +273,33 @@ export class ToolResult extends PromptElement { render(): Promise | PromptPiece | undefined { // note: future updates to content types should be handled here for backwards compatibility if (this.props.data.hasOwnProperty(contentType)) { - return ; + return ; } else { return {this.props.data.toString()}; } } +} - /** - * Modifies priorities of all elements in the tree to fractional increments - * past `this.priorty`. - */ - private rebasePriority(data: JSONT.PromptElementJSON) { - if (this.priority === undefined) { - return data; - } - - const cloned = structuredClone(data); +/** + * Marker element that uses the legacy global prioritization algorithm (0.2.x + * if this library) for pruning child elements. This will be removed in + * the future. + * + * @deprecated + */ +export class LegacyPrioritization extends PromptElement { + render() { + return <>{this.props.children}; + } +} - let maxPriorityInChildren = 1; - JSONT.forEachNode(cloned.node, node => { - // was initially undefined in the tree and was set to this implicitly: - if (node.priority === Number.MAX_SAFE_INTEGER) { - node.priority = undefined; - } - if (node.priority !== undefined) { - maxPriorityInChildren = Math.max(maxPriorityInChildren, node.priority); - } - }); - - // Elements without priority default to MAX_SAFE_INTEGER in the renderer, - // so follow similar behavior here. The denominator of the fractional part - // is set so that we maximal elements are `this.priority + (max + 1) / (max + 2)`, - // keeping `this.priority <= node.priority < this.priority + 1` - JSONT.forEachNode(cloned.node, node => { - const frac = (node.priority ?? (maxPriorityInChildren + 1)) / (maxPriorityInChildren + 2); - node.priority = this.priority + frac; - }); - - return cloned; +/** + * Marker element that ensures all of its children are either included, or + * not included. This is similar to the `` element, but it is more + * basic and can contain extrinsic children. + */ +export class Chunk extends PromptElement { + render() { + return <>{this.props.children}; } } diff --git a/src/base/promptRenderer.ts b/src/base/promptRenderer.ts index 1d275ef..97e55ca 100644 --- a/src/base/promptRenderer.ts +++ b/src/base/promptRenderer.ts @@ -5,14 +5,14 @@ import type { CancellationToken, Progress } from "vscode"; import * as JSONT from './jsonTypes'; import { PromptNodeType } from './jsonTypes'; -import { ChatMessage, ChatMessageToolCall, ChatRole } from "./openai"; +import { ContainerFlags, LineBreakBefore, MaterializedChatMessage, MaterializedChatMessageTextChunk, MaterializedContainer } from './materialized'; +import { ChatMessage } from "./openai"; import { PromptElement } from "./promptElement"; -import { AssistantMessage, BaseChatMessage, ChatMessagePromptElement, TextChunk, ToolMessage, isChatMessagePromptElement } from "./promptElements"; +import { AssistantMessage, BaseChatMessage, ChatMessagePromptElement, Chunk, LegacyPrioritization, TextChunk, ToolMessage, isChatMessagePromptElement } from "./promptElements"; import { PromptMetadata, PromptReference } from "./results"; import { ITokenizer } from "./tokenizer/tokenizer"; import { ITracer } from './tracer'; import { BasePromptElementProps, IChatEndpointInfo, PromptElementCtor, PromptPiece, PromptPieceChild, PromptSizing } from "./types"; -import { coalesce } from "./util/arrays"; import { URI } from "./util/vs/common/uri"; import { ChatDocumentContext, ChatResponsePart } from "./vscodeTypes"; @@ -20,6 +20,7 @@ export interface RenderPromptResult { readonly messages: ChatMessage[]; readonly tokenCount: number; readonly hasIgnoredFiles: boolean; + readonly metadata: MetadataMap; /** * The references that survived prioritization in the rendered {@link RenderPromptResult.messages messages}. */ @@ -55,12 +56,9 @@ export namespace MetadataMap { */ export class PromptRenderer

{ - // map the constructor to the meta data instances - private readonly _meta: Map PromptMetadata, PromptMetadata> = new Map(); private readonly _usedContext: ChatDocumentContext[] = []; private readonly _ignoredFiles: URI[] = []; private readonly _root = new PromptTreeElement(null, 0); - private readonly _references: PromptReference[] = []; public tracer: ITracer | undefined = undefined; /** @@ -76,23 +74,10 @@ export class PromptRenderer

{ private readonly _tokenizer: ITokenizer ) { } - public getAllMeta(): MetadataMap { - const metadataMap = this._meta; - return { - get(key: new (...args: any[]) => T): T | undefined { - return metadataMap.get(key) as T | undefined; - } - }; - } - public getIgnoredFiles(): URI[] { return Array.from(new Set(this._ignoredFiles)); } - public getMeta(key: new (...args: any[]) => T): T | undefined { - return this._meta.get(key) as T | undefined; - } - public getUsedContext(): ChatDocumentContext[] { return this._usedContext; } @@ -223,56 +208,6 @@ export class PromptRenderer

{ this.tracer?.endRenderPass(); } - private async _prioritize(things: T[], cmp: (a: T, b: T) => number, count: (thing: T) => Promise) { - const prioritizedChunks: { index: number; precedingLinebreak?: number }[] = []; // sorted by descending priority - const chunkResult: (T | null)[] = []; - - let i = 0; - while (i < things.length) { - // We only consider non-linebreaks for prioritization - if (!things[i].isImplicitLinebreak) { - const chunk = things[i - 1]?.isImplicitLinebreak ? { index: i, precedingLinebreak: i - 1 } : { index: i }; - prioritizedChunks.push(chunk); - chunkResult[i] = null; - } - i += 1; - } - - prioritizedChunks.sort((a, b) => cmp(things[a.index], things[b.index])); - - let remainingBudget = this._endpoint.modelMaxPromptTokens; - const omittedChunks: T[] = []; - while (prioritizedChunks.length > 0) { - const prioritizedChunk = prioritizedChunks.shift()!; - const index = prioritizedChunk.index; - const chunk = things[index]; - let tokenCount = await count(chunk); - let precedingLinebreak; - if (prioritizedChunk.precedingLinebreak) { - precedingLinebreak = things[prioritizedChunk.precedingLinebreak]; - tokenCount += await count(precedingLinebreak); - } - if (tokenCount > remainingBudget) { - // Wouldn't fit anymore - omittedChunks.push(chunk); - break; - } - chunkResult[index] = chunk; - if (prioritizedChunk.precedingLinebreak && precedingLinebreak) { - chunkResult[prioritizedChunk.precedingLinebreak] = precedingLinebreak; - } - remainingBudget -= tokenCount; - } - - for (const omittedChunk of prioritizedChunks) { - const index = omittedChunk.index; - const chunk = things[index]; - omittedChunks.push(chunk); - } - - return { result: coalesce(chunkResult), tokenCount: this._endpoint.modelMaxPromptTokens - remainingBudget, omittedChunks }; - } - /** * Renders the prompt element and its children to a JSON-serializable state. * @returns A promise that resolves to an object containing the rendered chat messages and the total token count. @@ -306,87 +241,70 @@ export class PromptRenderer

{ token, ); - // Convert prompt pieces to message chunks (text and linebreaks) - const { result: messageChunks, resultChunks } = this._root.materialize(); - - // First pass: sort message chunks by priority. Note that this can yield an imprecise result due to token boundaries within a single chat message - // so we also need to do a second pass over the full chat messages later - const chunkMessages = new Set(); - const { result: prioritizedChunks, omittedChunks } = await this._prioritize( - resultChunks, - (a, b) => MaterializedChatMessageTextChunk.cmp(a, b), - async (chunk) => { - let tokenLength = await this._tokenizer.tokenLength(chunk.text); - if (!chunkMessages.has(chunk.message)) { - chunkMessages.add(chunk.message); - tokenLength = await this._tokenizer.countMessageTokens(chunk.toChatMessage()); - } - return tokenLength; - }); - - // Update chat messages with their chunks that survived prioritization - const chatMessagesToChunks = new Map(); - for (const chunk of coalesce(prioritizedChunks)) { - const value = chatMessagesToChunks.get(chunk.message) ?? []; - value[chunk.childIndex] = chunk; - chatMessagesToChunks.set(chunk.message, value); + // Trim the elements to fit within the token budget. We check the "lower bound" + // first because that's much more cache-friendly as we remove elements. + const container = this._root.materialize() as MaterializedContainer; + const allMetadata = [...container.allMetadata()]; + while ( + await container.upperBoundTokenCount(this._tokenizer) > this._endpoint.modelMaxPromptTokens && + await container.tokenCount(this._tokenizer) > this._endpoint.modelMaxPromptTokens + ) { + container.removeLowestPriorityChild(); } - // Collect chat messages with surviving prioritized chunks in the order they were declared - const chatMessages: MaterializedChatMessage[] = []; - for (const message of messageChunks) { - const chunks = chatMessagesToChunks.get(message); - if (chunks) { - message.chunks = coalesce(chunks); - for (const chunk of chunks) { - if (chunk && chunk.references.length > 0) { - message.references.push(...chunk.references); - } - } - chatMessages.push(message); - } - } - - // Second pass: make sure the chat messages will fit within the token budget - const { result: prioritizedMaterializedChatMessages, tokenCount } = await this._prioritize(chatMessages, (a, b) => MaterializedChatMessage.cmp(a, b), async (message) => this._tokenizer.countMessageTokens(message.toChatMessage())); - // Then finalize the chat messages - const messageResult = prioritizedMaterializedChatMessages.map(message => message?.toChatMessage()); + const messageResult = [...container.toChatMessages()]; + const tokenCount = await container.tokenCount(this._tokenizer); + const remainingMetadata = [...container.allMetadata()]; // Remove undefined and duplicate references - const { references, names } = prioritizedMaterializedChatMessages.reduce<{ references: PromptReference[], names: Set }>((acc, message) => { - [...this._references, ...message.references].forEach((ref) => { - const isVariableName = 'variableName' in ref.anchor; - if (isVariableName && !acc.names.has(ref.anchor.variableName)) { - acc.references.push(ref); - acc.names.add(ref.anchor.variableName); - } else if (!isVariableName) { - acc.references.push(ref); - } - }); - return acc; - }, { references: [], names: new Set() }); + const referenceNames = new Set(); + const references = remainingMetadata.map(m => { + if (!(m instanceof ReferenceMetadata)) { + return; + } + + const ref = m.reference; + const isVariableName = 'variableName' in ref.anchor; + if (isVariableName && !referenceNames.has(ref.anchor.variableName)) { + referenceNames.add(ref.anchor.variableName); + return ref; + } else if (!isVariableName) { + return ref; + } + }).filter(isDefined); // Collect the references for chat message chunks that did not survive prioritization - const { references: omittedReferences } = omittedChunks.reduce<{ references: PromptReference[] }>((acc, message) => { - message.references.forEach((ref) => { - const isVariableName = 'variableName' in ref.anchor; - if (isVariableName && !names.has(ref.anchor.variableName)) { - acc.references.push(ref); - names.add(ref.anchor.variableName); - } else if (!isVariableName) { - acc.references.push(ref); - } - }); - return acc; - }, { references: [] }); + const omittedReferences = allMetadata.map(m => { + if (!(m instanceof ReferenceMetadata) || remainingMetadata.includes(m)) { + return; + } + + const ref = m.reference; + const isVariableName = 'variableName' in ref.anchor; + if (isVariableName && !referenceNames.has(ref.anchor.variableName)) { + referenceNames.add(ref.anchor.variableName); + return ref; + } else if (!isVariableName) { + return ref; + } + }).filter(isDefined); - return { messages: messageResult, hasIgnoredFiles: this._ignoredFiles.length > 0, tokenCount, references: coalesce(references), omittedReferences: coalesce(omittedReferences) }; + return { + metadata: { + get: ctor => remainingMetadata.find(m => m instanceof ctor) as any + }, + messages: messageResult, + hasIgnoredFiles: this._ignoredFiles.length > 0, + tokenCount, + references, + omittedReferences, + }; } private _handlePromptChildren(element: QueueItem, P>, pieces: ProcessedPromptPiece[], sizing: PromptSizingContext, progress: Progress | undefined, token: CancellationToken | undefined) { if (element.ctor === TextChunk) { - this._handleExtrinsicTextChunkChildren(element.node.parent!, element.node, element.props, pieces); + this._handleExtrinsicTextChunkChildren(element.node, element.node, element.props, pieces); return; } @@ -431,18 +349,19 @@ export class PromptRenderer

{ if (children.length > 0) { throw new Error(` must not have children!`); } - const key = Object.getPrototypeOf(props.value).constructor; - if (this._meta.has(key)) { - throw new Error(`Duplicate metadata ${key.name}!`); + + if (props.local) { + node.addMetadata(props.value); + } else { + this._root.addMetadata(props.value); } - this._meta.set(key, props.value); } private _handleIntrinsicLineBreak(node: PromptTreeElement, props: JSX.IntrinsicElements['br'], children: ProcessedPromptPiece[], inheritedPriority?: number, sortIndex?: number) { if (children.length > 0) { throw new Error(`
must not have children!`); } - node.appendLineBreak(true, inheritedPriority ?? Number.MAX_SAFE_INTEGER, sortIndex); + node.appendLineBreak(inheritedPriority ?? Number.MAX_SAFE_INTEGER, sortIndex); } private _handleIntrinsicElementJSON(node: PromptTreeElement, data: JSONT.PromptElementJSON) { @@ -460,8 +379,9 @@ export class PromptRenderer

{ if (children.length > 0) { throw new Error(` must not have children!`); } - node.addReferences(props.value); - this._references.push(...props.value); + for (const ref of props.value) { + node.addMetadata(new ReferenceMetadata(ref)); + } } @@ -481,7 +401,7 @@ export class PromptRenderer

{ */ private _handleExtrinsicTextChunkChildren(node: PromptTreeElement, textChunkNode: PromptTreeElement, props: BasePromptElementProps, children: ProcessedPromptPiece[]) { const content: string[] = []; - const references: PromptReference[] = []; + const metadata: PromptMetadata[] = []; for (const child of children) { if (child.kind === 'extrinsic') { @@ -498,15 +418,16 @@ export class PromptRenderer

{ content.push('\n'); } else if (child.name === 'references') { // For TextChunks, references must be propagated through the PromptText element that is appended to the node - references.push(...child.props.value); + for (const reference of child.props.value) { + metadata.push(new ReferenceMetadata(reference)); + } } else { this._handleIntrinsic(node, child.name, child.props, flattenAndReduceArr(child.children), textChunkNode.childIndex); } } } - node.appendLineBreak(false, undefined, textChunkNode.childIndex); - node.appendStringChild(content.join(''), props?.priority ?? Number.MAX_SAFE_INTEGER, references, textChunkNode.childIndex); + node.appendStringChild(content.join(''), props?.priority ?? Number.MAX_SAFE_INTEGER, metadata, textChunkNode.childIndex, true); } } @@ -583,8 +504,8 @@ class LiteralPromptPiece { type ProcessedPromptPiece = LiteralPromptPiece | IntrinsicPromptPiece | ExtrinsicPromptPiece; -type PromptNode = PromptTreeElement | PromptText | PromptLineBreak; -type LeafPromptNode = PromptText | PromptLineBreak; +type PromptNode = PromptTreeElement | PromptText; +type LeafPromptNode = PromptText; /** * A shared instance given to each PromptTreeElement that contains information @@ -615,15 +536,13 @@ class PromptSizingContext { class PromptTreeElement { public static fromJSON(index: number, json: JSONT.PieceJSON): PromptTreeElement { const element = new PromptTreeElement(null, index); - element._references = json.references?.map(r => PromptReference.fromJSON(r)) ?? []; + element._metadata = json.references?.map(r => new ReferenceMetadata(PromptReference.fromJSON(r))) ?? []; element._children = json.children.map((childJson, i) => { switch (childJson.type) { case JSONT.PromptNodeType.Piece: return PromptTreeElement.fromJSON(i, childJson); case JSONT.PromptNodeType.Text: return PromptText.fromJSON(element, i, childJson); - case JSONT.PromptNodeType.LineBreak: - return PromptLineBreak.fromJSON(element, i, childJson); default: softAssertNever(childJson); } @@ -647,7 +566,7 @@ class PromptTreeElement { private _obj: PromptElement | null = null; private _state: any | undefined = undefined; private _children: PromptNode[] = []; - private _references: PromptReference[] = []; + private _metadata: PromptMetadata[] = []; constructor( public readonly parent: PromptTreeElement | null = null, @@ -678,19 +597,12 @@ class PromptTreeElement { return child; } - public appendStringChild(text: string, priority?: number, references?: PromptReference[], sortIndex = this._children.length) { - this._children.push(new PromptText(this, sortIndex, text, priority, references)); + public appendStringChild(text: string, priority?: number, metadata?: PromptMetadata[], sortIndex = this._children.length, lineBreakBefore = false) { + this._children.push(new PromptText(this, sortIndex, text, priority, metadata, lineBreakBefore)); } - public appendLineBreak(explicit = true, priority?: number, sortIndex = this._children.length): void { - this._children.push(new PromptLineBreak(this, sortIndex, explicit, priority)); - } - - public materialize(): { result: MaterializedChatMessage[]; resultChunks: MaterializedChatMessageTextChunk[] } { - const result: MaterializedChatMessage[] = []; - const resultChunks: MaterializedChatMessageTextChunk[] = []; - this._materialize(result, resultChunks); - return { result, resultChunks }; + public appendLineBreak(priority?: number, sortIndex = this._children.length): void { + this._children.push(new PromptText(this, sortIndex, '\n', priority)); } public toJSON(): JSONT.PieceJSON { @@ -699,7 +611,7 @@ class PromptTreeElement { ctor: JSONT.PieceCtorKind.Other, children: this._children.slice().sort((a, b) => a.childIndex - b.childIndex).map(c => c.toJSON()), priority: this._obj?.props.priority, - references: this._references?.map(r => r.toJSON()), + references: this._metadata.filter(m => m instanceof ReferenceMetadata).map(r => r.reference.toJSON()), }; if (this._obj instanceof BaseChatMessage) { @@ -716,182 +628,46 @@ class PromptTreeElement { return json; } - private _materialize(result: MaterializedChatMessage[], resultChunks: MaterializedChatMessageTextChunk[]): void { + public materialize(): MaterializedChatMessage | MaterializedContainer { this._children.sort((a, b) => a.childIndex - b.childIndex); if (this._obj instanceof BaseChatMessage) { if (!this._obj.props.role) { throw new Error(`Invalid ChatMessage!`); } - const leafNodes: LeafPromptNode[] = []; - for (const child of this._children) { - child.collectLeafs(leafNodes); - } - const chunks: MaterializedChatMessageTextChunk[] = []; const parent = new MaterializedChatMessage( this._obj.props.role, this._obj.props.name, this._obj instanceof AssistantMessage ? this._obj.props.toolCalls : undefined, this._obj instanceof ToolMessage ? this._obj.props.toolCallId : undefined, - this._obj.props.priority, + this._obj.props.priority ?? 0, this.childIndex, - chunks + this._metadata, + this._children.map(child => child.materialize()), ); - let childIndex = resultChunks.length; - leafNodes.forEach((node, index) => { - if (node.kind === PromptNodeType.Text) { - chunks.push(new MaterializedChatMessageTextChunk(parent, node.text, node.priority, childIndex++, false, node.references ?? this._references)); - } else { - if (node.isExplicit) { - chunks.push(new MaterializedChatMessageTextChunk(parent, '\n', node.priority, childIndex++)); - } else if (chunks.length > 0 && chunks[chunks.length - 1].text !== '\n' || chunks[index - 1] && chunks[index - 1].text !== '\n') { - // Only insert an implicit linebreak if there wasn't already an explicit linebreak before - chunks.push(new MaterializedChatMessageTextChunk(parent, '\n', node.priority, childIndex++, true)); - } - } - }); - resultChunks.push(...chunks); - result.push(parent); + return parent; } else { - for (const child of this._children) { - if (child.kind === PromptNodeType.Text) { - throw new Error(`Cannot have a text node outside a ChatMessage. Text: "${child.text}"`); - } else if (child.kind === PromptNodeType.LineBreak) { - throw new Error(`Cannot have a line break node outside a ChatMessage!`); - } - child._materialize(result, resultChunks); - } - } - } - - public collectLeafs(result: LeafPromptNode[]): void { - if (this._obj instanceof BaseChatMessage) { - throw new Error(`Cannot have a ChatMessage nested inside a ChatMessage!`); - } - if (this._obj?.insertLineBreakBefore) { - // Add an implicit
before the element - result.push(new PromptLineBreak(this, 0, false)); - } - for (const child of this._children.sort((a, b) => a.childIndex - b.childIndex)) { - child.collectLeafs(result); - } - } - - public addReferences(references: PromptReference[]): void { - this._references.push(...references); - } -} - -interface Countable { - text: string; - isImplicitLinebreak?: boolean; -} - -class MaterializedChatMessageTextChunk implements Countable { - constructor( - public readonly message: MaterializedChatMessage, - public readonly text: string, - private readonly priority: number | undefined, - public readonly childIndex: number, - public readonly isImplicitLinebreak = false, - public readonly references: PromptReference[] = [] - ) { } - - public static cmp(a: MaterializedChatMessageTextChunk, b: MaterializedChatMessageTextChunk): number { - if (a.priority !== undefined && b.priority !== undefined && a.priority === b.priority) { - // If the chunks share the same parent, break priority ties based on the order - // that the chunks were declared in under its parent chat message - if (a.message === b.message) { - return a.childIndex - b.childIndex; - } - // Otherwise, prioritize chunks that were declared last - return b.childIndex - a.childIndex; - } - - if (a.priority !== undefined && b.priority !== undefined && a.priority !== b.priority) { - return b.priority - a.priority; + let flags = 0; + if (this._obj instanceof LegacyPrioritization) flags |= ContainerFlags.IsLegacyPrioritization; + if (this._obj instanceof Chunk) flags |= ContainerFlags.IsChunk; + + return new MaterializedContainer( + this._obj?.props.priority || 0, + this._children.map(child => child.materialize()), + this._metadata, + flags, + ); } - - return a.childIndex - b.childIndex; } - public toChatMessage(): ChatMessage { - const chatMessage = this.message.toChatMessage(); - chatMessage.content = this.text; - return chatMessage; + public addMetadata(metadata: PromptMetadata): void { + this._metadata.push(metadata); } } -class MaterializedChatMessage implements Countable { - constructor( - public readonly role: ChatRole, - public readonly name: string | undefined, - public readonly toolCalls: ChatMessageToolCall[] | undefined, - public readonly toolCallId: string | undefined, - private readonly priority: number | undefined, - private readonly childIndex: number, - private _chunks: MaterializedChatMessageTextChunk[], - public references: PromptReference[] = [] - ) { } - - public set chunks(chunks: MaterializedChatMessageTextChunk[]) { - this._chunks = chunks; - } - - public get text(): string { - return this._chunks.reduce((acc, c, i) => { - if (i !== (this._chunks.length - 1) || !c.isImplicitLinebreak) { - acc += c.text; - } - return acc; - }, ''); - } - - public toChatMessage(): ChatMessage { - if (this.role === ChatRole.System) { - return { - role: this.role, - content: this.text, - ...(this.name ? { name: this.name } : {}) - }; - } else if (this.role === ChatRole.Assistant) { - return { - role: this.role, - content: this.text, - ...(this.toolCalls ? { tool_calls: this.toolCalls } : {}), - ...(this.name ? { name: this.name } : {}) - }; - } else if (this.role === ChatRole.User) { - return { - role: this.role, - content: this.text, - ...(this.name ? { name: this.name } : {}) - } - } else if (this.role === ChatRole.Tool) { - return { - role: this.role, - content: this.text, - tool_call_id: this.toolCallId - }; - } else { - return { - role: this.role, - content: this.text, - name: this.name! - }; - } - } - - public static cmp(a: MaterializedChatMessage, b: MaterializedChatMessage): number { - if (a.priority !== b.priority) { - return (b.priority || 0) - (a.priority || 0); - } - return b.childIndex - a.childIndex; - } -} class PromptText { public static fromJSON(parent: PromptTreeElement, index: number, json: JSONT.TextJSON): PromptText { - return new PromptText(parent, index, json.text, json.priority, json.references?.map(r => PromptReference.fromJSON(r))); + return new PromptText(parent, index, json.text, json.priority, json.references?.map(r => new ReferenceMetadata(PromptReference.fromJSON(r))), json.lineBreakBefore); } public readonly kind = PromptNodeType.Text; @@ -901,46 +677,30 @@ class PromptText { public readonly childIndex: number, public readonly text: string, public readonly priority?: number, - public readonly references?: PromptReference[] + public readonly metadata?: PromptMetadata[], + public readonly lineBreakBefore = false, ) { } public collectLeafs(result: LeafPromptNode[]) { result.push(this); } + public materialize() { + const lineBreak = this.lineBreakBefore + ? LineBreakBefore.Always + : this.childIndex === 0 + ? LineBreakBefore.IfNotTextSibling + : LineBreakBefore.None; + return new MaterializedChatMessageTextChunk(this.text, this.priority ?? Number.MAX_SAFE_INTEGER, this.metadata || [], lineBreak); + } + public toJSON(): JSONT.TextJSON { return { type: JSONT.PromptNodeType.Text, priority: this.priority, text: this.text, - references: this.references?.map(r => r.toJSON()), - }; - } -} - -class PromptLineBreak { - public static fromJSON(parent: PromptTreeElement, index: number, json: JSONT.LineBreakJSON): PromptLineBreak { - return new PromptLineBreak(parent, index, json.isExplicit, json.priority); - } - - public readonly kind = PromptNodeType.LineBreak; - - constructor( - public readonly parent: PromptTreeElement, - public readonly childIndex: number, - public readonly isExplicit: boolean, - public readonly priority?: number - ) { } - - public collectLeafs(result: LeafPromptNode[]) { - result.push(this); - } - - public toJSON(): JSONT.LineBreakJSON { - return { - type: JSONT.PromptNodeType.LineBreak, - isExplicit: this.isExplicit, - priority: this.priority, + references: this.metadata?.filter(m => m instanceof ReferenceMetadata).map(r => r.reference.toJSON()), + lineBreakBefore: this.lineBreakBefore, }; } } @@ -957,3 +717,11 @@ function softAssertNever(x: never): void { function isDefined(x: T | undefined): x is T { return x !== undefined; } + +class InternalMetadata extends PromptMetadata { } + +class ReferenceMetadata extends InternalMetadata { + constructor(public readonly reference: PromptReference) { + super(); + } +} diff --git a/src/base/test/materialized.test.ts b/src/base/test/materialized.test.ts new file mode 100644 index 0000000..0aaf53b --- /dev/null +++ b/src/base/test/materialized.test.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation and GitHub. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { LineBreakBefore, MaterializedChatMessage, MaterializedChatMessageTextChunk, MaterializedContainer } from '../materialized'; +import { ChatRole } from '../openai'; +import { ITokenizer } from '../tokenizer/tokenizer'; +class MockTokenizer implements ITokenizer { + tokenLength(text: string): number { + return text.length; + } + countMessageTokens(message: any): number { + return message.content.length + 3; + } +} +suite('Materialized', () => { + test('should calculate token count correctly', async () => { + const tokenizer = new MockTokenizer(); + const child1 = new MaterializedChatMessageTextChunk('Hello', 1, [], LineBreakBefore.None); + const child2 = new MaterializedChatMessageTextChunk('World', 1, [], LineBreakBefore.None); + const message = new MaterializedChatMessage(ChatRole.User, 'user', undefined, undefined, 1, 0, [], [child1, child2]); + const container = new MaterializedContainer(1, [message], [], 0); + + assert.deepStrictEqual(await container.tokenCount(tokenizer), 13); + container.removeLowestPriorityChild(); + assert.deepStrictEqual(await container.tokenCount(tokenizer), 8); + }); + + test('should calculate lower bound token count correctly', async () => { + const tokenizer = new MockTokenizer(); + const child1 = new MaterializedChatMessageTextChunk('Hello', 1, [], LineBreakBefore.None); + const child2 = new MaterializedChatMessageTextChunk('World', 1, [], LineBreakBefore.None); + const message = new MaterializedChatMessage(ChatRole.User, 'user', undefined, undefined, 1, 0, [], [child1, child2]); + const container = new MaterializedContainer(1, [message], [], 0); + + assert.deepStrictEqual(await container.upperBoundTokenCount(tokenizer), 13); + container.removeLowestPriorityChild(); + assert.deepStrictEqual(await container.upperBoundTokenCount(tokenizer), 8); + }); +}); diff --git a/src/base/test/renderer.test.tsx b/src/base/test/renderer.test.tsx index 3794d49..1383c07 100644 --- a/src/base/test/renderer.test.tsx +++ b/src/base/test/renderer.test.tsx @@ -8,6 +8,8 @@ import { BaseTokensPerCompletion, ChatMessage, ChatRole } from '../openai'; import { PromptElement } from '../promptElement'; import { AssistantMessage, + Chunk, + LegacyPrioritization, PrioritizedList, SystemMessage, TextChunk, @@ -16,7 +18,7 @@ import { UserMessage, } from '../promptElements'; import { PromptRenderer, RenderPromptResult } from '../promptRenderer'; -import { PromptReference } from '../results'; +import { PromptMetadata, PromptReference } from '../results'; import { Cl100KBaseTokenizer } from '../tokenizer/cl100kBaseTokenizer'; import { ITokenizer } from '../tokenizer/tokenizer'; import { @@ -244,6 +246,211 @@ suite('PromptRenderer', () => { ]); }); + suite('prunes in priority order', () => { + async function assertPruningOrder(elements: PromptPiece, order: string[]) { + const initialRender = await new PromptRenderer( + { modelMaxPromptTokens: Number.MAX_SAFE_INTEGER } as any, + class extends PromptElement { + render() { + return elements; + } + }, + {}, + tokenizer + ).render(); + + let tokens = initialRender.tokenCount; + let last = ''; + for (let i = 0; i < order.length;) { + const res = await new PromptRenderer( + { modelMaxPromptTokens: tokens } as any, + class extends PromptElement { + render() { + return elements; + } + }, + {}, + tokenizer + ).render(); + + const messages = res.messages.map(m => `${m.role}: ${m.content}`).join('\n'); + if (messages === last) { + tokens--; + continue; + } + + for (let k = 0; k < i; k++) { + if (res.messages.some(m => m.content.includes(order[k]))) { + throw new Error(`Expected messages TO NOT HAVE "${order[k]}" at budget of ${tokens}. Got:\n\n${messages}\n\nLast was: ${last}`); + } + } + for (let k = i; k < order.length; k++) { + if (!res.messages.some(m => m.content.includes(order[k]))) { + throw new Error(`Expected messages TO INCLUDE "${order[k]}" at budget of ${tokens}. Got:\n\n${messages}\n\nLast was: ${last}`); + } + } + + last = messages; + tokens--; + i++; + } + } + + test('basic siblings', async () => { + await assertPruningOrder(<> + + a + b + c + + , ['a', 'b', 'c']); + }); + + test('chunks together', async () => { + await assertPruningOrder(<> + + + a + b + + c + + , ['a', 'c']); // 'b' should not get individually removed and cause a change + }); + + test('does not scope priorities in fragments', async () => { + await assertPruningOrder(<> + + b + <> + a + c + + d + + , ['a', 'b', 'c', 'd']); + }); + + test('scopes priorities normally', async () => { + class Wrap1 extends PromptElement { + render() { + return <> + a + b + + } + } + class Wrap2 extends PromptElement { + render() { + return <> + c + d + + } + } + await assertPruningOrder(<> + + + + + , ['a', 'b', 'c', 'd']); + }); + + test('balances priorities of equal children', async () => { + class Wrap1 extends PromptElement { + render() { + return <> + a + b + + } + } + class Wrap2 extends PromptElement { + render() { + return <> + c + d + + } + } + await assertPruningOrder(<> + + + + + , ['a', 'c', 'b', 'd']); + }); + + test('priority list', async () => { + await assertPruningOrder( + + a + b + c + + + d + e + f + + , ['a', 'b', 'c', 'f', 'e', 'd']); + }); + + test('balances priorities of equal across chat messages', async () => { + await assertPruningOrder(<> + + a + b + + + c + d + + , ['a', 'c', 'b', 'd']); + }); + + test('scopes priorities in messages', async () => { + await assertPruningOrder(<> + + a + b + + + c + d + + , ['a', 'b', 'c', 'd']); + }); + + test('uses legacy prioritization', async () => { + class Wrap1 extends PromptElement { + render() { + return <> + a + b + + } + } + class Wrap2 extends PromptElement { + render() { + return <> + c + d + + } + } + await assertPruningOrder( + + + + + + e + + , ['a', 'c', 'e', 'b', 'd']); + }); + }); + suite('truncates tokens exceeding token budget', async () => { class Prompt1 extends PromptElement { render(_: void, sizing: PromptSizing) { @@ -694,7 +901,6 @@ suite('PromptRenderer', () => { tokenizer ); const res2 = await inst2.render(undefined, undefined); - assert.equal(res2.tokenCount, 120 - BaseTokensPerCompletion); assert.deepStrictEqual(res2.messages, [ { role: 'system', @@ -720,160 +926,7 @@ suite('PromptRenderer', () => { }, { role: 'user', content: 'What is your name?' }, ]); - }); - - test('are globally prioritized across messages', async () => { - class TextChunkPrompt extends PromptElement { - render() { - return ( - <> - - - 00 01 02 03 04 05 06 07 08 09 -
- 10 11 12 13 14 15 16 17 18 19 -
-
-
- - - HI HI 00 01 02 03 04 05 06 07 08 09 -
- 10 11 12 13 14 15 16 17 18 19 -
-
- - HI MED 00 01 02 03 04 05 06 07 08 09 -
- 10 11 12 13 14 15 16 17 18 19 -
-
- - HI LOW 00 01 02 03 04 05 06 07 08 09 -
- 10 11 12 13 14 15 16 17 18 19 -
-
-
- - - LOW HI 00 01 02 03 04 05 06 07 08 09 -
- 10 11 12 13 14 15 16 17 18 19 -
-
- - LOW MED 00 01 02 03 04 05 06 07 08 09 -
- 10 11 12 13 14 15 16 17 18 19 -
-
- - LOW LOW 00 01 02 03 04 05 06 07 08 09 -
- 10 11 12 13 14 15 16 17 18 19 -
-
-
- - ); - } - } - - const smallTokenBudgetEndpoint: any = { - modelMaxPromptTokens: 150 - BaseTokensPerCompletion, - } satisfies Partial; - const inst2 = new PromptRenderer( - smallTokenBudgetEndpoint, - TextChunkPrompt, - {}, - tokenizer - ); - const res2 = await inst2.render(undefined, undefined); - assert.equal(res2.messages.length, 2); - assert.equal(res2.messages[0].role, ChatRole.System); - assert.equal(res2.messages[1].role, ChatRole.User); - assert.equal( - res2.messages[1].content, - `LOW HI 00 01 02 03 04 05 06 07 08 09 -10 11 12 13 14 15 16 17 18 19 - -LOW MED 00 01 02 03 04 05 06 07 08 09 -10 11 12 13 14 15 16 17 18 19 -` - ); - }); - - test('are prioritized within prioritized lists', async () => { - class PriorityListPrompt extends PromptElement { - render() { - const textChunksA = []; - for (let i = 0; i < 100; i++) { - textChunksA.push( - - {i.toString().padStart(3, '0')} - - ); - } - - const textChunksB = []; - for (let i = 100; i < 200; i++) { - textChunksB.push( - - {i.toString().padStart(3, '0')} - - ); - } - - return ( - <> - - Hello there, this is a system message. - - - - {...textChunksA} - - - {...textChunksB} - - - - ); - } - } - - const smallTokenBudgetEndpoint: any = { - modelMaxPromptTokens: 150 - BaseTokensPerCompletion, - } satisfies Partial; - const inst2 = new PromptRenderer( - smallTokenBudgetEndpoint, - PriorityListPrompt, - {}, - tokenizer - ); - const res2 = await inst2.render(undefined, undefined); - assert.equal(res2.messages.length, 2); - assert.equal(res2.messages[0].role, ChatRole.System); - assert.equal(res2.messages[1].role, ChatRole.User); - assert.ok( - res2.messages[1].content.includes('199'), - 'Higher-priority chunks from second user message were not included' - ); - assert.ok( - !res2.messages[1].content.includes('099'), - 'Lower-priority chunks from first user message were included' - ); - assert.ok( - !res2.messages[1].content.includes('000'), - 'Lower-priority chunks from first user message were included' - ); + assert.equal(res2.tokenCount, 120 - BaseTokensPerCompletion); }); } ); @@ -1118,6 +1171,83 @@ LOW MED 00 01 02 03 04 05 06 07 08 09 ); }); + test('does not emit empty messages', async () => { + const inst = new PromptRenderer( + fakeEndpoint, + class extends PromptElement { + render() { + return <> + + Hello! + ; + } + }, + {}, + new FakeTokenizer() + ); + const res = await inst.render(undefined, undefined); + assert.deepStrictEqual(res.messages, [ + { + role: 'user', + content: 'Hello!', + } + ]); + }); + + test('does not add a line break in an embedded message', async () => { + class Inner extends PromptElement { + render() { + return <>world; + } + } + const inst = new PromptRenderer( + fakeEndpoint, + class extends PromptElement { + render() { + return <> + Hello ! + ; + } + }, + {}, + new FakeTokenizer() + ); + const res = await inst.render(undefined, undefined); + assert.deepStrictEqual(res.messages, [ + { + role: 'user', + content: 'Hello world!', + } + ]); + }); + + test('adds line break between two nested embedded messages', async () => { + class Inner extends PromptElement { + render() { + return <>world; + } + } + const inst = new PromptRenderer( + fakeEndpoint, + class extends PromptElement { + render() { + return <> + + ; + } + }, + {}, + new FakeTokenizer() + ); + const res = await inst.render(undefined, undefined); + assert.deepStrictEqual(res.messages, [ + { + role: 'user', + content: 'world\nworld', + } + ]); + }); + test('none-grow, greedy-grow, grow elements', async () => { await flexTest(<> @@ -1274,85 +1404,86 @@ LOW MED 00 01 02 03 04 05 06 07 08 09 }); }); - suite('renderElementJSON', () => { - test('scopes priorities', async () => { - const json = await renderElementJSON( - class extends PromptElement { - render() { - return <> - hello50 - hello60 - hello70 - hello80 - hello90 + if (!process.env.IS_OUTSIDE_VSCODE) { + suite('renderElementJSON', () => { + test('scopes priorities', async () => { + const json = await renderElementJSON( + class extends PromptElement { + render() { + return <> + hello50 + hello60 + hello70 + hello80 + hello90 + ; + } + }, + {}, + { tokenBudget: 100, countTokens: t => Promise.resolve(tokenizer.tokenLength(t)) } + ); - ; - } - }, - {}, - { tokenBudget: 100, countTokens: t => Promise.resolve(tokenizer.tokenLength(t)) } - ); + const actual = await renderPrompt( + class extends PromptElement { + render() { + return + outer40 + + outer60 + outer70 + outer80 + outer90 + + } + }, + {}, { modelMaxPromptTokens: 20 }, tokenizer + ); - const actual = await renderPrompt( - class extends PromptElement { + // if priorities were not scoped, we'd see hello80 here instead of outer70 + assert.strictEqual(actual.messages[0].content, 'hello60\nhello70\nhello80\nhello90\nouter60\nouter70\nouter80\nouter90'); + }); + + test('round trips messages', async () => { + class MyElement extends PromptElement { render() { - return - outer40 - - outer60 - outer70 - outer80 - outer90 - + return <> + Hello world! + + chunk1 + + + chunk2 + ; } - }, - {}, { modelMaxPromptTokens: 20 }, tokenizer - ); - - // if priorities were not scoped, we'd see hello80 here instead of outer70 - assert.strictEqual(actual.messages[0].content, '\nhello90\nouter60\nouter70\nouter80\nouter90'); - }); - - test('round trips messages', async () => { - class MyElement extends PromptElement { - render() { - return <> - Hello world! - - chunk1 - - - chunk2 - ; - } - } - const r = await renderElementJSON( - MyElement, {}, { tokenBudget: 100, countTokens: t => Promise.resolve(tokenizer.tokenLength(t)) } - ); - - const expected = await renderPrompt(class extends PromptElement { - render() { - return - - ; } - }, {}, fakeEndpoint, tokenizer); + const r = await renderElementJSON( + MyElement, {}, { tokenBudget: 100, countTokens: t => Promise.resolve(tokenizer.tokenLength(t)) } + ); - const actual = await renderPrompt( - class extends PromptElement { + const expected = await renderPrompt(class extends PromptElement { render() { return - + ; } - }, - {}, fakeEndpoint, tokenizer - ); + }, {}, fakeEndpoint, tokenizer); + + const actual = await renderPrompt( + class extends PromptElement { + render() { + return + + ; + } + }, + {}, fakeEndpoint, tokenizer + ); - assert.deepStrictEqual(actual.messages, expected.messages); - assert.deepStrictEqual(actual.references, expected.references); + assert.deepStrictEqual(actual.messages, expected.messages); + assert.deepStrictEqual(actual.references, expected.references); + }); }); - }); + } test('correct ordering of child text chunks (#90)', async () => { class Wrapper extends PromptElement { @@ -1389,5 +1520,85 @@ LOW MED 00 01 02 03 04 05 06 07 08 09 'inafter', 'after', ].join('\n')); - }) + }); + + suite('metadata', () => { + class MyMeta extends PromptMetadata { + constructor(public cool: boolean) { + super(); + } + } + + test('is extractable and global', async () => { + const res = await new PromptRenderer( + { modelMaxPromptTokens: Number.MAX_SAFE_INTEGER } as any, + class extends PromptElement { + render() { + return + Hello world! + + ; + } + }, + {}, + tokenizer + ).render(); + + assert.deepStrictEqual(res.metadata.get(MyMeta), new MyMeta(true)); + }); + + test('local survives when chunk survives', async () => { + const res = await new PromptRenderer( + { modelMaxPromptTokens: Number.MAX_SAFE_INTEGER } as any, + class extends PromptElement { + render() { + return + Hello + world! + ; + } + }, + {}, + tokenizer + ).render(); + + assert.deepStrictEqual(res.metadata.get(MyMeta), new MyMeta(true)); + }); + + test('local is pruned when chunk is pruned', async () => { + const res = await new PromptRenderer( + { modelMaxPromptTokens: 1 } as any, + class extends PromptElement { + render() { + return + Hello + world! + ; + } + }, + {}, + tokenizer + ).render(); + + assert.deepStrictEqual(res.metadata.get(MyMeta), undefined); + }); + + test('global survives when chunk is pruned', async () => { + const res = await new PromptRenderer( + { modelMaxPromptTokens: 5 } as any, + class extends PromptElement { + render() { + return + Hello + world! + ; + } + }, + {}, + tokenizer + ).render(); + + assert.deepStrictEqual(res.metadata.get(MyMeta), new MyMeta(true)); + }); + }); }); diff --git a/src/base/tsx-globals.ts b/src/base/tsx-globals.ts index 3e8bfb9..8409cf8 100644 --- a/src/base/tsx-globals.ts +++ b/src/base/tsx-globals.ts @@ -15,6 +15,11 @@ declare global { */ "meta": { value: PromptMetadata; + /** + * If set, the metadata will only be included in the rendered result + * if the chunk it's in survives prioritization. + */ + local?: boolean; }; /** * `\n` character. diff --git a/src/base/types.ts b/src/base/types.ts index eecba7a..81fa68a 100644 --- a/src/base/types.ts +++ b/src/base/types.ts @@ -83,7 +83,7 @@ export interface PromptElementCtor

{ } export interface RuntimePromptElementProps { - children?: PromptPiece[]; + children?: PromptPieceChild[]; } export type PromptElementProps = T & BasePromptElementProps & RuntimePromptElementProps;