├── .dockerignore ├── .env.example ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── bin ├── cli └── shell ├── bun.lockb ├── package.json ├── src ├── adapters │ ├── claude │ │ ├── claudeBaseAdapter.ts │ │ ├── claudeMessageToMessage.ts │ │ └── messageToClaudeMessage.ts │ ├── claudeOpusAdapter.ts │ ├── claudeSonnetAdapter.ts │ ├── gpt4oAdapter.ts │ ├── gpt4oMiniAdapter.ts │ └── openai │ │ └── openaiAdapter.ts ├── chat.ts ├── chatState.ts ├── chatUtils.ts ├── cli.ts ├── constants.ts ├── strategies │ ├── backendStrategy │ │ ├── Dockerfile │ │ ├── backendStrategy.ts │ │ ├── projectTemplate │ │ │ ├── .gitignore │ │ │ ├── bin │ │ │ │ └── geni │ │ │ ├── bun.lockb │ │ │ ├── db │ │ │ │ └── migrations │ │ │ │ │ └── .gitKeep │ │ │ ├── nodemon.json │ │ │ ├── package.json │ │ │ ├── prisma │ │ │ │ ├── dev.db │ │ │ │ └── schema.prisma │ │ │ ├── src │ │ │ │ ├── app.ts │ │ │ │ ├── endpoints │ │ │ │ │ └── healthcheck.ts │ │ │ │ ├── prismaClient.ts │ │ │ │ ├── routes.ts │ │ │ │ └── server.ts │ │ │ └── tsconfig.json │ │ └── toolFunctions │ │ │ ├── deleteBackendFile │ │ │ ├── deleteBackendFileFunctionAction.ts │ │ │ └── deleteBackendFileFunctionDefinition.ts │ │ │ ├── editBackendFile │ │ │ ├── editBackendFileFunctionAction.ts │ │ │ └── editBackendFileFunctionDefinition.ts │ │ │ ├── executeSql │ │ │ ├── executeSqlFunctionActions.ts │ │ │ └── executeSqlFunctionDefinition.ts │ │ │ ├── migrateDatabase │ │ │ ├── migrateDatabaseFunction.ts │ │ │ └── migrateDatabaseFunctionDefinition.ts │ │ │ ├── planBackendFileChanges │ │ │ ├── planBackendFileChangesFunctionAction.ts │ │ │ └── planBackendFileChangesFunctionDefinition.ts │ │ │ └── writeBackendFile │ │ │ ├── writeBackendFileFunctionAction.ts │ │ │ └── writeBackendFileFunctionDefinition.ts │ ├── demoStrategy │ │ ├── Dockerfile │ │ ├── demoStrategy.ts │ │ └── toolFunctions │ │ │ └── getWidgets │ │ │ ├── getWidgetsToolFunctionAction.ts │ │ │ └── getWidgetsToolFunctionDefinition.ts │ └── shellStrategy │ │ ├── Dockerfile │ │ ├── shellStrategy.ts │ │ └── toolFunctions │ │ ├── runProcess │ │ ├── runProcessFunctionAction.ts │ │ └── runProcessFunctionDefinition.ts │ │ ├── shellExec │ │ ├── shellExecFunctionAction.ts │ │ └── shellExecFunctionDefinition.ts │ │ └── writeFile │ │ ├── writeFileFunctionAction.ts │ │ └── writeFileFunctionDefinition.ts └── types │ └── chat.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | project 3 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | OPENAI_API_KEY= 2 | ANTHROPIC_API_KEY= 3 | DATABASE_URL=sqlite:///usr/src/app/project/prisma/dev.db 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore 2 | 3 | # Logs 4 | 5 | logs 6 | _.log 7 | npm-debug.log_ 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | .pnpm-debug.log* 12 | 13 | # Caches 14 | 15 | .cache 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | 19 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 20 | 21 | # Runtime data 22 | 23 | pids 24 | _.pid 25 | _.seed 26 | *.pid.lock 27 | 28 | # Directory for instrumented libs generated by jscoverage/JSCover 29 | 30 | lib-cov 31 | 32 | # Coverage directory used by tools like istanbul 33 | 34 | coverage 35 | *.lcov 36 | 37 | # nyc test coverage 38 | 39 | .nyc_output 40 | 41 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 42 | 43 | .grunt 44 | 45 | # Bower dependency directory (https://bower.io/) 46 | 47 | bower_components 48 | 49 | # node-waf configuration 50 | 51 | .lock-wscript 52 | 53 | # Compiled binary addons (https://nodejs.org/api/addons.html) 54 | 55 | build/Release 56 | 57 | # Dependency directories 58 | 59 | node_modules/ 60 | jspm_packages/ 61 | 62 | # Snowpack dependency directory (https://snowpack.dev/) 63 | 64 | web_modules/ 65 | 66 | # TypeScript cache 67 | 68 | *.tsbuildinfo 69 | 70 | # Optional npm cache directory 71 | 72 | .npm 73 | 74 | # Optional eslint cache 75 | 76 | .eslintcache 77 | 78 | # Optional stylelint cache 79 | 80 | .stylelintcache 81 | 82 | # Microbundle cache 83 | 84 | .rpt2_cache/ 85 | .rts2_cache_cjs/ 86 | .rts2_cache_es/ 87 | .rts2_cache_umd/ 88 | 89 | # Optional REPL history 90 | 91 | .node_repl_history 92 | 93 | # Output of 'npm pack' 94 | 95 | *.tgz 96 | 97 | # Yarn Integrity file 98 | 99 | .yarn-integrity 100 | 101 | # dotenv environment variable files 102 | 103 | .env 104 | .env.development.local 105 | .env.test.local 106 | .env.production.local 107 | .env.local 108 | 109 | # parcel-bundler cache (https://parceljs.org/) 110 | 111 | .parcel-cache 112 | 113 | # Next.js build output 114 | 115 | .next 116 | out 117 | 118 | # Nuxt.js build / generate output 119 | 120 | .nuxt 121 | dist 122 | 123 | # Gatsby files 124 | 125 | # Comment in the public line in if your project uses Gatsby and not Next.js 126 | 127 | # https://nextjs.org/blog/next-9-1#public-directory-support 128 | 129 | # public 130 | 131 | # vuepress build output 132 | 133 | .vuepress/dist 134 | 135 | # vuepress v2.x temp and cache directory 136 | 137 | .temp 138 | 139 | # Docusaurus cache and generated files 140 | 141 | .docusaurus 142 | 143 | # Serverless directories 144 | 145 | .serverless/ 146 | 147 | # FuseBox cache 148 | 149 | .fusebox/ 150 | 151 | # DynamoDB Local files 152 | 153 | .dynamodb/ 154 | 155 | # TernJS port file 156 | 157 | .tern-port 158 | 159 | # Stores VSCode versions used for testing VSCode extensions 160 | 161 | .vscode-test 162 | 163 | # yarn v2 164 | 165 | .yarn/cache 166 | .yarn/unplugged 167 | .yarn/build-state.yml 168 | .yarn/install-state.gz 169 | .pnp.* 170 | 171 | # IntelliJ based IDEs 172 | .idea 173 | 174 | # Finder (MacOS) folder config 175 | .DS_Store 176 | 177 | project 178 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM oven/bun:latest 2 | 3 | WORKDIR /usr/src/app 4 | 5 | RUN apt-get update && apt-get install -y \ 6 | sqlite3 \ 7 | tree \ 8 | curl \ 9 | && apt-get clean && \ 10 | rm -rf /var/lib/apt/lists/* 11 | 12 | COPY package.json bun.lockb ./ 13 | RUN bun install --ci 14 | 15 | COPY . . 16 | 17 | RUN curl -fsSL -o /usr/local/bin/geni https://github.com/emilpriver/geni/releases/latest/download/geni-linux-amd64 && \ 18 | chmod +x /usr/local/bin/geni 19 | 20 | EXPOSE 8080 21 | 22 | CMD ["bun", "src/cli.ts"] 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2024 OpenAI 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![enginelabs](https://github.com/user-attachments/assets/ed537409-ab60-4473-9a5b-a8511f3b6d2b)](https://enginelabs.ai) 2 | 3 | [![](https://img.shields.io/discord/1113845829741056101?logo=discord&style=flat)](https://discord.gg/QnytC3Y7Wx) 4 | [![](https://img.shields.io/twitter/follow/enginelabsai)](https://x.com/enginelabsai) 5 | 6 | Engine is an open source software engineer. 7 | 8 | It is model agnostic and extensible, based on 'strategies' and 'adapters'. 9 | 10 | Chat strategies offer a means to dynamically alter context, system prompts, and available tools on every run to optimise for a particular engineering task or environment. 11 | 12 | This project includes 3 example strategies: 13 | 14 | 1. `demoStrategy` - a simple illustrative example which serves as a starting point for creating new strategies 15 | 2. `backendStrategy` - a slightly more comprehensive example where the LLM works on a local Fastify app (running on http://localhost:8080) to create database migrations and API endpoints 16 | 3. `shellStrategy` - a LLM powered shell that can write files and run processes 17 | 18 | Adapters make any foundational LLM (GPT, Claude) hot swappable. 19 | 20 | ## Getting started 21 | 22 | 1. Ensure Docker is installed and running 23 | 2. Copy `.env.example` to `.env` and add at least one of `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` 24 | 3. Run `bin/cli` 25 | 4. Select a LLM model for which you have provided an API key 26 | 5. Type `help` to see what you can do 27 | 28 | ## Contributing 29 | 30 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. 31 | 32 | ## License 33 | 34 | [Apache 2.0](LICENSE) 35 | -------------------------------------------------------------------------------- /bin/cli: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | IMAGE_NAME="engine-core" 4 | CONTAINER_NAME="engine-core" 5 | 6 | cd "$(dirname "$0")" 7 | cd ../ 8 | 9 | show_menu() { 10 | echo "Please select a chat strategy:" 11 | select option in "Backend - Build a backend API with SQLite and Fastify" "Shell - Build anything with a LLM powered shell" "Demo - Example strategy with a single tool function" "Quit"; do 12 | case $REPLY in 13 | 1) 14 | chat_strategy="backendStrategy" 15 | break 16 | ;; 17 | 2) 18 | chat_strategy="shellStrategy" 19 | break 20 | ;; 21 | 3) 22 | chat_strategy="demoStrategy" 23 | break 24 | ;; 25 | 4) 26 | echo "Exiting..." 27 | exit 0 28 | ;; 29 | *) 30 | echo "Invalid option. Please try again." 31 | ;; 32 | esac 33 | done 34 | } 35 | 36 | show_menu 37 | 38 | DOCKER_FILE_PATH=./src/strategies/$chat_strategy/Dockerfile 39 | 40 | docker rmi engine-core >/dev/null 2>&1 41 | 42 | if ! docker images | grep -q "$IMAGE_NAME"; then 43 | echo "Building $chat_strategy image..." 44 | 45 | docker build --file $DOCKER_FILE_PATH -t "$IMAGE_NAME" . >/dev/null 2>&1 46 | fi 47 | 48 | echo "Starting $CONTAINER_NAME container..." 49 | 50 | docker rm "$CONTAINER_NAME" >/dev/null 2>&1 51 | 52 | docker run --rm -it --name "$CONTAINER_NAME" -p 8080:8080 -e CHAT_STRATEGY=$chat_strategy -v ./project:/usr/src/app/project "$IMAGE_NAME" 53 | -------------------------------------------------------------------------------- /bin/shell: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | cd "$(dirname "$0")" 4 | cd ../ 5 | 6 | CONTAINER_NAME="engine-core" 7 | 8 | if [ "$(docker ps --filter "name=$CONTAINER_NAME" --filter "status=running" -q)" ]; then 9 | docker exec -it $CONTAINER_NAME bash 10 | else 11 | echo "You need to start the container first. Run 'bin/cli' in another terminal." 12 | fi 13 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Engine-Labs/engine-core/0aa69b8dd775d1a6a34de2c6ea21713d24b3b0d7/bun.lockb -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "engine-core", 3 | "version": "1.0.0", 4 | "author": "Engine Labs ", 5 | "repository": "github:Engine-Labs/engine-core", 6 | "license": "Apache-2.0", 7 | "type": "module", 8 | "scripts": { 9 | "start": "bun src/cli.ts", 10 | "check": "bunx tsc --noEmit" 11 | }, 12 | "dependencies": { 13 | "@anthropic-ai/sdk": "^0.25.0", 14 | "ajv": "^8.17.1", 15 | "blessed": "^0.1.81", 16 | "dotenv": "^16.4.5", 17 | "fetch-retry": "^6.0.0", 18 | "fs-extra": "^11.2.0", 19 | "handlebars": "^4.7.8", 20 | "inquirer": "^10.0.1", 21 | "openai": "^4.52.7", 22 | "pino": "^9.3.1", 23 | "pino-pretty": "^11.2.1", 24 | "sqlite3": "^5.1.7", 25 | "typescript": "^5.0.0" 26 | }, 27 | "devDependencies": { 28 | "@types/blessed": "^0.1.25", 29 | "@types/fs-extra": "^11.0.4", 30 | "@types/sqlite3": "^3.1.11" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/adapters/claude/claudeBaseAdapter.ts: -------------------------------------------------------------------------------- 1 | import Anthropic from "@anthropic-ai/sdk"; 2 | import { RequestOptions } from "@anthropic-ai/sdk/core.mjs"; 3 | import { MessageStreamParams } from "@anthropic-ai/sdk/resources/messages.mjs"; 4 | import { appendFileSync } from "fs"; 5 | import type { Readable } from "stream"; 6 | 7 | import { isChatCancelled } from "../../chatState"; 8 | import { callToolFunction } from "../../chatUtils"; 9 | import { ANTHROPIC_API_KEY, CHAT_HISTORY_FILE, logger } from "../../constants"; 10 | import type { 11 | ChatAdapter, 12 | ChatAdapterChatParams, 13 | ChatResponse, 14 | ChatStreamData, 15 | HistoryMessage, 16 | Message, 17 | ToolFunction, 18 | } from "../../types/chat"; 19 | import { convertClaudeMessageToMessage } from "./claudeMessageToMessage"; 20 | import { convertMessagesToClaudeMessages } from "./messageToClaudeMessage"; 21 | 22 | export class ClaudeBaseAdapter implements ChatAdapter { 23 | private _llmModel: string; 24 | private _maxOutputTokens: number; 25 | private _requestOptions: RequestOptions; 26 | 27 | runMessages: Message[] = []; 28 | 29 | currentMessageId: string = ""; 30 | currentToolCallId: string = ""; 31 | currentToolCallFunctionName: string = ""; 32 | 33 | constructor( 34 | llmModel: string, 35 | maxOutputTokens: number, 36 | requestOptions?: RequestOptions 37 | ) { 38 | this._llmModel = llmModel; 39 | this._maxOutputTokens = maxOutputTokens; 40 | this._requestOptions = requestOptions || {}; 41 | } 42 | 43 | get llmModel(): string { 44 | return this._llmModel; 45 | } 46 | 47 | get maxOutputTokens(): number { 48 | return this._maxOutputTokens; 49 | } 50 | 51 | get requestOptions(): RequestOptions { 52 | return this._requestOptions; 53 | } 54 | 55 | isToolCall(message: Message): boolean { 56 | return message.tool_calls !== undefined && message.tool_calls.length > 0; 57 | } 58 | 59 | async toolCallResponseMessages( 60 | message: Message, 61 | tools: ToolFunction[] 62 | ): Promise { 63 | if (!message.tool_calls) { 64 | throw new Error("No tool calls found in the last message"); 65 | } 66 | 67 | const toolCallResponseMessages: Message[] = []; 68 | 69 | for (const toolCall of message.tool_calls) { 70 | const toolName = toolCall.function.name; 71 | logger.debug(`Responding to tool call: ${toolName}`); 72 | 73 | let toolFunctionParams: any; 74 | try { 75 | toolFunctionParams = JSON.parse(toolCall.function.arguments); 76 | } catch (error) { 77 | logger.error( 78 | `${error} - failed to parse tool call: ${toolCall.function.arguments}` 79 | ); 80 | toolCallResponseMessages.push({ 81 | role: "tool", 82 | content: `Failed to parse tool call as JSON: ${toolCall.function.arguments}`, 83 | tool_call_id: toolCall.id, 84 | }); 85 | continue; 86 | } 87 | 88 | const toolCallResponse = await callToolFunction( 89 | toolName, 90 | toolFunctionParams, 91 | tools 92 | ); 93 | 94 | const message = { 95 | role: "tool", 96 | content: toolCallResponse.responseText, 97 | tool_call_id: toolCall.id, 98 | is_error: toolCallResponse.isError, 99 | } as Message; 100 | 101 | toolCallResponseMessages.push(message); 102 | this.saveMessageToChatHistory(message); 103 | } 104 | 105 | logger.debug( 106 | `Tool call responses: ${JSON.stringify(toolCallResponseMessages)}` 107 | ); 108 | 109 | return toolCallResponseMessages; 110 | } 111 | 112 | handleCancellation(messages: Message[]): ChatResponse { 113 | const cancellationMessage: Message = { 114 | role: "assistant", 115 | content: "Chat cancelled", 116 | }; 117 | messages.push(cancellationMessage); 118 | this.saveMessageToChatHistory(cancellationMessage); 119 | return { messages, lastCompletion: cancellationMessage }; 120 | } 121 | 122 | async chat( 123 | { messages, tools }: ChatAdapterChatParams, 124 | stream: Readable 125 | ): Promise { 126 | if (isChatCancelled()) { 127 | return this.handleCancellation(messages); 128 | } 129 | 130 | const anthropic = new Anthropic({ 131 | apiKey: ANTHROPIC_API_KEY, 132 | }); 133 | 134 | const toolParams = await this.generateToolParams(tools); 135 | 136 | const systemPrompt = messages 137 | .filter((message) => message.role === "system") 138 | .map((message) => message.content) 139 | .join("\n"); 140 | 141 | messages = messages.filter((message) => message.role !== "system"); 142 | 143 | const claudeMessages = convertMessagesToClaudeMessages(messages); 144 | 145 | let claudeChatParams: MessageStreamParams = { 146 | max_tokens: this.maxOutputTokens, 147 | model: this.llmModel, 148 | messages: claudeMessages, 149 | stream: true, 150 | }; 151 | 152 | if (toolParams.length > 0) { 153 | claudeChatParams.tools = toolParams; 154 | } 155 | 156 | if (systemPrompt) { 157 | claudeChatParams.system = systemPrompt; 158 | } 159 | 160 | const requestOptions: RequestOptions = this.requestOptions; 161 | 162 | const claudeStream = anthropic.messages.stream( 163 | claudeChatParams, 164 | requestOptions 165 | ); 166 | const streamingToolCallNames: Record = {}; 167 | 168 | for await (const chunk of claudeStream) { 169 | if (isChatCancelled()) { 170 | claudeStream.abort(); 171 | return this.handleCancellation(messages); 172 | } 173 | const transformedChunk = this.transformChunk( 174 | chunk, 175 | streamingToolCallNames 176 | ); 177 | const transformedChunkString = JSON.stringify(transformedChunk) + "\n"; 178 | if (transformedChunk) { 179 | stream.push(transformedChunkString); 180 | } 181 | } 182 | 183 | const finalMessage = await claudeStream.finalMessage(); 184 | 185 | const assistantResponseMessage = 186 | convertClaudeMessageToMessage(finalMessage); 187 | 188 | messages.push(assistantResponseMessage); 189 | this.saveMessageToChatHistory(assistantResponseMessage); 190 | 191 | return { messages, lastCompletion: assistantResponseMessage }; 192 | } 193 | 194 | saveMessageToChatHistory(message: Message): void { 195 | this.runMessages.push(message); 196 | const messageWithTimestamp: HistoryMessage = { 197 | ...message, 198 | timestamp: new Date().toISOString(), 199 | }; 200 | 201 | appendFileSync( 202 | CHAT_HISTORY_FILE, 203 | JSON.stringify(messageWithTimestamp) + "\n" 204 | ); 205 | } 206 | 207 | private async generateToolParams(tools: ToolFunction[]) { 208 | const toolParams: Anthropic.Messages.Tool[] = []; 209 | for (const tool of tools) { 210 | toolParams.push({ 211 | name: tool.name, 212 | description: tool.description, 213 | input_schema: { 214 | name: tool.name, 215 | type: "object", 216 | description: tool.description, 217 | parameters: await tool.getParameters(), 218 | }, 219 | }); 220 | } 221 | logger.debug(`Tool params: ${JSON.stringify(toolParams)}`); 222 | return toolParams; 223 | } 224 | 225 | private transformChunk( 226 | chunk: any, 227 | streamingToolCallNames: Record 228 | ): ChatStreamData | null { 229 | const event = chunk.type; 230 | 231 | switch (event) { 232 | case "message_start": 233 | this.currentMessageId = chunk.message.id; 234 | return null; 235 | case "content_block_start": 236 | if (chunk.content_block.type === "tool_use") { 237 | this.currentToolCallId = chunk.content_block.id; 238 | this.currentToolCallFunctionName = chunk.content_block.name; 239 | } 240 | return null; 241 | case "content_block_stop": 242 | case "message_stop": 243 | delete streamingToolCallNames[chunk.id]; 244 | return null; 245 | case "message_delta": 246 | if (chunk.delta.stop_reason === "tool_use") { 247 | } 248 | delete streamingToolCallNames[chunk.id]; 249 | return null; 250 | case "ping": 251 | return null; 252 | case "content_block_delta": 253 | if (this.currentMessageId) { 254 | return this.handleContinueReason( 255 | this.currentMessageId, 256 | chunk, 257 | streamingToolCallNames, 258 | this.currentToolCallId, 259 | this.currentToolCallFunctionName 260 | ); 261 | } else { 262 | logger.warn(`No messageId for chunk: ${JSON.stringify(chunk)}`); 263 | return null; 264 | } 265 | default: 266 | logger.warn( 267 | `Unexpected event: ${event} 268 | for chunk: ${JSON.stringify(chunk)}` 269 | ); 270 | return null; 271 | } 272 | } 273 | 274 | private handleContinueReason( 275 | messageId: string, 276 | chunk: any, 277 | streamingToolCallNames: Record, 278 | toolCallId?: string, 279 | toolCallFunctionName?: string 280 | ): ChatStreamData | null { 281 | if (chunk.delta.type === "text_delta") { 282 | return this.buildChatData(messageId, chunk); 283 | } 284 | 285 | if (chunk.delta.type === "input_json_delta") { 286 | if (toolCallFunctionName && toolCallId) { 287 | return this.handleToolCall( 288 | toolCallFunctionName, 289 | toolCallId, 290 | chunk, 291 | streamingToolCallNames 292 | ); 293 | } else { 294 | logger.warn( 295 | `Tool call data found without function name or id: ${JSON.stringify( 296 | chunk 297 | )}` 298 | ); 299 | return null; 300 | } 301 | } 302 | 303 | logger.warn(`Unknown delta format: ${JSON.stringify(chunk)}`); 304 | return null; 305 | } 306 | 307 | private buildChatData(messageId: string, chunk: any): ChatStreamData { 308 | return { 309 | id: messageId, 310 | type: "chat", 311 | chat: { content: chunk.delta.text }, 312 | }; 313 | } 314 | 315 | private handleToolCall( 316 | toolCallFunctionName: string, 317 | toolCallId: string, 318 | chunk: any, 319 | streamingToolCallNames: Record 320 | ): ChatStreamData { 321 | streamingToolCallNames[toolCallId] = toolCallFunctionName; 322 | return { 323 | id: toolCallId, 324 | type: "tool", 325 | tool: { 326 | name: toolCallFunctionName, 327 | content: chunk.delta.partial_json, 328 | }, 329 | }; 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /src/adapters/claude/claudeMessageToMessage.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MessageParam as ClaudeMessage, 3 | TextBlockParam as ClaudeTextBlock, 4 | } from "@anthropic-ai/sdk/resources/messages.mjs"; 5 | import { ChatCompletionMessageToolCall as OpenAiToolCall } from "openai/resources/chat/completions"; 6 | import { Message } from "../../types/chat"; 7 | 8 | export function convertClaudeMessageToMessage( 9 | claudeMessage: ClaudeMessage 10 | ): Message { 11 | const role = claudeMessage.role; 12 | 13 | switch (role) { 14 | case "user": 15 | return createUserMessages(claudeMessage); 16 | case "assistant": 17 | return createAssistantMessages(claudeMessage); 18 | default: 19 | throw new Error(`Unknown role: ${role}`); 20 | } 21 | } 22 | 23 | function createUserMessages(claudeMessage: ClaudeMessage): Message { 24 | if (typeof claudeMessage.content === "string") { 25 | return { role: "user", content: claudeMessage.content }; 26 | } 27 | 28 | if (claudeMessage.content[0].type === "tool_result") { 29 | const firstContentBlock = claudeMessage.content[0]; 30 | const textBlock = firstContentBlock.content as ClaudeTextBlock[]; 31 | const toolResponseText = textBlock[0].text; 32 | 33 | return { 34 | role: "tool", 35 | content: toolResponseText, 36 | tool_call_id: claudeMessage.content[0].tool_use_id, 37 | is_error: firstContentBlock.is_error, 38 | }; 39 | } else { 40 | throw new Error( 41 | `Unknown claudeMessage type in createUserMessages: ${claudeMessage}` 42 | ); 43 | } 44 | } 45 | 46 | function createAssistantMessages(claudeMessage: ClaudeMessage): Message { 47 | if (typeof claudeMessage.content === "string") { 48 | return { role: "assistant", content: claudeMessage.content.trim() }; 49 | } 50 | 51 | let textContent = ""; 52 | let toolCalls: OpenAiToolCall[] = []; 53 | 54 | for (const content of claudeMessage.content) { 55 | switch (content.type) { 56 | case "text": 57 | textContent = content.text; 58 | break; 59 | case "tool_use": 60 | toolCalls.push({ 61 | id: content.id, 62 | type: "function", 63 | function: { 64 | name: content.name, 65 | arguments: JSON.stringify(content.input), 66 | }, 67 | }); 68 | break; 69 | default: 70 | throw new Error(`Unknown content type`); 71 | } 72 | } 73 | 74 | const assistantMessage: Message = { 75 | role: "assistant", 76 | content: textContent.trim(), 77 | }; 78 | 79 | if (toolCalls.length > 0) { 80 | assistantMessage.tool_calls = toolCalls; 81 | } 82 | 83 | return assistantMessage; 84 | } 85 | -------------------------------------------------------------------------------- /src/adapters/claude/messageToClaudeMessage.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MessageParam as ClaudeMessage, 3 | TextBlockParam as ClaudeTextBlock, 4 | ToolResultBlockParam as ClaudeToolResult, 5 | ToolUseBlockParam as ClaudeToolUse, 6 | } from "@anthropic-ai/sdk/resources/messages.mjs"; 7 | import { Message } from "../../types/chat"; 8 | 9 | export function convertMessagesToClaudeMessages( 10 | messages: Message[] 11 | ): ClaudeMessage[] { 12 | let prevMessage: Message | undefined = undefined; 13 | let messagesToProcess: Message[] = []; 14 | let claudeMessages: ClaudeMessage[] = []; 15 | 16 | for (const message of messages) { 17 | if (prevMessage && prevMessage.role === "tool" && message.role === "tool") { 18 | messagesToProcess.push(message); 19 | } else { 20 | if (messagesToProcess.length > 0) { 21 | claudeMessages.push(convertMessageToClaudeMessage(messagesToProcess)); 22 | messagesToProcess = []; 23 | } 24 | messagesToProcess.push(message); 25 | } 26 | prevMessage = message; 27 | } 28 | if (messagesToProcess.length > 0) { 29 | claudeMessages.push(convertMessageToClaudeMessage(messagesToProcess)); 30 | } 31 | 32 | return claudeMessages; 33 | } 34 | 35 | export function convertMessageToClaudeMessage( 36 | messages: Message[] 37 | ): ClaudeMessage { 38 | const role = messages[0].role; 39 | 40 | switch (role) { 41 | case "user": 42 | return createClaudeUserMessage(messages[0]); 43 | case "assistant": 44 | return createClaudeAssistantMessage(messages[0]); 45 | case "tool": 46 | return createClaudeToolResponseMessage(messages); 47 | default: 48 | throw new Error(`Unknown role: ${role}`); 49 | } 50 | } 51 | 52 | function createClaudeUserMessage(message: Message): ClaudeMessage { 53 | return { 54 | role: "user", 55 | content: message.content, 56 | }; 57 | } 58 | 59 | function createClaudeAssistantMessage(message: Message): ClaudeMessage { 60 | let contentBlock = []; 61 | 62 | let trimmedContent = message.content?.trim(); 63 | 64 | if (trimmedContent) { 65 | const textContent: ClaudeTextBlock = { 66 | type: "text", 67 | text: message.content, 68 | }; 69 | contentBlock.push(textContent); 70 | } 71 | 72 | let claudeToolCalls: ClaudeToolUse[] = []; 73 | if (message.tool_calls) { 74 | claudeToolCalls = message.tool_calls.map((toolCall) => { 75 | return { 76 | type: "tool_use", 77 | id: toolCall.id, 78 | name: toolCall.function.name, 79 | input: JSON.parse(toolCall.function.arguments), 80 | } as ClaudeToolUse; 81 | }); 82 | } 83 | 84 | const content = [...contentBlock, ...claudeToolCalls]; 85 | 86 | return { 87 | role: "assistant", 88 | content, 89 | }; 90 | } 91 | 92 | function createClaudeToolResponseMessage(messages: Message[]): ClaudeMessage { 93 | const toolContent: ClaudeToolResult[] = messages.map((message) => { 94 | if (!message.tool_call_id) { 95 | throw new Error("Tool response message must have a tool_call_id"); 96 | } 97 | 98 | return { 99 | type: "tool_result", 100 | tool_use_id: message.tool_call_id, 101 | content: [ 102 | { 103 | type: "text", 104 | text: message.content, 105 | }, 106 | ], 107 | is_error: message.is_error, 108 | }; 109 | }); 110 | 111 | return { 112 | role: "user", 113 | content: toolContent, 114 | }; 115 | } 116 | -------------------------------------------------------------------------------- /src/adapters/claudeOpusAdapter.ts: -------------------------------------------------------------------------------- 1 | import { ClaudeBaseAdapter } from "./claude/claudeBaseAdapter"; 2 | 3 | export class ClaudeOpusAdapter extends ClaudeBaseAdapter { 4 | constructor() { 5 | const modelName = "claude-3-opus-20240229"; 6 | const maxOutputTokens = 4096; 7 | 8 | super(modelName, maxOutputTokens); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/adapters/claudeSonnetAdapter.ts: -------------------------------------------------------------------------------- 1 | import { RequestOptions } from "@anthropic-ai/sdk/core.mjs"; 2 | import { ClaudeBaseAdapter } from "./claude/claudeBaseAdapter"; 3 | 4 | export class ClaudeSonnetAdapter extends ClaudeBaseAdapter { 5 | constructor() { 6 | const modelName = "claude-3-5-sonnet-20240620"; 7 | const maxOutputTokens = 8192; 8 | const requestOptions: RequestOptions = { 9 | headers: { 10 | "anthropic-beta": "max-tokens-3-5-sonnet-2024-07-15", 11 | }, 12 | }; 13 | 14 | super(modelName, maxOutputTokens, requestOptions); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/adapters/gpt4oAdapter.ts: -------------------------------------------------------------------------------- 1 | import { OpenAIBaseAdapter } from "./openai/openaiAdapter"; 2 | 3 | export class Gpt4oAdapter extends OpenAIBaseAdapter { 4 | constructor() { 5 | const modelName = "gpt-4o"; 6 | super(modelName); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/adapters/gpt4oMiniAdapter.ts: -------------------------------------------------------------------------------- 1 | import { OpenAIBaseAdapter } from "./openai/openaiAdapter"; 2 | 3 | export class Gpt4oMiniAdapter extends OpenAIBaseAdapter { 4 | constructor() { 5 | const modelName = "gpt-4o-mini"; 6 | super(modelName); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/adapters/openai/openaiAdapter.ts: -------------------------------------------------------------------------------- 1 | import { appendFileSync } from "fs"; 2 | import nodeFetch from "node-fetch"; 3 | import OpenAI from "openai"; 4 | import type { 5 | ChatCompletionStream, 6 | ChatCompletionStreamParams, 7 | } from "openai/lib/ChatCompletionStream"; 8 | import type { Readable } from "stream"; 9 | 10 | import { isChatCancelled } from "../../chatState"; 11 | import { callToolFunction } from "../../chatUtils"; 12 | 13 | import { CHAT_HISTORY_FILE, logger, OPENAI_API_KEY } from "../../constants"; 14 | import { 15 | ChatAdapter, 16 | ChatAdapterChatParams, 17 | ChatResponse, 18 | ChatStreamData, 19 | HistoryMessage, 20 | Message, 21 | ToolFunction, 22 | } from "../../types/chat"; 23 | 24 | export class OpenAIBaseAdapter implements ChatAdapter { 25 | private _llmModel: string; 26 | runMessages: Message[] = []; 27 | 28 | constructor(llmModel: string) { 29 | this._llmModel = llmModel; 30 | } 31 | 32 | get llmModel(): string { 33 | return this._llmModel; 34 | } 35 | 36 | isToolCall(message: Message): boolean { 37 | return message.tool_calls !== undefined && message.tool_calls.length > 0; 38 | } 39 | 40 | async toolCallResponseMessages( 41 | message: Message, 42 | tools: ToolFunction[] 43 | ): Promise { 44 | if (!message.tool_calls) { 45 | throw new Error("No tool calls found in the last message"); 46 | } 47 | 48 | const toolCallResponseMessages: Message[] = []; 49 | 50 | for (const toolCall of message.tool_calls) { 51 | const toolName = toolCall.function.name; 52 | logger.debug(`Responding to tool call: ${toolName}`); 53 | 54 | let toolFunctionParams: any; 55 | try { 56 | toolFunctionParams = JSON.parse(toolCall.function.arguments); 57 | } catch (error) { 58 | logger.error( 59 | `${error} - failed to parse tool call: ${toolCall.function.arguments}` 60 | ); 61 | toolCallResponseMessages.push({ 62 | role: "tool", 63 | content: `Failed to parse tool call as JSON: ${toolCall.function.arguments}`, 64 | tool_call_id: toolCall.id, 65 | }); 66 | continue; 67 | } 68 | 69 | const toolCallResponse = await callToolFunction( 70 | toolName, 71 | toolFunctionParams, 72 | tools 73 | ); 74 | 75 | const message = { 76 | role: "tool", 77 | content: toolCallResponse.responseText, 78 | tool_call_id: toolCall.id, 79 | is_error: toolCallResponse.isError, 80 | } as Message; 81 | 82 | toolCallResponseMessages.push(message); 83 | this.saveMessageToChatHistory(message); 84 | } 85 | 86 | logger.debug( 87 | `Tool call responses: ${JSON.stringify(toolCallResponseMessages)}` 88 | ); 89 | 90 | return toolCallResponseMessages; 91 | } 92 | 93 | handleCancellation(messages: Message[]): ChatResponse { 94 | const cancellationMessage: Message = { 95 | role: "assistant", 96 | content: "Chat cancelled", 97 | }; 98 | messages.push(cancellationMessage); 99 | this.saveMessageToChatHistory(cancellationMessage); 100 | return { messages, lastCompletion: cancellationMessage }; 101 | } 102 | 103 | async chat( 104 | { messages, tools }: ChatAdapterChatParams, 105 | stream: Readable 106 | ): Promise { 107 | if (isChatCancelled()) { 108 | return this.handleCancellation(messages); 109 | } 110 | 111 | const openai = new OpenAI({ 112 | apiKey: OPENAI_API_KEY, 113 | // HACK: This is a workaround for a bug in the Bun runtime: 114 | // https://github.com/oven-sh/bun/issues/9429 115 | // @ts-ignore 116 | fetch: nodeFetch, 117 | }); 118 | 119 | const toolParams = await this.generateToolParams(tools); 120 | 121 | let openAiStream: ChatCompletionStream; 122 | let openAiChatParams: ChatCompletionStreamParams = { 123 | model: this.llmModel, 124 | messages: messages as OpenAI.Chat.Completions.ChatCompletionMessage[], 125 | stream: true, 126 | stream_options: { 127 | include_usage: true, 128 | }, 129 | }; 130 | 131 | if (toolParams.length > 0) { 132 | openAiChatParams.tools = toolParams; 133 | openAiChatParams.parallel_tool_calls = false; 134 | } 135 | 136 | openAiStream = openai.beta.chat.completions.stream(openAiChatParams); 137 | const streamingToolCallNames: Record = {}; 138 | 139 | for await (const chunk of openAiStream) { 140 | if (isChatCancelled()) { 141 | return this.handleCancellation(messages); 142 | } 143 | 144 | const transformedChunk = this.transformChunk( 145 | chunk, 146 | streamingToolCallNames 147 | ); 148 | if (transformedChunk) { 149 | const transformedChunkString = JSON.stringify(transformedChunk) + "\n"; 150 | stream.push(transformedChunkString); 151 | } 152 | } 153 | 154 | const completion = await openAiStream.finalChatCompletion(); 155 | 156 | const newMessage = completion.choices[0].message as Message; 157 | messages.push(newMessage); 158 | this.saveMessageToChatHistory(newMessage); 159 | 160 | return { messages, lastCompletion: newMessage }; 161 | } 162 | 163 | saveMessageToChatHistory(message: Message): void { 164 | this.runMessages.push(message); 165 | const messageWithTimestamp: HistoryMessage = { 166 | ...message, 167 | timestamp: new Date().toISOString(), 168 | }; 169 | 170 | appendFileSync( 171 | CHAT_HISTORY_FILE, 172 | JSON.stringify(messageWithTimestamp) + "\n" 173 | ); 174 | } 175 | 176 | private async generateToolParams(tools: ToolFunction[]) { 177 | const toolParams: OpenAI.Chat.Completions.ChatCompletionTool[] = []; 178 | for (const tool of tools) { 179 | toolParams.push({ 180 | type: "function", 181 | function: { 182 | name: tool.name, 183 | description: tool.description, 184 | parameters: await tool.getParameters(), 185 | }, 186 | }); 187 | } 188 | logger.debug(`Tool params: ${JSON.stringify(toolParams)}`); 189 | return toolParams; 190 | } 191 | 192 | // Stream must return deltas as ChatStreamData objects 193 | private transformChunk( 194 | chunk: any, 195 | streamingToolCallNames: Record 196 | ): ChatStreamData | null { 197 | if (chunk.choices.length === 0) { 198 | return null; 199 | } 200 | 201 | const choice = chunk.choices[0]; 202 | const delta = choice.delta; 203 | 204 | switch (choice.finish_reason) { 205 | case "stop": 206 | delete streamingToolCallNames[chunk.id]; 207 | return null; 208 | case "tool_calls": 209 | // TODO: I think this is a noop 210 | return null; 211 | case null: 212 | return this.handleContinueReason(chunk, delta, streamingToolCallNames); 213 | default: 214 | logger.warn( 215 | `Unexpected finish reason: ${ 216 | choice.finish_reason 217 | } for chunk: ${JSON.stringify(chunk)}` 218 | ); 219 | return null; 220 | } 221 | } 222 | 223 | private handleContinueReason( 224 | chunk: any, 225 | delta: any, 226 | streamingToolCallNames: Record 227 | ): ChatStreamData | null { 228 | if (delta?.content) { 229 | return this.buildChatData(chunk, delta.content); 230 | } 231 | 232 | if (delta?.tool_calls && delta.tool_calls.length > 0) { 233 | return this.handleToolCall( 234 | chunk, 235 | delta.tool_calls[0], 236 | streamingToolCallNames 237 | ); 238 | } 239 | 240 | // HACK: Ignore empty content from assistant 241 | if (delta?.role === "assistant" && !delta?.content) { 242 | return null; 243 | } 244 | 245 | logger.warn(`Unknown delta format: ${JSON.stringify(delta)}`); 246 | return null; 247 | } 248 | 249 | private buildChatData(chunk: any, content: string): ChatStreamData { 250 | return { 251 | id: chunk.id, 252 | type: "chat", 253 | chat: { content: content }, 254 | }; 255 | } 256 | 257 | private handleToolCall( 258 | chunk: any, 259 | toolCall: any, 260 | streamingToolCallNames: Record 261 | ): ChatStreamData { 262 | const toolContent = toolCall.function.arguments; 263 | streamingToolCallNames[chunk.id] = toolCall.function.name; 264 | return { 265 | id: chunk.id, 266 | type: "tool", 267 | tool: { 268 | name: toolCall.function.name, 269 | content: toolContent, 270 | }, 271 | }; 272 | } 273 | } 274 | -------------------------------------------------------------------------------- /src/chat.ts: -------------------------------------------------------------------------------- 1 | import { endChatState, setChatInProgress } from "./chatState"; 2 | import { getCompleteChatHistory } from "./chatUtils"; 3 | import type { 4 | ChatAdapterChatParams, 5 | ChatParams, 6 | ChatResponse, 7 | Message, 8 | } from "./types/chat"; 9 | 10 | export async function chat({ 11 | userMessage: userMessage, 12 | chatAdapter: chatAdapter, 13 | chatStrategy: chatStrategy, 14 | stream: stream, 15 | }: ChatParams): Promise { 16 | chatAdapter.saveMessageToChatHistory(userMessage); 17 | const completeChatHistory = getCompleteChatHistory(); 18 | const chatHistory: Message[] = 19 | chatStrategy.getChatHistory(completeChatHistory); 20 | 21 | let chatParams: ChatAdapterChatParams = await chatStrategy.call( 22 | chatHistory, 23 | [] 24 | ); 25 | 26 | try { 27 | setChatInProgress(); 28 | let chatResponse: ChatResponse = await chatAdapter.chat( 29 | chatParams, 30 | stream, 31 | chatStrategy 32 | ); 33 | 34 | while ( 35 | chatResponse.lastCompletion && 36 | chatAdapter.isToolCall(chatResponse.lastCompletion) 37 | ) { 38 | const toolCallResponseMessages: Message[] = 39 | await chatAdapter.toolCallResponseMessages( 40 | chatResponse.lastCompletion, 41 | chatParams.tools 42 | ); 43 | 44 | chatParams = await chatStrategy.call( 45 | chatResponse.messages, 46 | toolCallResponseMessages 47 | ); 48 | 49 | chatResponse = await chatAdapter.chat(chatParams, stream, chatStrategy); 50 | } 51 | await chatStrategy.onRunComplete(chatAdapter.runMessages); 52 | } catch (error) { 53 | throw error; 54 | } finally { 55 | endChatState(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/chatState.ts: -------------------------------------------------------------------------------- 1 | import { ChatState } from "./types/chat"; 2 | 3 | const CHAT_STATE: ChatState = { 4 | inProgress: false, 5 | cancelled: false, 6 | lastSuccessfulToolCall: null, 7 | lastToolCall: { 8 | name: null, 9 | success: false, 10 | }, 11 | perRunToolErrorCount: 0, 12 | }; 13 | 14 | export function popLastSuccessfulToolCall() { 15 | const lastToolCall = CHAT_STATE.lastSuccessfulToolCall; 16 | if (lastToolCall) { 17 | CHAT_STATE.lastSuccessfulToolCall = null; 18 | } 19 | return lastToolCall; 20 | } 21 | 22 | export function isChatInProgress() { 23 | return CHAT_STATE.inProgress; 24 | } 25 | 26 | export function setChatInProgress() { 27 | CHAT_STATE.inProgress = true; 28 | } 29 | 30 | export function getRunToolErrorCount() { 31 | return CHAT_STATE.perRunToolErrorCount; 32 | } 33 | 34 | export function isChatCancelled() { 35 | return CHAT_STATE.cancelled; 36 | } 37 | 38 | export function setChatCancelled() { 39 | CHAT_STATE.cancelled = true; 40 | } 41 | 42 | export function setLastToolCall(name: string, success: boolean) { 43 | CHAT_STATE.lastToolCall = { 44 | name: name, 45 | success: success, 46 | }; 47 | if (!success) { 48 | CHAT_STATE.perRunToolErrorCount += 1; 49 | } else { 50 | CHAT_STATE.lastSuccessfulToolCall = name; 51 | CHAT_STATE.perRunToolErrorCount = 0; 52 | } 53 | } 54 | 55 | export function endChatState() { 56 | CHAT_STATE.inProgress = false; 57 | CHAT_STATE.cancelled = false; 58 | CHAT_STATE.perRunToolErrorCount = 0; 59 | CHAT_STATE.lastToolCall = { 60 | name: null, 61 | success: false, 62 | }; 63 | } 64 | 65 | export function getLastToolCall() { 66 | return CHAT_STATE.lastToolCall; 67 | } 68 | -------------------------------------------------------------------------------- /src/chatUtils.ts: -------------------------------------------------------------------------------- 1 | import Ajv from "ajv"; 2 | import { existsSync, readFileSync } from "fs"; 3 | import { readdirSync } from "fs-extra"; 4 | import { setLastToolCall } from "./chatState"; 5 | import { CHAT_HISTORY_FILE, logger } from "./constants"; 6 | import type { 7 | HistoryMessage, 8 | ToolFunction, 9 | ToolFunctionResponse, 10 | } from "./types/chat"; 11 | 12 | const ajv = new Ajv(); 13 | 14 | export async function callToolFunction( 15 | toolFunctionName: string, 16 | toolFunctionParams: any, 17 | tools: ToolFunction[] 18 | ): Promise { 19 | const tool = tools.find((tool) => tool.name === toolFunctionName); 20 | 21 | if (!tool) { 22 | return { 23 | responseText: `You do not currently have access to the tool: ${toolFunctionName}. Your currently available tools are: ${tools 24 | .map((tool) => tool.name) 25 | .join(", ")}`, 26 | isError: true, 27 | }; 28 | } 29 | 30 | const validate = ajv.compile(await tool.getParameters()); 31 | const valid = validate(toolFunctionParams); 32 | if (!valid) { 33 | return { 34 | responseText: `Invalid parameters provided for tool ${toolFunctionName}. Check the parameters and try again. The errors are: ${JSON.stringify( 35 | validate.errors 36 | )}`, 37 | isError: true, 38 | }; 39 | } 40 | 41 | logger.info( 42 | `Calling tool function ${toolFunctionName} with params: ${JSON.stringify( 43 | toolFunctionParams 44 | )}` 45 | ); 46 | 47 | let responseText: string; 48 | try { 49 | responseText = await tool.run(toolFunctionParams); 50 | setLastToolCall(toolFunctionName, true); 51 | } catch (error) { 52 | setLastToolCall(toolFunctionName, false); 53 | if (error instanceof Error) { 54 | return { responseText: error.message, isError: true }; 55 | } else { 56 | return { responseText: JSON.stringify(error), isError: true }; 57 | } 58 | } 59 | logger.info(`Tool function ${toolFunctionName} response: ${responseText}`); 60 | return { responseText, isError: false }; 61 | } 62 | 63 | export function getCompleteChatHistory(): HistoryMessage[] { 64 | if (!existsSync(CHAT_HISTORY_FILE)) return []; 65 | 66 | try { 67 | const fileContent = readFileSync(CHAT_HISTORY_FILE, "utf-8"); 68 | return fileContent 69 | .split("\n") 70 | .filter((line) => line.trim()) 71 | .map((line) => JSON.parse(line)) 72 | .flat(); 73 | } catch (error) { 74 | console.error("Error reading or parsing file:", error); 75 | throw error; 76 | } 77 | } 78 | 79 | export function augmentErrorMessage( 80 | errorMessage: string, 81 | code: string, 82 | filepath: string 83 | ) { 84 | const filename = filepath.split("/").slice(-1)[0]; 85 | 86 | // matches strings like below, capturing line number, column number, and error message 87 | // "../../../tmp/enginebkKS7N/src/endpoints/example.ts(33,37): error TS2339: ..." 88 | const re = new RegExp(`${escapeRegExp(filename)}\\((\\d+),\\d+\\):(.*)`, "g"); 89 | let matches = re.exec(errorMessage); 90 | if (!matches) { 91 | return errorMessage; 92 | } 93 | 94 | const augmentedErrors: string[] = []; 95 | while (matches) { 96 | const lineNumber = parseInt(matches[1]); 97 | const tscError = matches[2].trim(); 98 | const lines = code.split("\n"); 99 | 100 | // take the 2 lines before and after the error line 101 | const startLine = Math.max(lineNumber - 3, 0); 102 | const endLine = lineNumber + 2; 103 | const relevantLines = lines.slice(startLine, endLine); 104 | 105 | const numberedLines = relevantLines 106 | .map((line, index) => `${startLine + index + 1}|${line}`) 107 | .join("\n"); 108 | 109 | augmentedErrors.push( 110 | `Error in ${filename} at line ${lineNumber}: ${tscError}\n${numberedLines}` 111 | ); 112 | 113 | matches = re.exec(errorMessage); 114 | } 115 | 116 | return augmentedErrors.join("\n\n"); 117 | } 118 | // taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions 119 | function escapeRegExp(toEscape: string) { 120 | return toEscape.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string 121 | } 122 | 123 | export function isDirectoryEmptySync(directoryPath: string) { 124 | try { 125 | const files = readdirSync(directoryPath); 126 | return files.length === 0; 127 | } catch (err) { 128 | console.error("Error reading directory:", err); 129 | return false; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import { select } from "@inquirer/prompts"; 2 | import "dotenv/config"; 3 | import inquirer from "inquirer"; 4 | import { Readable } from "stream"; 5 | import { chat } from "./chat"; 6 | import { 7 | ANTHROPIC_API_KEY, 8 | chatAdapters, 9 | chatStrategies, 10 | OPENAI_API_KEY, 11 | } from "./constants"; 12 | import type { ChatParams, Message } from "./types/chat"; 13 | 14 | async function cliChat( 15 | chatStrategyKey: string, 16 | chatAdapterKey: string, 17 | userMessage: Message, 18 | stream: Readable 19 | ) { 20 | const ChatStrategy = chatStrategies[chatStrategyKey]; 21 | const ChatAdapter = chatAdapters[chatAdapterKey]; 22 | 23 | const params: ChatParams = { 24 | userMessage: userMessage, 25 | stream: stream, 26 | chatAdapter: new ChatAdapter(), 27 | chatStrategy: new ChatStrategy(), 28 | }; 29 | 30 | return await chat(params); 31 | } 32 | 33 | async function chatLoop(chatStrategyKey: string, chatAdapterKey: string) { 34 | process.stdout.write("\n\n"); 35 | const stream = new Readable({ 36 | read(_size) {}, 37 | }); 38 | 39 | let message; 40 | while (!message) { 41 | const response = await inquirer.prompt([ 42 | { 43 | type: "input", 44 | name: "message", 45 | message: "Chat:", 46 | }, 47 | ]); 48 | 49 | if (response.message === "exit" || response.message === "quit") { 50 | process.stdout.write("Exiting...\n"); 51 | process.exit(0); 52 | } 53 | 54 | message = response.message; 55 | } 56 | 57 | const userMessage = { 58 | role: "user", 59 | content: message, 60 | }; 61 | 62 | let prevStreamData = { type: "" }; 63 | stream.on("data", (chunk) => { 64 | const streamData = JSON.parse(chunk.toString()); 65 | 66 | if ( 67 | prevStreamData && 68 | prevStreamData.type === "chat" && 69 | streamData.type === "tool" 70 | ) { 71 | process.stdout.write("\n\n"); 72 | } 73 | 74 | if (streamData.type === "chat") { 75 | process.stdout.write(streamData.chat.content); 76 | } 77 | prevStreamData = streamData; 78 | }); 79 | 80 | await cliChat(chatStrategyKey, chatAdapterKey, userMessage, stream); 81 | } 82 | 83 | async function main() { 84 | const chatAdapterKey = await select({ 85 | message: "Select a chat adapter", 86 | choices: [ 87 | { 88 | name: "Claude Sonnet", 89 | value: "claudeSonnet", 90 | description: "Claude Sonnet chat adapter", 91 | }, 92 | { 93 | name: "Claude Opus", 94 | value: "claudeOpus", 95 | description: "Claude Opus chat adapter", 96 | }, 97 | { 98 | name: "GPT-4o", 99 | value: "gpt4o", 100 | description: "GPT-4o chat adapter", 101 | }, 102 | { 103 | name: "GPT-4o-mini", 104 | value: "gpt4oMini", 105 | description: "GPT-4o-mini chat adapter", 106 | }, 107 | ], 108 | }); 109 | 110 | if ( 111 | (chatAdapterKey === "gpt4o" || chatAdapterKey === "gpt4oMini") && 112 | !OPENAI_API_KEY 113 | ) { 114 | process.stdout.write( 115 | "\nERROR: Please set OPENAI_API_KEY in your .env file to continue\n" 116 | ); 117 | process.exit(1); 118 | } 119 | 120 | if ( 121 | (chatAdapterKey === "claudeSonnet" || chatAdapterKey === "claudeOpus") && 122 | !ANTHROPIC_API_KEY 123 | ) { 124 | process.stdout.write( 125 | "\nERROR: Please set ANTHROPIC_API_KEY in your .env file to continue\n" 126 | ); 127 | process.exit(1); 128 | } 129 | 130 | const chatStrategyKey = process.env.CHAT_STRATEGY || "backendStrategy"; 131 | 132 | const ChatStrategy = new chatStrategies[chatStrategyKey](); 133 | await ChatStrategy.init(); 134 | 135 | process.stdin.on("data", (key) => { 136 | const keyString = key.toString(); 137 | if ( 138 | keyString === "\u0003" || // Ctrl + C 139 | keyString === "\u001b" || // ESC 140 | keyString === "\u0004" // Ctrl + D 141 | ) { 142 | process.stdout.write("Exiting...\n"); 143 | process.exit(0); 144 | } 145 | }); 146 | 147 | while (true) { 148 | await chatLoop(chatStrategyKey, chatAdapterKey); 149 | } 150 | } 151 | 152 | main().catch((error) => { 153 | console.error("An error occurred:", error); 154 | process.exit(1); 155 | }); 156 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import pino from "pino"; 3 | import { ClaudeOpusAdapter } from "./adapters/claudeOpusAdapter"; 4 | import { ClaudeSonnetAdapter } from "./adapters/claudeSonnetAdapter"; 5 | import { Gpt4oAdapter } from "./adapters/gpt4oAdapter"; 6 | import { Gpt4oMiniAdapter } from "./adapters/gpt4oMiniAdapter"; 7 | import { BackendStrategy } from "./strategies/backendStrategy/backendStrategy"; 8 | import { DemoStrategy } from "./strategies/demoStrategy/demoStrategy"; 9 | import { ChatAdapterConstructor, ChatStrategyConstructor } from "./types/chat"; 10 | import { ShellStrategy } from "./strategies/shellStrategy/shellStrategy"; 11 | 12 | const customTransport = pino.transport({ 13 | targets: [ 14 | { 15 | target: "pino-pretty", 16 | options: { 17 | colorize: true, 18 | messageFormat: "\n>>> {msg}\n", 19 | ignore: "pid,hostname,time,level", 20 | }, 21 | }, 22 | ], 23 | }); 24 | 25 | export const logger = pino(customTransport); 26 | 27 | export const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; 28 | export const OPENAI_API_KEY = process.env.OPENAI_API_KEY; 29 | export const MAX_TOOL_CALL_ITERATIONS = 10; 30 | export const MAX_TOOL_ERRORS_PER_RUN = 3; 31 | export const CHAT_HISTORY_FILE = "chat_history.jsonl"; 32 | 33 | export const PROJECT_DIR = path.normalize( 34 | path.normalize(path.resolve(process.cwd(), "project")) 35 | ); 36 | 37 | export const PROJECT_API_MIGRATIONS_DIR = path.join( 38 | PROJECT_DIR, 39 | "db", 40 | "migrations" 41 | ); 42 | 43 | export const SQLITE_DB_PATH = path.join(PROJECT_DIR, "prisma", "dev.db"); 44 | 45 | export const chatStrategies: Record = { 46 | demoStrategy: DemoStrategy, 47 | backendStrategy: BackendStrategy, 48 | shellStrategy: ShellStrategy, 49 | }; 50 | 51 | export const chatAdapters: Record = { 52 | claudeOpus: ClaudeOpusAdapter, 53 | claudeSonnet: ClaudeSonnetAdapter, 54 | gpt4o: Gpt4oAdapter, 55 | gpt4oMini: Gpt4oMiniAdapter, 56 | }; 57 | -------------------------------------------------------------------------------- /src/strategies/backendStrategy/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM oven/bun:latest 2 | 3 | WORKDIR /usr/src/app 4 | 5 | RUN apt-get update && apt-get install -y \ 6 | sqlite3 \ 7 | tree \ 8 | curl \ 9 | && apt-get clean && \ 10 | rm -rf /var/lib/apt/lists/* 11 | 12 | COPY package.json bun.lockb ./ 13 | RUN bun install --ci 14 | 15 | COPY . . 16 | 17 | RUN curl -fsSL -o /usr/local/bin/geni https://github.com/emilpriver/geni/releases/latest/download/geni-linux-amd64 && \ 18 | chmod +x /usr/local/bin/geni 19 | 20 | EXPOSE 8080 21 | 22 | CMD ["bun", "src/cli.ts"] 23 | -------------------------------------------------------------------------------- /src/strategies/backendStrategy/backendStrategy.ts: -------------------------------------------------------------------------------- 1 | import { exec as originalExec, spawn } from "child_process"; 2 | import fetchRetry from "fetch-retry"; 3 | import { copySync, ensureDirSync, readdirSync } from "fs-extra"; 4 | import Handlebars from "handlebars"; 5 | import path from "path"; 6 | import { promisify } from "util"; 7 | import { isDirectoryEmptySync } from "../../chatUtils"; 8 | import { 9 | logger, 10 | MAX_TOOL_CALL_ITERATIONS, 11 | PROJECT_API_MIGRATIONS_DIR, 12 | PROJECT_DIR, 13 | } from "../../constants"; 14 | import type { 15 | BackendStrategySystemPromptVars, 16 | ChatAdapterChatParams, 17 | ChatStrategy, 18 | Message, 19 | ToolFunction, 20 | } from "../../types/chat"; 21 | import { deleteBackendFileToolFunction } from "./toolFunctions/deleteBackendFile/deleteBackendFileFunctionDefinition"; 22 | import { editBackendFileToolFunction } from "./toolFunctions/editBackendFile/editBackendFileFunctionDefinition"; 23 | import { executeSql } from "./toolFunctions/executeSql/executeSqlFunctionActions"; 24 | import { executeSqlToolFunction } from "./toolFunctions/executeSql/executeSqlFunctionDefinition"; 25 | import { dbMigrate } from "./toolFunctions/migrateDatabase/migrateDatabaseFunction"; 26 | import { migrateToolFunction } from "./toolFunctions/migrateDatabase/migrateDatabaseFunctionDefinition"; 27 | import { planBackendFileChangesToolFunction } from "./toolFunctions/planBackendFileChanges/planBackendFileChangesFunctionDefinition"; 28 | import { writeBackendFileToolFunction } from "./toolFunctions/writeBackendFile/writeBackendFileFunctionDefinition"; 29 | 30 | const fetchWithRetry = fetchRetry(fetch); 31 | const exec = promisify(originalExec); 32 | 33 | async function waitForApiToStart(): Promise { 34 | console.info("Waiting for API to start"); 35 | try { 36 | const response = await fetchWithRetry("http://0.0.0.0:8080/docs/json", { 37 | retries: 3, 38 | retryDelay: 1000, 39 | }); 40 | if (response.ok) { 41 | console.info("API started at http://0.0.0.0:8080"); 42 | } else { 43 | console.info("Unable to check if API has started at http://0.0.0.0:8080"); 44 | } 45 | } catch (error) { 46 | console.info("Unable to check if API has started at http://0.0.0.0:8080"); 47 | } 48 | } 49 | 50 | export class BackendStrategy implements ChatStrategy { 51 | toolFunctions = [ 52 | deleteBackendFileToolFunction, 53 | editBackendFileToolFunction, 54 | executeSqlToolFunction, 55 | migrateToolFunction, 56 | planBackendFileChangesToolFunction, 57 | writeBackendFileToolFunction, 58 | ]; 59 | 60 | async init() { 61 | ensureDirSync(PROJECT_DIR); 62 | 63 | if (isDirectoryEmptySync(PROJECT_DIR)) { 64 | const templateDir = path.normalize( 65 | path.resolve( 66 | process.cwd(), 67 | "src", 68 | "strategies", 69 | "backendStrategy", 70 | "projectTemplate" 71 | ) 72 | ); 73 | console.info("Copying template to project directory"); 74 | copySync(templateDir, PROJECT_DIR); 75 | } 76 | 77 | console.info("Installing bun dependencies"); 78 | await exec(`cd ${PROJECT_DIR} && bun install`); 79 | 80 | const migrationFiles = readdirSync(PROJECT_API_MIGRATIONS_DIR); 81 | if (migrationFiles.length > 1) { 82 | console.info("Running database migrations"); 83 | await dbMigrate(); 84 | } 85 | 86 | await exec(`cd ${PROJECT_DIR} && bun prisma generate`); 87 | 88 | spawn("bun", ["start"], { 89 | stdio: "pipe", 90 | detached: true, 91 | shell: false, 92 | cwd: PROJECT_DIR, 93 | }); 94 | 95 | await waitForApiToStart(); 96 | } 97 | 98 | toolFunctionMap() { 99 | return Object.fromEntries( 100 | this.toolFunctions.map((toolFunction) => [ 101 | toolFunction.name, 102 | toolFunction, 103 | ]) 104 | ); 105 | } 106 | 107 | getToolFunctionByName(toolFunctionName: string) { 108 | return ( 109 | this.toolFunctions.find( 110 | (toolFunction) => toolFunction.name === toolFunctionName 111 | ) ?? null 112 | ); 113 | } 114 | 115 | async onRunComplete(_messages: Message[]): Promise { 116 | return; 117 | } 118 | 119 | callCount: number = 0; 120 | 121 | async call( 122 | messages: Message[], 123 | toolCallResponses: Message[] 124 | ): Promise { 125 | let tools = this.getTools(); 126 | if (this.callCount >= MAX_TOOL_CALL_ITERATIONS) { 127 | logger.info(`Maximum iterations reached: ${this.callCount}`); 128 | const lastToolCallResponse = 129 | toolCallResponses[toolCallResponses.length - 1]; 130 | lastToolCallResponse.content += 131 | "\nYou've reached the maximum number of tool calls, do not call any more tools now. Do not apologise to the user, update them with progress and check if they wish to continue"; 132 | } 133 | 134 | if (!messages[0] || messages[0].role !== "system") { 135 | const renderedSystemPrompt = await this.getSystemPrompt(); 136 | const systemPromptMessage: Message = { 137 | role: "system", 138 | content: renderedSystemPrompt, 139 | }; 140 | messages.unshift(systemPromptMessage); 141 | } 142 | 143 | this.callCount += 1; 144 | 145 | return { 146 | messages: [...messages, ...toolCallResponses], 147 | tools: tools, 148 | }; 149 | } 150 | 151 | getChatHistory(completeHistory: Message[]): Message[] { 152 | return completeHistory; 153 | } 154 | 155 | private getTools(): ToolFunction[] { 156 | return this.toolFunctions; 157 | } 158 | 159 | private async getSystemPrompt() { 160 | const promptTemplate = ` 161 | # Task instructions 162 | 163 | You are an expert software engineer who implements backend web applications on behalf of your users. 164 | You have access to tools which let you take actions to enact user requests. 165 | You are only able to help users with requests that are possible to fulfil with just a database, or a database and a backend API. 166 | For example, you cannot help with creating a native app, but you may be able to translate user requirements into an implementation of a backend for the app. 167 | 168 | When you can, use your tools - don't render code you're going to write, call the tools instead. 169 | 170 | We will dynamically alter the tools you have access to, you must only use the tools you have access to at the time. 171 | 172 | Keep replies within 3 to 5 sentences. Take a deep breath and think step by step. 173 | 174 | ## Instructions for API 175 | 176 | ## Guidelines for writing backend API code 177 | 178 | You can write endpoints for a Fastify API in TypeScript. 179 | Each endpoint must be in a separate file. 180 | All code that defines API endpoints must be in the \`/src/endpoints\` directory - all endpoints in this directory will automatically be added to the server routes. 181 | Therefore, you do not need to write any code to handle server routes yourself. 182 | When you create database migrations, we will automatically generate the Prisma schema from this for the API to use - the Prisma schema is therefore a generated artifact and should not be edited directly. 183 | 184 | ## Current backend API files and folders 185 | 186 | The following files make up the API. You are able to fetch the contents of any of these files and write to them. 187 | You will be automatically provided with the file contents when designing changes to an existing backend file. 188 | 189 | {{{apiFileTree}}} 190 | 191 | ## Current OpenAPI document 192 | 193 | The following YAML document describes the current API. 194 | 195 | \`\`\`yaml 196 | {{{openApiDocument}}} 197 | \`\`\` 198 | 199 | ## Guidelines for writing SQL 200 | 201 | You are an expert at writing SQLite SQL. 202 | YOU MUST USE MIGRATIONS WHEN ALTERING THE DATABASE SCHEMA. 203 | Execute SQL directly only when you want to create, read, update or delete data. 204 | 205 | The SQLite database that you have access has special features that regular SQLite does not. 206 | It allows altering tables to add, remove and change foreign key constraints from columns without having to recreate the table. 207 | For example, you can do this with a statement that looks like: 208 | 209 | \`\`\`sql 210 | ALTER TABLE emails ALTER COLUMN user_id TO user_id INTEGER REFERENCES users(id); 211 | \`\`\` 212 | 213 | Foreign key constraints on columns with \`REFERENCES\`, e.g. \`user_id INTEGER REFERENCES users (id)\` can be removed as below. 214 | 215 | \`\`\`sql 216 | ALTER TABLE emails ALTER COLUMN user_id TO user_id INTEGER; 217 | \`\`\` 218 | 219 | Foreign key constraints on tables, e.g. \`FOREIGN KEY (user_id) REFERENCES users(id)\` cannot be removed in the above way, so avoid setting constraints on tables - try to only set constraints on columns. 220 | Instead, you will need to recreate the table without the constraint and migrate the data into it. 221 | 222 | Assume \`PRAGMA foreign_keys=ON\` - do not turn it off. 223 | 224 | ### Current database schema 225 | 226 | The current state of the database is described in the following schema. 227 | You are able to freely discuss this with the user. 228 | 229 | {{{databaseSchema}}} 230 | 231 | Always strive for accuracy, clarity, and efficiency in your responses and actions. Your instructions must be precise and comprehensive.`; 232 | 233 | try { 234 | const systemPromptTemplate = Handlebars.compile(promptTemplate); 235 | const systemPromptVars = await this.getSystemPromptVars(); 236 | let systemPromptRendered = systemPromptTemplate(systemPromptVars); 237 | 238 | systemPromptRendered = systemPromptRendered.trim().replace(/\n/g, " "); 239 | systemPromptRendered = systemPromptRendered.replace(/ {4}/g, " "); 240 | 241 | return systemPromptRendered; 242 | } catch (e) { 243 | logger.error(e); 244 | throw new Error("Backend strategy system prompt not found"); 245 | } 246 | } 247 | 248 | async getSystemPromptVars(): Promise { 249 | const [openApiDocument, databaseSchema, apiFileTree] = await Promise.all([ 250 | this.getOpenApiDocument(), 251 | this.getDatabaseSchema(), 252 | this.getApiFileTree(), 253 | ]); 254 | 255 | return { 256 | apiFileTree: apiFileTree, 257 | openApiDocument: openApiDocument, 258 | databaseSchema: databaseSchema, 259 | }; 260 | } 261 | 262 | async getOpenApiDocument(): Promise { 263 | const openApiUrl = "http://0.0.0.0:8080/api/docs/yaml"; 264 | const response = await fetchWithRetry(openApiUrl, { 265 | retries: 3, 266 | retryDelay: 1000, 267 | }); 268 | return response.text(); 269 | } 270 | 271 | async getDatabaseSchema(): Promise { 272 | try { 273 | const result = await executeSql("SELECT sql FROM sqlite_schema;"); 274 | const schema = Object.values(result) 275 | .map((row: any) => row.sql) 276 | .join("\n"); 277 | return schema.trim(); 278 | } catch (error) { 279 | logger.error(error); 280 | throw new Error("Failed to dump schema"); 281 | } 282 | } 283 | 284 | async getApiFileTree(): Promise { 285 | const projectFilesPromise = await exec( 286 | `tree ${PROJECT_DIR} -I 'node_modules|bin|bun.lockb|nodemon.json|project.db*'` 287 | ); 288 | 289 | return projectFilesPromise.stdout; 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /src/strategies/backendStrategy/projectTemplate/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .env 4 | 5 | db/project.db* 6 | -------------------------------------------------------------------------------- /src/strategies/backendStrategy/projectTemplate/bin/geni: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Engine-Labs/engine-core/0aa69b8dd775d1a6a34de2c6ea21713d24b3b0d7/src/strategies/backendStrategy/projectTemplate/bin/geni -------------------------------------------------------------------------------- /src/strategies/backendStrategy/projectTemplate/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Engine-Labs/engine-core/0aa69b8dd775d1a6a34de2c6ea21713d24b3b0d7/src/strategies/backendStrategy/projectTemplate/bun.lockb -------------------------------------------------------------------------------- /src/strategies/backendStrategy/projectTemplate/db/migrations/.gitKeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Engine-Labs/engine-core/0aa69b8dd775d1a6a34de2c6ea21713d24b3b0d7/src/strategies/backendStrategy/projectTemplate/db/migrations/.gitKeep -------------------------------------------------------------------------------- /src/strategies/backendStrategy/projectTemplate/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": ["src/server.ts", "src/**/*"], 3 | "ext": "ts,json", 4 | "exec": "bun src/server.ts --tsconfig tsconfig.json -- -p 8080", 5 | "signal": "SIGTERM" 6 | } 7 | -------------------------------------------------------------------------------- /src/strategies/backendStrategy/projectTemplate/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "api", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "migrate": "prisma migrate deploy", 6 | "build": "prisma generate && tsc -p tsconfig.json", 7 | "start": "nodemon" 8 | }, 9 | "keywords": [], 10 | "author": "", 11 | "license": "ISC", 12 | "dependencies": { 13 | "@fastify/cors": "^9.0.1", 14 | "@fastify/swagger": "^8.14.0", 15 | "@fastify/swagger-ui": "^3.0.0", 16 | "@fastify/type-provider-typebox": "^4.0.0", 17 | "@prisma/client": "5.15.0", 18 | "@sinclair/typebox": "^0.32.30", 19 | "dotenv": "^16.4.5", 20 | "fastify": "^4.27.0", 21 | "fastify-plugin": "^4.5.1", 22 | "nodemon": "^3.1.0" 23 | }, 24 | "devDependencies": { 25 | "prisma": "5.15.0", 26 | "@types/node": "^20.10.4", 27 | "openapi-typescript": "^6.7.1", 28 | "tsx": "^4.6.0", 29 | "typescript": "^5.3.2" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/strategies/backendStrategy/projectTemplate/prisma/dev.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Engine-Labs/engine-core/0aa69b8dd775d1a6a34de2c6ea21713d24b3b0d7/src/strategies/backendStrategy/projectTemplate/prisma/dev.db -------------------------------------------------------------------------------- /src/strategies/backendStrategy/projectTemplate/prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | generator client { 2 | provider = "prisma-client-js" 3 | } 4 | 5 | datasource db { 6 | provider = "sqlite" 7 | url = "file:/usr/src/app/project/prisma/dev.db" 8 | } 9 | 10 | model schema_migrations { 11 | id String @id 12 | } 13 | -------------------------------------------------------------------------------- /src/strategies/backendStrategy/projectTemplate/src/app.ts: -------------------------------------------------------------------------------- 1 | import "dotenv/config"; 2 | import { TypeBoxTypeProvider } from "@fastify/type-provider-typebox"; 3 | import fastify from "fastify"; 4 | 5 | const LOG_LEVEL = process.env["LOG_LEVEL"] || "info"; 6 | 7 | const app = fastify({ 8 | logger: { 9 | level: LOG_LEVEL, 10 | }, 11 | }).withTypeProvider(); 12 | 13 | export default app; 14 | export const logger = app.log; 15 | -------------------------------------------------------------------------------- /src/strategies/backendStrategy/projectTemplate/src/endpoints/healthcheck.ts: -------------------------------------------------------------------------------- 1 | import type { FastifyInstance } from "fastify"; 2 | 3 | export default async function healthcheck(server: FastifyInstance) { 4 | server.get("/healthcheck", async (_request, reply) => { 5 | reply.code(200).send({ 6 | status: "ok", 7 | uptime: process.uptime(), 8 | }); 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /src/strategies/backendStrategy/projectTemplate/src/prismaClient.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | export const prisma = new PrismaClient(); 4 | -------------------------------------------------------------------------------- /src/strategies/backendStrategy/projectTemplate/src/routes.ts: -------------------------------------------------------------------------------- 1 | import { FastifyPluginAsync } from "fastify"; 2 | import fs from "fs"; 3 | import path from "path"; 4 | 5 | export async function loadRoutes() { 6 | const routes: FastifyPluginAsync[] = []; 7 | 8 | // Assuming all route files are .ts files 9 | const endpointsDir = path.join(__dirname, "./endpoints"); 10 | if (!fs.existsSync(endpointsDir)) { 11 | return routes; 12 | } 13 | 14 | const routeFiles = fs 15 | .readdirSync(endpointsDir) 16 | .filter((file) => file.endsWith(".ts")); 17 | 18 | for (const file of routeFiles) { 19 | const routeModule = await import(`./endpoints/${file}`); 20 | if (routeModule.default && typeof routeModule.default === "function") { 21 | routes.push(routeModule.default); 22 | } else { 23 | console.warn( 24 | `Module ${file} does not have a default export or is not a function.` 25 | ); 26 | } 27 | } 28 | 29 | return routes; 30 | } 31 | -------------------------------------------------------------------------------- /src/strategies/backendStrategy/projectTemplate/src/server.ts: -------------------------------------------------------------------------------- 1 | import cors from "@fastify/cors"; 2 | import fastifySwagger, { FastifyDynamicSwaggerOptions } from "@fastify/swagger"; 3 | import fastifySwaggerUi from "@fastify/swagger-ui"; 4 | import app from "./app"; 5 | import { loadRoutes } from "./routes"; 6 | 7 | const port = parseInt(process.env.PORT || "8080"); 8 | const host = "::"; 9 | 10 | const apiUrl = process.env.API_URL || `http://0.0.0.0:${port}`; 11 | 12 | export const swaggerOptions: FastifyDynamicSwaggerOptions = { 13 | openapi: { 14 | info: { 15 | title: "Interactive API", 16 | version: "0.1.0", 17 | }, 18 | servers: [ 19 | { 20 | url: apiUrl, 21 | }, 22 | ], 23 | }, 24 | }; 25 | export const swaggerUiOptions = { 26 | routePrefix: "/docs", 27 | exposeRoute: true, 28 | }; 29 | 30 | export async function setupAndRun() { 31 | app.register(fastifySwagger, swaggerOptions); 32 | app.register(fastifySwaggerUi, swaggerUiOptions); 33 | 34 | await app.register(cors, { 35 | origin: ["*"], 36 | methods: ["GET", "POST", "PUT", "DELETE", "PATCH"], 37 | }); 38 | 39 | const routes = await loadRoutes(); 40 | for (const route of routes) { 41 | app.register(route); 42 | } 43 | 44 | app.listen({ host: host, port: port }, (err, address) => { 45 | if (err) { 46 | console.error(err); 47 | process.exit(1); 48 | } 49 | console.log(`Server listening at ${address}`); 50 | }); 51 | } 52 | 53 | setupAndRun(); 54 | -------------------------------------------------------------------------------- /src/strategies/backendStrategy/projectTemplate/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ESNext", "DOM"], 4 | "target": "ESNext", 5 | "module": "ESNext", 6 | "moduleDetection": "force", 7 | "allowJs": true, 8 | "esModuleInterop": true, 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "verbatimModuleSyntax": false, 12 | "noEmit": true, 13 | "strict": true, 14 | "skipLibCheck": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "noPropertyAccessFromIndexSignature": false 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/strategies/backendStrategy/toolFunctions/deleteBackendFile/deleteBackendFileFunctionAction.ts: -------------------------------------------------------------------------------- 1 | import { exec as originalExec } from "child_process"; 2 | import { existsSync, rmSync } from "fs"; 3 | import path from "path"; 4 | import { promisify } from "util"; 5 | import { logger, PROJECT_DIR } from "../../../../constants"; 6 | const exec = promisify(originalExec); 7 | 8 | export async function deleteBackendFile(filepath: string): Promise { 9 | const fullPath = path.normalize(path.resolve(PROJECT_DIR, filepath)); 10 | 11 | if (!existsSync(fullPath)) { 12 | throw new Error(`File ${filepath} does not exist`); 13 | } 14 | 15 | logger.info(`Deleting file ${fullPath}`); 16 | 17 | try { 18 | if (!fullPath.startsWith(PROJECT_DIR)) { 19 | throw new Error( 20 | `Cannot delete file ${filepath}, it is not in the project directory` 21 | ); 22 | } 23 | 24 | rmSync(fullPath); 25 | 26 | logger.info("Deleted file, restarting project api"); 27 | await restartProjectApi(); 28 | } catch (e) { 29 | logger.error(e); 30 | throw new Error(`Failed to delete file`); 31 | } 32 | } 33 | 34 | export async function restartProjectApi() { 35 | let serverFile: string; 36 | 37 | serverFile = path.join(PROJECT_DIR, "src", "server.ts"); 38 | await exec(`touch ${serverFile}`); 39 | } 40 | -------------------------------------------------------------------------------- /src/strategies/backendStrategy/toolFunctions/deleteBackendFile/deleteBackendFileFunctionDefinition.ts: -------------------------------------------------------------------------------- 1 | import { ToolFunction } from "../../../../types/chat"; 2 | import { deleteBackendFile } from "./deleteBackendFileFunctionAction"; 3 | 4 | const name = "deleteBackendFile"; 5 | 6 | const description = "Delete a backend file in the project"; 7 | 8 | const parameters = { 9 | type: "object", 10 | properties: { 11 | filepath: { 12 | type: "string", 13 | description: `The full path to the backend file you want to delete, relative to the project root`, 14 | }, 15 | }, 16 | required: ["filepath"], 17 | }; 18 | 19 | async function run(params: any): Promise { 20 | await deleteBackendFile(params.filepath); 21 | 22 | return `Successfully deleted the backend file \`${params.filepath}\``; 23 | } 24 | 25 | export const deleteBackendFileToolFunction: ToolFunction = { 26 | name: name, 27 | description: description, 28 | getParameters: async () => parameters, 29 | run: run, 30 | }; 31 | -------------------------------------------------------------------------------- /src/strategies/backendStrategy/toolFunctions/editBackendFile/editBackendFileFunctionAction.ts: -------------------------------------------------------------------------------- 1 | import { exec as originalExec, spawn } from "child_process"; 2 | import { existsSync, readFileSync, writeFileSync } from "fs"; 3 | import { cpSync, mkdtempSync, rmSync } from "fs-extra"; 4 | import os from "os"; 5 | import path from "path"; 6 | import { promisify } from "util"; 7 | import { logger, PROJECT_DIR } from "../../../../constants"; 8 | 9 | const exec = promisify(originalExec); 10 | 11 | export async function editBackendFile( 12 | filepath: string, 13 | editStartLine: number, 14 | editEndLine: number, 15 | content: string 16 | ): Promise { 17 | if (filepath.startsWith(PROJECT_DIR)) { 18 | filepath = path.relative(PROJECT_DIR, filepath); 19 | } 20 | 21 | const fullPath = path.join(PROJECT_DIR, filepath); 22 | if (!fullPath.startsWith(PROJECT_DIR)) { 23 | throw new Error( 24 | `Cannot write files with this tool outside of the API directory` 25 | ); 26 | } 27 | 28 | if (fullPath === path.join(PROJECT_DIR, "prisma", "schema.prisma")) { 29 | throw new Error( 30 | "Cannot edit the Prisma schema file manually - it is generated automatically from database migrations" 31 | ); 32 | } 33 | 34 | if (fullPath === path.join(PROJECT_DIR, "src", "prismaClient.ts")) { 35 | throw new Error( 36 | "Cannot edit prismaClient.ts - it has been written carefully so that it does not need to be changed - use it as it is." 37 | ); 38 | } 39 | 40 | if (!existsSync(fullPath)) { 41 | throw new Error(`File ${filepath} does not exist`); 42 | } 43 | 44 | const existingFileContents = readFileSync(fullPath, { encoding: "utf-8" }); 45 | 46 | const editedFileContents = replaceLines( 47 | existingFileContents, 48 | editStartLine, 49 | editEndLine, 50 | content 51 | ); 52 | 53 | try { 54 | await compileFastifyProjectApi(editedFileContents, filepath); 55 | } catch (e) { 56 | logger.error(e); 57 | const error = e as Error; 58 | throw new Error( 59 | `Failed to edit file, please correct the following errors and try again: ${error.message}` 60 | ); 61 | } 62 | 63 | writeFileSync(path.resolve(PROJECT_DIR, filepath), editedFileContents); 64 | } 65 | 66 | function replaceLines( 67 | existingFileContents: string, 68 | editStartLine: number, 69 | editEndLine: number, 70 | editContents: string 71 | ): string { 72 | if (editStartLine > editEndLine) { 73 | throw new Error("editStartLine must be less than or equal to editEndLine"); 74 | } 75 | 76 | if (editStartLine < 0 || editEndLine < 0) { 77 | throw new Error("editStartLine and editEndLine must be non-negative"); 78 | } 79 | 80 | const contentLines = editContents.split(/\r?\n/); 81 | 82 | const existingContentLines = existingFileContents.split(/\r?\n/); 83 | 84 | if (editEndLine > existingContentLines.length) { 85 | throw new Error( 86 | `editEndLine must be less than or equal to the number of lines in the file` 87 | ); 88 | } 89 | 90 | const newContentLines = existingContentLines 91 | .slice(0, editStartLine - 1) 92 | .concat(contentLines, existingContentLines.slice(editEndLine)); 93 | 94 | return newContentLines.join("\n"); 95 | } 96 | 97 | async function compileFastifyProjectApi( 98 | code: string, 99 | filepath: string 100 | ): Promise { 101 | const tempDir = copyToTempDirectory(PROJECT_DIR); 102 | 103 | writeFileSync(path.join(tempDir, filepath), code); 104 | 105 | try { 106 | await exec(`npx tsc --project ${tempDir}/tsconfig.json`); 107 | } catch (error) { 108 | rmSync(tempDir, { recursive: true, force: true }); 109 | const execError = error as { stdout: string }; 110 | const errorMessage = augmentErrorMessage(execError.stdout, code, filepath); 111 | throw new Error(errorMessage); 112 | } 113 | 114 | await testAndStopFastifyDevServer(tempDir); 115 | rmSync(tempDir, { recursive: true, force: true }); 116 | } 117 | 118 | function copyToTempDirectory(sourceDir: string) { 119 | const tempDir = mkdtempSync(`${os.tmpdir()}/engine`); 120 | cpSync(sourceDir, tempDir, { recursive: true }); 121 | return tempDir; 122 | } 123 | 124 | function augmentErrorMessage( 125 | errorMessage: string, 126 | code: string, 127 | filepath: string 128 | ) { 129 | const filename = filepath.split("/").slice(-1)[0]; 130 | 131 | // matches strings like 132 | // "../../../tmp/enginebkKS7N/src/endpoints/example.ts(33,37): error TS2339: ..." 133 | const re = new RegExp(`${escapeRegExp(filename)}\\((\\d+),\\d+\\):(.*)`, "g"); 134 | let matches = re.exec(errorMessage); 135 | if (!matches) { 136 | return errorMessage; 137 | } 138 | 139 | const augmentedErrors: string[] = []; 140 | while (matches) { 141 | const lineNumber = parseInt(matches[1]); 142 | const tscError = matches[2].trim(); 143 | const lines = code.split("\n"); 144 | 145 | // take the 2 lines before and after the error line 146 | const startLine = Math.max(lineNumber - 3, 0); 147 | const endLine = lineNumber + 2; 148 | const relevantLines = lines.slice(startLine, endLine); 149 | 150 | const numberedLines = relevantLines 151 | .map((line, index) => `${startLine + index + 1}|${line}`) 152 | .join("\n"); 153 | 154 | augmentedErrors.push( 155 | `Error in ${filename} at line ${lineNumber}: ${tscError}\n${numberedLines}` 156 | ); 157 | 158 | matches = re.exec(errorMessage); 159 | } 160 | 161 | return augmentedErrors.join("\n\n"); 162 | } 163 | // taken from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions 164 | function escapeRegExp(toEscape: string) { 165 | return toEscape.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string 166 | } 167 | 168 | async function testAndStopFastifyDevServer(path: string, timeoutMs = 6000) { 169 | const server = spawn( 170 | "bun", 171 | [`${path}/src/server.ts`, "--tsconfig", `${path}/tsconfig.json`], 172 | { 173 | env: { 174 | ...process.env, 175 | PORT: "10101", 176 | NODE_NO_WARNINGS: "1", 177 | }, 178 | } 179 | ); 180 | 181 | const consoleOutput: string[] = []; 182 | 183 | server.stderr.on("data", (data) => { 184 | consoleOutput.push(data.toString()); 185 | }); 186 | 187 | const serverPromise = new Promise((_resolve, reject) => { 188 | server.on("exit", (_code: unknown) => { 189 | const err = consoleOutput.join("\n"); 190 | reject(new Error(err)); 191 | }); 192 | }); 193 | 194 | const timeoutPromise = new Promise((resolve, reject) => { 195 | setTimeout(() => { 196 | if (!server.killed) { 197 | server.kill(); 198 | resolve(); 199 | } else { 200 | reject(new Error("tsx process already killed")); 201 | } 202 | }, timeoutMs); 203 | }); 204 | 205 | await Promise.race([serverPromise, timeoutPromise]); 206 | } 207 | -------------------------------------------------------------------------------- /src/strategies/backendStrategy/toolFunctions/editBackendFile/editBackendFileFunctionDefinition.ts: -------------------------------------------------------------------------------- 1 | import { ToolFunction } from "../../../../types/chat"; 2 | import { editBackendFile } from "./editBackendFileFunctionAction"; 3 | 4 | const name = "editBackendFile"; 5 | 6 | const description = 7 | "Edit the contents of a file in the API directory by replacing the specified lines with the provided content. Line numbers are 1-indexed, and inclusive - e.g. to replace line 10, use editStartLine=10 and editEndLine=10."; 8 | 9 | const parameters = { 10 | type: "object", 11 | properties: { 12 | filepath: { 13 | type: "string", 14 | description: `The path to the file you want to edit, relative to the project root, e.g. src/endpoints/healthcheck.ts`, 15 | }, 16 | editStartLine: { 17 | type: "number", 18 | description: "The line number to start replacing from", 19 | }, 20 | editEndLine: { 21 | type: "number", 22 | description: "The line number to end replacing at", 23 | }, 24 | content: { 25 | type: "string", 26 | description: 27 | "The content you want to write to the file that will replace the existing content", 28 | }, 29 | }, 30 | required: ["filepath", "editStartLine", "editEndLine", "content"], 31 | }; 32 | 33 | async function run(params: any): Promise { 34 | await editBackendFile( 35 | params.filepath, 36 | params.editStartLine, 37 | params.editEndLine, 38 | params.content 39 | ); 40 | 41 | return `Successfully edited lines ${params.editStartLine}-${params.editEndLine} in the API file \`${params.filepath}\``; 42 | } 43 | 44 | export const editBackendFileToolFunction: ToolFunction = { 45 | name: name, 46 | description: description, 47 | getParameters: async () => parameters, 48 | run: run, 49 | }; 50 | -------------------------------------------------------------------------------- /src/strategies/backendStrategy/toolFunctions/executeSql/executeSqlFunctionActions.ts: -------------------------------------------------------------------------------- 1 | import { Database, OPEN_READWRITE } from "sqlite3"; 2 | import { SQLITE_DB_PATH } from "../../../../constants"; 3 | 4 | function openDatabase(mode: number = OPEN_READWRITE): Promise { 5 | return new Promise((resolve, reject) => { 6 | const db = new Database(SQLITE_DB_PATH, mode, (err) => { 7 | if (err) { 8 | return reject(err); 9 | } 10 | resolve(db); 11 | }); 12 | }); 13 | } 14 | 15 | function runNonSelectQuery( 16 | db: Database, 17 | sql: string, 18 | params: any[] = [] 19 | ): Promise<{ lastID: number; changes: number }> { 20 | return new Promise((resolve, reject) => { 21 | db.run(sql, params, function (err) { 22 | if (err) { 23 | return reject(err); 24 | } 25 | resolve({ lastID: this.lastID, changes: this.changes }); 26 | }); 27 | }); 28 | } 29 | 30 | function runSelectQuery( 31 | db: Database, 32 | sql: string, 33 | params: any[] = [] 34 | ): Promise { 35 | return new Promise((resolve, reject) => { 36 | db.all(sql, params, (err, rows) => { 37 | if (err) { 38 | return reject(err); 39 | } 40 | resolve(rows); 41 | }); 42 | }); 43 | } 44 | 45 | function closeDatabase(db: Database): Promise { 46 | return new Promise((resolve, reject) => { 47 | db.close((err) => { 48 | if (err) { 49 | return reject(err); 50 | } 51 | resolve(); 52 | }); 53 | }); 54 | } 55 | 56 | export async function executeSql( 57 | sql: string, 58 | params: any[] = [] 59 | ): Promise { 60 | const db = await openDatabase(); 61 | try { 62 | const sqlTrimmed = sql.trim().toUpperCase(); 63 | if (sqlTrimmed.startsWith("SELECT") || sqlTrimmed.startsWith("PRAGMA")) { 64 | const result = await runSelectQuery(db, sql, params); 65 | return result; 66 | } else { 67 | const result = await runNonSelectQuery(db, sql, params); 68 | return result; 69 | } 70 | } finally { 71 | await closeDatabase(db); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/strategies/backendStrategy/toolFunctions/executeSql/executeSqlFunctionDefinition.ts: -------------------------------------------------------------------------------- 1 | import { ToolFunction } from "../../../../types/chat"; 2 | import { executeSql } from "./executeSqlFunctionActions"; 3 | 4 | const name = "executeSql"; 5 | 6 | const description = 7 | "Execute a SQLite SQL statement against the database. Never mutate the database schema with this tool."; 8 | 9 | const parameters = { 10 | type: "object", 11 | properties: { 12 | sql: { 13 | type: "string", 14 | description: "The SQL statement to execute", 15 | }, 16 | }, 17 | required: ["sql"], 18 | }; 19 | 20 | async function run(params: any): Promise { 21 | const rows: any[] = await executeSql(params.sql); 22 | if (rows.length === 0) { 23 | return "SQL query executed successfully. No data returned."; 24 | } else { 25 | return JSON.stringify(rows); 26 | } 27 | } 28 | 29 | export const executeSqlToolFunction: ToolFunction = { 30 | name: name, 31 | description: description, 32 | getParameters: async () => parameters, 33 | run: run, 34 | }; 35 | -------------------------------------------------------------------------------- /src/strategies/backendStrategy/toolFunctions/migrateDatabase/migrateDatabaseFunction.ts: -------------------------------------------------------------------------------- 1 | import { ExecException, exec as originalExec } from "child_process"; 2 | import { rmSync, writeFileSync } from "fs"; 3 | import { ensureDirSync } from "fs-extra"; 4 | import { promisify } from "util"; 5 | import { 6 | logger, 7 | PROJECT_API_MIGRATIONS_DIR, 8 | PROJECT_DIR, 9 | } from "../../../../constants"; 10 | 11 | const exec = promisify(originalExec); 12 | 13 | function addSemicolons(migrations: string[]): string[] { 14 | return migrations.map((migration) => { 15 | if (migration.endsWith(";")) { 16 | return migration; 17 | } else { 18 | return `${migration};`; 19 | } 20 | }); 21 | } 22 | 23 | function getUnixTimestamp() { 24 | return Math.floor(new Date().getTime() / 1000); 25 | } 26 | 27 | export async function dbMigrate(): Promise<{ stdout: string; stderr: string }> { 28 | return exec( 29 | `DATABASE_NO_DUMP_SCHEMA=true DATABASE_MIGRATIONS_FOLDER=${PROJECT_API_MIGRATIONS_DIR} /usr/local/bin/geni up` 30 | ); 31 | } 32 | 33 | export async function createMigration( 34 | upMigrations: string[], 35 | downMigrations: string[] 36 | ): Promise { 37 | const upFileContents = addSemicolons(upMigrations).join("\n"); 38 | const downFileContents = addSemicolons(downMigrations).join("\n"); 39 | 40 | ensureDirSync(PROJECT_API_MIGRATIONS_DIR); 41 | 42 | const timestamp = getUnixTimestamp(); 43 | const upMigrationFileName = `${PROJECT_API_MIGRATIONS_DIR}/${timestamp}_migration.up.sql`; 44 | const downMigrationFileName = `${PROJECT_API_MIGRATIONS_DIR}/${timestamp}_migration.down.sql`; 45 | 46 | writeFileSync(upMigrationFileName, upFileContents); 47 | writeFileSync(downMigrationFileName, downFileContents); 48 | 49 | try { 50 | await dbMigrate(); 51 | await exec( 52 | `cd ${PROJECT_DIR} && bun prisma db pull && bun prisma generate` 53 | ); 54 | } catch (error) { 55 | logger.error(error); 56 | const execError = error as ExecException; 57 | 58 | // TODO: check cmd exists instead of casting 59 | const command = execError.cmd as string; 60 | const index = execError.message.indexOf(command); 61 | const errorMessage = execError.message.slice(index + command.length + 1); 62 | logger.error(errorMessage); 63 | 64 | // delete files 65 | rmSync(upMigrationFileName); 66 | rmSync(downMigrationFileName); 67 | throw new Error(errorMessage); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/strategies/backendStrategy/toolFunctions/migrateDatabase/migrateDatabaseFunctionDefinition.ts: -------------------------------------------------------------------------------- 1 | import { ToolFunction } from "../../../../types/chat"; 2 | import { createMigration } from "./migrateDatabaseFunction"; 3 | 4 | const name = "migrateDatabase"; 5 | 6 | const description = 7 | "Use this tool to modify the SQLite database schema by executing SQLite SQL statements in a database migration."; 8 | 9 | const parameters = { 10 | type: "object", 11 | properties: { 12 | upMigrations: { 13 | type: "object", 14 | description: "SQLite SQL migration statements", 15 | properties: { 16 | sqlStatements: { 17 | type: "array", 18 | items: { 19 | type: "string", 20 | description: "A SQLite SQL statement to run against a database", 21 | }, 22 | }, 23 | }, 24 | required: ["sqlStatements"], 25 | }, 26 | downMigrations: { 27 | type: "object", 28 | description: "SQLite SQL migration statements", 29 | properties: { 30 | sqlStatements: { 31 | type: "array", 32 | items: { 33 | type: "string", 34 | description: "A SQLite SQL statement to run against a database", 35 | }, 36 | }, 37 | }, 38 | required: ["sqlStatements"], 39 | }, 40 | }, 41 | required: ["upMigrations", "downMigrations"], 42 | }; 43 | 44 | async function run(params: any): Promise { 45 | await createMigration( 46 | params.upMigrations.sqlStatements, 47 | params.downMigrations.sqlStatements 48 | ); 49 | 50 | return `Successfully ran migration`; 51 | } 52 | 53 | export const migrateToolFunction: ToolFunction = { 54 | name: name, 55 | description: description, 56 | getParameters: async () => parameters, 57 | run: run, 58 | }; 59 | -------------------------------------------------------------------------------- /src/strategies/backendStrategy/toolFunctions/planBackendFileChanges/planBackendFileChangesFunctionAction.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { existsSync, readFileSync } from "fs"; 3 | import { PROJECT_DIR } from "../../../../constants"; 4 | 5 | export async function planBackendFileChanges( 6 | filepath: string, 7 | detailedDescription: string 8 | ): Promise { 9 | if (filepath.startsWith(PROJECT_DIR)) { 10 | filepath = path.relative(PROJECT_DIR, filepath); 11 | } 12 | 13 | const fullPath = path.join(PROJECT_DIR, filepath); 14 | 15 | const currentFileContentsString = existsSync(fullPath) 16 | ? `Current file contents: 17 | \`\`\`typescript 18 | ${readFileSync(fullPath, "utf8")} 19 | \`\`\`` 20 | : ""; 21 | 22 | const prismaSchemaPath = path.join(PROJECT_DIR, "prisma", "schema.prisma"); 23 | const currentPrismaSchemaString = existsSync(prismaSchemaPath) 24 | ? `Current prisma schema: 25 | \`\`\`prisma 26 | ${readFileSync(prismaSchemaPath, "utf8")} 27 | \`\`\`` 28 | : ""; 29 | 30 | return `Your current task is to create or edit code in a backend file at ${fullPath} given the following description, existing implementation (if it exists), instructions and examples 31 | 32 | Description of changes: ${detailedDescription} 33 | 34 | Make sure you provide schemas for all requests and responses. 35 | Do not leave any placeholders in files - code you write is deployed to production immediately. You may use example values instead. 36 | 37 | When querying the database in Typescript code: 38 | 1. Import the prisma const from the generated client. e.g. \`import { prisma } from "./prismaClient";\` 39 | 2. Use it to write Prisma queries. 40 | 41 | ${currentPrismaSchemaString} 42 | 43 | ${currentFileContentsString} 44 | 45 | ### Examples 46 | 47 | \`\`\`typescript 48 | // file: src/endpoints/getPosts.ts 49 | import type { FastifyInstance } from "fastify"; 50 | import { prisma } from "../prismaClient"; 51 | 52 | export default async function getPosts(server: FastifyInstance) { 53 | server.get( 54 | "/posts", 55 | { 56 | schema: { 57 | response: { 58 | 200: { 59 | type: "array", 60 | items: { 61 | type: "object", 62 | properties: { 63 | id: { type: "number" }, 64 | title: { type: "string" }, 65 | content: { type: "string" }, 66 | user_id: { type: "string" }, 67 | }, 68 | }, 69 | }, 70 | 500: { 71 | type: "object", 72 | properties: { 73 | error: { type: "string" }, 74 | }, 75 | }, 76 | }, 77 | }, 78 | }, 79 | async (_request, reply) => { 80 | try { 81 | const result = await prisma.posts.findMany(); 82 | return reply.status(200).send(result); 83 | } catch (err) { 84 | const error = err as Error; 85 | return reply.status(500).send({ error: error.message }); 86 | } 87 | } 88 | ); 89 | } 90 | \`\`\` 91 | 92 | \`\`\`typescript 93 | // file: src/endpoints/createPost.ts 94 | import type { FastifyInstance } from "fastify"; 95 | import { prisma } from "../prismaClient"; 96 | import { getUserIdFromRequest } from "../users"; 97 | 98 | export default async function createPost(server: FastifyInstance) { 99 | server.post( 100 | "/posts", 101 | { 102 | schema: { 103 | body: { 104 | type: "object", 105 | properties: { 106 | title: { type: "string" }, 107 | content: { type: "string" }, 108 | }, 109 | required: ["title", "content"], 110 | }, 111 | response: { 112 | 201: { 113 | type: "object", 114 | properties: { 115 | id: { type: "number" }, 116 | title: { type: "string" }, 117 | content: { type: "string" }, 118 | user_id: { type: "string" }, 119 | }, 120 | }, 121 | 500: { 122 | type: "object", 123 | properties: { 124 | error: { type: "string" }, 125 | }, 126 | }, 127 | }, 128 | }, 129 | }, 130 | async (request, reply) => { 131 | const requestBody = request.body as any; 132 | const userId = await getUserIdFromRequest(request); 133 | 134 | try { 135 | const result = await prisma.posts.create({ 136 | data: { 137 | title: requestBody.title, 138 | content: requestBody.content, 139 | user_id: userId, 140 | }, 141 | }); 142 | return reply.status(201).send({ 143 | id: result.id, 144 | title: requestBody.title, 145 | content: requestBody.content, 146 | user_id: userId, 147 | }); 148 | } catch (err) { 149 | const error = err as Error; 150 | reply.status(500).send({ error: error.message }); 151 | } 152 | } 153 | ); 154 | } 155 | \`\`\` 156 | 157 | \`\`\`typescript 158 | // file: src/endpoints/getPost.ts 159 | import type { FastifyInstance } from "fastify"; 160 | import { prisma } from "../prismaClient"; 161 | 162 | export default async function getPost(server: FastifyInstance) { 163 | server.get( 164 | "/posts/:id", 165 | { 166 | schema: { 167 | params: { 168 | type: "object", 169 | properties: { 170 | id: { type: "number" }, 171 | }, 172 | }, 173 | response: { 174 | 200: { 175 | type: "object", 176 | properties: { 177 | id: { type: "number" }, 178 | title: { type: "string" }, 179 | content: { type: "string" }, 180 | user_id: { type: "string" }, 181 | }, 182 | }, 183 | 404: { 184 | type: "object", 185 | properties: { 186 | error: { type: "string" }, 187 | }, 188 | }, 189 | 500: { 190 | type: "object", 191 | properties: { 192 | error: { type: "string" }, 193 | }, 194 | }, 195 | }, 196 | }, 197 | }, 198 | async (request, reply) => { 199 | const params = request.params as any; 200 | 201 | try { 202 | const result = await prisma.posts.findFirst({ 203 | where: { 204 | id: params.id, 205 | }, 206 | }); 207 | 208 | if (!result) { 209 | return reply.status(404).send({ error: "Post not found" }); 210 | } 211 | return reply.status(200).send(result); 212 | } catch (err) { 213 | const error = err as Error; 214 | return reply.status(500).send({ error: error.message }); 215 | } 216 | } 217 | ); 218 | } 219 | \`\`\` 220 | 221 | \`\`\`typescript 222 | // file: src/endpoints/updatePost.ts 223 | import type { FastifyInstance } from "fastify"; 224 | import { prisma } from "../prismaClient"; 225 | import { getUserIdFromRequest } from "../users"; 226 | 227 | export default async function updatePost(server: FastifyInstance) { 228 | server.put( 229 | "/posts/:id", 230 | { 231 | schema: { 232 | params: { 233 | type: "object", 234 | properties: { 235 | id: { type: "number" }, 236 | }, 237 | }, 238 | body: { 239 | type: "object", 240 | properties: { 241 | title: { type: "string" }, 242 | content: { type: "string" }, 243 | }, 244 | }, 245 | response: { 246 | 200: { 247 | type: "object", 248 | properties: { 249 | id: { type: "number" }, 250 | title: { type: "string" }, 251 | content: { type: "string" }, 252 | user_id: { type: "string" }, 253 | }, 254 | }, 255 | }, 256 | }, 257 | }, 258 | async (request, reply) => { 259 | try { 260 | const params = request.params as any; 261 | const requestBody = request.body as any; 262 | const userId = await getUserIdFromRequest(request); 263 | 264 | const post = await prisma.posts.findFirst({ 265 | where: { 266 | id: params.id, 267 | user_id: userId, 268 | }, 269 | }); 270 | 271 | if (post) { 272 | await prisma.posts.update({ 273 | where: { 274 | id: params.id, 275 | }, 276 | data: { 277 | title: requestBody.title, 278 | content: requestBody.content, 279 | }, 280 | }); 281 | reply.status(200).send({ 282 | id: params.id, 283 | title: requestBody.title, 284 | content: requestBody.content, 285 | user_id: userId, 286 | }); 287 | } else { 288 | reply 289 | .status(404) 290 | .send({ error: "Post not found or no changes made" }); 291 | } 292 | } catch (error) { 293 | console.error(error); 294 | reply.status(500).send({ error: "Internal server error" }); 295 | } 296 | } 297 | ); 298 | } 299 | \`\`\` 300 | 301 | \`\`\`typescript 302 | // file: src/endpoints/deletePost.ts 303 | import type { FastifyInstance } from "fastify"; 304 | import { prisma } from "../prismaClient"; 305 | import { getUserIdFromRequest } from "../users"; 306 | 307 | export default async function deletePost(server: FastifyInstance) { 308 | server.delete( 309 | "/posts/:id", 310 | { 311 | schema: { 312 | params: { 313 | type: "object", 314 | properties: { 315 | id: { type: "number" }, 316 | }, 317 | }, 318 | response: { 319 | 204: { 320 | type: "null", 321 | }, 322 | 404: { 323 | type: "object", 324 | properties: { 325 | error: { type: "string" }, 326 | }, 327 | }, 328 | 500: { 329 | type: "object", 330 | properties: { 331 | error: { type: "string" }, 332 | }, 333 | }, 334 | }, 335 | }, 336 | }, 337 | async (request, reply) => { 338 | const params = request.params as any; 339 | 340 | try { 341 | const userId = await getUserIdFromRequest(request); 342 | 343 | const post = await prisma.posts.findFirst({ 344 | where: { 345 | id: params.id, 346 | user_id: userId, 347 | }, 348 | }); 349 | if (!post) { 350 | return reply.status(404).send({ error: "Post not found" }); 351 | } 352 | 353 | await prisma.posts.delete({ 354 | where: { 355 | id: post.id, 356 | }, 357 | }); 358 | 359 | return reply.status(204).send(); 360 | } catch (error) { 361 | return reply.status(500).send({ error: "Internal server error" }); 362 | } 363 | } 364 | ); 365 | } 366 | \`\`\``; 367 | } 368 | -------------------------------------------------------------------------------- /src/strategies/backendStrategy/toolFunctions/planBackendFileChanges/planBackendFileChangesFunctionDefinition.ts: -------------------------------------------------------------------------------- 1 | import { ToolFunction } from "../../../../types/chat"; 2 | import { planBackendFileChanges } from "./planBackendFileChangesFunctionAction"; 3 | 4 | const name = "planBackendFileChanges"; 5 | 6 | const description = 7 | "Use when you want to create a new backend file or make changes to an existing backend file."; 8 | 9 | const parameters = { 10 | type: "object", 11 | properties: { 12 | filepath: { 13 | type: "string", 14 | description: `The path to the file, relative to the project root, e.g. src/endpoints/healthcheck.ts`, 15 | }, 16 | detailedDescription: { 17 | type: "string", 18 | description: 19 | "A detailed description of the purpose and functionality of the file, or changes you would like to make to this file.", 20 | }, 21 | }, 22 | required: ["filepath", "detailedDescription"], 23 | }; 24 | 25 | async function run(params: any): Promise { 26 | return await planBackendFileChanges( 27 | params.filepath, 28 | params.detailedDescription 29 | ); 30 | } 31 | 32 | export const planBackendFileChangesToolFunction: ToolFunction = { 33 | name: name, 34 | description: description, 35 | getParameters: async () => parameters, 36 | run: run, 37 | }; 38 | -------------------------------------------------------------------------------- /src/strategies/backendStrategy/toolFunctions/writeBackendFile/writeBackendFileFunctionAction.ts: -------------------------------------------------------------------------------- 1 | import { exec as originalExec, spawn } from "child_process"; 2 | import { 3 | cpSync, 4 | existsSync, 5 | mkdtempSync, 6 | readFileSync, 7 | rmSync, 8 | writeFileSync, 9 | } from "fs"; 10 | import os from "os"; 11 | import path from "path"; 12 | import { promisify } from "util"; 13 | import { logger, PROJECT_DIR } from "../../../../constants"; 14 | import { augmentErrorMessage } from "../../../../chatUtils"; 15 | 16 | const exec = promisify(originalExec); 17 | 18 | export function getFileContents(filepath: string): string { 19 | const fullPath = path.join(PROJECT_DIR, filepath); 20 | if (existsSync(fullPath)) { 21 | return readFileSync(fullPath, { encoding: "utf-8" }); 22 | } else { 23 | throw new Error(`File ${filepath} does not exist`); 24 | } 25 | } 26 | 27 | export async function restartProjectApi() { 28 | // touch the server file to restart the project api 29 | let serverFile: string; 30 | 31 | serverFile = path.join(PROJECT_DIR, "src", "server.ts"); 32 | await exec(`touch ${serverFile}`); 33 | } 34 | 35 | export async function writeBackendFile( 36 | filepath: string, 37 | code: string 38 | ): Promise { 39 | if (filepath.startsWith(PROJECT_DIR)) { 40 | filepath = path.relative(PROJECT_DIR, filepath); 41 | } 42 | 43 | const fullPath = path.join(PROJECT_DIR, filepath); 44 | 45 | if (fullPath === path.join(PROJECT_DIR, "prisma", "schema.prisma")) { 46 | throw new Error( 47 | "Cannot overwrite the Prisma schema file manually - it is generated automatically from database migrations" 48 | ); 49 | } 50 | 51 | if (fullPath === path.join(PROJECT_DIR, "src", "prismaClient.ts")) { 52 | throw new Error( 53 | "Cannot overwrite prismaClient.ts - it has been written carefully so that it does not need to be changed - use it as it is." 54 | ); 55 | } 56 | 57 | try { 58 | await compileFastifyProjectApi(code, filepath); 59 | } catch (e) { 60 | logger.error(e); 61 | const error = e as Error; 62 | throw new Error( 63 | `Failed to write file, please correct the following errors and try again: ${error.message}` 64 | ); 65 | } 66 | 67 | writeFileSync(path.resolve(PROJECT_DIR, filepath), code); 68 | } 69 | 70 | async function testAndStopFastifyDevServer(path: string, timeoutMs = 6000) { 71 | const server = spawn( 72 | "bun", 73 | [`${path}/src/server.ts`, "--tsconfig", `${path}/tsconfig.json`], 74 | { 75 | env: { 76 | ...process.env, 77 | PORT: "10101", 78 | NODE_NO_WARNINGS: "1", 79 | }, 80 | } 81 | ); 82 | 83 | const consoleOutput: string[] = []; 84 | 85 | server.stderr.on("data", (data) => { 86 | consoleOutput.push(data.toString()); 87 | }); 88 | 89 | const serverPromise = new Promise((_resolve, reject) => { 90 | server.on("exit", (_code: unknown) => { 91 | const err = consoleOutput.join("\n"); 92 | reject(new Error(err)); 93 | }); 94 | }); 95 | 96 | const timeoutPromise = new Promise((resolve, reject) => { 97 | setTimeout(() => { 98 | if (!server.killed) { 99 | server.kill(); 100 | resolve(); 101 | } else { 102 | reject(new Error("tsx process already killed")); 103 | } 104 | }, timeoutMs); 105 | }); 106 | 107 | await Promise.race([serverPromise, timeoutPromise]); 108 | } 109 | 110 | export async function compileFastifyProjectApi( 111 | code: string, 112 | filepath: string 113 | ): Promise { 114 | const tempDir = copyToTempDirectory(PROJECT_DIR); 115 | 116 | writeFileSync(path.join(tempDir, filepath), code); 117 | 118 | try { 119 | await exec(`bun tsc --project ${tempDir}/tsconfig.json`); 120 | } catch (error) { 121 | rmSync(tempDir, { recursive: true, force: true }); 122 | const execError = error as { stdout: string }; 123 | const errorMessage = augmentErrorMessage(execError.stdout, code, filepath); 124 | throw new Error(errorMessage); 125 | } 126 | 127 | await testAndStopFastifyDevServer(tempDir); 128 | rmSync(tempDir, { recursive: true, force: true }); 129 | } 130 | 131 | function copyToTempDirectory(sourceDir: string) { 132 | const tempDir = mkdtempSync(`${os.tmpdir()}/engine`); 133 | cpSync(sourceDir, tempDir, { recursive: true }); 134 | return tempDir; 135 | } 136 | -------------------------------------------------------------------------------- /src/strategies/backendStrategy/toolFunctions/writeBackendFile/writeBackendFileFunctionDefinition.ts: -------------------------------------------------------------------------------- 1 | import { ToolFunction } from "../../../../types/chat"; 2 | import { writeBackendFile } from "./writeBackendFileFunctionAction"; 3 | 4 | const name = "writeBackendFileWithContent"; 5 | 6 | const description = 7 | "Write a file in the API directory with the provided content"; 8 | 9 | const parameters = { 10 | type: "object", 11 | properties: { 12 | filepath: { 13 | type: "string", 14 | description: `The path to the file you want to write, relative to the project root, e.g. src/endpoints/healthcheck.ts`, 15 | }, 16 | content: { 17 | type: "string", 18 | description: "The content you want to write to the file", 19 | }, 20 | }, 21 | required: ["filepath", "content"], 22 | }; 23 | 24 | async function run(params: any): Promise { 25 | if (!params.content) { 26 | throw new Error("File content is required for writeBackendFile"); 27 | } 28 | 29 | await writeBackendFile(params.filepath, params.content); 30 | 31 | return `Successfully wrote to the API file \`${params.filepath}\``; 32 | } 33 | 34 | export const writeBackendFileToolFunction: ToolFunction = { 35 | name: name, 36 | description: description, 37 | getParameters: async () => parameters, 38 | run: run, 39 | }; 40 | -------------------------------------------------------------------------------- /src/strategies/demoStrategy/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM oven/bun:latest 2 | 3 | WORKDIR /usr/src/app 4 | 5 | RUN apt-get update && apt-get install -y \ 6 | sqlite3 \ 7 | tree \ 8 | curl \ 9 | && apt-get clean && \ 10 | rm -rf /var/lib/apt/lists/* 11 | 12 | COPY package.json bun.lockb ./ 13 | RUN bun install --ci 14 | 15 | COPY . . 16 | 17 | RUN curl -fsSL -o /usr/local/bin/geni https://github.com/emilpriver/geni/releases/latest/download/geni-linux-amd64 && \ 18 | chmod +x /usr/local/bin/geni 19 | 20 | EXPOSE 8080 21 | 22 | CMD ["bun", "src/cli.ts"] 23 | -------------------------------------------------------------------------------- /src/strategies/demoStrategy/demoStrategy.ts: -------------------------------------------------------------------------------- 1 | import { logger, MAX_TOOL_CALL_ITERATIONS } from "../../constants"; 2 | import type { 3 | ChatAdapterChatParams, 4 | ChatStrategy, 5 | Message, 6 | ToolFunction, 7 | } from "../../types/chat"; 8 | import { getWidgetsToolFunction } from "./toolFunctions/getWidgets/getWidgetsToolFunctionDefinition"; 9 | 10 | export class DemoStrategy implements ChatStrategy { 11 | toolFunctions = [getWidgetsToolFunction]; 12 | 13 | async init() { 14 | return; 15 | } 16 | 17 | toolFunctionMap() { 18 | return Object.fromEntries( 19 | this.toolFunctions.map((toolFunction) => [ 20 | toolFunction.name, 21 | toolFunction, 22 | ]) 23 | ); 24 | } 25 | 26 | getToolFunctionByName(toolFunctionName: string) { 27 | return ( 28 | this.toolFunctions.find( 29 | (toolFunction) => toolFunction.name === toolFunctionName 30 | ) ?? null 31 | ); 32 | } 33 | 34 | async onRunComplete(_messages: Message[]): Promise { 35 | return; 36 | } 37 | 38 | callCount: number = 0; 39 | 40 | async call( 41 | messages: Message[], 42 | toolCallResponses: Message[] 43 | ): Promise { 44 | // this basic implementation just returns the vanilla system prompt, messages, and tools 45 | // i.e. attempts nothing dynamic, a standard continuous chat 46 | 47 | let tools = this.getTools(); 48 | if (this.callCount >= MAX_TOOL_CALL_ITERATIONS) { 49 | logger.info(`Maximum iterations reached: ${this.callCount}`); 50 | const lastToolCallResponse = 51 | toolCallResponses[toolCallResponses.length - 1]; 52 | lastToolCallResponse.content += 53 | "\nYou've reached the maximum number of tool calls, do not call any more tools now. Do not apologise to the user, update them with progress and check if they wish to continue"; 54 | } 55 | 56 | // If the first message is not the system prompt then prepend it 57 | if (!messages[0] || messages[0].role !== "system") { 58 | const systemPromptMessage: Message = { 59 | role: "system", 60 | content: this.getSystemPrompt(), 61 | }; 62 | messages.unshift(systemPromptMessage); 63 | } 64 | 65 | this.callCount += 1; 66 | 67 | return { 68 | messages: [...messages, ...toolCallResponses], 69 | tools: tools, 70 | }; 71 | } 72 | 73 | getChatHistory(completeHistory: Message[]): Message[] { 74 | return completeHistory; 75 | } 76 | 77 | private getTools(): ToolFunction[] { 78 | return this.toolFunctions; 79 | } 80 | 81 | private getSystemPrompt() { 82 | return `You are a helpful assistant`; 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /src/strategies/demoStrategy/toolFunctions/getWidgets/getWidgetsToolFunctionAction.ts: -------------------------------------------------------------------------------- 1 | export function getWidgets(colour: string): string[] { 2 | switch (colour) { 3 | case "red": 4 | return [ 5 | "Fire chilli widget", 6 | "Big stop sign widget", 7 | "Bursting heart widget", 8 | ]; 9 | case "yellow": 10 | return ["Hello sunshine widget", "Banana custard widget"]; 11 | case "green": 12 | return ["Slimey widget", "Fresh meadow widget", "Granny smith widget"]; 13 | case "purple": 14 | return ["Prince rain widget", "Hendrix haze widget"]; 15 | default: 16 | return []; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/strategies/demoStrategy/toolFunctions/getWidgets/getWidgetsToolFunctionDefinition.ts: -------------------------------------------------------------------------------- 1 | import { ToolFunction } from "../../../../types/chat"; 2 | import { getWidgets } from "./getWidgetsToolFunctionAction"; 3 | 4 | const name = "getWidgets"; 5 | 6 | // Description for the llm 7 | const description = "Return a list of widgets of a given colour"; 8 | 9 | // JSON schema parameters for the this function 10 | const parameters = { 11 | type: "object", 12 | properties: { 13 | colour: { 14 | type: "string", 15 | description: "The colour of the desired widgets", 16 | }, 17 | }, 18 | required: ["colour"], 19 | }; 20 | 21 | // The function that will be called when this tool is invoked 22 | // It accepts the parameters define here in JSON schema 23 | // It MUST return a string which clearly describes the result of the function whether it was successful or not 24 | async function run(params: any): Promise { 25 | const widgets: string[] = getWidgets(params.colour); 26 | 27 | if (widgets.length === 0) { 28 | return `No ${params.colour} widgets found`; 29 | } 30 | 31 | return `${params.colour} widgets: ${widgets.join(", ")}`; 32 | } 33 | 34 | export const getWidgetsToolFunction: ToolFunction = { 35 | name: name, 36 | description: description, 37 | getParameters: async () => parameters, 38 | run: async (params: any) => { 39 | return await run(params); 40 | }, 41 | }; 42 | -------------------------------------------------------------------------------- /src/strategies/shellStrategy/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:24.04 2 | 3 | ARG DEBIAN_FRONTEND=noninteractive 4 | 5 | RUN apt-get update && apt-get install -y \ 6 | software-properties-common \ 7 | wget \ 8 | curl \ 9 | unzip \ 10 | sqlite3 \ 11 | tree \ 12 | lsof \ 13 | procps \ 14 | pkg-config \ 15 | libssl-dev \ 16 | git \ 17 | apt-utils \ 18 | build-essential \ 19 | sudo \ 20 | && apt-get clean && \ 21 | rm -rf /var/lib/apt/lists/* 22 | 23 | RUN groupadd -r -g 1010 engine && \ 24 | useradd --no-log-init -r -u 1030 -g engine engine && \ 25 | echo 'engine ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/engine && \ 26 | echo 'Defaults:engine !requiretty' >> /etc/sudoers.d/engine && \ 27 | chmod 0440 /etc/sudoers.d/engine && \ 28 | mkdir -p /home/engine /home/engine/.ssh && chown -R engine:engine /home/engine && \ 29 | git config --system --add safe.directory '*' 30 | 31 | USER engine 32 | WORKDIR /home/engine/app 33 | 34 | RUN sudo apt-get update && sudo apt-get install -y \ 35 | python3 \ 36 | python3-pip \ 37 | python3-venv \ 38 | && sudo apt-get clean && \ 39 | sudo rm -rf /var/lib/apt/lists/* && \ 40 | sudo ln -s /usr/bin/python3 /usr/bin/python && \ 41 | curl https://sh.rustup.rs -sSf | sh -s -- -y && \ 42 | curl -fsSL https://deb.nodesource.com/setup_18.x | sudo bash - && \ 43 | sudo apt-get install -y nodejs && \ 44 | sudo npm install -g npm@latest && \ 45 | curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add - && \ 46 | echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list && \ 47 | sudo apt-get update && \ 48 | sudo apt-get install -y yarn && \ 49 | curl -fsSL https://bun.sh/install | bash && \ 50 | sudo chmod -R a+x /home/engine/.bun/bin && \ 51 | sudo mv /home/engine/.bun /usr/local/share/bun && \ 52 | sudo ln -s /usr/local/share/bun/bin/bun /usr/local/bin/bun && \ 53 | sudo chmod a+x /usr/local/bin/bun 54 | 55 | ENV PATH="/home/engine/.cargo/bin:/usr/local/share/bun/bin:${PATH}" 56 | ENV SHELL=/bin/bash 57 | ENV DEBIAN_FRONTEND=noninteractive 58 | 59 | COPY --chown=engine:engine package.json bun.lockb ./ 60 | RUN mkdir -p /home/engine/app/node_modules && sudo chown -R engine:engine /home/engine/app/node_modules 61 | 62 | RUN bun install 63 | 64 | COPY --chown=engine:engine . . 65 | 66 | EXPOSE 8080 67 | 68 | CMD ["bun", "src/cli.ts"] 69 | -------------------------------------------------------------------------------- /src/strategies/shellStrategy/shellStrategy.ts: -------------------------------------------------------------------------------- 1 | import Handlebars from "handlebars"; 2 | import { getRunToolErrorCount } from "../../chatState"; 3 | import { runProcessToolFunction } from "./toolFunctions/runProcess/runProcessFunctionDefinition"; 4 | import { shellExecToolFunction } from "./toolFunctions/shellExec/shellExecFunctionDefinition"; 5 | import { writeFileToolFunction } from "./toolFunctions/writeFile/writeFileFunctionDefinition"; 6 | import { 7 | ChatAdapterChatParams, 8 | ChatStrategy, 9 | HistoryMessage, 10 | Message, 11 | ToolFunction, 12 | } from "../../types/chat"; 13 | import { 14 | logger, 15 | MAX_TOOL_CALL_ITERATIONS, 16 | MAX_TOOL_ERRORS_PER_RUN, 17 | PROJECT_DIR, 18 | } from "../../constants"; 19 | import { ensureDirSync } from "fs-extra"; 20 | 21 | export class ShellStrategy implements ChatStrategy { 22 | async init() { 23 | ensureDirSync(PROJECT_DIR); 24 | } 25 | 26 | toolFunctions = [ 27 | shellExecToolFunction, 28 | runProcessToolFunction, 29 | writeFileToolFunction, 30 | ]; 31 | 32 | HISTORY_LIMIT = 20; 33 | 34 | toolFunctionMap() { 35 | return Object.fromEntries( 36 | this.toolFunctions.map((toolFunction) => [ 37 | toolFunction.name, 38 | toolFunction, 39 | ]) 40 | ); 41 | } 42 | 43 | getToolFunctionByName(toolFunctionName: string) { 44 | return ( 45 | this.toolFunctions.find( 46 | (toolFunction) => toolFunction.name === toolFunctionName 47 | ) ?? null 48 | ); 49 | } 50 | 51 | requiresInit(): boolean { 52 | return false; 53 | } 54 | 55 | async onRunComplete(messages: Message[]): Promise {} 56 | 57 | callCount: number = 0; 58 | 59 | async call( 60 | messages: Message[], 61 | toolCallResponses: Message[] 62 | ): Promise { 63 | let tools = this.getTools(); 64 | if (this.callCount >= MAX_TOOL_CALL_ITERATIONS) { 65 | logger.info(`Maximum iterations reached: ${this.callCount}`); 66 | const lastToolCallResponse = 67 | toolCallResponses[toolCallResponses.length - 1]; 68 | lastToolCallResponse.content += 69 | "\nYou've reached the maximum number of tool calls, do not call any more tools now. Do not apologise to the user, but update them with progress so and check if they wish to continue"; 70 | } 71 | 72 | const runToolErrorCount = getRunToolErrorCount(); 73 | if (runToolErrorCount >= MAX_TOOL_ERRORS_PER_RUN) { 74 | logger.info(`Maximum tool errors reached: ${runToolErrorCount}`); 75 | const lastToolCallResponse = 76 | toolCallResponses[toolCallResponses.length - 1]; 77 | lastToolCallResponse.content += 78 | "\nYou've reached the maximum number of tool errors for this run, do not call any more tools now. Do not apologise to the user, but update them with progress so and check if they wish to continue"; 79 | } 80 | 81 | if (messages[0] && messages[0].role === "system") { 82 | messages.shift(); 83 | } 84 | messages.unshift(await this.getSystemPrompt()); 85 | 86 | this.callCount += 1; 87 | 88 | return { 89 | messages: [...messages, ...toolCallResponses], 90 | tools: tools, 91 | }; 92 | } 93 | 94 | getChatHistory(completeHistory: HistoryMessage[]): Message[] { 95 | // Get messages up to the history limit 96 | let startIndex = Math.max(0, completeHistory.length - this.HISTORY_LIMIT); 97 | let chatHistory = completeHistory.slice(startIndex); 98 | 99 | // Include previous messages until one with the role 'user' is reached 100 | while (startIndex > 0 && chatHistory[0].role !== "user") { 101 | startIndex--; 102 | chatHistory = completeHistory.slice(startIndex); 103 | } 104 | 105 | return chatHistory.map((historyMessage) => { 106 | let content = historyMessage.content; 107 | if (historyMessage.role === "tool") { 108 | switch (historyMessage.is_error) { 109 | case true: 110 | content = ``; 111 | break; 112 | case false: 113 | content = ``; 114 | break; 115 | default: 116 | content = ``; 117 | } 118 | } 119 | 120 | const message: Message = { 121 | role: historyMessage.role, 122 | content: content, 123 | }; 124 | 125 | if (historyMessage.tool_call_id) { 126 | message.tool_call_id = historyMessage.tool_call_id; 127 | } 128 | 129 | if (historyMessage.tool_calls) { 130 | message.tool_calls = historyMessage.tool_calls; 131 | } 132 | 133 | return message; 134 | }); 135 | } 136 | 137 | private getTools(): ToolFunction[] { 138 | return this.toolFunctions; 139 | } 140 | 141 | private async getSystemPrompt() { 142 | const promptTemplate = ` 143 | You are an AI assistant acting as a skilled software engineer. Your task is to build any software project the user requires. Follow these instructions carefully: 144 | 145 | 1. Available tools and system setup: 146 | - You have Linux shell access to the project directory. 147 | - You can use any shell command, install programs, and manage packages. 148 | - The following are pre-installed: wget, curl, unzip, sqlite3, tree, lsof, procps, libssl-dev, git, build-essential, python3, python3-pip, python3-venv, rust, nodejs, yarn, bun. 149 | - Your shell is running as a user with full sudo access without requiring a password. 150 | 151 | 2. Project directory review: 152 | - First, review the context of the project directory. 153 | - Determine if you're starting a new project or continuing an existing one. 154 | - Do not overwrite existing work without confirmation. 155 | 156 | 3. Language and framework selection: 157 | - If instructed, use the specified programming language and framework. 158 | - Otherwise, choose based on the project requirements. 159 | 160 | 4. Project development: 161 | - Work step-by-step, keeping the user informed of your progress. 162 | - When writing code, don't render code you're going to write; call the tools instead to actually write, compile, and run code. 163 | - Use appropriate tool functions for file operations and process management. 164 | - When installing package, use the appropriate package manager (e.g., pip, npm, yarn, cargo). to install dependencies, 165 | don't manaually edit files like package.json, Cargo.toml, etc. 166 | 167 | 5. Using tool functions: 168 | - Use provided tool functions for writing files and starting background processes. 169 | - Do not use the shell to write files or start blocking processes directly. 170 | 171 | 6. Error handling and user interaction: 172 | - If a tool call fails, inform the user of the error and ask if they want to continue. 173 | - If commands don't work as expected, stop and check with the user before proceeding. 174 | 175 | 7. Running processes and port exposure: 176 | - Always use the runProcess tool function for blocking processes. 177 | - For webservers or processes exposing ports, use port 8080 and IP address 0.0.0.0. 178 | - The user will be able to access processes running on port 8080 at the URL {{{apiUrl}}}, be sure to let them know. 179 | - Use the shell to stop a process or check if a process is running on port 8080 before starting a new one. 180 | 181 | 8. Code compilation and testing: 182 | - Assert that your code compiles before running it. 183 | - For Python scripts, use 'python3 -m py_compile script.py' before execution. 184 | - For Rust, use 'cargo build' before running the binary. 185 | - For other languages, use the appropriate compilation command. 186 | 187 | 9. Step-by-step work with the user: 188 | - Provide clear updates on your progress. 189 | - Ask for user input or confirmation when necessary. 190 | 191 | 10. Handling failures and continuing: 192 | - If issues arise, provide a brief update and ask the user if they wish to continue. 193 | - Avoid excessive apologies; focus on problem-solving and progress. 194 | 195 | Work systematically and keep the user informed throughout the development process. 196 | `; 197 | try { 198 | const systemPromptTemplate = Handlebars.compile(promptTemplate); 199 | let systemPromptRendered = systemPromptTemplate({ 200 | apiUrl: "http://localhost:8080", 201 | }); 202 | return { 203 | role: "system", 204 | content: systemPromptRendered, 205 | }; 206 | } catch (e) { 207 | logger.error(e); 208 | throw new Error("Shell strategy system prompt could not be rendered"); 209 | } 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /src/strategies/shellStrategy/toolFunctions/runProcess/runProcessFunctionAction.ts: -------------------------------------------------------------------------------- 1 | import { ChildProcessWithoutNullStreams, spawn } from "child_process"; 2 | import { logger, PROJECT_DIR } from "../../../../constants"; 3 | 4 | export function runProcess(command: string): ChildProcessWithoutNullStreams { 5 | const process = spawn(`/bin/bash`, ["-c", `. ~/.profile && ${command}`], { 6 | stdio: "pipe", 7 | detached: true, 8 | shell: false, 9 | cwd: PROJECT_DIR, 10 | }); 11 | 12 | logger.debug(`Started subprocess with PID: ${process.pid}`); 13 | 14 | if (!process.pid) { 15 | throw new Error("Failed to start subprocess"); 16 | } 17 | 18 | process.on("error", (err) => { 19 | logger.error(`Failed to start subprocess: ${err.message}`); 20 | throw new Error(`Failed to start subprocess: ${err.message}`); 21 | }); 22 | 23 | process.on("exit", (code, signal) => { 24 | logger.debug(`Subprocess exited with code ${code} and signal ${signal}`); 25 | }); 26 | 27 | return process; 28 | } 29 | -------------------------------------------------------------------------------- /src/strategies/shellStrategy/toolFunctions/runProcess/runProcessFunctionDefinition.ts: -------------------------------------------------------------------------------- 1 | import { ToolFunction } from "../../../../types/chat"; 2 | import { runProcess } from "./runProcessFunctionAction"; 3 | 4 | const name = "runProcess"; 5 | 6 | const description = "Run a process in the project directory in the background"; 7 | 8 | const parameters = { 9 | type: "object", 10 | properties: { 11 | command: { 12 | type: "string", 13 | description: 14 | "The full shell command to run the process with flags e.g. `npm run dev`", 15 | }, 16 | }, 17 | required: ["command"], 18 | }; 19 | 20 | async function run(params: any): Promise { 21 | runProcess(params.command); 22 | 23 | return `App is running`; 24 | } 25 | 26 | export const runProcessToolFunction: ToolFunction = { 27 | name: name, 28 | description: description, 29 | getParameters: async () => parameters, 30 | run: run, 31 | }; 32 | -------------------------------------------------------------------------------- /src/strategies/shellStrategy/toolFunctions/shellExec/shellExecFunctionAction.ts: -------------------------------------------------------------------------------- 1 | import { ExecOptions, exec as originalExec } from "child_process"; 2 | import { promisify } from "util"; 3 | import { logger, PROJECT_DIR } from "../../../../constants"; 4 | 5 | const exec = promisify(originalExec); 6 | 7 | export async function shellExec( 8 | command: string, 9 | cwd: string = PROJECT_DIR, 10 | timeout: number = 60000 11 | ): Promise { 12 | const execOptions: ExecOptions = { cwd, timeout }; 13 | 14 | try { 15 | logger.debug(`Executing command: ${command}`); 16 | const { stdout, stderr } = await exec(command, execOptions); 17 | logger.debug(`Command ${command} output: ${stdout}`); 18 | if (stderr) { 19 | logger.warn(`shellExec warning: ${stderr}`); 20 | return stdout === "" ? "Command executed with warnings" : stdout; 21 | } 22 | return stdout === "" ? "Command executed successfully" : stdout; 23 | } catch (error: unknown) { 24 | if (error instanceof Error) { 25 | if (error.message.includes("timed out")) { 26 | logger.error(`shellExec timeout: ${error.message}`); 27 | throw new Error(`shellExec timed out: ${error.message}`); 28 | } 29 | logger.error(`shellExec failed: ${error.message}`); 30 | throw new Error(`shellExec failed: ${error.message}`); 31 | } 32 | logger.error(`shellExec unknown error: ${error}`); 33 | throw new Error(`shellExec unknown error: ${error}`); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/strategies/shellStrategy/toolFunctions/shellExec/shellExecFunctionDefinition.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "../../../../constants"; 2 | import { ToolFunction } from "../../../../types/chat"; 3 | import { shellExec } from "./shellExecFunctionAction"; 4 | 5 | const name = "shellExec"; 6 | 7 | const description = "Run any shell command in the project directory"; 8 | 9 | const parameters = { 10 | type: "object", 11 | properties: { 12 | command: { 13 | type: "string", 14 | description: 15 | "The full shell command you wish to run in the project directory with flags e.g. `find . -type d -name target -prune -o -print`", 16 | }, 17 | }, 18 | required: ["command"], 19 | }; 20 | 21 | async function run(params: any): Promise { 22 | const result = await shellExec(params.command); 23 | 24 | logger.info(`Shell command results: ${result}`); 25 | 26 | return `Successfully ran shell command: ${result}`; 27 | } 28 | 29 | export const shellExecToolFunction: ToolFunction = { 30 | name: name, 31 | description: description, 32 | getParameters: async () => parameters, 33 | run: run, 34 | }; 35 | -------------------------------------------------------------------------------- /src/strategies/shellStrategy/toolFunctions/writeFile/writeFileFunctionAction.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from "fs"; 2 | import path from "path"; 3 | import { PROJECT_DIR } from "../../../../constants"; 4 | 5 | export function writeFile(filePath: string, content: string): string { 6 | const fullPath = path.join(PROJECT_DIR, filePath); 7 | try { 8 | writeFileSync(fullPath, content, "utf8"); 9 | return "File written successfully"; 10 | } catch (error) { 11 | if (error instanceof Error) { 12 | throw new Error(`Failed to write file: ${error.message}`); 13 | } else { 14 | throw new Error("Failed to write file due to an unknown error."); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/strategies/shellStrategy/toolFunctions/writeFile/writeFileFunctionDefinition.ts: -------------------------------------------------------------------------------- 1 | import { ToolFunction } from "../../../../types/chat"; 2 | import { writeFile } from "./writeFileFunctionAction"; 3 | 4 | const name = "writeFile"; 5 | 6 | const description = "Write the contents to a file in the project"; 7 | 8 | const parameters = { 9 | type: "object", 10 | properties: { 11 | filepath: { 12 | type: "string", 13 | description: 14 | "The full path to the file you want to write, relative to the project root", 15 | }, 16 | content: { 17 | type: "string", 18 | description: "The content you want to write to the file", 19 | }, 20 | }, 21 | required: ["filepath", "content"], 22 | }; 23 | 24 | async function run(params: any): Promise { 25 | writeFile(params.filepath, params.content); 26 | 27 | return `Successfully wrote to the file \`${params.filepath}\``; 28 | } 29 | 30 | export const writeFileToolFunction: ToolFunction = { 31 | name: name, 32 | description: description, 33 | getParameters: async () => parameters, 34 | run: run, 35 | }; 36 | -------------------------------------------------------------------------------- /src/types/chat.ts: -------------------------------------------------------------------------------- 1 | import { type Readable } from "stream"; 2 | 3 | export type LastCompletion = Message | null; 4 | 5 | export type ChatResponse = { 6 | messages: Message[]; 7 | lastCompletion: LastCompletion; 8 | }; 9 | 10 | export type Message = { 11 | role: string; 12 | content: string; 13 | tool_call_id?: string; 14 | tool_calls?: any[]; 15 | is_error?: boolean; 16 | }; 17 | 18 | export type HistoryMessage = Message & { 19 | timestamp?: string; 20 | }; 21 | 22 | export type ChatParams = { 23 | userMessage: Message; 24 | chatAdapter: ChatAdapter; 25 | chatStrategy: ChatStrategy; 26 | stream: Readable; 27 | }; 28 | 29 | export type CallToolFunctionParams = { 30 | toolFunctionName: string; 31 | toolFunctionParams: any; 32 | }; 33 | 34 | export type ChatStreamData = { 35 | id: string; 36 | type: "chat" | "tool"; 37 | chat?: { 38 | content: string; 39 | }; 40 | tool?: { 41 | name: string; 42 | content: string; 43 | }; 44 | }; 45 | 46 | interface ToolFunctionRunner { 47 | (params: any): Promise; 48 | } 49 | 50 | interface GetFunctionParameters { 51 | (): Promise; 52 | } 53 | export interface ToolFunction { 54 | name: string; 55 | description: string; 56 | getParameters: GetFunctionParameters; 57 | requiredEnvVars?: string[]; 58 | run: ToolFunctionRunner; 59 | } 60 | 61 | export interface ToolFunctionResponse { 62 | responseText: string; 63 | isError: boolean; 64 | } 65 | 66 | export interface ChatAdapter { 67 | runMessages: Message[]; 68 | llmModel: string; 69 | maxOutputTokens?: number; 70 | requestOptions?: {}; 71 | isToolCall(message: Message): boolean; 72 | toolCallResponseMessages( 73 | message: Message, 74 | tools: ToolFunction[] 75 | ): Promise; 76 | chat( 77 | { messages, tools }: ChatAdapterChatParams, 78 | stream: Readable, 79 | chatStrategy: ChatStrategy 80 | ): Promise; 81 | saveMessageToChatHistory(message: Message): void; 82 | handleCancellation(messages: Message[]): ChatResponse; 83 | } 84 | 85 | export type ChatAdapterChatParams = { 86 | messages: Message[]; 87 | tools: ToolFunction[]; 88 | }; 89 | 90 | export type LastToolCall = { 91 | name: string | null; 92 | success: boolean; 93 | }; 94 | 95 | export interface ChatStrategy { 96 | toolFunctions: ToolFunction[]; 97 | call( 98 | messages: Message[], 99 | toolCallResponses: Message[] 100 | ): Promise; 101 | toolFunctionMap(): { [key: string]: ToolFunction }; 102 | getToolFunctionByName(toolFunctionName: string): ToolFunction | null; 103 | onRunComplete(messages: Message[]): Promise; 104 | getChatHistory(completeHistory: HistoryMessage[]): Message[]; 105 | init(): Promise; 106 | } 107 | 108 | export type ChatState = { 109 | inProgress: boolean; 110 | cancelled: boolean; 111 | lastSuccessfulToolCall: string | null; 112 | lastToolCall: LastToolCall; 113 | perRunToolErrorCount: number; 114 | }; 115 | 116 | export type ChatAdapterConstructor = new (...args: any[]) => ChatAdapter; 117 | export type ChatStrategyConstructor = new (...args: any[]) => ChatStrategy; 118 | 119 | export type BackendStrategySystemPromptVars = { 120 | apiFileTree: string; 121 | openApiDocument: string; 122 | databaseSchema: string; 123 | }; 124 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["ESNext", "DOM"], 4 | "target": "ESNext", 5 | "module": "ESNext", 6 | "moduleDetection": "force", 7 | "allowJs": true, 8 | "esModuleInterop": true, 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "verbatimModuleSyntax": false, 12 | "noEmit": true, 13 | "strict": true, 14 | "skipLibCheck": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "noUnusedLocals": true, 17 | "noUnusedParameters": true, 18 | "noPropertyAccessFromIndexSignature": false, 19 | "outDir": "./dist", 20 | "rootDir": "./src" 21 | }, 22 | "exclude": [ 23 | "src/strategies/backendStrategy/projectTemplate", 24 | "node_modules", 25 | "dist", 26 | "tests", 27 | "project" 28 | ] 29 | } 30 | --------------------------------------------------------------------------------