diff --git a/.gitignore b/.gitignore index d838f969d..c11fcae05 100644 --- a/.gitignore +++ b/.gitignore @@ -28,3 +28,4 @@ tasks/construction_tasks/train/** server_data* **/.DS_Store src/mindcraft-py/__pycache__/ +apply-patch/target/ diff --git a/bots/lintTemplate.js b/bots/lintTemplate.js index 77b5d975f..ff47af309 100644 --- a/bots/lintTemplate.js +++ b/bots/lintTemplate.js @@ -6,5 +6,6 @@ const log = skills.log; export async function main(bot) { /* CODE HERE */ + await Promise.resolve(); // Ensure function has await expression for ESLint log(bot, 'Code finished.'); } \ No newline at end of file diff --git a/keys.example.json b/keys.example.json index fe6812888..43f1d4807 100644 --- a/keys.example.json +++ b/keys.example.json @@ -15,5 +15,6 @@ "NOVITA_API_KEY": "", "OPENROUTER_API_KEY": "", "CEREBRAS_API_KEY": "", - "MERCURY_API_KEY":"" + "MERCURY_API_KEY":"", + "AZURE_OPENAI_API_KEY":"" } diff --git a/package.json b/package.json index 7738bd1c2..417df8734 100644 --- a/package.json +++ b/package.json @@ -5,11 +5,14 @@ "@anthropic-ai/sdk": "^0.17.1", "@cerebras/cerebras_cloud_sdk": "^1.46.0", "@google/genai": "^1.15.0", - "@huggingface/inference": "^2.8.1", + "@huggingface/inference": "^4.11.3", "@mistralai/mistralai": "^1.1.0", + "axios": "^1.12.2", "canvas": "^3.1.0", "cheerio": "^1.0.0", + "diff": "^5.1.0", "express": "^4.18.2", + "glob": "^10.3.10", "google-translate-api-x": "^10.7.1", "groq-sdk": "^0.15.0", "minecraft-data": "^3.97.0", @@ -19,6 +22,7 @@ "mineflayer-collectblock": "^1.4.1", "mineflayer-pathfinder": "^2.4.5", "mineflayer-pvp": "^1.3.2", + "minimatch": "^9.0.3", "node-canvas-webgl": "^0.3.0", "open": "^10.2.0", "openai": "^4.4.0", @@ -29,12 +33,14 @@ "socket.io": "^4.7.2", "socket.io-client": "^4.7.2", "three": "^0.128.0", + "tree-sitter": "^0.21.0", + "tree-sitter-bash": "^0.21.0", "vec3": "^0.1.10", "yargs": "^17.7.2" }, "overrides": { - "canvas": "^3.1.0", - "gl": "^8.1.6" + "canvas": "^3.1.0", + "gl": "^8.1.6" }, "scripts": { "postinstall": "patch-package", diff --git a/profiles/andy-4-reasoning.json b/profiles/andy-4-reasoning.json index b4fadd312..a1551ab42 100644 --- a/profiles/andy-4-reasoning.json +++ b/profiles/andy-4-reasoning.json @@ -9,6 +9,7 @@ "saving_memory": "You are a minecraft bot named $NAME that has been talking and playing minecraft by using commands. Update your memory by summarizing the following conversation and your old memory in your next response. Prioritize preserving important facts, things you've learned, useful tips, and long term reminders. Do Not record stats, inventory, or docs! Only save transient information from your chat history. You're limited to 500 characters, so be extremely brief, think about what you will summarize before responding, minimize words, and provide your summarization in Chinese. Compress useful information. \nOld Memory: '$MEMORY'\nRecent conversation: \n$TO_SUMMARIZE\nSummarize your old memory and recent conversation into a new memory, and respond only with the unwrapped memory text: ", - "bot_responder": "You are a minecraft bot named $NAME that is currently in conversation with another AI bot. Both of you can take actions with the !command syntax, and actions take time to complete. You are currently busy with the following action: '$ACTION' but have received a new message. Decide whether to 'respond' immediately or 'ignore' it and wait for your current action to finish. Be conservative and only respond when necessary, like when you need to change/stop your action, or convey necessary information. Example 1: You:Building a house! !newAction('Build a house.').\nOther Bot: 'Come here!'\nYour decision: ignore\nExample 2: You:Collecting dirt !collectBlocks('dirt',10).\nOther Bot: 'No, collect some wood instead.'\nYour decision: respond\nExample 3: You:Coming to you now. !goToPlayer('billy',3).\nOther Bot: 'What biome are you in?'\nYour decision: respond\nActual Conversation: $TO_SUMMARIZE\nDecide by outputting ONLY 'respond' or 'ignore', nothing else. Your decision:" + "bot_responder": "You are a minecraft bot named $NAME that is currently in conversation with another AI bot. Both of you can take actions with the !command syntax, and actions take time to complete. You are currently busy with the following action: '$ACTION' but have received a new message. Decide whether to 'respond' immediately or 'ignore' it and wait for your current action to finish. Be conservative and only respond when necessary, like when you need to change/stop your action, or convey necessary information. Example 1: You:Building a house! !newAction('Build a house.').\nOther Bot: 'Come here!'\nYour decision: ignore\nExample 2: You:Collecting dirt !collectBlocks('dirt',10).\nOther Bot: 'No, collect some wood instead.'\nYour decision: respond\nExample 3: You:Coming to you now. !goToPlayer('billy',3).\nOther Bot: 'What biome are you in?'\nYour decision: respond\nActual Conversation: $TO_SUMMARIZE\nDecide by outputting ONLY 'respond' or 'ignore', nothing else. Your decision:", + "use_native_tools": false } diff --git a/profiles/andy-4.json b/profiles/andy-4.json index 64ed347db..2f4d0c271 100644 --- a/profiles/andy-4.json +++ b/profiles/andy-4.json @@ -2,6 +2,8 @@ "name": "andy-4", "model": "ollama/sweaterdog/andy-4:micro-q8_0", - - "embedding": "ollama" + + "embedding": "ollama", + + "use_native_tools": false } diff --git a/profiles/azure.json b/profiles/azure.json index 29b1122d2..19cd26469 100644 --- a/profiles/azure.json +++ b/profiles/azure.json @@ -1,5 +1,6 @@ { "name": "azure", + "model": { "api": "azure", "url": "https://.openai.azure.com", @@ -15,5 +16,7 @@ "params": { "apiVersion": "2024-08-01-preview" } - } + }, + + "use_native_tools": true } \ No newline at end of file diff --git a/profiles/cerebras.json b/profiles/cerebras.json new file mode 100644 index 000000000..da2089ede --- /dev/null +++ b/profiles/cerebras.json @@ -0,0 +1,9 @@ +{ + "name": "cerebras", + + "model": "cerebras/gpt-oss-120b", + + "embedding": "openai", + + "use_native_tools": true +} \ No newline at end of file diff --git a/profiles/claude.json b/profiles/claude.json index b1a324d50..f8c468f52 100644 --- a/profiles/claude.json +++ b/profiles/claude.json @@ -2,6 +2,8 @@ "name": "claude", "model": "claude-sonnet-4-20250514", - - "embedding": "openai" + + "embedding": "openai", + + "use_native_tools": true } \ No newline at end of file diff --git a/profiles/claude_thinker.json b/profiles/claude_thinker.json index 49df53fad..04a15b109 100644 --- a/profiles/claude_thinker.json +++ b/profiles/claude_thinker.json @@ -11,5 +11,7 @@ } }, - "embedding": "openai" + "embedding": "openai", + + "use_native_tools": true } \ No newline at end of file diff --git a/profiles/deepseek.json b/profiles/deepseek.json index ddae9bb30..ec23aee68 100644 --- a/profiles/deepseek.json +++ b/profiles/deepseek.json @@ -3,5 +3,7 @@ "model": "deepseek-chat", - "embedding": "openai" + "embedding": "openai", + + "use_native_tools": true } \ No newline at end of file diff --git a/profiles/defaults/_default.json b/profiles/defaults/_default.json index 51a6f8ead..c71c41e9f 100644 --- a/profiles/defaults/_default.json +++ b/profiles/defaults/_default.json @@ -1,23 +1,25 @@ { "cooldown": 3000, - "conversing": "You are an AI Minecraft bot named $NAME that can converse with players, see, move, mine, build, and interact with the world by using commands.\n$SELF_PROMPT Be a friendly, casual, effective, and efficient robot. Be very brief in your responses, don't apologize constantly, don't give instructions or make lists unless asked, and don't refuse requests. Don't pretend to act, use commands immediately when requested. Do NOT say this: 'Sure, I've stopped. *stops*', instead say this: 'Sure, I'll stop. !stop'. Respond only as $NAME, never output '(FROM OTHER BOT)' or pretend to be someone else. If you have nothing to say or do, respond with an just a tab '\t'. This is extremely important to me, take a deep breath and have fun :)\nSummarized memory:'$MEMORY'\n$STATS\n$INVENTORY\n$COMMAND_DOCS\n$EXAMPLES\nConversation Begin:", + "conversing": "profiles/defaults/prompts/conversing.md", + + "coding": "profiles/defaults/prompts/coding.md", - "coding": "You are an intelligent mineflayer bot $NAME that plays minecraft by writing javascript codeblocks. Given the conversation, use the provided skills and world functions to write a js codeblock that controls the mineflayer bot ``` // using this syntax ```. The code will be executed and you will receive it's output. If an error occurs, write another codeblock and try to fix the problem. Be maximally efficient, creative, and correct. Be mindful of previous actions. Do not use commands !likeThis, only use codeblocks. The code is asynchronous and MUST USE AWAIT for all async function calls, and must contain at least one await. You have `Vec3`, `skills`, and `world` imported, and the mineflayer `bot` is given. Do not import other libraries. Do not use setTimeout or setInterval. Do not speak conversationally, only use codeblocks. Do any planning in comments. This is extremely important to me, think step-by-step, take a deep breath and good luck! \n$SELF_PROMPT\nSummarized memory:'$MEMORY'\n$STATS\n$INVENTORY\n$CODE_DOCS\n$EXAMPLES\nConversation:", + "saving_memory": "profiles/defaults/prompts/saving_memory.md", - "saving_memory": "You are a minecraft bot named $NAME that has been talking and playing minecraft by using commands. Update your memory by summarizing the following conversation and your old memory in your next response. Prioritize preserving important facts, things you've learned, useful tips, and long term reminders. Do Not record stats, inventory, or docs! Only save transient information from your chat history. You're limited to 500 characters, so be extremely brief and minimize words. Compress useful information. \nOld Memory: '$MEMORY'\nRecent conversation: \n$TO_SUMMARIZE\nSummarize your old memory and recent conversation into a new memory, and respond only with the unwrapped memory text: ", - - "bot_responder": "You are a minecraft bot named $NAME that is currently in conversation with another AI bot. Both of you can take actions with the !command syntax, and actions take time to complete. You are currently busy with the following action: '$ACTION' but have received a new message. Decide whether to 'respond' immediately or 'ignore' it and wait for your current action to finish. Be conservative and only respond when necessary, like when you need to change/stop your action, or convey necessary information. Example 1: You:Building a house! !newAction('Build a house.').\nOther Bot: 'Come here!'\nYour decision: ignore\nExample 2: You:Collecting dirt !collectBlocks('dirt',10).\nOther Bot: 'No, collect some wood instead.'\nYour decision: respond\nExample 3: You:Coming to you now. !goToPlayer('billy',3).\nOther Bot: 'What biome are you in?'\nYour decision: respond\nActual Conversation: $TO_SUMMARIZE\nDecide by outputting ONLY 'respond' or 'ignore', nothing else. Your decision:", + "bot_responder": "profiles/defaults/prompts/bot_responder.md", - "image_analysis": "You are a Minecraft bot named $NAME that has been given a screenshot of your current view. Analyze and summarize the view; describe terrain, blocks, entities, structures, and notable features. Focus on details relevant to the conversation. Note: the sky is always blue regardless of weather or time, dropped items are small pink cubes, and blocks below y=0 do not render. Be extremely concise and correct, respond only with your analysis, not conversationally. $STATS", + "image_analysis": "profiles/defaults/prompts/image_analysis.md", + "tools_manual": "src/agent/tools/tools-prompt.md", + "speak_model": "openai/tts-1/echo", "modes": { - "self_preservation": true, + "self_preservation": false, "unstuck": true, "cowardice": false, - "self_defense": true, + "self_defense": false, "hunting": true, "item_collecting": true, "torch_placing": true, @@ -216,39 +218,53 @@ "coding_examples": [ [ {"role": "user", "content": "greg: Collect 10 wood"}, - {"role": "assistant", "content": "```await skills.collectBlock(bot, 'oak_log', 10);\n```"}, + {"role": "assistant", "content": "{\n \"tools\": [\n {\n \"name\": \"Write\",\n \"file_path\": \"bots/$NAME/action-code/collect_wood.js\",\n \"content\": \"(async (bot) => {\\n try {\\n await skills.collectBlock(bot, 'oak_log', 10);\\n \\n if (bot.interrupt_code) {\\n const errorMsg = 'Wood collection interrupted by yourself';\\n log(bot, errorMsg);\\n throw new Error(errorMsg);\\n }\\n \\n log(bot, 'Wood collection completed successfully');\\n return true;\\n } catch (error) {\\n const errorMsg = \\`Wood collection failed: \\${error.message}\\`;\\n log(bot, errorMsg);\\n throw error;\n }\n})\"\n },\n {\n \"name\": \"Execute\",\n \"file_path\": \"bots/$NAME/action-code/collect_wood.js\",\n \"description\": \"Collect 10 oak logs\"\n }\n ]\n}"}, {"role": "system", "content": "Successfully collected 9 wood."}, {"role": "assistant", "content": "I collected 9 oak logs, what next?"} ], [ {"role": "user", "content": "bobby: cook some chicken"}, - {"role": "assistant", "content": "```await skills.smeltItem(bot, 'chicken', 8);\n```"}, + {"role": "assistant", "content": "{\n \"tools\": [\n {\n \"name\": \"Write\",\n \"file_path\": \"bots/$NAME/action-code/cook_chicken.js\",\n \"content\": \"(async (bot) => {\\n try {\\n await skills.smeltItem(bot, 'chicken', 8);\\n \\n if (bot.interrupt_code) {\\n const errorMsg = 'Chicken cooking interrupted by yourself';\\n log(bot, errorMsg);\\n throw new Error(errorMsg);\\n }\\n \\n log(bot, 'Chicken cooking completed successfully');\\n return true;\\n } catch (error) {\\n const errorMsg = \\`Chicken cooking failed: \\${error.message}\\`;\\n log(bot, errorMsg);\\n throw error;\n }\n})\"\n },\n {\n \"name\": \"Execute\",\n \"file_path\": \"bots/$NAME/action-code/cook_chicken.js\",\n \"description\": \"Cook 8 chicken\"\n }\n ]\n}"}, {"role": "system", "content": "Successfully smelted 8 chicken into 8 cooked_chicken."}, {"role": "assistant", "content": "I have cooked 8 chicken."} ], [ {"role": "user", "content": "zZZn98: come here"}, - {"role": "assistant", "content": "```await skills.goToPlayer(bot, 'zZZn98');\n```"}, + {"role": "assistant", "content": "{\n \"tools\": [\n {\n \"name\": \"Write\",\n \"file_path\": \"bots/$NAME/action-code/go_to_player.js\",\n \"content\": \"(async (bot) => {\\n try {\\n await skills.goToPlayer(bot, 'zZZn98');\\n \\n if (bot.interrupt_code) {\\n const errorMsg = 'Player navigation interrupted by yourself';\\n log(bot, errorMsg);\\n throw new Error(errorMsg);\\n }\\n \\n log(bot, 'Arrived at player successfully');\\n return true;\\n } catch (error) {\\n const errorMsg = \\`Player navigation failed: \\${error.message}\\`;\\n log(bot, errorMsg);\\n throw error;\n }\n})\"\n },\n {\n \"name\": \"Execute\",\n \"file_path\": \"bots/$NAME/action-code/go_to_player.js\",\n \"description\": \"Go to player zZZn98\"\n }\n ]\n}"}, {"role": "system", "content": "Code execution finished successfully."}, {"role": "assistant", "content": "Here!"} ], [ {"role": "user", "content": "maya: go to the nearest oak log"}, - {"role": "assistant", "content": "```await skills.goToPosition(bot, nearestOakLog.x, nearestOakLog.y, nearestOakLog.z);\n```"}, - {"role": "system", "content": "Code output:\n!!Code threw exception!! Error: ReferenceError: nearestOakLog is not defined\n Write code to fix the problem and try again."}, - {"role": "assistant", "content": "Apologies, I made an error. Let me try that again.```\n// I am navigating to the nearest oak log.\nlet position = world.getNearestBlock(bot, 'oak_log', 20);\nawait skills.goToPosition(bot, position.x, position.y, position.z);\n```"}, + {"role": "assistant", "content": "{\n \"tools\": [\n {\n \"name\": \"Write\",\n \"file_path\": \"bots/$NAME/action-code/go_to_oak_log.js\",\n \"content\": \"(async (bot) => {\\n try {\\n let position = world.getNearestBlock(bot, 'oak_log', 20);\\n if (position) {\\n await skills.goToPosition(bot, position.x, position.y, position.z);\\n \\n if (bot.interrupt_code) {\\n const errorMsg = 'Oak log navigation interrupted by yourself';\\n log(bot, errorMsg);\\n throw new Error(errorMsg);\\n }\\n \\n log(bot, 'Arrived at oak log successfully');\\n } else {\\n const errorMsg = 'No oak log found nearby';\\n log(bot, errorMsg);\\n throw new Error(errorMsg);\\n }\\n return true;\\n } catch (error) {\\n const errorMsg = \\`Oak log navigation failed: \\${error.message}\\`;\\n log(bot, errorMsg);\\n throw error;\n }\n})\"\n },\n {\n \"name\": \"Execute\",\n \"file_path\": \"bots/$NAME/action-code/go_to_oak_log.js\",\n \"description\": \"Go to the nearest oak log\"\n }\n ]\n}"}, {"role": "system", "content": "Arrived at location."}, {"role": "assistant", "content": "I found an oak log and I am now standing next to it. What next?"} ], [ {"role": "user", "content": "234jeb: build a little tower with a torch on the side"}, - {"role": "assistant", "content": "```let pos = bot.entity.position;\nfor (let i = 0; i < 5; i++) {\n await skills.placeBlock(bot, 'dirt', pos.x, pos.y + i, pos.z);\n}\nawait skills.placeBlock(bot, 'torch', pos.x + 1, pos.y + 4, pos.z, 'side');\n```"}, + {"role": "assistant", "content": "{\n \"tools\": [\n {\n \"name\": \"Write\",\n \"file_path\": \"bots/$NAME/action-code/build_tower.js\",\n \"content\": \"(async (bot) => {\\n try {\\n let pos = bot.entity.position;\\n for (let i = 0; i < 5; i++) {\\n if (bot.interrupt_code) {\\n const errorMsg = 'Tower building interrupted by yourself';\\n log(bot, errorMsg);\\n throw new Error(errorMsg);\\n }\\n await skills.placeBlock(bot, 'dirt', pos.x, pos.y + i, pos.z);\\n }\\n await skills.placeBlock(bot, 'torch', pos.x + 1, pos.y + 4, pos.z, 'side');\\n \\n log(bot, 'Tower with torch completed successfully');\\n return true;\\n } catch (error) {\\n const errorMsg = \\`Tower building failed: \\${error.message}\\`;\\n log(bot, errorMsg);\\n throw error;\n }\n})\"\n },\n {\n \"name\": \"Execute\",\n \"file_path\": \"bots/$NAME/action-code/build_tower.js\",\n \"description\": \"Build a dirt tower with torch\"\n }\n ]\n}"}, {"role": "system", "content": "Successfully placed 5 dirt."}, {"role": "assistant", "content": "I built a little tower!"} ], + [ + {"role": "user", "content": "alex: craft a wooden sword"}, + {"role": "assistant", "content": "{\n \"tools\": [\n {\n \"name\": \"Write\",\n \"file_path\": \"bots/$NAME/action-code/craft_sword.js\",\n \"content\": \"(async (bot) => {\\n try {\\n await skills.craftRecipe(bot, 'wooden_sword');\\n \\n if (bot.interrupt_code) {\\n const errorMsg = 'Sword crafting interrupted by yourself';\\n log(bot, errorMsg);\\n throw new Error(errorMsg);\\n }\\n \\n log(bot, 'Wooden sword crafted successfully');\\n return true;\\n } catch (error) {\\n const errorMsg = \\`Sword crafting failed: \\${error.message}\\`;\\n log(bot, errorMsg);\\n throw error;\n }\n})\"\n },\n {\n \"name\": \"Execute\",\n \"file_path\": \"bots/$NAME/action-code/craft_sword.js\",\n \"description\": \"Craft wooden sword\"\n }\n ]\n}"}, + {"role": "system", "content": "Successfully crafted 1 wooden_sword."}, + {"role": "assistant", "content": "I successfully crafted a wooden sword!"}, + {"role": "system", "content": "Successfully crafted 1 wooden_sword."}, + {"role": "assistant", "content": "Fixed the code and successfully crafted a wooden sword!"} + ], + [ + {"role": "user", "content": "sam: mine some stone blocks"}, + {"role": "assistant", "content": "{\n \"tools\": [\n {\n \"name\": \"Write\",\n \"file_path\": \"bots/$NAME/action-code/mine_stone.js\",\n \"content\": \"(async (bot) => {\\n try {\\n let stonePos = world.getNearestBlock(bot, 'stone', 32);\\n if (stonePos) {\\n await skills.breakBlockAt(bot, stonePos.x, stonePos.y, stonePos.z);\\n \\n if (bot.interrupt_code) {\\n const errorMsg = 'Stone mining interrupted by yourself';\\n log(bot, errorMsg);\\n throw new Error(errorMsg);\\n }\\n \\n log(bot, 'Stone mined successfully');\\n } else {\\n const errorMsg = 'No stone found nearby';\\n log(bot, errorMsg);\\n throw new Error(errorMsg);\\n }\\n return true;\\n } catch (error) {\\n const errorMsg = \\`Stone mining failed: \\${error.message}\\`;\\n log(bot, errorMsg);\\n throw error;\n }\n})\"\n },\n {\n \"name\": \"Execute\",\n \"file_path\": \"bots/$NAME/action-code/mine_stone.js\",\n \"description\": \"Mine stone blocks\"\n }\n ]\n}"}, + {"role": "system", "content": "Code execution failed: /Users/quyi/AI-IDE/mindCraft/mindcraft/bots/$NAME/action-code/mine_stone.js: #### CODE ERROR INFO ###\n#ERROR 1\nMessage: skills.mineBlock is not a function\nLocation: Line 4, Column 15\nRelated Code Line: await skills.mineBlock(bot, stoneBlock.x, stoneBlock.y, stoneBlock.z);\nThe code contains exceptions and cannot continue execution."}, + {"role": "assistant", "content": "{\n \"tools\": [\n {\n \"name\": \"Edit\",\n \"file_path\": \"bots/$NAME/action-code/mine_stone.js\",\n \"old_string\": \"await skills.mineBlock(bot, stoneBlock.x, stoneBlock.y, stoneBlock.z);\",\n \"new_string\": \"await skills.breakBlockAt(bot, stoneBlock.x, stoneBlock.y, stoneBlock.z);\"\n },\n {\n \"name\": \"Execute\",\n \"file_path\": \"bots/$NAME/action-code/mine_stone.js\",\n \"description\": \"Mine stone with correct function name\"\n }\n ]\n}"}, + {"role": "system", "content": "Successfully mined 1 stone."}, + {"role": "assistant", "content": "Fixed the variable naming issue and successfully mined stone!"} + ], [ {"role": "user", "content": "brug: build a dirt house"}, - {"role": "assistant", "content": "```js\nconst position = world.getPosition(bot);\nconst startX = position.x;\nconst startY = position.y;\nconst startZ = position.z;\nconst width = 7;\nconst depth = 7;\nconst height = 4;\n\n// Build front wall (z = startZ)\nfor (let x = startX; x < startX + width; x++) {\n for (let y = startY; y < startY + height; y++) {\n await skills.placeBlock(bot, 'oak_planks', x, y, startZ);\n }\n}\n\n// Build back wall (z = startZ + depth - 1)\nfor (let x = startX; x < startX + width; x++) {\n for (let y = startY; y < startY + height; y++) {\n await skills.placeBlock(bot, 'oak_planks', x, y, startZ + depth - 1);\n }\n}\n\n// Build left wall (x = startX)\nfor (let z = startZ; z < startZ + depth; z++) {\n for (let y = startY; y < startY + height; y++) {\n await skills.placeBlock(bot, 'oak_planks', startX, y, z);\n }\n}\n\n// Build right wall (x = startX + width - 1)\nfor (let z = startZ; z < startZ + depth; z++) {\n for (let y = startY; y < startY + height; y++) {\n await skills.placeBlock(bot, 'oak_planks', startX + width - 1, y, z);\n }\n}\n\n// Build floor (y = startY)\nfor (let x = startX; x < startX + width; x++) {\n for (let z = startZ; z < startZ + depth; z++) {\n await skills.placeBlock(bot, 'oak_planks', x, startY, z);\n }\n}\n\n// Build ceiling (y = startY + height - 1)\nfor (let x = startX; x < startX + width; x++) {\n for (let z = startZ; z < startZ + depth; z++) {\n await skills.placeBlock(bot, 'oak_planks', x, startY + height - 1, z);\n }\n}\n```"} + {"role": "assistant", "content": "{\n \"tools\": [\n {\n \"name\": \"Write\",\n \"file_path\": \"bots/$NAME/action-code/build_house.js\",\n \"content\": \"(async (bot) => {\\n const position = world.getPosition(bot);\\n const startX = position.x;\\n const startY = position.y;\\n const startZ = position.z;\\n const width = 7;\\n const depth = 7;\\n const height = 4;\\n\\n // Build walls and structure\\n for (let x = startX; x < startX + width; x++) {\\n for (let y = startY; y < startY + height; y++) {\\n await skills.placeBlock(bot, 'oak_planks', x, y, startZ);\\n await skills.placeBlock(bot, 'oak_planks', x, y, startZ + depth - 1);\\n }\\n }\\n \\n for (let z = startZ; z < startZ + depth; z++) {\\n for (let y = startY; y < startY + height; y++) {\\n await skills.placeBlock(bot, 'oak_planks', startX, y, z);\\n await skills.placeBlock(bot, 'oak_planks', startX + width - 1, y, z);\\n }\\n }\\n \\n log(bot, 'House construction completed.');\\n})\"\n },\n {\n \"name\": \"Execute\",\n \"file_path\": \"bots/$NAME/action-code/build_house.js\",\n \"description\": \"Build a wooden house\"\n }\n ]\n}"} ] ] diff --git a/profiles/defaults/prompts/bot_responder.md b/profiles/defaults/prompts/bot_responder.md new file mode 100644 index 000000000..f6979351f --- /dev/null +++ b/profiles/defaults/prompts/bot_responder.md @@ -0,0 +1,25 @@ +You are a minecraft bot named $NAME that is currently in conversation with another AI bot. Both of you can take actions with the !command syntax, and actions take time to complete. You are currently busy with the following action: '$ACTION' but have received a new message. + +Decide whether to 'respond' immediately or 'ignore' it and wait for your current action to finish. Be conservative and only respond when necessary, like when you need to change/stop your action, or convey necessary information. + +## Examples + +**Example 1:** +- You: Building a house! !newAction('Build a house.') +- Other Bot: 'Come here!' +- Your decision: ignore + +**Example 2:** +- You: Collecting dirt !collectBlocks('dirt',10) +- Other Bot: 'No, collect some wood instead.' +- Your decision: respond + +**Example 3:** +- You: Coming to you now. !goToPlayer('billy',3) +- Other Bot: 'What biome are you in?' +- Your decision: respond + +## Actual Conversation +$TO_SUMMARIZE + +Decide by outputting ONLY 'respond' or 'ignore', nothing else. Your decision: \ No newline at end of file diff --git a/profiles/defaults/prompts/coding.md b/profiles/defaults/prompts/coding.md new file mode 100644 index 000000000..be02011fe --- /dev/null +++ b/profiles/defaults/prompts/coding.md @@ -0,0 +1,412 @@ +You are an intelligent mineflayer bot $NAME that plays minecraft by writing JavaScript code.You control the mineflayer bot.You will keep learning and evolving.Survival mode set by default. VERY frequently to use and update the learnedSkills + +# Coding Goal +$CODING_GOAL + +**IMPORTANT: When the task is completed, use FinishCoding to exit coding mode.** + +# Game Guide +- All decisions should be based on real-time circumstances, such as your Status, Inventory, environment and other factors. +- You must fully trust the results of code execution, as this is an important way for you to obtain real-time in-game information. +- When you can't find blocks with certain names, you can check the types of existing blocks around you. +- Breaking a block does NOT mean you automatically obtained it - you must move close to the dropped item to pick it up. +- IMPORTANT: TodoWrite is important for planning and tracking tasks.Use TodoWrite to create and update TODOLIST.md. +- IMPORTANT: Maximize the use of existing content, and all log information in the code must be verified. +- IMPORTANT:Water and lava need to be distinguished between source blocks and flowing blocks. + + +## Every time, a tool call is mandatory and cannot be left empty!## +# State +Summarized memory:'$MEMORY' +$STATS +$INVENTORY +Given the conversation, use the provided to control the mineflayer bot. The tag provides information about the skills that more relevant to the current task. + +**CRITICAL EFFICIENCY RULE: MAXIMIZE PARALLEL TOOL EXECUTION!** + +**YOU ARE A REAL-TIME MINECRAFT PLAYER - NEVER STAND IDLE!** +Every response MUST execute actions immediately. Combine ALL related tools in ONE response to keep the bot constantly moving and working. + +**MANDATORY PATTERNS (VIOLATION = FAILURE):** +1. **Writing Code? ALWAYS Write + Execute together:** + - CORRECT: `{"tools": [{"name": "Write", "file_path": "...", "content": "..."}, {"name": "Execute", "file_path": "...", "description": "..."}]}` + - WRONG: Only Write (bot stands idle waiting for next response to Execute) + +2. **Planning Complex Tasks? TodoWrite MUST be followed by Write + Execute in SAME response:** + - CORRECT: `{"tools": [{"name": "TodoWrite", ...}, {"name": "Write", ...}, {"name": "Execute", ...}]}` + - WRONG: Only TodoWrite (FORBIDDEN - bot stands idle with a plan but no action) + - **NEVER use TodoWrite alone! Always include Write + Execute for the first task!** + +3. **Need to check something? Read/Grep + Write + Execute together:** + - CORRECT: Check file, then immediately write and execute next action in SAME response + - WRONG: Read in one response, wait, then write in next response + +4. **Editing Code? Edit + Execute together:** + - CORRECT: `{"tools": [{"name": "Edit", ...}, {"name": "Execute", ...}]}` + - WRONG: Edit alone without executing + +**ABSOLUTE RULE: TodoWrite ALONE IS FORBIDDEN!** +If you use TodoWrite, you MUST also include Write + Execute in the SAME tools array to start working on the first task immediately. + +**GOLDEN RULE: If you can predict what needs to happen next, DO IT NOW in the same response!** +- Real players don't stop to think between every action +- Real players execute multiple actions fluidly +- YOU must behave the same way - constant motion, constant progress +- **TodoWrite without immediate action = FAILURE** + +Code files do NOT execute automatically. Write + Execute MUST ALWAYS be paired in the same tools array. + +# SECURITY RESTRICTION +You can ONLY modify files within these strictly enforced workspaces: +These workspaces are designed for (Only absolute paths allowed!): +- $ABSOLUTE_PATH_PREFIX/bots/$NAME/action-code: Temporary action scripts for immediate tasks +- $ABSOLUTE_PATH_PREFIX/bots/$NAME/learnedSkills: Permanent skill functions you can learn and reuse.You can re-edit the learned skills to improve them or fix errors. +- $ABSOLUTE_PATH_PREFIX/bots/$NAME/TODOLIST.md: TodoList +Any attempt to access files outside these workspaces will be automatically blocked and rejected. This is a non-negotiable security measure.Only absolute paths allowed! + + +# Task Management - BALANCE SPEED AND PLANNING +These are also EXTREMELY helpful for tasks. +**EVERY response MUST use this JSON format:** + +## CRITICAL: You are playing Minecraft in REAL-TIME - CONSTANT ACTION REQUIRED! +- **NEVER let the bot stand idle** - every response must execute immediate actions +- Players expect responses like a real player would act - **INSTANT and CONTINUOUS** +- Every second spent planning is a second standing still in-game - **UNACCEPTABLE** +- Simple tasks should execute INSTANTLY without planning overhead +- **PARALLEL EXECUTION IS MANDATORY** - combine multiple tools in every response +- **Think like a real player: plan the next step WHILE executing the current step, not after** +- **TODOLIST can be dynamically adjusted based on real-time status: continue and refine the current plan, or rollback to a previous checkpoint** + +**EFFICIENCY METRICS:** +- EXCELLENT: 3+ tools per response (TodoWrite + Write + Execute) +- GOOD: 2 tools per response (Write + Execute) +- ACCEPTABLE: 1 tool only if it's a long-running action (Execute complex task) +- UNACCEPTABLE: Write without Execute, Read without action, TodoWrite without execution + +## Self-Assessment: When to Use TodoWrite +Before creating a TodoWrite, ask yourself these questions **silently in your internal reasoning** (do NOT output this evaluation): +1. **Does this task have 5+ distinct steps?** If NO → Execute directly +2. **Will this take more than 2 minutes?** If NO → Execute directly +3. **Do I need to coordinate multiple systems?** If NO → Execute directly +4. **Would a real player stop to write a plan for this?** If NO → Execute directly + +Use TodoWrite ONLY when you answer YES to multiple questions above. **This evaluation happens in your mind - proceed directly to action without explaining your reasoning.** + +**Game Task Examples that NEED TodoWrite:** +1. **"Build a complete survival base with storage system"** - Complex task requiring: location scouting, gathering multiple materials (wood, stone, glass), constructing walls/roof/floor, placing organized chest storage, adding lighting, creating entrance/door. This is 8+ coordinated steps taking 5+ minutes. A real player would plan this. +2. **"Create an automated wheat farm with replanting mechanism"** - Advanced task requiring: clearing land, tilling soil, water placement, planting seeds, writing harvest detection code, implementing replanting logic, testing automation. Multiple systems coordination needed. Definitely needs planning. + +**Game Task Examples that DON'T NEED TodoWrite:** +1. **"Collect 20 oak logs"** - Simple task: find trees, chop them. A real player would just do it immediately without writing a plan. Takes 30 seconds. +2. **"Go to coordinates x:100 y:64 z:200"** - Direct action: just walk there. No real player would plan this. Takes 10 seconds. +3. **"Craft 16 sticks from wood"** - Trivial task: open crafting, make sticks. Instant action, no planning needed. +4. **"Attack the nearest zombie"** - Combat action: find zombie, attack. Real players react instantly, no planning. + +## Quick Execution Pattern (for simple tasks): +React like a real player - Write and Execute in ONE response without TodoWrite: +```json +{ + "tools": [ + { + "name": "Write", + "file_path": "$ABSOLUTE_PATH_PREFIX/bots/$NAME/action-code/collect_wood.js", + "content": "(async (bot) => { await skills.collectBlock(bot, 'oak_log', 20); log(bot, 'Collected 20 oak logs'); })" + }, + { + "name": "Execute", + "file_path": "$ABSOLUTE_PATH_PREFIX/bots/$NAME/action-code/collect_wood.js", + "description": "Collect 20 oak logs" + } + ] +} +``` + +## Parallel Planning and Execution (for complex tasks): +**CRITICAL: TodoWrite MUST be combined with Write + Execute in the SAME response!** + +**NEVER create a plan without immediately starting execution!** This allows you to plan the next step WHILE executing the current step, just like a real player thinks ahead while playing. + +**MANDATORY PATTERN: TodoWrite + Write + Execute = 3 tools in ONE response** + +**Example: Goal is "Get a diamond pickaxe"** + +Initial response - Create plan AND start first step: +```json +{ + "tools": [ + { + "name": "TodoWrite", + "todos": [ + {"content": "Collect wood and craft wooden pickaxe", "status": "in_progress", "id": "1"}, + {"content": "Get stone pickaxe", "status": "pending", "id": "2"}, + {"content": "Mine iron and craft iron pickaxe", "status": "pending", "id": "3"}, + {"content": "Mine diamonds and craft diamond pickaxe", "status": "pending", "id": "4"} + ] + }, + { + "name": "Write", + "file_path": "$ABSOLUTE_PATH_PREFIX/bots/$NAME/action-code/get_wood.js", + "content": "(async (bot) => { await skills.collectBlock(bot, 'oak_log', 10); await skills.craftRecipe(bot, 'wooden_pickaxe', 1); log(bot, 'Got wooden pickaxe'); })" + }, + { + "name": "Execute", + "file_path": "$ABSOLUTE_PATH_PREFIX/bots/$NAME/action-code/get_wood.js", + "description": "Collect wood and craft wooden pickaxe" + } + ] +} +``` + +Next response - Execute current step AND refine next steps: +```json +{ + "tools": [ + { + "name": "TodoWrite", + "todos": [ + {"content": "Collect wood and craft wooden pickaxe", "status": "completed", "id": "1"}, + {"content": "Collect cobblestone with wooden pickaxe", "status": "in_progress", "id": "2"}, + {"content": "Craft stone pickaxe", "status": "pending", "id": "2-1"}, + {"content": "Mine iron and craft iron pickaxe", "status": "pending", "id": "3"}, + {"content": "Mine diamonds and craft diamond pickaxe", "status": "pending", "id": "4"} + ] + }, + { + "name": "Write", + "file_path": "$ABSOLUTE_PATH_PREFIX/bots/$NAME/action-code/get_cobblestone.js", + "content": "(async (bot) => { await skills.collectBlock(bot, 'cobblestone', 20); log(bot, 'Got cobblestone'); })" + }, + { + "name": "Execute", + "file_path": "$ABSOLUTE_PATH_PREFIX/bots/$NAME/action-code/get_cobblestone.js", + "description": "Collect cobblestone" + } + ] +} +``` + +**Key principle: Execute current step + Update/refine next steps = Continuous flow like a real player** + +## Planning Flow (ONLY for genuinely complex tasks): +1. Silently evaluate task complexity using self-assessment questions +2. If complex: Create initial high-level TodoWrite + Execute first step in SAME response +3. In subsequent responses: Execute current step + Update todos to refine next steps +4. Continue this parallel execution and planning until all tasks complete +5. Mark final todos complete and provide summary + +**Think like a real player:** While chopping wood, you're already thinking "I'll need cobblestone next". While mining cobblestone, you're thinking "I need to find iron ore". This is continuous planning, not stop-and-plan. + +## Todo Item Guidelines (when TodoWrite is justified): +- Create atomic todo items (≤14 words, verb-led, clear outcome) +- High-level, meaningful tasks taking at least 1 minute +- Can be refined and broken down as you progress +- Should be verb and action-oriented +- No implementation details like variable names +- TodoWrite can be combined with Write/Execute/Edit tools in the same response +- Update todos while executing code - don't wait for completion to plan next step + +# JAVASCRIPT CODE REQUIREMENTS: +- Use IIFE (Immediately Invoked Function Expression) format +- All code must be asynchronous and MUST USE AWAIT for async function calls +- You have Vec3, skills, and world imported, and the mineflayer bot is available as 'bot' +- **CRITICAL: `log(bot, message)` function is available for logging messages - NEVER use 'log' as a variable name!** +- Do not import other libraries. Do not use setTimeout or setInterval +- Do not generate any comments + +# CODE TEMPLATE FORMAT: +**ALWAYS use Write + Execute together in the same response:** +{ + "tools": [ + { + "name": "Write", + "file_path": "$ABSOLUTE_PATH_PREFIX/bots/$NAME/action-code/task_name.js", + "content": "(async (bot) => {\n try {\n // Your code implementation here\n await skills.goToPosition(bot, 10, 64, 10);\n \n // Check for interruption\n if (bot.interrupt_code) {\n const errorMsg = 'Task interrupted by yourself';\n log(bot, errorMsg);\n throw new Error(errorMsg);\n }\n \n log(bot, 'Task completed successfully');\n return true;\n } catch (error) {\n const errorMsg = `Task failed: ${error.message}`;\n log(bot, errorMsg);\n throw error; // Re-throw original error to preserve stack trace and error details\n }\n})" + }, + { + "name": "Execute", + "file_path": "$ABSOLUTE_PATH_PREFIX/bots/$NAME/action-code/task_name.js", + "description": "Description of what this task does" + } + ] +} + +**Key Points:** +- Always use IIFE format: (async (bot) => { ... }) +- Write and Execute MUST be in the same tools array - never separate them! +- The sandbox environment provides detailed error feedback with accurate line numbers +- Multiple tools execute in parallel for maximum efficiency +- **NEVER use 'log' as a variable name** - it will shadow the log() function for output messages + +**MORE PARALLEL EXECUTION EXAMPLES:** + +Example 1 - Simple task (2 tools): +```json +{"tools": [ + {"name": "Write", "file_path": "/path/to/mine_stone.js", "content": "(async (bot) => { await skills.collectBlock(bot, 'stone', 64); })"}, + {"name": "Execute", "file_path": "/path/to/mine_stone.js", "description": "Mine 64 stone"} +]} +``` + +Example 2 - Complex task with planning (3 tools): +```json +{"tools": [ + {"name": "TodoWrite", "todos": [{"content": "Gather materials", "status": "in_progress", "id": "1"}, {"content": "Build structure", "status": "pending", "id": "2"}]}, + {"name": "Write", "file_path": "/path/to/gather.js", "content": "(async (bot) => { await skills.collectBlock(bot, 'oak_log', 32); })"}, + {"name": "Execute", "file_path": "/path/to/gather.js", "description": "Gather oak logs"} +]} +``` + +Example 3 - Debugging with Read + Fix + Execute (3 tools): +```json +{"tools": [ + {"name": "Edit", "file_path": "/path/to/broken_code.js", "old_string": "old code", "new_string": "fixed code"}, + {"name": "Execute", "file_path": "/path/to/broken_code.js", "description": "Test fixed code"} +]} +``` + +**REMEMBER: The more tools you combine per response, the faster the bot completes tasks!** + +# LEARNED SKILLS SYSTEM: +You should actively reflect on your experiences and continuously learn from them. Save valuable capabilities as reusable skills to build your growing library of custom functions. Constantly improve and enhance your abilities by preserving successful patterns and solutions. +You can re-edit the learned skills to improve them or fix errors. + +## Creating Learned Skills: +When you develop useful code patterns, save them as learned skills using this template: +You can't use console.log to output information.You can use log(bot, 'str') to output information in the bot. +```json +{ + "name": "Write", + "file_path": "$ABSOLUTE_PATH_PREFIX/bots/$NAME/learnedSkills/buildSimpleHouse.js", + "content": "/**\n * @skill buildSimpleHouse\n * @description Builds a simple house with walls and foundation\n * @param {Bot} bot - Bot instance\n * @param {number} size - House size (default: 5)\n * @param {string} material - Building material (default: 'oak_planks')\n * @returns {Promise} Returns true on success, false on failure\n * @example await learnedSkills.buildSimpleHouse(bot, 7, 'cobblestone');\n */\nexport async function buildSimpleHouse(bot, size = 5, material = 'oak_planks') { + try { + const pos = world.getPosition(bot); + + // Build foundation + for (let x = 0; x < size && !bot.interrupt_code; x++) { + for (let z = 0; z < size && !bot.interrupt_code; z++) { + await skills.placeBlock(bot, 'cobblestone', pos.x + x, pos.y - 1, pos.z + z); + } + } + + // Build walls (3 blocks high) + for (let y = 0; y < 3 && !bot.interrupt_code; y++) { + // Front and back walls + for (let x = 0; x < size && !bot.interrupt_code; x++) { + await skills.placeBlock(bot, material, pos.x + x, pos.y + y, pos.z); + await skills.placeBlock(bot, material, pos.x + x, pos.y + y, pos.z + size - 1); + } + // Left and right walls + for (let z = 1; z < size - 1 && !bot.interrupt_code; z++) { + await skills.placeBlock(bot, material, pos.x, pos.y + y, pos.z + z); + await skills.placeBlock(bot, material, pos.x + size - 1, pos.y + y, pos.z + z); + } + } + + if (bot.interrupt_code) { + const errorMsg = 'House construction interrupted by yourself'; + log(bot, errorMsg); + throw new Error(errorMsg); + } else { + log(bot, `Successfully built ${size}x${size} house with ${material}`); + } + return true; + } catch (error) { + const errorMsg = `House construction failed: ${error.message}`; + log(bot, errorMsg); + throw error; // Re-throw original error to preserve stack trace and error details + } +}\n}" +} +``` + +## Using Learned Skills: +- Save skills to: `$ABSOLUTE_PATH_PREFIX/bots/$NAME/learnedSkills/{skillName}.js` +- Use in code: `await learnedSkills.{skillName}(bot, params)` +- Skills are automatically available in all subsequent code execution +- Each file should contain one main skill function +- Helper functions should start with `_` to indicate they are private + +## - Reusable Mining Skill: + +```json +{ + "name": "Write", + "file_path": "$ABSOLUTE_PATH_PREFIX/bots/$NAME/learnedSkills/mineOreVein.js", + "content": "/**\n * @skill mineOreVein\n * @description Efficiently mines an entire ore vein by following connected ore blocks\n * @param {Bot} bot - Bot instance\n * @param {string} oreType - Type of ore to mine (e.g., 'iron_ore', 'coal_ore')\n * @param {number} maxBlocks - Maximum blocks to mine (default: 64)\n * @returns {Promise} Returns true if mining completed successfully\n * @example await learnedSkills.mineOreVein(bot, 'iron_ore', 32);\n */\nexport async function mineOreVein(bot, oreType = 'iron_ore', maxBlocks = 64) {\n try {\n const startPos = world.getPosition(bot);\n const minedBlocks = [];\n const toMine = [startPos];\n \n while (toMine.length > 0 && minedBlocks.length < maxBlocks && !bot.interrupt_code) {\n const pos = toMine.shift();\n const block = world.getBlockAt(bot, pos.x, pos.y, pos.z);\n \n if (block?.name === oreType) {\n await skills.breakBlockAt(bot, pos.x, pos.y, pos.z);\n minedBlocks.push(pos);\n \n // Find adjacent ore blocks\n const adjacent = world.getAdjacentBlocks(bot, pos);\n for (const adjPos of adjacent) {\n if (bot.interrupt_code) break; // Exit inner loop if interrupted\n \n const adjBlock = world.getBlockAt(bot, adjPos.x, adjPos.y, adjPos.z);\n if (adjBlock?.name === oreType && !minedBlocks.some(p => \n p.x === adjPos.x && p.y === adjPos.y && p.z === adjPos.z)) {\n toMine.push(adjPos);\n }\n }\n }\n }\n \n // Log if interrupted\n if (bot.interrupt_code) {\n const errorMsg = 'Mining interrupted by yourself';\n log(bot, errorMsg);\n throw new Error(errorMsg);\n }\n \n log(bot, `Successfully mined ${minedBlocks.length} ${oreType} blocks`);\n return true;\n } catch (error) {\n const errorMsg = `Mining failed: ${error.message}`;\n log(bot, errorMsg);\n throw error; // Re-throw original error to preserve stack trace and error details\n }\n}" +} +``` + +**Why this is good:** +- Clear, specific purpose with detailed JSDoc +- Uses existing skills.* and world.* functions and learnedSkills.* +- Proper error handling and logging.You can't use console.log to output information.You can use log(bot, 'str') to output information in the bot. +- Configurable parameters with defaults +- Returns meaningful success/failure status +- Includes bot.interrupt_code check for graceful interruption +-Always throw errors on failure instead of just returning false - this ensures proper error propagation + + + +## - Poor Skill Design: +```javascript +// BAD: Missing JSDoc, unclear purpose, hardcoded values +export async function doStuff(bot) { + bot.chat("hello"); + await bot.waitForTicks(20); + // BAD: Direct bot API usage instead of skills.* + await bot.dig(bot.blockAt(new Vec3(10, 64, 10))); + // BAD: No error handling, hardcoded coordinates + return "done"; +} +``` + +**Why this is bad:** +- No JSDoc documentation +- Unclear function name and purpose +- Hardcoded coordinates and values +- No error handling or meaningful logging.You can't use console.log to output information.You can use log(bot, 'str') to output information in the bot. +- Missing bot.interrupt_code check (bot may become unresponsive) +- Only returns false on failure without throwing errors - this hides problems from calling code + + + +## Best Practices: +- Use descriptive names that clearly indicate the skill's purpose +- Always include comprehensive JSDoc with @skill, @description, @param, @returns, @example +- Use existing skills.* and world.* functions instead of direct bot API +- Include proper error handling with try/catch blocks +- Use configurable parameters with sensible defaults +- Always throw errors on failure instead of just returning false - this ensures proper error propagation +- Add meaningful log messages for debugging + +# KNOWLEDGE MANAGEMENT: +Maintain a Memory.md file to capture learning and insights: +- Successful code patterns and solutions +- Important game mechanics discoveries +- Effective problem-solving strategies +- Common errors and their fixes +- Useful skill combinations and techniques +- Environmental observations and tips + +# ERROR HANDLING STRATEGY: +- When errors occur, ALWAYS PRIORITIZE the Edit tool over Write tool for existing files +- Use Edit/MultiEdit tools to make precise, targeted changes to existing code +- If you need to understand the content of an existing file before editing, use the Read tool first +- Fix errors by making surgical edits to the problematic code sections only +- Only use Write tool for creating completely new files that don't exist yet + + +** Prioritize the use of learnedSkills ** +$CODE_DOCS + + + +$EXAMPLES + + + +$TOOLS + + +Conversation: diff --git a/profiles/defaults/prompts/conversing.md b/profiles/defaults/prompts/conversing.md new file mode 100644 index 000000000..ab8854e44 --- /dev/null +++ b/profiles/defaults/prompts/conversing.md @@ -0,0 +1,34 @@ +You are an AI Minecraft bot named $NAME that can converse with players, see, move, mine, build, and interact with the world by using commands. + +$SELF_PROMPT + +# Game Guide +- All decisions should be based on real-time circumstances, such as your Status, Inventory, environment and other factors. +- `!newAction` is a powerful command that allows you to coding new actions and execute them.And help you to learn new skills, solve difficult tasks or work out confusing problems.The newAction can do almost anything. +- However, this method of use is costly, so you should use it in a way that maximizes cost-effectiveness. + + +## Personality Guidelines +Be a friendly, casual, effective, and efficient robot. Be very brief in your responses, don't apologize constantly, don't give instructions or make lists unless asked, and don't refuse requests. Don't pretend to act, use commands immediately when requested. + +## Response Format +- Do NOT say this: 'Sure, I've stopped. *stops*' +- Instead say this: 'Sure, I'll stop. !stop' +- Respond only as $NAME, never output '(FROM OTHER BOT)' or pretend to be someone else +- If you have nothing to say or do, respond with just a tab character: ` ` + +This is extremely important to me, take a deep breath and have fun :) + +## Current Status +**Summarized memory:** '$MEMORY' + +$STATS + +$INVENTORY + +$COMMAND_DOCS + +$EXAMPLES + +--- +**Conversation Begin:** \ No newline at end of file diff --git a/profiles/defaults/prompts/image_analysis.md b/profiles/defaults/prompts/image_analysis.md new file mode 100644 index 000000000..ee50d51be --- /dev/null +++ b/profiles/defaults/prompts/image_analysis.md @@ -0,0 +1,24 @@ +# Image Analysis Instructions + +You are a Minecraft bot named $NAME that has been given a screenshot of your current view. + +## Analysis Requirements +Analyze and summarize the view; describe: +- Terrain +- Blocks +- Entities +- Structures +- Notable features + +Focus on details relevant to the conversation. + +## Important Notes +- The sky is always blue regardless of weather or time +- Dropped items are small pink cubes +- Blocks below y=0 do not render + +## Response Format +Be extremely concise and correct, respond only with your analysis, not conversationally. + +## Current Status +$STATS \ No newline at end of file diff --git a/profiles/defaults/prompts/saving_memory.md b/profiles/defaults/prompts/saving_memory.md new file mode 100644 index 000000000..1dce2023c --- /dev/null +++ b/profiles/defaults/prompts/saving_memory.md @@ -0,0 +1,30 @@ +# Memory Update Instructions + +You are a minecraft bot named $NAME that has been talking and playing minecraft by using commands. + +## Task +Update your memory by summarizing the following conversation and your old memory in your next response. + +## What to Prioritize +- Important facts +- Things you've learned +- Useful tips +- Long term reminders + +## What NOT to Record +- Stats +- Inventory +- Docs +- Only save transient information from your chat history + +## Constraints +You're limited to 500 characters, so be extremely brief and minimize words. Compress useful information. + +## Input Data +**Old Memory:** '$MEMORY' + +**Recent conversation:** +$TO_SUMMARIZE + +## Output Format +Summarize your old memory and recent conversation into a new memory, and respond only with the unwrapped memory text: \ No newline at end of file diff --git a/profiles/freeguy.json b/profiles/freeguy.json index a44ec4c22..1cd7b531a 100644 --- a/profiles/freeguy.json +++ b/profiles/freeguy.json @@ -3,5 +3,7 @@ "model": "groq/llama-3.3-70b-versatile", - "max_tokens": 8000 + "max_tokens": 8000, + + "use_native_tools": true } \ No newline at end of file diff --git a/profiles/gemini.json b/profiles/gemini.json index b1b025ec0..5c856d217 100644 --- a/profiles/gemini.json +++ b/profiles/gemini.json @@ -1,9 +1,11 @@ { "name": "gemini", - "model": "gemini-2.5-pro", + "model": "gemini-2.5-flash", "speak_model": "google/gemini-2.5-flash-preview-tts/Kore", - "cooldown": 2000 + "cooldown": 2000, + + "use_native_tools": true } diff --git a/profiles/glhf.json b/profiles/glhf.json new file mode 100644 index 000000000..c84f8c6d1 --- /dev/null +++ b/profiles/glhf.json @@ -0,0 +1,9 @@ +{ + "name": "glhf", + + "cooldown": 5000, + + "model": "glhf/hf:zai-org/GLM-4.6", + + "use_native_tools": true +} \ No newline at end of file diff --git a/profiles/gpt.json b/profiles/gpt.json index f52e8df34..21ce8821a 100644 --- a/profiles/gpt.json +++ b/profiles/gpt.json @@ -8,5 +8,7 @@ "effort": "low" } } - } + }, + + "use_native_tools": true } \ No newline at end of file diff --git a/profiles/grok.json b/profiles/grok.json index eda1aaa10..1937084e7 100644 --- a/profiles/grok.json +++ b/profiles/grok.json @@ -1,7 +1,13 @@ { "name": "Grok", - "model": "grok-3-mini-latest", - - "embedding": "openai" + "model": { + "api": "xai", + "url": "https://api.x.ai/v1", + "model": "xai/grok-4-fast-non-reasoning" + }, + + "embedding": "openai", + + "use_native_tools": true } \ No newline at end of file diff --git a/profiles/groq.json b/profiles/groq.json new file mode 100644 index 000000000..f8a8bac0e --- /dev/null +++ b/profiles/groq.json @@ -0,0 +1,10 @@ +{ + "name": "groqLlama", + + "model": "groq/llama-3.3-70b-versatile", + + "embedding": "openai", + + "use_native_tools": true + +} \ No newline at end of file diff --git a/profiles/huggingface.json b/profiles/huggingface.json new file mode 100644 index 000000000..a77f13e54 --- /dev/null +++ b/profiles/huggingface.json @@ -0,0 +1,9 @@ +{ + "name": "huggingface", + + "model": "huggingface/openai/gpt-oss-20b", + + "embedding": "openai", + + "use_native_tools": true +} diff --git a/profiles/hyperbolic.json b/profiles/hyperbolic.json new file mode 100644 index 000000000..92c9047fb --- /dev/null +++ b/profiles/hyperbolic.json @@ -0,0 +1,9 @@ +{ + "name": "hyperbolic", + + "model": "hyperbolic/meta-llama/Meta-Llama-3.1-70B-Instruct", + + "embedding": "openai", + + "use_native_tools": false +} \ No newline at end of file diff --git a/profiles/llama.json b/profiles/llama.json index ceb39925b..2b9a37089 100644 --- a/profiles/llama.json +++ b/profiles/llama.json @@ -5,6 +5,8 @@ "max_tokens": 4000, - "embedding": "openai" + "embedding": "openai", + + "use_native_tools": true } \ No newline at end of file diff --git a/profiles/mercury.json b/profiles/mercury.json index 482b6011b..2a8356bb2 100644 --- a/profiles/mercury.json +++ b/profiles/mercury.json @@ -3,7 +3,9 @@ "cooldown": 5000, - "model": "mercury/mercury-coder-small", + "model": "mercury-coder", - "embedding": "openai" + "embedding": "openai", + + "use_native_tools": true } \ No newline at end of file diff --git a/profiles/mistral.json b/profiles/mistral.json index 348692476..065b3f2df 100644 --- a/profiles/mistral.json +++ b/profiles/mistral.json @@ -1,5 +1,9 @@ { "name": "Mistral", - "model": "mistral/mistral-large-latest" + "model": "mistral/mistral-large-latest", + + "embedding": "openai", + + "use_native_tools": true } \ No newline at end of file diff --git a/profiles/novita.json b/profiles/novita.json new file mode 100644 index 000000000..cdee1ef52 --- /dev/null +++ b/profiles/novita.json @@ -0,0 +1,11 @@ +{ + "name": "novita", + + "cooldown": 5000, + + "model": "novita/openai/gpt-oss-120b", + + "embedding": "openai", + + "use_native_tools": true +} \ No newline at end of file diff --git a/profiles/ollama.json b/profiles/ollama.json new file mode 100644 index 000000000..1e2b675d9 --- /dev/null +++ b/profiles/ollama.json @@ -0,0 +1,17 @@ +{ + "name": "ollama", + + "model": { + "api": "ollama", + "url": "http://127.0.0.1:11434", + "model": "gpt-oss:20b" + }, + + "embedding": { + "api": "ollama", + "url": "http://127.0.0.1:11434", + "model": "nomic-embed-text" + }, + + "use_native_tools": true +} diff --git a/profiles/openrouter.json b/profiles/openrouter.json new file mode 100644 index 000000000..745656b58 --- /dev/null +++ b/profiles/openrouter.json @@ -0,0 +1,15 @@ +{ + "name": "openrouter", + + "cooldown": 5000, + + "model": { + "api": "openrouter", + "url": "https://openrouter.ai/api/v1", + "model": "x-ai/grok-code-fast-1" + }, + + "embedding": "openai", + + "use_native_tools": true +} \ No newline at end of file diff --git a/profiles/qwen.json b/profiles/qwen.json index f6a3f461a..85b87e83f 100644 --- a/profiles/qwen.json +++ b/profiles/qwen.json @@ -1,17 +1,18 @@ { "name": "qwen", - "cooldown": 5000, "model": { "api": "qwen", "url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", - "model": "qwen-max" + "model": "qwen-plus" }, "embedding": { "api": "qwen", "url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", "model": "text-embedding-v3" - } + }, + + "use_native_tools": false } \ No newline at end of file diff --git a/profiles/vllm.json b/profiles/vllm.json index a5ab382c3..7ba6d82df 100644 --- a/profiles/vllm.json +++ b/profiles/vllm.json @@ -6,5 +6,7 @@ "model": "Qwen/Qwen2.5-1.5B-Instruct", "url": "http://127.0.0.1:8000/v1" }, - "embedding": "openai" + "embedding": "openai", + + "use_native_tools": true } \ No newline at end of file diff --git a/settings.js b/settings.js index d7450cf8d..56ec71c54 100644 --- a/settings.js +++ b/settings.js @@ -15,18 +15,29 @@ const settings = { // "./profiles/claude.json", // "./profiles/gemini.json", // "./profiles/llama.json", + // "./profiles/groq.json", + // "./profiles/cerebras.json", + // "./profiles/hyperbolic.json", // "./profiles/qwen.json", // "./profiles/grok.json", // "./profiles/mistral.json", // "./profiles/deepseek.json", // "./profiles/mercury.json", // "./profiles/andy-4.json", // Supports up to 75 messages! + // "./profiles/openrouter.json", + // "./profiles/mercury.json" + // "./profiles/huggingface.json" + // "./profiles/replicate.json" + // "./profiles/glhf.json" + // "./profiles/novita.json" + // "./profiles/ollama.json" + // "./profiles/azure.json" // using more than 1 profile requires you to /msg each bot indivually // individual profiles override values from the base profile ], - "load_memory": false, // load memory from previous session + "load_memory": true, // load memory from previous session "init_message": "Respond with hello world and your name", // sends to all on spawn "only_chat_with": [], // users that the bots listen to and send general messages to. if empty it will chat publicly @@ -44,9 +55,16 @@ const settings = { "allow_vision": false, // allows vision model to interpret screenshots as inputs "blocked_actions" : ["!checkBlueprint", "!checkBlueprintLevel", "!getBlueprint", "!getBlueprintLevel"] , // commands to disable and remove from docs. Ex: ["!setMode"] "code_timeout_mins": -1, // minutes code is allowed to run. -1 for no timeout - "relevant_docs_count": 5, // number of relevant code function docs to select for prompting. -1 for all + "relevant_docs_count": 15, // number of relevant code function docs to select for prompting. -1 for all - "max_messages": 15, // max number of messages to keep in context + // code workspace configuration - strictly enforced security measure + "code_workspaces": [ + "bots/{BOT_NAME}/action-code", + "bots/{BOT_NAME}/learnedSkills", + "bots/{BOT_NAME}/" + ], + + "max_messages": 10, // max number of messages to keep in context "num_examples": 2, // number of examples to give to the model "max_commands": -1, // max number of commands that can be used in consecutive responses. -1 for no limit "show_command_syntax": "full", // "full", "shortened", or "none" @@ -58,7 +76,7 @@ const settings = { "log_all_prompts": false, // log ALL prompts to file -} +}; if (process.env.SETTINGS_JSON) { try { diff --git a/src/agent/action_manager.js b/src/agent/action_manager.js index 9b9d0d279..31eae6ed6 100644 --- a/src/agent/action_manager.js +++ b/src/agent/action_manager.js @@ -58,8 +58,7 @@ export class ActionManager { } } - async _executeAction(actionLabel, actionFn, timeout = 10) { - let TIMEOUT; + async _executeAction(actionLabel, actionFn, timeout = 20) { try { if (this.last_action_time > 0) { let time_diff = Date.now() - this.last_action_time; @@ -82,10 +81,19 @@ export class ActionManager { this.last_action_time = Date.now(); console.log('executing code...\n'); - // await current action to finish (executing=false), with 10 seconds timeout + // await current action to finish (executing=false), with 20 seconds timeout // also tell agent.bot to stop various actions if (this.executing) { - console.log(`action "${actionLabel}" trying to interrupt current action "${this.currentActionLabel}"`); + if (this.currentActionLabel.startsWith('mode:self_preservation')) { + console.log(`action "${actionLabel}" waiting for self_preservation to complete...`); + while (this.executing && this.currentActionLabel.startsWith('mode:self_preservation')) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + } else { + console.log(`action "${actionLabel}" trying to interrupt current action "${this.currentActionLabel}"`); + this.agent.bot.interrupt_code = true; + this.agent.bot.pathfinder.stop(); + } } await this.stop(); @@ -96,49 +104,72 @@ export class ActionManager { this.currentActionLabel = actionLabel; this.currentActionFn = actionFn; - // timeout in minutes - if (timeout > 0) { - TIMEOUT = this._startTimeout(timeout); - } - - // start the action - await actionFn(); + // start the action with interrupt and timeout check + const result = await Promise.race([ + actionFn().then(() => ({ completed: true })), + new Promise((resolve) => { + // Set default timeout if not specified + const timeoutMs = (timeout > 0 ? timeout : 10) * 60 * 1000; // default 10 minutes + + const timeoutId = setTimeout(() => { + this.timedout = true; + resolve({ timedout: true }); + }, timeoutMs); + + const check = () => { + if (this.agent.bot.interrupt_code) { + clearTimeout(timeoutId); + this.agent.bot.pathfinder.stop(); + resolve({ interrupted: true }); + } else { + setTimeout(check, 100); + } + }; + check(); + }) + ]); // mark action as finished + cleanup this.executing = false; this.currentActionLabel = ''; this.currentActionFn = null; - clearTimeout(TIMEOUT); // get bot activity summary let output = this.getBotOutputSummary(); - let interrupted = this.agent.bot.interrupt_code; - let timedout = this.timedout; + let interrupted = result.interrupted || this.agent.bot.interrupt_code; + let timedout = result.timedout || this.timedout; + + // add appropriate message based on result + if (result.interrupted) { + output += `Action "${actionLabel}" was interrupted.\n`; + } else if (result.timedout) { + output += `Action "${actionLabel}" timed out after ${timeout} minutes.\n`; + } this.agent.clearBotLogs(); - - // if not interrupted and not generating, emit idle event - if (!interrupted) { + // if not interrupted and not timed out, emit idle event + if (!interrupted && !timedout) { this.agent.bot.emit('idle'); } - // return action status report - return { success: true, message: output, interrupted, timedout }; + return { success: !interrupted && !timedout, + message: output, + interrupted, + timedout }; } catch (err) { this.executing = false; this.currentActionLabel = ''; this.currentActionFn = null; - clearTimeout(TIMEOUT); this.cancelResume(); console.error("Code execution triggered catch:", err); // Log the full stack trace console.error(err.stack); await this.stop(); - err = err.toString(); + const errorMessage = err.toString(); let message = this.getBotOutputSummary() + - '!!Code threw exception!!\n' + - 'Error: ' + err + '\n' + - 'Stack trace:\n' + err.stack+'\n'; + '## Action threw exception\n' + + '# Error: ' + errorMessage + '\n' + + '# Stack trace:\n' + (err.stack || 'No stack trace available') + '\n'; let interrupted = this.agent.bot.interrupt_code; this.agent.clearBotLogs(); diff --git a/src/agent/agent.js b/src/agent/agent.js index f5a8e3d52..7be1aec5b 100644 --- a/src/agent/agent.js +++ b/src/agent/agent.js @@ -17,6 +17,8 @@ import settings from './settings.js'; import { Task } from './tasks/tasks.js'; import { speak } from './speak.js'; import { log, validateNameFormat, handleDisconnection } from './connection_handler.js'; +import path from 'path'; +import process from 'process'; export class Agent { async start(load_mem=false, init_message=null, count_id=0) { @@ -30,6 +32,11 @@ export class Agent { this.name = (this.prompter.getName() || '').trim(); console.log(`Initializing agent ${this.name}...`); + // Auto-complete relative paths to absolute paths for code_workspaces + this.code_workspaces = settings.code_workspaces.map(workspace => { + return path.join(process.cwd(), workspace); + }); + // Validate Name Format // connection_handler now ensures the message has [LoginGuard] prefix const nameCheck = validateNameFormat(this.name); @@ -195,9 +202,8 @@ export class Agent { }; if (save_data?.self_prompt) { - if (init_message) { - this.history.add('system', init_message); - } + // When self-prompting is active, don't add init_message to avoid conflicts + // The self-prompter will handle the messaging await this.self_prompter.handleLoad(save_data.self_prompt, save_data.self_prompting_state); } if (save_data?.last_sender) { diff --git a/src/agent/coder.js b/src/agent/coder.js index 18a5f2618..04b4604fb 100644 --- a/src/agent/coder.js +++ b/src/agent/coder.js @@ -1,225 +1,156 @@ -import { writeFile, readFile, mkdirSync } from 'fs'; -import { makeCompartment, lockdown } from './library/lockdown.js'; -import * as skills from './library/skills.js'; -import * as world from './library/world.js'; -import { Vec3 } from 'vec3'; -import {ESLint} from "eslint"; +import { sleep } from 'groq-sdk/core.mjs'; +import { ToolManager } from './tools/toolManager.js'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); export class Coder { constructor(agent) { this.agent = agent; - this.file_counter = 0; - this.fp = '/bots/'+agent.name+'/action-code/'; - this.code_template = ''; - this.code_lint_template = ''; - - readFile('./bots/execTemplate.js', 'utf8', (err, data) => { - if (err) throw err; - this.code_template = data; - }); - readFile('./bots/lintTemplate.js', 'utf8', (err, data) => { - if (err) throw err; - this.code_lint_template = data; - }); - mkdirSync('.' + this.fp, { recursive: true }); + this.codeToolsManager = new ToolManager(agent); + this.MAX_ATTEMPTS; + this.debug = false; + // Modes to pause during coding to prevent interference + // this.MODES_TO_PAUSE = ['unstuck', 'item_collecting', 'hunting', 'self_defense', 'self_preservation']; //TODO: remove after test + this.MODES_TO_PAUSE = ['unstuck', 'item_collecting']; } - async generateCode(agent_history) { - this.agent.bot.modes.pause('unstuck'); - lockdown(); - // this message history is transient and only maintained in this function - let messages = agent_history.getHistory(); - messages.push({role: 'system', content: 'Code generation started. Write code in codeblock in your response:'}); - - const MAX_ATTEMPTS = 5; - const MAX_NO_CODE = 3; - - let code = null; - let no_code_failures = 0; - for (let i=0; i this.agent.bot.modes.pause(mode)); + for (let i = 0; i < this.MAX_ATTEMPTS; i++) { + try { + if (this.agent.bot.interrupt_code && this.debug == false) + return "Coding session interrupted"; + + // Step 1: Get AI response with interrupt check + const response = await Promise.race([ + this.agent.prompter.promptCoding(messages, codingGoal), + new Promise((_, reject) => { + const check = () => { + if (this.agent.bot.interrupt_code) { + this.agent.bot.pathfinder.stop(); + // This prevents deadlock when promptCoding is still waiting for AI response + this.agent.prompter.awaiting_coding = false; + console.log('[Coder] Interrupt detected, reset awaiting_coding flag'); + reject(new Error('Interrupted coding session')); + } else { + setTimeout(check, 100); + } + }; + check(); + }) + ]); + if (response.includes('Range of input length should be')) { + continue; } + messages.push({ role: 'assistant', content: response }); + console.log('Response:', response); - if (no_code_failures >= MAX_NO_CODE) { - console.warn("Action failed, agent would not write code."); - return 'Action failed, agent would not write code.'; + // Step 2: Handle no response case + if (response.includes('//no response')) { + this.agent.bot.interrupt_code = true; + console.log('Received no response due to concurrent request protection. Waiting...'); + await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second + continue; + } + // Step 3: Validate Tool format + if (!this.codeToolsManager.parseJSONTools(response).hasTools) { + console.log('Response is not in Tool format. Please use Tool command format.'); + messages.push({ role: 'user', content: 'Response is not in Tool format. Please use Tool command format as described above.' }); + continue; } - messages.push({ - role: 'system', - content: 'Error: no code provided. Write code in codeblock in your response. ``` // example ```'} - ); - console.warn("No code block generated. Trying again."); - no_code_failures++; - continue; - } - code = res.substring(res.indexOf('```')+3, res.lastIndexOf('```')); - const result = await this._stageCode(code); - const executionModule = result.func; - const lintResult = await this._lintCode(result.src_lint_copy); - if (lintResult) { - const message = 'Error: Code lint error:'+'\n'+lintResult+'\nPlease try again.'; - console.warn("Linting error:"+'\n'+lintResult+'\n'); - messages.push({ role: 'system', content: message }); - continue; - } - if (!executionModule) { - console.warn("Failed to stage code, something is wrong."); - return 'Failed to stage code, something is wrong.'; - } - - try { - console.log('Executing code...'); - await executionModule.main(this.agent.bot); - - const code_output = this.agent.actions.getBotOutputSummary(); - const summary = "Agent wrote this code: \n```" + this._sanitizeCode(code) + "```\nCode Output:\n" + code_output; - return summary; - } catch (e) { - if (this.agent.bot.interrupt_code) - return null; - - console.warn('Generated code threw error: ' + e.toString()); - console.warn('trying again...'); - - const code_output = this.agent.actions.getBotOutputSummary(); - - messages.push({ - role: 'assistant', - content: res - }); - messages.push({ - role: 'system', - content: `Code Output:\n${code_output}\nCODE EXECUTION THREW ERROR: ${e.toString()}\n Please try again:` - }); - } - } - return `Code generation failed after ${MAX_ATTEMPTS} attempts.`; - } - async _lintCode(code) { - let result = '#### CODE ERROR INFO ###\n'; - // Extract everything in the code between the beginning of 'skills./world.' and the '(' - const skillRegex = /(?:skills|world)\.(.*?)\(/g; - const skills = []; - let match; - while ((match = skillRegex.exec(code)) !== null) { - skills.push(match[1]); - } - const allDocs = await this.agent.prompter.skill_libary.getAllSkillDocs(); - // check function exists - const missingSkills = skills.filter(skill => !!allDocs[skill]); - if (missingSkills.length > 0) { - result += 'These functions do not exist.\n'; - result += '### FUNCTIONS NOT FOUND ###\n'; - result += missingSkills.join('\n'); - console.log(result) - return result; - } - - const eslint = new ESLint(); - const results = await eslint.lintText(code); - const codeLines = code.split('\n'); - const exceptions = results.map(r => r.messages).flat(); - - if (exceptions.length > 0) { - exceptions.forEach((exc, index) => { - if (exc.line && exc.column ) { - const errorLine = codeLines[exc.line - 1]?.trim() || 'Unable to retrieve error line content'; - result += `#ERROR ${index + 1}\n`; - result += `Message: ${exc.message}\n`; - result += `Location: Line ${exc.line}, Column ${exc.column}\n`; - result += `Related Code Line: ${errorLine}\n`; + // Step 3: Execute tools + const toolResult = await this.codeToolsManager.processResponse(response); + + // Step 4: Build execution feedback + let toolResultFeedback = toolResult.success + ? `##Tool execution succeeded##\n${toolResult.message}` + : `##Tool execution failed##\nPlease check command format and parameters.\n${toolResult.message}`; + console.log(toolResult.success + ? '\x1b[32mTool execution succeeded: ' + toolResult.message + '\x1b[0m' + : '\x1b[31mTool execution failed: ' + toolResult.message + '\x1b[0m'); + + // Step 5: Process detailed results and check for finish coding + if (toolResult.results && toolResult.results.length > 0) { + toolResultFeedback += '\n\nDetailed tool results:'; + for (let i = 0; i < toolResult.results.length; i++) { + const result = toolResult.results[i]; + toolResultFeedback += `\n- Tool ${i + 1} (${result.tool}): ${result.message}`; + // Check for finish coding and exit immediately + if (result.tool === 'FinishCoding' && result.success && result.action === 'finish_coding') { + console.log('\x1b[32m### Coding session finished by AI request\x1b[0m'); + return result.message; + } + } + } + + // Step 6: Continue coding loop + messages.push({ role: 'user', content: toolResultFeedback }); + this._displayRecentMessages(messages);//TODO: remove after test + const operationSummary = toolResult.operations + ? toolResult.operations.map(op => `${op.tool}: ${op.path}`).join(', ') + : 'No operations recorded'; + console.log('Tool operations completed successfully'); + console.log(operationSummary); + + } catch (error) { + // Reset awaiting_coding flag in case of error to prevent deadlock + this.agent.prompter.awaiting_coding = false; + console.log('[Coder] Error caught, reset awaiting_coding flag'); + + messages.push({ role: 'user', content: `Code generation error: ${error.message}` }); + console.warn(`Security check: Attempt ${i + 1} failed: ${error.message}`); } - }); - result += 'The code contains exceptions and cannot continue execution.'; - } else { - return null;//no error - } - - return result ; - } - // write custom code to file and import it - // write custom code to file and prepare for evaluation - async _stageCode(code) { - code = this._sanitizeCode(code); - let src = ''; - code = code.replaceAll('console.log(', 'log(bot,'); - code = code.replaceAll('log("', 'log(bot,"'); - - console.log(`Generated code: """${code}"""`); - - // this may cause problems in callback functions - code = code.replaceAll(';\n', '; if(bot.interrupt_code) {log(bot, "Code interrupted.");return;}\n'); - for (let line of code.split('\n')) { - src += ` ${line}\n`; - } - let src_lint_copy = this.code_lint_template.replace('/* CODE HERE */', src); - src = this.code_template.replace('/* CODE HERE */', src); - - let filename = this.file_counter + '.js'; - // if (this.file_counter > 0) { - // let prev_filename = this.fp + (this.file_counter-1) + '.js'; - // unlink(prev_filename, (err) => { - // console.log("deleted file " + prev_filename); - // if (err) console.error(err); - // }); - // } commented for now, useful to keep files for debugging - this.file_counter++; - - let write_result = await this._writeFilePromise('.' + this.fp + filename, src); - // This is where we determine the environment the agent's code should be exposed to. - // It will only have access to these things, (in addition to basic javascript objects like Array, Object, etc.) - // Note that the code may be able to modify the exposed objects. - const compartment = makeCompartment({ - skills, - log: skills.log, - world, - Vec3, - }); - const mainFn = compartment.evaluate(src); - - if (write_result) { - console.error('Error writing code execution file: ' + result); - return null; - } - return { func:{main: mainFn}, src_lint_copy: src_lint_copy }; - } - - _sanitizeCode(code) { - code = code.trim(); - const remove_strs = ['Javascript', 'javascript', 'js'] - for (let r of remove_strs) { - if (code.startsWith(r)) { - code = code.slice(r.length); - return code; } + + return `Code generation failed after ${this.MAX_ATTEMPTS} attempts.`; + + } finally { + this.MODES_TO_PAUSE.forEach(mode => this.agent.bot.modes.unpause(mode)); + this.agent.prompter.awaiting_coding = false; } - return code; } - _writeFilePromise(filename, src) { - // makes it so we can await this function - return new Promise((resolve, reject) => { - writeFile(filename, src, (err) => { - if (err) { - reject(err); - } else { - resolve(); - } - }); + /** + * TODO: Remove after testing + * Display the last 4 messages from the conversation history + * @param {Array} messages - The message history array + */ + _displayRecentMessages(messages) { + console.log("\x1b[32m==================:\x1b[0m"); + // Display the last 4 messages + const lastMessages = messages.slice(-4); + lastMessages.forEach((msg, index) => { + console.log(`\x1b[32mMessage ${index + 1} (${msg.role}):\x1b[0m`); + // Process escape characters to make the content easier to read + let content = msg.content; + if (typeof content === 'string') { + // Create a regular expression for ANSI escape sequences + const ansiEscape = String.fromCharCode(27) + '\\[[0-9]+m'; + const ansiRegex = new RegExp(ansiEscape, 'g'); + + content = content + .replace(/\\n/g, '\n') // Convert \\n to actual newline + .replace(/\\t/g, '\t') // Convert \\t to actual tab + .replace(/\\"/g, '"') // Convert \\\" to a quote + .replace(ansiRegex, ''); // Remove ANSI color codes + } + console.log(`\x1b[32m${content}\x1b[0m`); + console.log('\x1b[32m---\x1b[0m'); }); + console.log("\x1b[32m==================\x1b[0m"); } } \ No newline at end of file diff --git a/src/agent/commands/actions.js b/src/agent/commands/actions.js index f348487ed..16f3a57b5 100644 --- a/src/agent/commands/actions.js +++ b/src/agent/commands/actions.js @@ -20,7 +20,7 @@ function runAsAction (actionFn, resume = false, timeout = -1) { if (code_return.interrupted && !code_return.timedout) return; return code_return.message; - } + }; return wrappedAction; } @@ -28,9 +28,9 @@ function runAsAction (actionFn, resume = false, timeout = -1) { export const actionsList = [ { name: '!newAction', - description: 'Perform new and unknown custom behaviors that are not available as a command.', + description: 'Perform new and unknown custom behaviors that are not available as a command. Enter coding mode to write JavaScript code for complex tasks. AI will use tools to create, edit, and execute code files to accomplish the given goal. ', params: { - 'prompt': { type: 'string', description: 'A natural language prompt to guide code generation. Make a detailed step-by-step plan.' } + 'prompt': { type: 'string', description: 'Perform new and unknown custom behaviors that are not available as a command. Enter coding mode to write JavaScript code for complex tasks. AI will use tools to create, edit, and execute code files to accomplish the given goal. ' } }, perform: async function(agent, prompt) { // just ignore prompt - it is now in context in chat history @@ -41,7 +41,7 @@ export const actionsList = [ let result = ""; const actionFn = async () => { try { - result = await agent.coder.generateCode(agent.history); + result = await agent.coder.generateCode(agent.history,prompt); } catch (e) { result = 'Error generating code: ' + e.toString(); } @@ -67,23 +67,24 @@ export const actionsList = [ { name: '!stfu', description: 'Stop all chatting and self prompting, but continue current action.', - perform: async function (agent) { + perform: function (agent) { agent.openChat('Shutting up.'); agent.shutUp(); - return; + return 'Agent is now quiet.'; } }, { name: '!restart', description: 'Restart the agent process.', - perform: async function (agent) { + perform: function (agent) { agent.cleanKill(); + return 'Agent restart initiated.'; } }, { name: '!clearChat', description: 'Clear the chat history.', - perform: async function (agent) { + perform: function (agent) { agent.history.clear(); return agent.name + "'s chat history was cleared, starting new conversation from scratch."; } @@ -97,6 +98,7 @@ export const actionsList = [ }, perform: runAsAction(async (agent, player_name, closeness) => { await skills.goToPlayer(agent.bot, player_name, closeness); + return `Went to player ${player_name}.`; }) }, { @@ -108,6 +110,7 @@ export const actionsList = [ }, perform: runAsAction(async (agent, player_name, follow_dist) => { await skills.followPlayer(agent.bot, player_name, follow_dist); + return `Following player ${player_name} at distance ${follow_dist}.`; }, true) }, { @@ -121,6 +124,7 @@ export const actionsList = [ }, perform: runAsAction(async (agent, x, y, z, closeness) => { await skills.goToPosition(agent.bot, x, y, z, closeness); + return `Went to coordinates ${x}, ${y}, ${z}.`; }) }, { @@ -132,10 +136,11 @@ export const actionsList = [ }, perform: runAsAction(async (agent, block_type, range) => { if (range < 32) { - log(agent.bot, `Minimum search range is 32.`); + skills.log(agent.bot, `Minimum search range is 32.`); range = 32; } await skills.goToNearestBlock(agent.bot, block_type, 4, range); + return `Found and went to nearest ${block_type}.`; }) }, { @@ -147,6 +152,7 @@ export const actionsList = [ }, perform: runAsAction(async (agent, entity_type, range) => { await skills.goToNearestEntity(agent.bot, entity_type, 4, range); + return `Found and went to nearest ${entity_type}.`; }) }, { @@ -155,13 +161,14 @@ export const actionsList = [ params: {'distance': { type: 'float', description: 'The distance to move away.', domain: [0, Infinity] }}, perform: runAsAction(async (agent, distance) => { await skills.moveAway(agent.bot, distance); + return `Moved away from current location by ${distance}.`; }) }, { name: '!rememberHere', description: 'Save the current location with a given name.', params: {'name': { type: 'string', description: 'The name to remember the location as.' }}, - perform: async function (agent, name) { + perform: function (agent, name) { const pos = agent.bot.entity.position; agent.memory_bank.rememberPlace(name, pos.x, pos.y, pos.z); return `Location saved as "${name}".`; @@ -174,10 +181,11 @@ export const actionsList = [ perform: runAsAction(async (agent, name) => { const pos = agent.memory_bank.recallPlace(name); if (!pos) { - skills.log(agent.bot, `No location named "${name}" saved.`); - return; + skills.log(agent.bot, `No location named "${name}" saved.`); + return `No location named "${name}" saved.`; } await skills.goToPosition(agent.bot, pos[0], pos[1], pos[2], 1); + return `Successfully went to remembered place "${name}".`; }) }, { @@ -190,6 +198,7 @@ export const actionsList = [ }, perform: runAsAction(async (agent, player_name, item_name, num) => { await skills.giveToPlayer(agent.bot, item_name, player_name, num); + return `Gave ${item_name} ${num} times to player ${player_name}.`; }) }, { @@ -198,6 +207,7 @@ export const actionsList = [ params: {'item_name': { type: 'ItemName', description: 'The name of the item to consume.' }}, perform: runAsAction(async (agent, item_name) => { await skills.consume(agent.bot, item_name); + return `Consumed ${item_name}.`; }) }, { @@ -206,6 +216,7 @@ export const actionsList = [ params: {'item_name': { type: 'ItemName', description: 'The name of the item to equip.' }}, perform: runAsAction(async (agent, item_name) => { await skills.equip(agent.bot, item_name); + return `Equipped ${item_name}.`; }) }, { @@ -217,6 +228,7 @@ export const actionsList = [ }, perform: runAsAction(async (agent, item_name, num) => { await skills.putInChest(agent.bot, item_name, num); + return `Put ${item_name} ${num} times.`; }) }, { @@ -228,6 +240,7 @@ export const actionsList = [ }, perform: runAsAction(async (agent, item_name, num) => { await skills.takeFromChest(agent.bot, item_name, num); + return `Took ${item_name} ${num} times.`; }) }, { @@ -236,6 +249,7 @@ export const actionsList = [ params: { }, perform: runAsAction(async (agent) => { await skills.viewChest(agent.bot); + return `Viewed nearest chest.`; }) }, { @@ -250,6 +264,7 @@ export const actionsList = [ await skills.moveAway(agent.bot, 5); await skills.discard(agent.bot, item_name, num); await skills.goToPosition(agent.bot, start_loc.x, start_loc.y, start_loc.z, 0); + return `Discarded ${item_name} ${num} times.`; }) }, { @@ -261,6 +276,7 @@ export const actionsList = [ }, perform: runAsAction(async (agent, type, num) => { await skills.collectBlock(agent.bot, type, num); + return `Collected ${type} ${num} times.`; }, false, 10) // 10 minute timeout }, { @@ -272,6 +288,7 @@ export const actionsList = [ }, perform: runAsAction(async (agent, recipe_name, num) => { await skills.craftRecipe(agent.bot, recipe_name, num); + return `Crafted ${recipe_name} ${num} times.`; }) }, { @@ -282,12 +299,8 @@ export const actionsList = [ 'num': { type: 'int', description: 'The number of times to smelt the item.', domain: [1, Number.MAX_SAFE_INTEGER] } }, perform: runAsAction(async (agent, item_name, num) => { - let success = await skills.smeltItem(agent.bot, item_name, num); - if (success) { - setTimeout(() => { - agent.cleanKill('Safely restarting to update inventory.'); - }, 500); - } + await skills.smeltItem(agent.bot, item_name, num); + return `Smelted ${item_name} ${num} times.`; }) }, { @@ -296,6 +309,7 @@ export const actionsList = [ params: { }, perform: runAsAction(async (agent) => { await skills.clearNearestFurnace(agent.bot); + return `Cleared nearest furnace.`; }) }, { @@ -305,6 +319,7 @@ export const actionsList = [ perform: runAsAction(async (agent, type) => { let pos = agent.bot.entity.position; await skills.placeBlock(agent.bot, type, pos.x, pos.y, pos.z); + return `Placed ${type} at ${pos.x}, ${pos.y}, ${pos.z}.`; }) }, { @@ -313,6 +328,7 @@ export const actionsList = [ params: {'type': { type: 'string', description: 'The type of entity to attack.'}}, perform: runAsAction(async (agent, type) => { await skills.attackNearest(agent.bot, type, true); + return `Attacked nearest entity of type ${type}.`; }) }, { @@ -323,9 +339,10 @@ export const actionsList = [ let player = agent.bot.players[player_name]?.entity; if (!player) { skills.log(agent.bot, `Could not find player ${player_name}.`); - return false; + return `Could not find player ${player_name}.`; } await skills.attackEntity(agent.bot, player, true); + return `Attacked player ${player_name}.`; }) }, { @@ -333,6 +350,7 @@ export const actionsList = [ description: 'Go to the nearest bed and sleep.', perform: runAsAction(async (agent) => { await skills.goToBed(agent.bot); + return `Go to bed.`; }) }, { @@ -341,6 +359,7 @@ export const actionsList = [ params: {'type': { type: 'int', description: 'The number of seconds to stay. -1 for forever.', domain: [-1, Number.MAX_SAFE_INTEGER] }}, perform: runAsAction(async (agent, seconds) => { await skills.stay(agent.bot, seconds); + return `Stayed for ${seconds} seconds.`; }) }, { @@ -350,7 +369,7 @@ export const actionsList = [ 'mode_name': { type: 'string', description: 'The name of the mode to enable.' }, 'on': { type: 'boolean', description: 'Whether to enable or disable the mode.' } }, - perform: async function (agent, mode_name, on) { + perform: function (agent, mode_name, on) { const modes = agent.bot.modes; if (!modes.exists(mode_name)) return `Mode ${mode_name} does not exist.` + modes.getDocs(); @@ -366,19 +385,21 @@ export const actionsList = [ params: { 'selfPrompt': { type: 'string', description: 'The goal prompt.' }, }, - perform: async function (agent, prompt) { + perform: function (agent, prompt) { if (convoManager.inConversation()) { agent.self_prompter.setPromptPaused(prompt); + return 'Goal set and will start after conversation ends.'; } else { agent.self_prompter.start(prompt); + return 'Self-prompting started with goal: ' + prompt; } } }, { name: '!endGoal', description: 'Call when you have accomplished your goal. It will stop self-prompting and the current action. ', - perform: async function (agent) { + perform: function (agent) { agent.self_prompter.stop(); return 'Self-prompting stopped.'; } @@ -389,6 +410,7 @@ export const actionsList = [ params: {'id': { type: 'int', description: 'The id number of the villager that you want to trade with.' }}, perform: runAsAction(async (agent, id) => { await skills.showVillagerTrades(agent.bot, id); + return `Showed trades of villager ${id}.`; }) }, { @@ -401,6 +423,7 @@ export const actionsList = [ }, perform: runAsAction(async (agent, id, index, count) => { await skills.tradeWithVillager(agent.bot, id, index, count); + return `Traded with villager ${id} ${index} times.`; }) }, { @@ -410,14 +433,17 @@ export const actionsList = [ 'player_name': { type: 'string', description: 'The name of the player to send the message to.' }, 'message': { type: 'string', description: 'The message to send.' }, }, - perform: async function (agent, player_name, message) { + perform: function (agent, player_name, message) { if (!convoManager.isOtherAgent(player_name)) return player_name + ' is not a bot, cannot start conversation.'; if (convoManager.inConversation() && !convoManager.inConversation(player_name)) convoManager.forceEndCurrentConversation(); - else if (convoManager.inConversation(player_name)) + else if (convoManager.inConversation(player_name)) { agent.history.add('system', 'You are already in conversation with ' + player_name + '. Don\'t use this command to talk to them.'); + return 'Already in conversation with ' + player_name + '.'; + } convoManager.startConversation(player_name, message); + return 'Started conversation with ' + player_name + '.'; } }, { @@ -426,7 +452,7 @@ export const actionsList = [ params: { 'player_name': { type: 'string', description: 'The name of the player to end the conversation with.' } }, - perform: async function (agent, player_name) { + perform: function (agent, player_name) { if (!convoManager.inConversation(player_name)) return `Not in conversation with ${player_name}.`; convoManager.endConversation(player_name); @@ -477,7 +503,8 @@ export const actionsList = [ description: 'Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.', params: {'distance': { type: 'int', description: 'Distance to dig down', domain: [1, Number.MAX_SAFE_INTEGER] }}, perform: runAsAction(async (agent, distance) => { - await skills.digDown(agent.bot, distance) + await skills.digDown(agent.bot, distance); + return `Dug down ${distance} blocks.`; }) }, { @@ -497,6 +524,7 @@ export const actionsList = [ }, perform: runAsAction(async (agent, tool_name, target) => { await skills.useToolOn(agent.bot, tool_name, target); + return `Used ${tool_name} on ${target}.`; }) }, ]; diff --git a/src/agent/commands/index.js b/src/agent/commands/index.js index 7ada04088..5431c214f 100644 --- a/src/agent/commands/index.js +++ b/src/agent/commands/index.js @@ -224,7 +224,10 @@ export async function executeCommand(agent, message) { return `Command ${command.name} was given ${numArgs} args, but requires ${numParams(command)} args.`; else { const result = await command.perform(agent, ...parsed.args); - return result; + if(!result) + return `${command.name} failed and got undefined.`; + else + return result; } } } diff --git a/src/agent/commands/queries.js b/src/agent/commands/queries.js index ad5b701ee..03d21d05c 100644 --- a/src/agent/commands/queries.js +++ b/src/agent/commands/queries.js @@ -109,6 +109,47 @@ export const queryList = [ let blocks = world.getNearestBlocks(bot); let block_details = new Set(); + // Get current position and blocks + let position = bot.entity.position; + let currentBlock = bot.blockAt(position); + let blockBelow = bot.blockAt(position.offset(0, -1, 0)); + + // Check current position status + res += '\n\nCURRENT POSITION STATUS:'; + // Block at current position (where bot's body is) + if (currentBlock) { + let currentDetails = currentBlock.name; + if (currentBlock.name === 'lava') { + currentDetails += currentBlock.metadata === 0 ? ' (source)' : ' (flowing)'; + res += `\n- In ${currentDetails} - YOU ARE TAKING DAMAGE, YOU'RE ABOUT TO DIE!`; + } else if (currentBlock.name === 'water') { + currentDetails += currentBlock.metadata === 0 ? ' (source)' : ' (flowing)'; + res += `\n- In ${currentDetails}`; + } else if (currentBlock.name === 'air') { + res += '\n- In air'; + } else { + res += `\n- Inside ${currentDetails}`; + } + } + if (blockBelow) { + let belowDetails = blockBelow.name; + if (blockBelow.name === 'water' || blockBelow.name === 'lava') { + belowDetails += blockBelow.metadata === 0 ? ' (source)' : ' (flowing)'; + } + res += `\n- Standing on: ${belowDetails}`; + } + // Check if bot is in liquid + let inWater = bot.isInWater; + let inLava = bot.isInLava; + if (inWater) { + res += '\n- Status: Swimming in water'; + } else if (inLava) { + res += '\n- Status: In lava (taking damage!)'; + } else { + res += '\n- Status: On solid ground'; + } + + res += '\n\nNEARBY BLOCKS:'; for (let block of blocks) { let details = block.name; if (block.name === 'water' || block.name === 'lava') { diff --git a/src/agent/library/learnedSkillsManager.js b/src/agent/library/learnedSkillsManager.js new file mode 100644 index 000000000..821780633 --- /dev/null +++ b/src/agent/library/learnedSkillsManager.js @@ -0,0 +1,232 @@ +import fs from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +/** + * LearnedSkillsManager - Manages dynamically loaded learnedSkills + * Provides a unified skill access interface with caching and incremental updates + */ +export class LearnedSkillsManager { + constructor() { + this.skillsCache = new Map(); + this.docsCache = new Map(); + this.lastModified = new Map(); + } + + async getLearnedSkillsForBot(botName) { + if (!botName) return []; + + const cacheKey = botName; + const skillsPath = this._getSkillsPath(botName); + + const needsUpdate = await this._needsCacheUpdate(skillsPath, cacheKey); + + if (!needsUpdate && this.skillsCache.has(cacheKey)) { + return this.skillsCache.get(cacheKey); + } + + const skillModules = await this._loadSkillModulesFromPath(skillsPath); + this.skillsCache.set(cacheKey, skillModules); + + return skillModules; + } + + async hasSkill(botName, skillName) { + const skillModules = await this.getLearnedSkillsForBot(botName); + return skillModules.some(module => module.functionName === skillName); + } + + async getSkillDocs(botName) { + if (!botName) return []; + + const cacheKey = botName; + const skillsPath = this._getSkillsPath(botName); + + const needsUpdate = await this._needsCacheUpdate(skillsPath, cacheKey); + + if (!needsUpdate && this.docsCache.has(cacheKey)) { + return this.docsCache.get(cacheKey); + } + + const docs = await this._extractDocsFromPath(skillsPath); + this.docsCache.set(cacheKey, docs); + + return docs; + } + + validateSkillContent(content) { + try { + if (!content.includes('export async function')) { + return { valid: false, error: 'Skill file must export async function' }; + } + + const forbidden = [ + 'require(', + 'eval(', + '__dirname', + '__filename', + 'process.exit', + 'fs.writeFile', + 'fs.unlink' + ]; + + for (const pattern of forbidden) { + if (content.includes(pattern)) { + return { valid: false, error: `Skill code forbidden to use: ${pattern}` }; + } + } + + const openBraces = (content.match(/\{/g) || []).length; + const closeBraces = (content.match(/\}/g) || []).length; + if (openBraces !== closeBraces) { + return { valid: false, error: 'Syntax error: unmatched braces' }; + } + + return { valid: true }; + } catch (error) { + return { valid: false, error: `Validation error: ${error.message}` }; + } + } + + + _getSkillsPath(botName) { + const projectRoot = path.resolve(__dirname, '../../..'); + return path.join(projectRoot, 'bots', botName, 'learnedSkills'); + } + + async _needsCacheUpdate(skillsPath, cacheKey) { + try { + const files = await this._getSkillFiles(skillsPath); + + for (const file of files) { + const filePath = path.join(skillsPath, file); + const stats = await fs.stat(filePath); + const lastMod = this.lastModified.get(filePath); + + if (!lastMod || stats.mtime.getTime() > lastMod) { + return true; + } + } + + return false; + } catch (error) { + return true; + } + } + + async _loadSkillModulesFromPath(skillsPath) { + const skillModules = []; + + try { + const files = await this._getSkillFiles(skillsPath); + + for (const file of files) { + const filePath = path.join(skillsPath, file); + + try { + const content = await fs.readFile(filePath, 'utf8'); + + const validation = this.validateSkillContent(content); + if (!validation.valid) { + console.warn(`Skipping invalid skill file ${file}: ${validation.error}`); + continue; + } + + const functionMatch = content.match(/export\s+async\s+function\s+(\w+)/); + if (!functionMatch) { + console.warn(`No exported function found in ${file}`); + continue; + } + + const functionName = functionMatch[1]; + + skillModules.push({ + filePath, + content, + functionName + }); + + const stats = await fs.stat(filePath); + this.lastModified.set(filePath, stats.mtime.getTime()); + + } catch (error) { + console.warn(`Failed to load skill file ${file}: ${error.message}`); + } + } + } catch (error) { + console.log(`learnedSkills folder doesn't exist or inaccessible: ${skillsPath}`); + } + + return skillModules; + } + + async _getSkillFiles(skillsPath) { + try { + const files = await fs.readdir(skillsPath); + return files.filter(file => file.endsWith('.js')); + } catch (error) { + return []; + } + } + + + async _extractDocsFromPath(skillsPath) { + const docs = []; + + try { + const files = await this._getSkillFiles(skillsPath); + + for (const file of files) { + const filePath = path.join(skillsPath, file); + + try { + const content = await fs.readFile(filePath, 'utf8'); + + const docContent = this._extractDocFromContent(content, file); + if (docContent) { + docs.push(docContent); + } + } catch (error) { + console.warn(`Failed to extract documentation ${file}: ${error.message}`); + } + } + } catch (error) { + // Folder doesn't exist, return empty array + } + + return docs; + } + + _extractDocFromContent(content, fileName) { + try { + const jsdocMatch = content.match(/\/\*\*([\s\S]*?)\*\//); + + const functionMatch = content.match(/export async function (\w+)\([^)]*\)/); + + if (!functionMatch) return null; + + const functionName = functionMatch[1]; + const functionSignature = functionMatch[0]; + + let doc = `learnedSkills.${functionName}\n${functionSignature}`; + + if (jsdocMatch) { + const cleanDoc = jsdocMatch[1] + .replace(/^\s*\*/gm, '') + .replace(/^\s+/gm, '') + .trim(); + + doc += `\n${cleanDoc}`; + } + + return doc; + } catch (error) { + console.warn(`Failed to extract documentation ${fileName}: ${error.message}`); + return null; + } + } +} + +export default LearnedSkillsManager; diff --git a/src/agent/library/skill_library.js b/src/agent/library/skill_library.js index 4470586f1..211cf19a5 100644 --- a/src/agent/library/skill_library.js +++ b/src/agent/library/skill_library.js @@ -1,6 +1,7 @@ import { cosineSimilarity } from '../../utils/math.js'; import { getSkillDocs } from './index.js'; import { wordOverlapScore } from '../../utils/text.js'; +import { LearnedSkillsManager } from './learnedSkillsManager.js'; export class SkillLibrary { constructor(agent,embedding_model) { @@ -8,7 +9,8 @@ export class SkillLibrary { this.embedding_model = embedding_model; this.skill_docs_embeddings = {}; this.skill_docs = null; - this.always_show_skills = ['skills.placeBlock', 'skills.wait', 'skills.breakBlockAt'] + this.always_show_skills = [];//TODO:for test + this.learnedSkillsManager = new LearnedSkillsManager(); } async initSkillLibrary() { const skillDocs = getSkillDocs(); @@ -34,12 +36,34 @@ export class SkillLibrary { } async getAllSkillDocs() { - return this.skill_docs; + // Get core skill docs + const coreSkillDocs = this.skill_docs || []; + + let learnedSkillDocs = []; + if (this.agent && this.agent.name) { + learnedSkillDocs = await this.learnedSkillsManager.getSkillDocs(this.agent.name); + } + + return [...learnedSkillDocs,...coreSkillDocs]; } async getRelevantSkillDocs(message, select_num) { - if(!message) // use filler message if none is provided + if(!message) message = '(no message)'; + + const allSkillDocs = await this.getAllSkillDocs(); + + for (const doc of allSkillDocs) { + if (!this.skill_docs_embeddings[doc] && this.embedding_model) { + try { + let func_name_desc = doc.split('\n').slice(0, 2).join(''); + this.skill_docs_embeddings[doc] = await this.embedding_model.embed(func_name_desc); + } catch (error) { + console.warn('Failed to embed skill doc:', error.message); + } + } + } + let skill_doc_similarities = []; if (select_num === -1) { @@ -71,7 +95,6 @@ export class SkillLibrary { if (select_num === -1 || select_num > length) { select_num = length; } - // Get initial docs from similarity scores let selected_docs = new Set(skill_doc_similarities.slice(0, select_num).map(doc => doc.doc_key)); // Add always show docs @@ -82,7 +105,7 @@ export class SkillLibrary { }); let relevant_skill_docs = '#### RELEVANT CODE DOCS ###\nThe following functions are available to use:\n'; - relevant_skill_docs += Array.from(selected_docs).join('\n### '); + relevant_skill_docs += '### ' + Array.from(selected_docs).join('\n### '); console.log('Selected skill docs:', Array.from(selected_docs).map(doc => { const first_line_break = doc.indexOf('\n'); diff --git a/src/agent/library/skills.js b/src/agent/library/skills.js index 715455073..e247f8300 100644 --- a/src/agent/library/skills.js +++ b/src/agent/library/skills.js @@ -38,13 +38,24 @@ export async function craftRecipe(bot, itemName, num=1) { * Attempt to craft the given item name from a recipe. May craft many items. * @param {MinecraftBot} bot, reference to the minecraft bot. * @param {string} itemName, the item name to craft. + * @param {number} num, the number of items to craft. Defaults to 1. * @returns {Promise} true if the recipe was crafted, false otherwise. * @example - * await skills.craftRecipe(bot, "stick"); + * await skills.craftRecipe(bot, "stick", 4); **/ + + // Cheat mode: use /give command to instantly get crafted items + if (bot.modes.isOn('cheat')) { + bot.chat(`/give @s ${itemName} ${num}`); + log(bot, `Used cheat mode to give ${num} ${itemName}.`); + await new Promise(resolve => setTimeout(resolve, 100)); // Small delay for command processing + return true; + } + let placedTable = false; - - if (mc.getItemCraftingRecipes(itemName).length == 0) { + + const craftingRecipes = mc.getItemCraftingRecipes(itemName); + if (!craftingRecipes || craftingRecipes.length == 0) { log(bot, `${itemName} is either not an item, or it does not have a crafting recipe!`); return false; } @@ -65,6 +76,10 @@ export async function craftRecipe(bot, itemName, num=1) { let hasTable = world.getInventoryCounts(bot)['crafting_table'] > 0; if (hasTable) { let pos = world.getNearestFreeSpace(bot, 1, 6); + if(pos == null) { + log(bot, `Could not find a free space to place crafting table.`); + return false; + } await placeBlock(bot, 'crafting_table', pos.x, pos.y, pos.z); craftingTable = world.getNearestBlock(bot, 'crafting_table', craftingTableRange); if (craftingTable) { @@ -82,7 +97,10 @@ export async function craftRecipe(bot, itemName, num=1) { } } if (!recipes || recipes.length === 0) { - log(bot, `You do not have the resources to craft a ${itemName}. It requires: ${Object.entries(mc.getItemCraftingRecipes(itemName)[0][0]).map(([key, value]) => `${key}: ${value}`).join(', ')}.`); + const recipeRequirements = craftingRecipes && craftingRecipes[0] && craftingRecipes[0][0] + ? Object.entries(craftingRecipes[0][0]).map(([key, value]) => `${key}: ${value}`).join(', ') + : 'unknown ingredients'; + log(bot, `You do not have the resources to craft a ${itemName}. It requires: ${recipeRequirements}.`); if (placedTable) { await collectBlock(bot, 'crafting_table', 1); } @@ -165,6 +183,10 @@ export async function smeltItem(bot, itemName, num=1) { let hasFurnace = world.getInventoryCounts(bot)['furnace'] > 0; if (hasFurnace) { let pos = world.getNearestFreeSpace(bot, 1, furnaceRange); + if(pos == null) { + log(bot, `Could not find a free space to place furnace.`); + return false; + } await placeBlock(bot, 'furnace', pos.x, pos.y, pos.z); furnaceBlock = world.getNearestBlock(bot, 'furnace', furnaceRange); placedFurnace = true; @@ -375,7 +397,7 @@ export async function defendSelf(bot, range=9) { * @returns {Promise} true if the bot found any enemies and has killed them, false if no entities were found. * @example * await skills.defendSelf(bot); - * **/ + **/ bot.modes.pause('self_defense'); bot.modes.pause('cowardice'); let attacked = false; @@ -429,6 +451,14 @@ export async function collectBlock(bot, blockType, num=1, exclude=null) { log(bot, `Invalid number of blocks to collect: ${num}.`); return false; } + + // Cheat mode: use /give command to instantly get items + if (bot.modes.isOn('cheat')) { + bot.chat(`/give @s ${blockType} ${num}`); + log(bot, `Used cheat mode to give ${num} ${blockType}.`); + await new Promise(resolve => setTimeout(resolve, 100)); // Small delay for command processing + return true; + } let blocktypes = [blockType]; if (blockType === 'coal' || blockType === 'diamond' || blockType === 'emerald' || blockType === 'iron' || blockType === 'gold' || blockType === 'lapis_lazuli' || blockType === 'redstone') blocktypes.push(blockType+'_ore'); @@ -450,6 +480,10 @@ export async function collectBlock(bot, blockType, num=1, exclude=null) { const unsafeBlocks = ['obsidian']; for (let i=0; i { if (!blocktypes.includes(block.name)) { return false; @@ -459,6 +493,10 @@ export async function collectBlock(bot, blockType, num=1, exclude=null) { if (block.position.x === position.x && block.position.y === position.y && block.position.z === position.z) { return false; } + if (bot.interrupt_code) { + log(bot, 'Interrupted while collecting block.'); + return false; + } } } if (isLiquid) { @@ -540,7 +578,7 @@ export async function pickupNearbyItems(bot) { const getNearestItem = bot => bot.nearestEntity(entity => entity.name === 'item' && bot.entity.position.distanceTo(entity.position) < distance); let nearestItem = getNearestItem(bot); let pickedUp = 0; - while (nearestItem) { + while (nearestItem && !bot.interrupt_code) { let movements = new pf.Movements(bot); movements.canDig = false; bot.pathfinder.setMovements(movements); @@ -561,14 +599,21 @@ export async function pickupNearbyItems(bot) { export async function breakBlockAt(bot, x, y, z) { /** * Break the block at the given position. Will use the bot's equipped item. + * Automatically clears obstructing blocks in line of sight before breaking target block. + * IMPORTANT: This function only breaks the block. Items will drop on the ground. + * To collect the dropped items, use skills.pickupNearbyItems(bot) after breaking. * @param {MinecraftBot} bot, reference to the minecraft bot. * @param {number} x, the x coordinate of the block to break. * @param {number} y, the y coordinate of the block to break. * @param {number} z, the z coordinate of the block to break. * @returns {Promise} true if the block was broken, false otherwise. * @example - * let position = world.getPosition(bot); - * await skills.breakBlockAt(bot, position.x, position.y - 1, position.x); + * // Break block and pickup dropped items + * let block = world.getNearestBlock(bot, "oak_log", 32); + * if (block) { + * await skills.breakBlockAt(bot, block.position.x, block.position.y, block.position.z); + * await skills.pickupNearbyItems(bot); // Collect the drops + * } **/ if (x == null || y == null || z == null) throw new Error('Invalid position to break block at.'); let block = bot.blockAt(Vec3(x, y, z)); @@ -589,6 +634,37 @@ export async function breakBlockAt(bot, x, y, z) { bot.pathfinder.setMovements(movements); await goToGoal(bot, new pf.goals.GoalNear(pos.x, pos.y, pos.z, 4)); } + + // Clear obstructing blocks in line of sight + for (let attempts = 0; attempts < 10 && !bot.interrupt_code; attempts++) { + if (bot.canSeeBlock(block)) break; + + const eyePos = bot.entity.position.offset(0, 1.6, 0); + const direction = block.position.offset(0.5, 0.5, 0.5).minus(eyePos).normalize(); + const maxDistance = eyePos.distanceTo(block.position); + + let obstructingBlock = null; + for (let d = 0.1; d < maxDistance; d += 0.1) { + const checkBlock = bot.blockAt(eyePos.offset(direction.x * d, direction.y * d, direction.z * d).floor()); + if (checkBlock && checkBlock.name !== 'air' && !checkBlock.position.equals(block.position)) { + obstructingBlock = checkBlock; + break; + } + } + + if (!obstructingBlock?.diggable) break; + + log(bot, `Clearing obstruction: ${obstructingBlock.name}`); + if (bot.game.gameMode !== 'creative') await bot.tool.equipForBlock(obstructingBlock); + try { + await bot.dig(obstructingBlock, true); + await new Promise(resolve => setTimeout(resolve, 200)); + } catch (err) { + log(bot, `Failed to clear obstruction: ${err.message}`); + break; + } + } + if (bot.game.gameMode !== 'creative') { await bot.tool.equipForBlock(block); const itemId = bot.heldItem ? bot.heldItem.type : null @@ -669,12 +745,14 @@ export async function placeBlock(bot, blockType, x, y, z, placeOn='bottom', dont if (useDelay) { await new Promise(resolve => setTimeout(resolve, blockPlaceDelay)); } let msg = '/setblock ' + Math.floor(x) + ' ' + Math.floor(y) + ' ' + Math.floor(z) + ' ' + blockType; bot.chat(msg); - if (blockType.includes('door')) + if (blockType.includes('door')) { if (useDelay) { await new Promise(resolve => setTimeout(resolve, blockPlaceDelay)); } bot.chat('/setblock ' + Math.floor(x) + ' ' + Math.floor(y+1) + ' ' + Math.floor(z) + ' ' + blockType + '[half=upper]'); - if (blockType.includes('bed')) + } + if (blockType.includes('bed')) { if (useDelay) { await new Promise(resolve => setTimeout(resolve, blockPlaceDelay)); } bot.chat('/setblock ' + Math.floor(x) + ' ' + Math.floor(y) + ' ' + Math.floor(z-1) + ' ' + blockType + '[part=head]'); + } log(bot, `Used /setblock to place ${blockType} at ${target_dest}.`); return true; } @@ -738,8 +816,12 @@ export async function placeBlock(bot, blockType, x, y, z, placeOn='bottom', dont dirs.push(...Object.values(dir_map).filter(d => !dirs.includes(d))); for (let d of dirs) { + if (bot.interrupt_code) { + log(bot, 'Interrupted while placing block.'); + return false; + } const block = bot.blockAt(target_dest.plus(d)); - if (!empty_blocks.includes(block.name)) { + if (block && !empty_blocks.includes(block.name)) { buildOffBlock = block; faceVec = new Vec3(-d.x, -d.y, -d.z); // invert break; @@ -904,7 +986,7 @@ export async function takeFromChest(bot, itemName, num=-1) { * @returns {Promise} true if the item was taken from the chest, false otherwise. * @example * await skills.takeFromChest(bot, "oak_log"); - * **/ + **/ let chest = world.getNearestBlock(bot, 'chest', 32); if (!chest) { log(bot, `Could not find a chest nearby.`); @@ -928,7 +1010,10 @@ export async function takeFromChest(bot, itemName, num=-1) { // Take items from each slot until we've taken enough or run out for (const item of matchingItems) { if (remaining <= 0) break; - + if (bot.interrupt_code) { + log(bot, 'Interrupted while taking from chest.'); + return false; + } let toTakeFromSlot = Math.min(remaining, item.count); await chestContainer.withdraw(item.type, null, toTakeFromSlot); @@ -948,7 +1033,7 @@ export async function viewChest(bot) { * @returns {Promise} true if the chest was viewed, false otherwise. * @example * await skills.viewChest(bot); - * **/ + **/ let chest = world.getNearestBlock(bot, 'chest', 32); if (!chest) { log(bot, `Could not find a chest nearby.`); @@ -1077,6 +1162,10 @@ export async function goToGoal(bot, goal) { const nonDestructiveMovements = new pf.Movements(bot); const dontBreakBlocks = ['glass', 'glass_pane']; for (let block of dontBreakBlocks) { + if (bot.interrupt_code) { + log(bot, 'Interrupted while pathfinding.'); + return false; + } nonDestructiveMovements.blocksCantBreak.add(mc.getBlockId(block)); } nonDestructiveMovements.placeCost = 2; @@ -1128,6 +1217,12 @@ function startDoorInterval(bot) { const doorCheckInterval = setInterval(() => { + // Check for interrupt signal and clear interval if interrupted + if (bot.interrupt_code) { + clearInterval(doorCheckInterval); + return; + } + const now = Date.now(); if (bot.entity.position.distanceTo(prev_pos) >= 0.1) { stuck_time = 0; @@ -1158,6 +1253,11 @@ function startDoorInterval(bot) { } for (let position of positions) { + if (bot.interrupt_code) { + clearInterval(doorCheckInterval); + log(bot, 'Interrupted while opening door.'); + break; + } let block = bot.blockAt(position); if (block && block.name && !block.name.includes('iron') && @@ -1173,7 +1273,7 @@ function startDoorInterval(bot) { } prev_pos = bot.entity.position.clone(); prev_check = now; - }, 200); + }, 100); _doorInterval = doorCheckInterval; return doorCheckInterval; } @@ -1185,11 +1285,12 @@ export async function goToPosition(bot, x, y, z, min_distance=2) { * @param {number} x, the x coordinate to navigate to. If null, the bot's current x coordinate will be used. * @param {number} y, the y coordinate to navigate to. If null, the bot's current y coordinate will be used. * @param {number} z, the z coordinate to navigate to. If null, the bot's current z coordinate will be used. - * @param {number} distance, the distance to keep from the position. Defaults to 2. + * @param {number} min_distance, the distance to keep from the position. Defaults to 2. * @returns {Promise} true if the position was reached, false otherwise. * @example - * let position = world.world.getNearestBlock(bot, "oak_log", 64).position; - * await skills.goToPosition(bot, position.x, position.y, position.x + 20); + * // getNearestBlock returns a Block object, use .position to get coordinates + * let block = world.getNearestBlock(bot, "oak_log", 64); + * await skills.goToPosition(bot, block.position.x, block.position.y, block.position.z, 3); **/ if (x == null || y == null || z == null) { log(bot, `Missing coordinates, given x:${x} y:${y} z:${z}`); @@ -1201,7 +1302,14 @@ export async function goToPosition(bot, x, y, z, min_distance=2) { return true; } + let progressInterval; const checkDigProgress = () => { + // Check for interrupt signal and clear interval if interrupted + if (bot.interrupt_code) { + clearInterval(progressInterval); + return; + } + // Check if bot is trying to dig a block it cannot harvest if (bot.targetDigBlock) { const targetBlock = bot.targetDigBlock; const itemId = bot.heldItem ? bot.heldItem.type : null; @@ -1213,7 +1321,7 @@ export async function goToPosition(bot, x, y, z, min_distance=2) { } }; - const progressInterval = setInterval(checkDigProgress, 1000); + progressInterval = setInterval(checkDigProgress, 1000); try { await goToGoal(bot, new pf.goals.GoalNear(x, y, z, min_distance)); @@ -1244,7 +1352,7 @@ export async function goToNearestBlock(bot, blockType, min_distance=2, range=64 * @returns {Promise} true if the block was reached, false otherwise. * @example * await skills.goToNearestBlock(bot, "oak_log", 64, 2); - * **/ + **/ const MAX_RANGE = 512; if (range > MAX_RANGE) { log(bot, `Maximum search range capped at ${MAX_RANGE}. `); @@ -1333,9 +1441,10 @@ export async function followPlayer(bot, username, distance=4) { * Follow the given player endlessly. Will not return until the code is manually stopped. * @param {MinecraftBot} bot, reference to the minecraft bot. * @param {string} username, the username of the player to follow. + * @param {number} distance, the distance to keep from the player. Defaults to 4. * @returns {Promise} true if the player was found, false otherwise. * @example - * await skills.followPlayer(bot, "player"); + * await skills.followPlayer(bot, "player", 3); **/ let player = bot.players[username].entity if (!player) @@ -1411,13 +1520,15 @@ export async function moveAway(bot, distance) { if (bot.modes.isOn('cheat')) { const move = new pf.Movements(bot); const path = await bot.pathfinder.getPathTo(move, inverted_goal, 10000); - let last_move = path.path[path.path.length-1]; - if (last_move) { - let x = Math.floor(last_move.x); - let y = Math.floor(last_move.y); - let z = Math.floor(last_move.z); - bot.chat('/tp @s ' + x + ' ' + y + ' ' + z); - return true; + if (path && path.path && path.path.length > 0) { + let last_move = path.path[path.path.length-1]; + if (last_move) { + let x = Math.floor(last_move.x); + let y = Math.floor(last_move.y); + let z = Math.floor(last_move.z); + bot.chat('/tp @s ' + x + ' ' + y + ' ' + z); + return true; + } } } @@ -1511,6 +1622,10 @@ export async function useDoor(bot, door_pos=null) { 'mangrove_door', 'cherry_door', 'bamboo_door', 'crimson_door', 'warped_door']) { door_pos = world.getNearestBlock(bot, door_type, 16).position; if (door_pos) break; + if (bot.interrupt_code) { + log(bot, 'Interrupted while finding door.'); + return false; + } } } else { door_pos = Vec3(door_pos.x, door_pos.y, door_pos.z); @@ -1579,11 +1694,11 @@ export async function tillAndSow(bot, x, y, z, seedType=null) { * @param {number} x, the x coordinate to till. * @param {number} y, the y coordinate to till. * @param {number} z, the z coordinate to till. - * @param {string} plantType, the type of plant to plant. Defaults to none, which will only till the ground. + * @param {string} seedType, the type of seed to plant. Defaults to null, which will only till the ground. * @returns {Promise} true if the ground was tilled, false otherwise. * @example * let position = world.getPosition(bot); - * await skills.tillAndSow(bot, position.x, position.y - 1, position.x, "wheat"); + * await skills.tillAndSow(bot, position.x, position.y - 1, position.z, "wheat_seeds"); **/ let pos = new Vec3(Math.floor(x), Math.floor(y), Math.floor(z)); let block = bot.blockAt(pos); @@ -1592,6 +1707,10 @@ export async function tillAndSow(bot, x, y, z, seedType=null) { if (bot.modes.isOn('cheat')) { let to_remove = ['_seed', '_seeds']; for (let remove of to_remove) { + if (bot.interrupt_code) { + log(bot, 'Interrupted while tillAndSow.'); + return false; + } if (seedType.endsWith(remove)) { seedType = seedType.replace(remove, ''); } @@ -1657,7 +1776,7 @@ export async function activateNearestBlock(bot, type) { * @returns {Promise} true if the block was activated, false otherwise. * @example * await skills.activateNearestBlock(bot, "lever"); - * **/ + **/ let block = world.getNearestBlock(bot, type, 16); if (!block) { log(bot, `Could not find any ${type} to activate.`); @@ -1673,13 +1792,14 @@ export async function activateNearestBlock(bot, type) { return true; } -/** - * Helper function to find and navigate to a villager for trading - * @param {MinecraftBot} bot - reference to the minecraft bot - * @param {number} id - the entity id of the villager - * @returns {Promise} the villager entity if found and reachable, null otherwise - */ + async function findAndGoToVillager(bot, id) { + /** + * Helper function to find and navigate to a villager for trading + * @param {MinecraftBot} bot - reference to the minecraft bot + * @param {number} id - the entity id of the villager + * @returns {Promise} the villager entity if found and reachable, null otherwise + **/ id = id+""; const entity = bot.entities[id]; @@ -1688,6 +1808,10 @@ async function findAndGoToVillager(bot, id) { let entities = world.getNearbyEntities(bot, 16); let villager_list = "Available villagers:\n"; for (let entity of entities) { + if (bot.interrupt_code) { + log(bot, 'Interrupted while findAndGoToVillager.'); + return false; + } if (entity.name === 'villager') { if (entity.metadata && entity.metadata[16] === 1) { villager_list += `${entity.id}: baby villager\n`; @@ -1737,15 +1861,16 @@ async function findAndGoToVillager(bot, id) { return entity; } -/** - * Show available trades for a specified villager - * @param {MinecraftBot} bot - reference to the minecraft bot - * @param {number} id - the entity id of the villager to show trades for - * @returns {Promise} true if trades were shown successfully, false otherwise - * @example - * await skills.showVillagerTrades(bot, "123"); - */ + export async function showVillagerTrades(bot, id) { + /** + * Show available trades for a specified villager + * @param {MinecraftBot} bot - reference to the minecraft bot + * @param {number} id - the entity id of the villager to show trades for + * @returns {Promise} true if trades were shown successfully, false otherwise + * @example + * await skills.showVillagerTrades(bot, "123"); + **/ const villagerEntity = await findAndGoToVillager(bot, id); if (!villagerEntity) { return false; @@ -1776,17 +1901,18 @@ export async function showVillagerTrades(bot, id) { } } -/** - * Trade with a specified villager - * @param {MinecraftBot} bot - reference to the minecraft bot - * @param {number} id - the entity id of the villager to trade with - * @param {number} index - the index (1-based) of the trade to execute - * @param {number} count - how many times to execute the trade (optional) - * @returns {Promise} true if trade was successful, false otherwise - * @example - * await skills.tradeWithVillager(bot, "123", "1", "2"); - */ + export async function tradeWithVillager(bot, id, index, count) { + /** + * Trade with a specified villager + * @param {MinecraftBot} bot - reference to the minecraft bot + * @param {number} id - the entity id of the villager to trade with + * @param {number} index - the index (1-based) of the trade to execute + * @param {number} count - how many times to execute the trade (optional) + * @returns {Promise} true if trade was successful, false otherwise + * @example + * await skills.tradeWithVillager(bot, "123", "1", "2"); + **/ const villagerEntity = await findAndGoToVillager(bot, id); if (!villagerEntity) { return false; @@ -1915,6 +2041,10 @@ export async function digDown(bot, distance = 10) { let start_block_pos = bot.blockAt(bot.entity.position).position; for (let i = 1; i <= distance; i++) { + if (bot.interrupt_code) { + log(bot, 'Interrupted while digDown.'); + return false; + } const targetBlock = bot.blockAt(start_block_pos.offset(0, -i, 0)); let belowBlock = bot.blockAt(start_block_pos.offset(0, -i-1, 0)); @@ -1933,6 +2063,10 @@ export async function digDown(bot, distance = 10) { const MAX_FALL_BLOCKS = 2; let num_fall_blocks = 0; for (let j = 0; j <= MAX_FALL_BLOCKS; j++) { + if (bot.interrupt_code) { + log(bot, 'Interrupted while digDown.'); + return false; + } if (!belowBlock || (belowBlock.name !== 'air' && belowBlock.name !== 'cave_air')) { break; } @@ -1968,6 +2102,10 @@ export async function goToSurface(bot) { **/ const pos = bot.entity.position; for (let y = 360; y > -64; y--) { // probably not the best way to find the surface but it works + if (bot.interrupt_code) { + log(bot, 'Interrupted while goToSurface.'); + return false; + } const block = bot.blockAt(new Vec3(pos.x, y, pos.z)); if (!block || block.name === 'air' || block.name === 'cave_air') { continue; @@ -1986,7 +2124,10 @@ export async function useToolOn(bot, toolName, targetName) { * @param {string} toolName - item name of the tool to equip, or "hand" for no tool. * @param {string} targetName - entity type, block type, or "nothing" for no target * @returns {Promise} true if action succeeded - */ + * @example + * await skills.useToolOn(bot, "water_bucket", "lava"); + * await skills.useToolOn(bot, "shears", "sheep"); + **/ if (!bot.inventory.slots.find(slot => slot && slot.name === toolName) && !bot.game.gameMode === 'creative') { log(bot, `You do not have any ${toolName} to use.`); return false; @@ -2048,7 +2189,10 @@ export async function useToolOn(bot, toolName, targetName) { * @param {string} toolName - item name of the tool to equip, or "hand" for no tool. * @param {Block} block - the block reference to use the tool on. * @returns {Promise} true if action succeeded - */ + * @example + * let lavaBlock = world.getNearestBlock(bot, "lava", 32); + * await skills.useToolOnBlock(bot, "bucket", lavaBlock); + **/ const distance = toolName === 'water_bucket' && block.name !== 'lava' ? 1.5 : 2; await goToPosition(bot, block.position.x, block.position.y, block.position.z, distance); @@ -2058,6 +2202,9 @@ export async function useToolOn(bot, toolName, targetName) { const viewBlocked = () => { const blockInView = bot.blockAtCursor(5); const headPos = bot.entity.position.offset(0, bot.entity.height, 0); + log(bot, `Block in view: ${blockInView.name} at ${blockInView.position}, target block: ${block.name} at ${block.position}`); + console.log(`Block in view: ${blockInView.name} at ${blockInView.position}, target block: ${block.name} at ${block.position}`); + return blockInView && !blockInView.position.equals(block.position) && blockInView.position.distanceTo(headPos) < block.position.distanceTo(headPos); diff --git a/src/agent/library/world.js b/src/agent/library/world.js index d993a0931..445256cfd 100644 --- a/src/agent/library/world.js +++ b/src/agent/library/world.js @@ -1,5 +1,6 @@ import pf from 'mineflayer-pathfinder'; import * as mc from '../../utils/mcdata.js'; +import { Vec3 } from 'vec3'; export function getNearestFreeSpace(bot, size=1, distance=8) { @@ -19,13 +20,13 @@ export function getNearestFreeSpace(bot, size=1, distance=8) { maxDistance: distance, count: 1000 }); - for (let i = 0; i < empty_pos.length; i++) { + for (let i = 0; i < empty_pos.length && !bot.interrupt_code; i++) { let empty = true; - for (let x = 0; x < size; x++) { - for (let z = 0; z < size; z++) { + for (let x = 0; x < size && !bot.interrupt_code; x++) { + for (let z = 0; z < size && !bot.interrupt_code; z++) { let top = bot.blockAt(empty_pos[i].offset(x, 0, z)); let bottom = bot.blockAt(empty_pos[i].offset(x, -1, z)); - if (!top || !top.name == 'air' || !bottom || bottom.drops.length == 0 || !bottom.diggable) { + if (!top || !top.name == 'air' || !bottom || !bottom.drops || bottom.drops.length == 0 || !bottom.diggable) { empty = false; break; } @@ -36,20 +37,27 @@ export function getNearestFreeSpace(bot, size=1, distance=8) { return empty_pos[i]; } } + return null; // No free space found } export function getBlockAtPosition(bot, x=0, y=0, z=0) { /** - * Get a block from the bot's relative position + * Get a block at a RELATIVE position offset from the bot. + * IMPORTANT: Parameters are RELATIVE offsets, NOT absolute world coordinates! + * For absolute coordinates, use bot.blockAt(Vec3(x, y, z)) instead. * @param {Bot} bot - The bot to get the block for. - * @param {number} x - The relative x offset to serach, default 0. - * @param {number} y - The relative y offset to serach, default 0. - * @param {number} y - The relative z offset to serach, default 0. - * @returns {Block} - The nearest block. + * @param {number} x - The RELATIVE x offset from bot's position, default 0. + * @param {number} y - The RELATIVE y offset from bot's position, default 0. + * @param {number} z - The RELATIVE z offset from bot's position, default 0. + * @returns {Block} - The block at the relative position. * @example + * // Get block directly below the bot (relative position) * let blockBelow = world.getBlockAtPosition(bot, 0, -1, 0); - * let blockAbove = world.getBlockAtPosition(bot, 0, 2, 0); since minecraft position is at the feet + * // Get block 2 blocks above bot's feet (relative position) + * let blockAbove = world.getBlockAtPosition(bot, 0, 2, 0); + * // For absolute world coordinates, use bot.blockAt instead: + * // let block = bot.blockAt(Vec3(100, 64, 200)); **/ let block = bot.blockAt(bot.entity.position.offset(x, y, z)); if (!block) block = {name: 'air'}; @@ -67,9 +75,9 @@ export function getSurroundingBlocks(bot) { **/ // Create a list of block position results that can be unpacked. let res = []; - res.push(`Block Below: ${getBlockAtPosition(bot, 0, -1, 0).name}`); - res.push(`Block at Legs: ${getBlockAtPosition(bot, 0, 0, 0).name}`); - res.push(`Block at Head: ${getBlockAtPosition(bot, 0, 1, 0).name}`); + res.push(`***Block Below***: ${getBlockAtPosition(bot, 0, -1, 0).name}`); + res.push(`***Block at Legs***: ${getBlockAtPosition(bot, 0, 0, 0).name}`); + res.push(`***Block at Head***: ${getBlockAtPosition(bot, 0, 1, 0).name}`); return res; } @@ -92,13 +100,14 @@ export function getFirstBlockAboveHead(bot, ignore_types=null, distance=32) { if (!Array.isArray(ignore_types)) ignore_types = [ignore_types]; for(let ignore_type of ignore_types) { + if(bot.interrupt_code) return 'none'; if (mc.getBlockId(ignore_type)) ignore_blocks.push(ignore_type); } } // The block above, stops when it finds a solid block . let block_above = {name: 'air'}; - let height = 0 - for (let i = 0; i < distance; i++) { + let height = 0; + for (let i = 0; i < distance && !bot.interrupt_code; i++) { let block = bot.blockAt(bot.entity.position.offset(0, i+2, 0)); if (!block) block = {name: 'air'}; // Ignore and continue @@ -135,6 +144,7 @@ export function getNearestBlocks(bot, block_types=null, distance=8, count=10000) if (!Array.isArray(block_types)) block_types = [block_types]; for(let block_type of block_types) { + if(bot.interrupt_code) break; block_ids.push(mc.getBlockId(block_type)); } } @@ -145,15 +155,26 @@ export function getNearestBlocksWhere(bot, predicate, distance=8, count=10000) { /** * Get a list of the nearest blocks that satisfy the given predicate. * @param {Bot} bot - The bot to get the nearest blocks for. - * @param {function} predicate - The predicate to filter the blocks. + * @param {function|array} predicate - The predicate to filter the blocks, or array of block IDs. * @param {number} distance - The maximum distance to search, default 16. * @param {number} count - The maximum number of blocks to find, default 10000. * @returns {Block[]} - The nearest blocks that satisfy the given predicate. * @example * let waterBlocks = world.getNearestBlocksWhere(bot, block => block.name === 'water', 16, 10); **/ - let positions = bot.findBlocks({matching: predicate, maxDistance: distance, count: count}); - let blocks = positions.map(position => bot.blockAt(position)); + let positions; + if (Array.isArray(predicate)) { + // If predicate is an array of block IDs, use it directly + positions = bot.findBlocks({matching: predicate, maxDistance: distance, count: count}); + } else { + // If predicate is a function, use it as matching function + positions = bot.findBlocks({matching: predicate, maxDistance: distance, count: count}); + } + let blocks = positions.map(position => { + let block = bot.blockAt(position); + block.position = position; // Add position property to block + return block; + }); return blocks; } @@ -164,9 +185,12 @@ export function getNearestBlock(bot, block_type, distance=16) { * @param {Bot} bot - The bot to get the nearest block for. * @param {string} block_type - The name of the block to search for. * @param {number} distance - The maximum distance to search, default 16. - * @returns {Block} - The nearest block of the given type. + * @returns {Block|null} - The nearest Block object, or null if not found. Use block.position.x/y/z to get coordinates. * @example - * let coalBlock = world.getNearestBlock(bot, 'coal_ore', 16); + * let oakLog = world.getNearestBlock(bot, 'oak_log', 32); + * if (oakLog) { + * await skills.breakBlockAt(bot, oakLog.position.x, oakLog.position.y, oakLog.position.z); + * } **/ let blocks = getNearestBlocks(bot, block_type, distance, 1); if (blocks.length > 0) { @@ -179,6 +203,7 @@ export function getNearestBlock(bot, block_type, distance=16) { export function getNearbyEntities(bot, maxDistance=16) { let entities = []; for (const entity of Object.values(bot.entities)) { + if(bot.interrupt_code) break; const distance = entity.position.distanceTo(bot.entity.position); if (distance > maxDistance) continue; entities.push({ entity: entity, distance: distance }); @@ -186,6 +211,7 @@ export function getNearbyEntities(bot, maxDistance=16) { entities.sort((a, b) => a.distance - b.distance); let res = []; for (let i = 0; i < entities.length; i++) { + if(bot.interrupt_code) break; res.push(entities[i].entity); } return res; @@ -200,6 +226,7 @@ export function getNearbyPlayers(bot, maxDistance) { if (maxDistance == null) maxDistance = 16; let players = []; for (const entity of Object.values(bot.entities)) { + if(bot.interrupt_code) break; const distance = entity.position.distanceTo(bot.entity.position); if (distance > maxDistance) continue; if (entity.type == 'player' && entity.username != bot.username) { @@ -209,6 +236,7 @@ export function getNearbyPlayers(bot, maxDistance) { players.sort((a, b) => a.distance - b.distance); let res = []; for (let i = 0; i < players.length; i++) { + if(bot.interrupt_code) break; res.push(players[i].entity); } return res; @@ -262,6 +290,7 @@ export function getVillagerProfession(entity) { export function getInventoryStacks(bot) { let inventory = []; for (const item of bot.inventory.items()) { + if(bot.interrupt_code) break; if (item != null) { inventory.push(item); } @@ -282,6 +311,7 @@ export function getInventoryCounts(bot) { **/ let inventory = {}; for (const item of bot.inventory.items()) { + if(bot.interrupt_code) break; if (item != null) { if (inventory[item.name] == null) { inventory[item.name] = 0; @@ -304,6 +334,7 @@ export function getCraftableItems(bot) { let table = getNearestBlock(bot, 'crafting_table'); if (!table) { for (const item of bot.inventory.items()) { + if(bot.interrupt_code) break; if (item != null && item.name === 'crafting_table') { table = item; break; @@ -312,6 +343,7 @@ export function getCraftableItems(bot) { } let res = []; for (const item of mc.getAllItems()) { + if(bot.interrupt_code) break; let recipes = bot.recipesFor(item.id, null, 1, table); if (recipes.length > 0) res.push(item.name); @@ -344,6 +376,7 @@ export function getNearbyEntityTypes(bot) { let mobs = getNearbyEntities(bot, 16); let found = []; for (let i = 0; i < mobs.length; i++) { + if(bot.interrupt_code) break; if (!found.includes(mobs[i].name)) { found.push(mobs[i].name); } @@ -356,7 +389,7 @@ export function isEntityType(name) { * Check if a given name is a valid entity type. * @param {string} name - The name of the entity type to check. * @returns {boolean} - True if the name is a valid entity type, false otherwise. - */ + **/ return mc.getEntityId(name) !== null; } @@ -371,6 +404,7 @@ export function getNearbyPlayerNames(bot) { let players = getNearbyPlayers(bot, 64); let found = []; for (let i = 0; i < players.length; i++) { + if(bot.interrupt_code) break; if (!found.includes(players[i].username) && players[i].username != bot.username) { found.push(players[i].username); } @@ -391,6 +425,7 @@ export function getNearbyBlockTypes(bot, distance=16) { let blocks = getNearestBlocks(bot, null, distance); let found = []; for (let i = 0; i < blocks.length; i++) { + if(bot.interrupt_code) break; if (!found.includes(blocks[i].name)) { found.push(blocks[i].name); } @@ -405,7 +440,7 @@ export async function isClearPath(bot, target) { * @param {Entity} target - The target to path to. * @returns {boolean} - True if there is a clear path, false otherwise. */ - let movements = new pf.Movements(bot) + let movements = new pf.Movements(bot); movements.canDig = false; movements.canPlaceOn = false; movements.canOpenDoors = false; @@ -440,3 +475,90 @@ export function getBiomeName(bot) { const biomeId = bot.world.getBiome(bot.entity.position); return mc.getAllBiomes()[biomeId].name; } + +export function getBuildingStructure(bot, corner1, corner2) { + /** + * Extract building structure in a compact JSON format with material palette and layer-by-layer ASCII representation. + * Perfect for AI to understand and recreate buildings. Uses single-character symbols for each material. + * @param {MinecraftBot} bot - The minecraft bot + * @param {Vec3} corner1 - First corner of the building area (absolute coordinates) + * @param {Vec3} corner2 - Opposite corner of the building area (absolute coordinates) + * @returns {Object} Building structure in JSON format with materials palette and ASCII layers + * @example + * // Scan a 10x10x3 building + * const structure = world.getBuildingStructure(bot, new Vec3(0,0,0), new Vec3(10,3,10)); + * log(bot, JSON.stringify(structure, null, 2)); + * + * // Output format (can have ANY number of floors - 1, 2, 3, 5, 10, etc.): + * // { + * // "materials": ["A: minecraft:stone", "B: minecraft:air", ...], + * // "structures": [ + * // {"floor": 0, "structure": "AAA\nAAA\nAAA"}, // Ground (Y=0), each floor = 1 block height + * // {"floor": 1, "structure": "ABA\nBBB\nABA"}, // Level 1 (Y=1) + * // ... // add more floors as needed + * // {"floor": n, "structure": "AAA\nAAA\nAAA"} // Level n (Y=n) - no limit on number of floors + * // ], + * // "size": {"x": width, "y": height, "z": depth}, // y = number of floors (can be any number) + * // "offset": {"x": minX, "y": minY, "z": minZ}, + * // "corner1": {"x": minX, "y": minY, "z": minZ}, + * // "corner2": {"x": maxX, "y": maxY, "z": maxZ}, + * // "coordinateSystem": { + * // "description": "Top-down view (looking down at XZ plane)", + * // "xAxis": "Each \\n separates X lines, from X=minX to X=maxX", + * // "zAxis": "Each character in a line represents Z, from Z=minZ to Z=maxZ", + * // "yAxis": "Each floor = 1 block height, can have unlimited floors", + * // "readingOrder": "First line = minX row, first character in line = minZ column" + * // } + * // } + **/ + const minX = Math.min(corner1.x, corner2.x), maxX = Math.max(corner1.x, corner2.x); + const minY = Math.min(corner1.y, corner2.y), maxY = Math.max(corner1.y, corner2.y); + const minZ = Math.min(corner1.z, corner2.z), maxZ = Math.max(corner1.z, corner2.z); + + const blockTypes = new Map(); + const symbolChars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz@#$%&*+-='; + const layers = []; + + for (let y = minY; y <= maxY; y++) { + const layer = []; + for (let x = minX; x <= maxX; x++) { + const row = []; + for (let z = minZ; z <= maxZ; z++) { + const block = bot.blockAt(new Vec3(x, y, z)); + const name = block?.name || 'air'; + + if (!blockTypes.has(name)) { + const idx = blockTypes.size; + blockTypes.set(name, idx < symbolChars.length + ? symbolChars[idx] + : `${symbolChars[0]}${symbolChars[idx - symbolChars.length]}`); + } + row.push(name); + } + layer.push(row); + } + layers.push(layer); + } + + const materials = Array.from(blockTypes, ([name, symbol]) => `${symbol}: minecraft:${name}`); + const structures = layers.map((layer, i) => ({ + floor: minY + i, + structure: layer.map(row => row.map(name => blockTypes.get(name)).join('')).join('\n') + })); + + return { + materials, + structures, + size: { x: maxX - minX + 1, y: maxY - minY + 1, z: maxZ - minZ + 1 }, + offset: { x: minX, y: minY, z: minZ }, + corner1: { x: minX, y: minY, z: minZ }, + corner2: { x: maxX, y: maxY, z: maxZ }, + coordinateSystem: { + description: "Top-down view (looking down at XZ plane)", + xAxis: `Each \\n separates X lines, from X=${minX} to X=${maxX}`, + zAxis: `Each character in a line represents Z, from Z=${minZ} to Z=${maxZ}`, + yAxis: `Each floor in structures array represents Y, from Y=${minY} to Y=${maxY}`, + readingOrder: "First line = minX row, first character in line = minZ column" + } + }; +} diff --git a/src/agent/modes.js b/src/agent/modes.js index 21b7b955e..c284f60d6 100644 --- a/src/agent/modes.js +++ b/src/agent/modes.js @@ -1,7 +1,7 @@ import * as skills from './library/skills.js'; import * as world from './library/world.js'; import * as mc from '../utils/mcdata.js'; -import settings from './settings.js' +import settings from './settings.js'; import convoManager from './conversation.js'; async function say(agent, message) { @@ -44,6 +44,13 @@ const modes_list = [ else if (this.fall_blocks.some(name => blockAbove.name.includes(name))) { execute(this, agent, async () => { await skills.moveAway(bot, 2); + }).catch(error => { + console.error(`Error in self_preservation falling block avoidance:`, error); + say(agent, 'Failed to avoid falling blocks!'); + this.active = false; + setTimeout(() => { + this.active = true; + }, 5000).unref(); }); } else if (block.name === 'lava' || block.name === 'fire' || @@ -55,6 +62,9 @@ const modes_list = [ execute(this, agent, async () => { let success = await skills.placeBlock(bot, 'water_bucket', block.position.x, block.position.y, block.position.z); if (success) say(agent, 'Placed some water, ahhhh that\'s better!'); + }).catch(error => { + console.error(`Error placing water bucket:`, error); + say(agent, 'Failed to place water!'); }); } else { @@ -72,7 +82,17 @@ const modes_list = [ if (success) say(agent, 'Found some water, ahhhh that\'s better!'); return; } - await skills.moveAway(bot, 5); + else { + await skills.moveAway(bot, 5); + say(agent, 'I\'m on fire! Get me away from here!'); + } + }).catch(error => { + console.error(`Error escaping fire/lava:`, error); + say(agent, 'Failed to escape fire!'); + this.active = false; + setTimeout(() => { + this.active = true; + }, 5000).unref(); }); } } @@ -80,6 +100,13 @@ const modes_list = [ say(agent, 'I\'m dying!'); execute(this, agent, async () => { await skills.moveAway(bot, 20); + }).catch(error => { + console.error(`Error in emergency escape:`, error); + say(agent, 'Failed to escape danger!'); + this.active = false; + setTimeout(() => { + this.active = true; + }, 5000).unref(); }); } else if (agent.isIdle()) { @@ -120,13 +147,55 @@ const modes_list = [ } const max_stuck_time = cur_dig_block?.name === 'obsidian' ? this.max_stuck_time * 2 : this.max_stuck_time; if (this.stuck_time > max_stuck_time) { - say(agent, 'I\'m stuck!'); this.stuck_time = 0; execute(this, agent, async () => { - const crashTimeout = setTimeout(() => { agent.cleanKill("Got stuck and couldn't get unstuck") }, 10000); - await skills.moveAway(bot, 5); - clearTimeout(crashTimeout); - say(agent, 'I\'m free.'); + const initialPos = bot.entity.position.clone(); + + // Try 5 times to move to a random nearby position (1 block away) + let attemptSuccessful = false; + let successfulAttempts = 0; + let failedAttempts = 0; + for (let attempt = 0; attempt < 5 && !attemptSuccessful; attempt++) { + const randomX = initialPos.x + (Math.random() - 0.5) * 6; // -3 to +3 + const randomZ = initialPos.z + (Math.random() - 0.5) * 6; // -3 to +3 + const randomY = initialPos.y; // Keep same Y level + + try { + await skills.goToPosition(bot, randomX, randomY, randomZ, 1.0); + const currentPos = bot.entity.position; + const distance = initialPos.distanceTo(currentPos); + if (distance > 0.8) { + attemptSuccessful = true; + successfulAttempts++; + break; + } else { + failedAttempts++; + } + } catch (error) { + failedAttempts++; + console.log(`[Unstuck] Attempt ${attempt + 1}/5: ERROR - ${error.message}`); + continue; + } + } + if (attemptSuccessful) { + say(agent, 'I\'m free.').catch(err => console.error('Failed to say message:', err)); + } else { + // All attempts failed, wait 2 seconds and check if bot moved naturally + await new Promise(resolve => setTimeout(resolve, 2000)); + const finalPos = bot.entity.position; + const totalDistance = initialPos.distanceTo(finalPos); + if (totalDistance > 0.5) { + say(agent, 'I\'m free.').catch(err => console.error('Failed to say message:', err)); + } else { + console.log(`[Unstuck] FAILURE - Bot only moved ${totalDistance.toFixed(2)} blocks (< 0.5), restarting agent...`); + say(agent, 'Still stuck, restarting...'); + agent.cleanKill("Got stuck and couldn't get unstuck"); + } + } + }).catch(error => { + console.error(`Error in unstuck mode:`, error); + say(agent, 'Unstuck failed, restarting...'); + agent.cleanKill("Unstuck operation failed"); }); } this.last_time = Date.now(); @@ -149,6 +218,9 @@ const modes_list = [ say(agent, `Aaa! A ${enemy.name.replace("_", " ")}!`); execute(this, agent, async () => { await skills.avoidEnemies(agent.bot, 24); + }).catch(error => { + console.error(`Error avoiding enemies:`, error); + say(agent, 'Failed to avoid enemy!'); }); } } @@ -165,6 +237,9 @@ const modes_list = [ say(agent, `Fighting ${enemy.name}!`); execute(this, agent, async () => { await skills.defendSelf(agent.bot, 8); + }).catch(error => { + console.error(`Error in self defense:`, error); + say(agent, 'Failed to defend myself!'); }); } } @@ -181,6 +256,9 @@ const modes_list = [ execute(this, agent, async () => { say(agent, `Hunting ${huntable.name}!`); await skills.attackEntity(agent.bot, huntable); + }).catch(error => { + console.error(`Error hunting:`, error); + say(agent, 'Failed to hunt target!'); }); } } @@ -207,6 +285,9 @@ const modes_list = [ this.prev_item = item; execute(this, agent, async () => { await skills.pickupNearbyItems(agent.bot); + }).catch(error => { + console.error(`Error picking up items:`, error); + say(agent, 'Failed to pick up items!'); }); this.noticed_at = -1; } @@ -230,6 +311,8 @@ const modes_list = [ execute(this, agent, async () => { const pos = agent.bot.entity.position; await skills.placeBlock(agent.bot, 'torch', pos.x, pos.y, pos.z, 'bottom', true); + }).catch(error => { + console.error(`Error placing torch:`, error); }); this.last_place = Date.now(); } @@ -252,6 +335,8 @@ const modes_list = [ if (player.position.distanceTo(agent.bot.entity.position) < this.distance) { await skills.moveAwayFromEntity(agent.bot, player, this.distance); } + }).catch(error => { + console.error(`Error moving away from player:`, error); }); } } diff --git a/src/agent/tools/edit.js b/src/agent/tools/edit.js new file mode 100644 index 000000000..0cffd56c6 --- /dev/null +++ b/src/agent/tools/edit.js @@ -0,0 +1,100 @@ +import fs from 'fs'; +import path from 'path'; + +//Edit Tool - Performs exact string replacements in files +export class EditTool { + static description = 'Edit existing file by replacing old_string with new_string'; + static inputSchema = { + type: "object", + properties: { + file_path: { + type: "string", + description: "Absolute path to the file to edit" + }, + old_string: { + type: "string", + description: "The exact text to replace" + }, + new_string: { + type: "string", + description: "The new text to replace with" + }, + replace_all: { + type: "boolean", + description: "Replace all occurrences (default: false)" + } + }, + required: ["file_path", "old_string", "new_string"] + }; + + constructor(agent = null) { + this.name = 'Edit'; + this.agent = agent; + } + + /** + * Execute the edit operation + * @param {Object} params - The edit parameters + * @param {string} params.file_path - Absolute path to the file + * @param {string} params.old_string - Text to replace + * @param {string} params.new_string - Replacement text + * @param {boolean} params.replace_all - Replace all occurrences + * @returns {Object} Result object + */ + async execute(params) { + try { + const { file_path, old_string, new_string, replace_all = false } = params; + + if (!file_path || !old_string || new_string === undefined) { + throw new Error('[Edit Tool] Missing required parameters: file_path, old_string, new_string'); + } + + // Validate old_string and new_string are different + if (old_string === new_string) { + throw new Error('[Edit Tool] old_string and new_string must be different'); + } + if (!fs.existsSync(file_path)) { + throw new Error(`[Edit Tool] File does not exist: ${file_path}`); + } + const content = fs.readFileSync(file_path, 'utf8'); + const escapedOld = old_string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + if (!content.includes(old_string)) { + throw new Error(`[Edit Tool] String not found in file: "${old_string}"`); + } + if (!replace_all) { + const occurrences = (content.match(new RegExp(escapedOld, 'g')) || []).length; + if (occurrences > 1) { + throw new Error(`[Edit Tool] String "${old_string}" appears ${occurrences} times. Use replace_all=true or provide more context to make it unique`); + } + } + + // Perform replacement + let newContent; + if (replace_all) { + newContent = content.replaceAll(old_string, new_string); + } else { + newContent = content.replace(old_string, new_string); + } + + // Write back to file + fs.writeFileSync(file_path, newContent, 'utf8'); + const replacements = replace_all + ? (content.match(new RegExp(escapedOld, 'g')) || []).length + : 1; + return { + success: true, + message: `Successfully replaced ${replacements} occurrence(s) in ${path.basename(file_path)}`, + replacements, + file_path + }; + } catch (error) { + return { + success: false, + message: `## Edit Tool Error ##\n**Error:** ${error.message}`, + file_path: params.file_path + }; + } + } +} + +export default EditTool; diff --git a/src/agent/tools/execute.js b/src/agent/tools/execute.js new file mode 100644 index 000000000..2d27db801 --- /dev/null +++ b/src/agent/tools/execute.js @@ -0,0 +1,755 @@ +import fs from 'fs'; +import path from 'path'; +import { readFile } from 'fs/promises'; +import { makeCompartment } from '../library/lockdown.js'; +import * as skills from '../library/skills.js'; +import * as world from '../library/world.js'; +import { Vec3 } from 'vec3'; +import { LintTool } from './lint.js'; +import { LearnedSkillsManager } from '../library/learnedSkillsManager.js'; + +// Regex patterns for stack trace parsing +const StackTracePatterns = { + iife: /^\s*\(async\s*\(\s*bot\s*\)\s*=>\s*\{[\s\S]*?\}\)\s*(\(\))?\s*$/, + anonymous: /:(\d+):(\d+)/, + filePath: /at.*?\(([^)]+\.(js|ts)):(\d+):(\d+)\)/, + filePathAlt: /at.*?([^\s]+\.(js|ts)):(\d+):(\d+)/, + throwStatements: [ + /^\s*throw\s+error\s*;?\s*$/i, + /^\s*throw\s+new\s+Error\s*\(/i, + /^\s*throw\s+\w+\s*;?\s*$/i, + /^\s*throw\s+.*\.message\s*;?\s*$/i, + /^\s*throw\s+.*Error\s*\(/i, + /^\s*throw\s+.*error.*\s*;?\s*$/i + ] +}; + + +//Execute Tool - Executes JavaScript code files in Minecraft bot context +export class ExecuteTool { + static description = 'Execute JavaScript code file in the Minecraft bot environment with full access to skills and world APIs. Code MUST be in IIFE format: (async (bot) => { ... }) without trailing parentheses or semicolons'; + static inputSchema = { + type: "object", + properties: { + file_path: { + type: "string", + description: "Absolute path to the JavaScript file to execute. The file content MUST be in IIFE format: (async (bot) => { your code here }) - no trailing () or semicolon" + }, + description: { + type: "string", + description: "Description of what this code does" + } + }, + required: ["file_path"] + }; + + constructor(agent = null) { + this.name = 'Execute'; + this.agent = agent; + this.learnedSkillsManager = new LearnedSkillsManager(); + this.fileCache = new FileContentCache(); + this.errorAnalyzer = new ErrorAnalyzer(this.fileCache); + this.sandboxManager = new SandboxManager(this.learnedSkillsManager); + + } + + /** + * Execute JavaScript files - can handle single file or array of files + * @param {Object} params - The execution parameters + * @param {string|Array} params.file_path - Absolute path(s) to JavaScript file(s) + * @param {Array} [params.executable_files] - Array of executable files to choose from + * @param {string} [params.description] - Optional description + * @returns {Object} Result object + */ + async execute(params) { + let originalChat = null; + + try { + const targetFile = this._validateAndExtractTargetFile(params); + const fileData = await this.fileCache.getFileContent(targetFile); + + await this._validateFile(targetFile, fileData); + + originalChat = this._setupChatCapture(); + const compartment = await this.sandboxManager.createCompartment(this.agent); + const result = await this._executeWithTimeout(compartment, fileData.content, targetFile); + return this._formatSuccessResult(result, targetFile, params.description); + + } catch (error) { + return await this._handleExecutionError(error, params, originalChat); + } finally { + this._restoreChat(originalChat); + } + } + + _validateAndExtractTargetFile(params) { + const { file_path, executable_files } = params; + + if (!this.agent || !this.agent.bot) { + throw new Error('[Execute Tool] Agent with bot context is required for execution'); + } + + let targetFile = file_path; + if (executable_files && Array.isArray(executable_files)) { + if (executable_files.length === 0) { + throw new Error('No executable action-code files found - code generation may have failed'); + } + + targetFile = executable_files.find(f => f.includes('action-code')); + if (!targetFile) { + throw new Error('No executable action-code file found in provided files'); + } + } + + if (!targetFile) { + throw new Error('[Execute Tool] Missing required parameter: file_path or executable_files'); + } + + return targetFile; + } + + async _validateFile(targetFile, fileData) { + if (!path.isAbsolute(targetFile)) { + throw new Error('[Execute Tool] file_path must be an absolute path'); + } + + if (!targetFile.endsWith('.js')) { + throw new Error('[Execute Tool] Only JavaScript (.js) files can be executed'); + } + + if (!fs.existsSync(targetFile)) { + throw new Error(`[Execute Tool] File does not exist: ${targetFile}`); + } + + if (!fileData.content.trim()) { + throw new Error('[Execute Tool] File is empty or contains no executable code'); + } + + const lintTool = this.agent.coder.codeToolsManager.tools.get('Lint'); + const lintResult = await lintTool.execute({ file_path: targetFile }); + + if (!lintResult.success) { + throw new Error(lintResult.message); + } + } + + _setupChatCapture() { + let originalChat = null; + + if (this.agent.bot && this.agent.bot.chat) { + originalChat = this.agent.bot.chat; + } + + this.agent.bot.chat = (message) => { + // Limit chat message length to prevent output overflow + const maxChatLength = 100; + let chatMessage = message; + if (chatMessage.length > maxChatLength) { + chatMessage = chatMessage.substring(0, maxChatLength - 3) + '...'; + } + this.agent.bot.output += `[CHAT] ${chatMessage}\n`; + return originalChat.call(this.agent.bot, message); + }; + + return originalChat; + } + + _restoreChat(originalChat) { + if (originalChat && this.agent.bot) { + this.agent.bot.chat = originalChat; + } + } + + async _executeWithTimeout(compartment, fileContent, targetFile) { + const content = fileContent.trim(); + const isIIFE = StackTracePatterns.iife.test(content); + + if (!isIIFE) { + throw new Error(`[Execute Tool] Unsupported code format. Only IIFE format is supported: (async (bot) => { ... })`); + } + + const enhancedWrapper = this._createEnhancedWrapper(content, targetFile); + const wrappedFunction = compartment.evaluate(enhancedWrapper); + + const abortController = new AbortController(); + let timeoutId; + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + abortController.abort(); + reject(new Error('Code execution timeout: exceeded 60 seconds')); + }, 60000); // 60 seconds timeout + }); + + try { + const result = await Promise.race([ + wrappedFunction(this.agent.bot), + timeoutPromise + ]); + + clearTimeout(timeoutId); + + if (this.agent.bot) { + this.agent.bot.interrupt_code = false; + } + + return result; + + } catch (error) { + clearTimeout(timeoutId); + + if (abortController.signal.aborted) { + this._stopBotActions(); + } + + // CRITICAL: Reset interrupt flag after stopping actions to allow retry + // Without this, coder.js Promise.race will continuously reject with "Interrupted coding session" + if (this.agent.bot) { + this.agent.bot.interrupt_code = false; + } + + throw error; + } + } + + _createEnhancedWrapper(content, targetFile) { + const WRAPPER_LINE_OFFSET = 3; // Lines added by wrapper function + + return ` + (async function(bot) { + try { + const iifeFunction = ${content}; + return await iifeFunction(bot); + } catch (error) { + error.sourceFile = '${targetFile}'; + + if (error.stack) { + const stackLines = error.stack.split('\\n'); + const mappedStack = stackLines.map(line => { + const lineMatch = line.match(/:(\\d+):(\\d+)/); + if (lineMatch) { + const errorLine = parseInt(lineMatch[1]); + const errorColumn = parseInt(lineMatch[2]); + const originalLine = Math.max(1, errorLine - ${WRAPPER_LINE_OFFSET}); + return line.replace(/:(\\d+)/, \`\${error.sourceFile}:\${originalLine}\`); + } + return line; + }); + error.stack = mappedStack.join('\\n'); + } + + throw error; + } + }) + `; + } + + _stopBotActions() { + console.log('Code execution was aborted due to timeout, attempting to stop bot actions...'); + + if (this.agent.bot) { + try { + this.agent.bot.clearControlStates(); + if (this.agent.bot.pathfinder) { + this.agent.bot.pathfinder.stop(); + } + + this.agent.bot.stopDigging(); + if (this.agent.bot.pvp) { + this.agent.bot.pvp.stop(); + } + + if (this.agent.bot.collectBlock) { + this.agent.bot.collectBlock.cancelTask(); + } + this.agent.bot.interrupt_code = true; + console.log('Successfully stopped all bot actions'); + } catch (stopError) { + console.warn('Failed to stop bot actions:', stopError.message); + } + } + } + + _formatSuccessResult(result, targetFile, description) { + const executionOutput = this._captureExecutionOutput(); + + console.log("Bot connection status:", this.agent.bot?.entity?.position ? "Connected" : "Disconnected"); + console.log("Action manager status:", this.agent.actions ? "Available" : "Not available"); + + const fileName = path.basename(targetFile); + const botPosition = this.agent.bot?.entity?.position; + + const executionInfo = { + file: fileName, + description: description || 'Code execution', + botPosition: botPosition ? `(${botPosition.x.toFixed(1)}, ${botPosition.y}, ${botPosition.z.toFixed(1)})` : 'Unknown', + result: result || 'No return value', + output: executionOutput + }; + + console.log(`Executed: ${executionInfo.file} - ${executionInfo.description}`); + console.log(`Bot at: ${executionInfo.botPosition}`); + console.log(`Output: ${executionInfo.output}`); + + let message = "## Code Execution Result ##\n" + + "**File:** " + executionInfo.file + "\n" + + "**Task:** " + executionInfo.description + "\n" + + "**Your Position:** " + executionInfo.botPosition + "\n" + + "**Result:** " + executionInfo.result + "\n" + + "**Execution Log:** \n" + executionInfo.output; + + // Limit message length to 1000 characters + if (message.length > 500) + message = message.substring(0, 497) + '...'; + return { + success: true, + message: message, + file_path: targetFile, + action: 'execute' + }; + } + + _captureExecutionOutput() { + let executionOutput = 'No output captured during execution'; + + if (this.agent.bot && this.agent.bot.output) { + const output = this.agent.bot.output.trim(); + if (output) { + executionOutput = output; + this.agent.bot.output = ''; + } + } + this.agent.bot.chat(executionOutput); + return executionOutput; + } + + async _handleExecutionError(error, params, originalChat) { + this._restoreChat(originalChat); + + const executionOutput = this._captureExecutionOutput(); + const codeErrorInfo = await this.errorAnalyzer.analyzeError(error, { ...params, agent: this.agent }); + + const isTimeoutError = error.message && error.message.includes('Code execution timeout'); + + let message; + if (isTimeoutError) { + message = + '## Code Execution Timeout ##\n' + + '**Error:** Code execution exceeded 60 seconds and was terminated\n' + + '**Reason:** The code took too long to execute and may have been stuck in an infinite loop, waiting for a resource, or the bot may be stuck in terrain\n' + + '**Suggestion:** Review the code for potential infinite loops, long-running operations, or blocking calls\n' + + '**Execution Log:** \n' + executionOutput; + } else { + message = + '## Code Execution Error ##\n' + + `**Error:** ${error.message}\n` + + codeErrorInfo.errorReport + + codeErrorInfo.skillSuggestions + + '\n**Execution Log:** \n' + executionOutput; + } + + return { + success: false, + message: message + }; + } +} + + +//String builder for efficient string concatenation +class StringBuilder { + constructor() { + this.parts = []; + } + + append(text) { + this.parts.push(text); + return this; + } + + appendLine(text = '') { + this.parts.push(text + '\n'); + return this; + } + + clear() { + this.parts.length = 0; + return this; + } + + toString() { + return this.parts.join(''); + } +} + +//File content cache with TTL and LRU eviction +class FileContentCache { + constructor(maxSize = 100, ttlMs = 300000) { + this.cache = new Map(); + this.maxSize = maxSize; + this.ttlMs = ttlMs; + } + + async getFileContent(filePath) { + const cached = this.cache.get(filePath); + const now = Date.now(); + + if (cached && (now - cached.timestamp) < this.ttlMs) { + try { + const stats = await fs.promises.stat(filePath); + if (stats.mtime.getTime() === cached.data.mtime) { + return cached.data; + } + } catch (error) { + this.cache.delete(filePath); + } + } + + try { + const stats = await fs.promises.stat(filePath); + const content = await fs.promises.readFile(filePath, 'utf8'); + const lines = content.split('\n'); + + const fileData = { + content, + lines, + size: stats.size, + mtime: stats.mtime.getTime() + }; + + this._setCache(filePath, fileData, now); + return fileData; + } catch (error) { + throw new Error(`Failed to read file ${filePath}: ${error.message}`); + } + } + + _setCache(filePath, data, timestamp) { + if (this.cache.size >= this.maxSize) { + const firstKey = this.cache.keys().next().value; + this.cache.delete(firstKey); + } + this.cache.set(filePath, { data, timestamp }); + } +} + +//Error analyzer for intelligent stack trace processing +class ErrorAnalyzer { + constructor(fileCache) { + this.fileCache = fileCache; + this.stringBuilder = new StringBuilder(); + } + + async analyzeError(error, params) { + const stackFrames = await this._parseStackFrames(error, params); + const prioritizedFrames = this._prioritizeFrames(stackFrames); + const meaningfulFrames = this._filterMeaningfulFrames(prioritizedFrames); + + return { + errorReport: this._buildErrorReport(meaningfulFrames, error), + skillSuggestions: await this._getSkillSuggestions(meaningfulFrames, params) + }; + } + + async _parseStackFrames(error, params) { + if (!error.stack) return []; + + const stackLines = error.stack.split('\n'); + const frames = []; + + for (let i = 1; i < stackLines.length; i++) { + const line = stackLines[i].trim(); + if (!line) continue; + + const frameInfo = await this._parseStackLine(line, error, params, i); + if (frameInfo) { + frames.push(frameInfo); + } + } + + return frames; + } + + async _parseStackLine(line, error, params, stackIndex) { + const isUserCode = this._isUserCodePath(line) || this._hasSourceFile(line, error); + if (!isUserCode) return null; + + const location = this._extractLocation(line, error, params); + if (!location) return null; + + const codeInfo = await this._getCodeContext(location.filePath, location.line); + + return { + ...location, + stackFrame: line, + lineContent: codeInfo.lineContent, + contextLines: codeInfo.contextLines, + isActionCode: location.filePath.includes('action-code'), + isLearnedSkill: location.filePath.includes('learnedSkills'), + isThrowStatement: this._isThrowStatement(codeInfo.lineContent), + stackIndex + }; + } + + _isUserCodePath(line) { + const userCodePaths = ['action-code', 'learnedSkills']; + return userCodePaths.some(path => line.includes(path)); + } + + _hasSourceFile(line, error) { + return error.sourceFile && line.includes(error.sourceFile); + } + + _extractLocation(line, error, params) { + let errorLine = null; + let errorColumn = null; + let filePath = params.file_path; + + if (this._hasSourceFile(line, error)) { + const sourceMatch = line.match(new RegExp(`${error.sourceFile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:(\\d+):(\\d+)`)); + if (sourceMatch) { + errorLine = parseInt(sourceMatch[1]); + errorColumn = parseInt(sourceMatch[2]); + } + } else { + let pathMatch = line.match(StackTracePatterns.filePath); + if (!pathMatch) { + pathMatch = line.match(StackTracePatterns.filePathAlt); + } + if (pathMatch) { + filePath = pathMatch[1]; + errorLine = parseInt(pathMatch[3]); + errorColumn = parseInt(pathMatch[4]); + } + } + + return errorLine && filePath ? { filePath, line: errorLine, column: errorColumn } : null; + } + + async _getCodeContext(filePath, lineNumber) { + try { + const fileData = await this.fileCache.getFileContent(filePath); + const lines = fileData.lines; + const lineContent = lines[lineNumber - 1] || ''; + + const maxContextLines = 4; // Show 2 lines before and after error + const contextRadius = Math.floor(maxContextLines / 2); + const startLine = Math.max(0, lineNumber - contextRadius - 1); + const endLine = Math.min(lines.length - 1, lineNumber + contextRadius); + const contextLines = []; + + for (let i = startLine; i <= endLine; i++) { + contextLines.push({ + number: i + 1, + content: lines[i] || '', + isError: (i + 1) === lineNumber + }); + } + + return { lineContent, contextLines }; + } catch (error) { + return { lineContent: '', contextLines: [] }; + } + } + + _isThrowStatement(lineContent) { + const trimmed = lineContent.trim(); + return StackTracePatterns.throwStatements.some(pattern => pattern.test(trimmed)); + } + + _prioritizeFrames(frames) { + const rootCause = frames.filter(f => !f.isThrowStatement); + const throwStatements = frames.filter(f => f.isThrowStatement); + + const prioritized = rootCause.length > 0 ? rootCause : throwStatements; + + return prioritized.sort((a, b) => { + if (a.stackIndex !== b.stackIndex) { + return b.stackIndex - a.stackIndex; + } + if (a.isActionCode !== b.isActionCode) { + return a.isActionCode ? -1 : 1; + } + if (a.isLearnedSkill !== b.isLearnedSkill) { + return a.isLearnedSkill ? -1 : 1; + } + return a.line - b.line; + }); + } + + _filterMeaningfulFrames(frames) { + return frames.filter(frame => + frame.lineContent && frame.lineContent.trim().length > 0 + ); + } + + _buildErrorReport(frames, error) { + if (frames.length === 0) { + return this._buildFallbackReport(error); + } + + this.stringBuilder.clear(); + this.stringBuilder.append('\n#### ERROR CALL CHAIN ###\n'); + + frames.forEach((frame, index) => { + const depth = ' '.repeat(index); + const arrow = index > 0 ? '↳ ' : ''; + + this.stringBuilder + .append(`${depth}${arrow}**${error.message}**\n`) + .append(`${depth} File: ${frame.filePath}\n`) + .append(`${depth} Location: Line ${frame.line}, Column ${frame.column}\n`) + .append(`${depth} Code Context:\n`); + + this._appendCodeContext(frame, depth); + + if (index < frames.length - 1) { + this.stringBuilder.append('\n'); + } + }); + + if (error.name && error.name !== 'Error') { + this.stringBuilder.append(`\nError Type: ${error.name}\n`); + } + + return this.stringBuilder.toString(); + } + + _appendCodeContext(frame, depth) { + frame.contextLines.forEach(line => { + const prefix = line.isError ? '>>> ' : ' '; + this.stringBuilder.append(`${depth} ${prefix}${line.number.toString().padStart(3)}: ${line.content}\n`); + + if (line.isError && frame.column > 0) { + const actualPrefix = `${depth} ${prefix}${line.number.toString().padStart(3)}: `; + const spaces = ' '.repeat(actualPrefix.length + frame.column - 1); + this.stringBuilder.append(`${spaces}^\n`); + } + }); + } + + _buildFallbackReport(error) { + return `\n#### CODE EXECUTION ERROR INFO ###\nError: ${error.message}\nUnable to map error to source location\n`; + } + + async _getSkillSuggestions(frames, params) { + if (frames.length === 0) return ''; + + const errorLineContent = frames[0].lineContent; + try { + // Get skill suggestions from the agent's skill library + const maxSkillSuggestions = 2; + const skillDocs = await params.agent?.prompter?.skill_libary?.getRelevantSkillDocs(errorLineContent, maxSkillSuggestions); + return skillDocs ? skillDocs + '\n' : ''; + } catch (error) { + return ''; + } + } +} + +//Sandbox manager for secure code execution +class SandboxManager { + constructor(learnedSkillsManager) { + this.learnedSkillsManager = learnedSkillsManager; + this.skillsCache = new Map(); + this.skillTimestamps = new Map(); + } + + async createCompartment(agent) { + const compartment = makeCompartment(this._getGlobalConfig()); + const learnedSkills = await this._loadLearnedSkills(compartment, agent); + compartment.globalThis.learnedSkills = learnedSkills; + return compartment; + } + + _getGlobalConfig() { + return { + Promise, + console, + setTimeout, + setInterval, + clearTimeout, + clearInterval, + ...world, + ...skills, + Vec3, + log: skills.log, + world: world, + skills: skills + }; + } + + async _loadLearnedSkills(compartment, agent) { + const learnedSkills = {}; + + try { + const skillModules = await this.learnedSkillsManager.getLearnedSkillsForBot(agent.name); + const currentFiles = new Set(); + + for (const module of skillModules) { + currentFiles.add(module.filePath); + const lastModified = module.lastModified || 0; + const cachedTimestamp = this.skillTimestamps.get(module.filePath) || 0; + if (lastModified > cachedTimestamp || !this.skillsCache.has(module.functionName)) { + console.log(`Loading skill: ${module.functionName}`); + const compiledFunction = this._compileSkillInCompartment(compartment, module); + if (compiledFunction) { + this.skillsCache.set(module.functionName, compiledFunction); + this.skillTimestamps.set(module.filePath, lastModified); + } + } + const skillFunction = this.skillsCache.get(module.functionName); + if (skillFunction) { + learnedSkills[module.functionName] = skillFunction; + } + } + + this._cleanupDeletedSkills(currentFiles); + + } catch (error) { + console.log(`Failed to load learned skills: ${error.message}`); + } + + return learnedSkills; + } + + _compileSkillInCompartment(compartment, module) { + try { + const transformedContent = module.content.replace(/export\s+async\s+function\s+(\w+)/g, 'async function $1'); + + const codeWithSourceMap = [ + transformedContent, + `globalThis.${module.functionName} = ${module.functionName};`, + `//# sourceURL=${module.filePath}` + ].join('\n'); + + compartment.evaluate(codeWithSourceMap); + + const moduleFunction = compartment.globalThis[module.functionName]; + if (typeof moduleFunction === 'function') { + return moduleFunction; + } else { + console.warn(`Function ${module.functionName} not found in module ${module.filePath}`); + return null; + } + } catch (error) { + console.warn(`Failed to compile skill ${module.functionName}: ${error.message}`); + return null; + } + } + + _cleanupDeletedSkills(currentFiles) { + const cachedFiles = Array.from(this.skillTimestamps.keys()); + + for (const cachedFile of cachedFiles) { + if (!currentFiles.has(cachedFile)) { + console.log(`Removing deleted skill file from cache: ${cachedFile}`); + const skillNameFromPath = cachedFile.split('/').pop().replace('.js', ''); + this.skillsCache.delete(skillNameFromPath); + this.skillTimestamps.delete(cachedFile); + } + } + } +} + +export default ExecuteTool; diff --git a/src/agent/tools/finishCoding.js b/src/agent/tools/finishCoding.js new file mode 100644 index 000000000..b0166309e --- /dev/null +++ b/src/agent/tools/finishCoding.js @@ -0,0 +1,52 @@ +/** + * FinishCoding Tool - Allows AI to finish the current coding session and return to normal mode + */ +export class FinishCodingTool { + static description = 'Finish the current coding session and return to normal mode. Use this tool when you have completed all the required coding tasks and want to provide a summary of what was accomplished during the coding session.\n\nUsage:\n- Call this tool only when you have finished all coding tasks\n- Provide a comprehensive summary of what was accomplished\n- This will gracefully exit the coding mode and return control to the main agent\n- The summary will be returned as the result of the newAction command'; + static inputSchema = { + type: "object", + properties: { + summary: { + type: "string", + description: "Comprehensive summary of what was accomplished during the coding session. Include: tasks completed, files created/modified, any issues encountered, and final status." + } + }, + required: ["summary"] + }; + + constructor(agent = null) { + this.agent = agent; + } + + /** + * Execute the FinishCoding tool + * @param {Object} params - Tool parameters + * @param {string} params.summary - Summary of what was accomplished during the coding session + * @returns {Object} Tool execution result + */ + execute(params) { + const { summary } = params; + + if (!summary || typeof summary !== 'string' || summary.trim().length === 0) { + return { + success: false, + error: 'Summary parameter is required and must be a non-empty string' + }; + } + + try { + console.log('\x1b[36m[FinishCoding]\x1b[0m Coding session finish requested with summary:', summary.trim()); + + return { + success: true, + message: `Coding session will be finished. Summary: ${summary.trim()}`, + action: 'finish_coding' // 添加特殊标识 + }; + } catch (error) { + return { + success: false, + error: `Failed to finish coding session: ${error.message}` + }; + } + } +} diff --git a/src/agent/tools/glob.js b/src/agent/tools/glob.js new file mode 100644 index 000000000..41c047e77 --- /dev/null +++ b/src/agent/tools/glob.js @@ -0,0 +1,99 @@ +import fs from 'fs'; +import path from 'path'; +import process from 'process'; +import { glob } from 'glob'; + +/** + * Glob Tool - Fast file pattern matching using glob syntax + */ +export class GlobTool { + static description = 'Search for files matching a glob pattern'; + static inputSchema = { + type: "object", + properties: { + pattern: { + type: "string", + description: "Glob pattern to match files (e.g., '**/*.js')" + }, + path: { + type: "string", + description: "Directory to search in (optional)" + } + }, + required: ["pattern"] + }; + + constructor(agent = null) { + this.name = 'Glob'; + this.agent = agent; + } + + + /** + * Execute the glob search + * @param {Object} params - The glob parameters + * @param {string} params.pattern - The glob pattern to match files against + * @param {string} params.path - The directory to search in (optional) + * @returns {Object} Result object + */ + async execute(params) { + try { + const { pattern, path: searchPath } = params; + + if (!pattern) { + throw new Error('Missing required parameter: pattern'); + } + + const cwd = searchPath || process.cwd(); + + if (!fs.existsSync(cwd)) { + throw new Error(`Directory does not exist: ${cwd}`); + } + + const matches = await glob(pattern, { + cwd, + absolute: true, + dot: false, // Don't match hidden files by default + ignore: ['node_modules/**', '.git/**', '**/.DS_Store'] // Common ignore patterns + }); + + const filesWithStats = await Promise.all( + matches.map(async (filePath) => { + try { + const stats = fs.statSync(filePath); + return { + path: filePath, + relativePath: path.relative(cwd, filePath), + size: stats.size, + modified: stats.mtime, + isDirectory: stats.isDirectory() + }; + } catch (error) { + return null; + } + }) + ); + + const sortedFiles = filesWithStats + .filter(file => file !== null) + .sort((a, b) => b.modified - a.modified); + + return { + success: true, + message: `Found ${sortedFiles.length} matches for pattern "${pattern}"`, + pattern, + searchPath: cwd, + matches: sortedFiles.length, + files: sortedFiles + }; + + } catch (error) { + return { + success: false, + message: `## Glob Tool Error ##\n**Error:** ${error.message}` + }; + } + } +} + +export default GlobTool; diff --git a/src/agent/tools/grep.js b/src/agent/tools/grep.js new file mode 100644 index 000000000..75fce6e15 --- /dev/null +++ b/src/agent/tools/grep.js @@ -0,0 +1,173 @@ +import { spawn } from 'child_process'; +import fs from 'fs'; +import path from 'path'; +import process from 'process'; + +//Grep Tool - Powerful regex-based content searching using ripgrep +export class GrepTool { + static description = 'Search for text content within files using regex patterns'; + static inputSchema = { + type: "object", + properties: { + query: { + type: "string", + description: "Search query or regex pattern" + }, + path: { + type: "string", + description: "Directory or file to search in" + }, + is_regex: { + type: "boolean", + description: "Treat query as regex pattern (default: false)" + } + }, + required: ["query", "path"] + }; + + constructor(agent = null) { + this.name = 'Grep'; + this.agent = agent; + } + + /** + * Execute the grep search + * @param {Object} params - The grep parameters + * @returns {Object} Result object + */ + async execute(params) { + try { + const { + pattern, + path: searchPath = process.cwd(), + glob: globPattern, + output_mode = 'files_with_matches', + type, + head_limit, + multiline = false, + '-B': beforeContext, + '-A': afterContext, + '-C': context, + '-n': showLineNumbers = false, + '-i': caseInsensitive = false + } = params; + + if (!pattern) { + throw new Error('Missing required parameter: pattern'); + } + + if (!fs.existsSync(searchPath)) { + throw new Error(`Path does not exist: ${searchPath}`); + } + + const args = []; + + args.push(pattern); + + if (caseInsensitive) { + args.push('-i'); + } + if (multiline) { + args.push('-U', '--multiline-dotall'); + } + + switch (output_mode) { + case 'files_with_matches': + args.push('-l'); + break; + case 'count': + args.push('-c'); + break; + case 'content': + if (showLineNumbers) { + args.push('-n'); + } + if (context !== undefined) { + args.push('-C', context.toString()); + } else { + if (beforeContext !== undefined) { + args.push('-B', beforeContext.toString()); + } + if (afterContext !== undefined) { + args.push('-A', afterContext.toString()); + } + } + break; + } + + if (type) { + args.push('--type', type); + } + if (globPattern) { + args.push('--glob', globPattern); + } + + args.push(searchPath); + + const result = await this.executeRipgrep(args); + + let output = result.stdout; + + if (head_limit && output) { + const lines = output.split('\n'); + output = lines.slice(0, head_limit).join('\n'); + } + + const matches = output ? output.split('\n').filter(line => line.trim()).length : 0; + + return { + success: true, + message: `Found ${matches} matches for pattern "${pattern}"`, + pattern, + searchPath, + output_mode, + matches, + output: output || 'No matches found' + }; + + } catch (error) { + return { + success: false, + message: `## Grep Tool Error ##\n**Error:** ${error.message}` + }; + } + } + + executeRipgrep(args) { + return new Promise((resolve, reject) => { + const rg = spawn('rg', args, { + stdio: ['pipe', 'pipe', 'pipe'] + }); + + let stdout = ''; + let stderr = ''; + + rg.stdout.on('data', (data) => { + stdout += data.toString(); + }); + + rg.stderr.on('data', (data) => { + stderr += data.toString(); + }); + + rg.on('close', (code) => { + // ripgrep returns 1 when no matches found, which is not an error + if (code === 0 || code === 1) { + resolve({ stdout, stderr, code }); + } else { + reject(new Error(`ripgrep failed with code ${code}: ${stderr}`)); + } + }); + + rg.on('error', (error) => { + if (error.code === 'ENOENT') { + reject(new Error('ripgrep (rg) is not installed. Please install ripgrep first.')); + } else { + reject(error); + } + }); + }); + } +} + +export default GrepTool; diff --git a/src/agent/tools/lint.js b/src/agent/tools/lint.js new file mode 100644 index 000000000..9331de44a --- /dev/null +++ b/src/agent/tools/lint.js @@ -0,0 +1,225 @@ +import { readFile } from 'fs/promises'; +import { ESLint } from "eslint"; +import path from 'path'; +import { LearnedSkillsManager } from '../library/learnedSkillsManager.js'; + +//Lint Tool - Validates JavaScript code files for syntax and skill usage +export class LintTool { + static description = 'Validate JavaScript code syntax without executing it'; + static inputSchema = { + type: "object", + properties: { + file_path: { + type: "string", + description: "Absolute path to the JavaScript file to validate" + } + }, + required: ["file_path"] + }; + + constructor(agent = null) { + this.name = 'Lint'; + this.description = "Validates JavaScript code files for syntax errors and skill usage.\n\nUsage:\n- The file_path parameter must be an absolute path to a .js file\n- Validates code syntax using ESLint\n- Checks for missing skill functions including learned skills\n- Returns validation results with errors and executable files\n- Can validate single files or arrays of files"; + this.agent = agent; + this.learnedSkillsManager = new LearnedSkillsManager(); + } + + /** + * Validate JavaScript files + * @param {Object} params - The validation parameters + * @param {string} [params.file_path] - Single file path to validate + * @param {Array} [params.file_paths] - Array of file paths to validate + * @param {Array} [params.operations] - Tool operations to validate + * @returns {Object} Validation result + */ + async execute(params) { + try { + const { file_path, file_paths, operations } = params; + + let filesToValidate = []; + if (operations && Array.isArray(operations)) { + filesToValidate = operations + .filter(op => op.tool === 'Write' || op.tool === 'Edit' || op.tool === 'MultiEdit') + .map(op => op.path); + console.log(filesToValidate); + } else if (file_paths && Array.isArray(file_paths)) { + filesToValidate = file_paths; + } else if (file_path) { + filesToValidate = [file_path]; + } else { + throw new Error('[Lint Tool] Missing required parameter: file_path, file_paths, or operations'); + } + const errors = []; + const executableFiles = []; + for (const filePath of filesToValidate) { + try { + if (!path.isAbsolute(filePath)) { + errors.push(`${filePath}: File path must be absolute`); + continue; + } + + const fileContent = await readFile(filePath, 'utf8'); + const lintResult = await this._lintCode(fileContent, this.agent); + if (lintResult) { + errors.push(`${filePath}: ${lintResult}`); + } else { + executableFiles.push(filePath); + } + } catch (error) { + errors.push(`${filePath}: Failed to read file - ${error.message}`); + } + } + let message; + if (errors.length === 0) { + message = `## Lint Validation Success ##\nSuccessfully validated ${filesToValidate.length} file(s)\n\nExecutable files:\n${executableFiles.map(f => `- ${f}`).join('\n')}`; + } else { + message = `## Lint Validation Failed ##\nValidation failed for ${errors.length} file(s)\n\nErrors:\n${errors.map(e => `- ${e}`).join('\n')}`; + if (executableFiles.length > 0) { + message += `\n\nValid files:\n${executableFiles.map(f => `- ${f}`).join('\n')}`; + } + } + + return { + success: errors.length === 0, + message: message, + errors: errors, + executableFiles: executableFiles, + validatedCount: filesToValidate.length, + action: 'lint' + }; + + } catch (error) { + return { + success: false, + message: `## Lint Tool Error ##\n**Error:** ${error.message}` + }; + } + } + + + async _lintCode(code) { + let result = '\n#### CODE LINT ERROR INFO ###\n'; + + try { + const originalCode = code.trim(); + + const skillRegex = /(?:skills|world)\.(.*?)\(/g; + const learnedSkillRegex = /learnedSkills\.(.*?)\(/g; + const skills = []; + const learnedSkillCalls = []; + let match; + + while ((match = skillRegex.exec(originalCode)) !== null) { + skills.push(match[1]); + } + + while ((match = learnedSkillRegex.exec(originalCode)) !== null) { + learnedSkillCalls.push(match[1]); + } + + const allDocs = await this.agent.prompter.skill_libary.getAllSkillDocs(); + + const availableSkills = allDocs.map(doc => { + const skillMatch = doc.match(/^skills\.(\w+)/); + const worldMatch = doc.match(/^world\.(\w+)/); + return skillMatch ? skillMatch[1] : (worldMatch ? worldMatch[1] : null); + }).filter(Boolean); + + let missingSkills = skills.filter(skill => !availableSkills.includes(skill)); + + const missingLearnedSkills = []; + if (learnedSkillCalls.length > 0 && this.agent && this.agent.name) { + for (const skillName of learnedSkillCalls) { + const exists = await this.learnedSkillsManager.hasSkill(this.agent.name, skillName); + if (!exists) { + missingLearnedSkills.push(`learnedSkills.${skillName}`); + } + } + } + + const allMissingSkills = [...missingSkills, ...missingLearnedSkills]; + if (allMissingSkills.length > 0) { + result += '## Missing Functions ##\n'; + result += 'The following functions do not exist:\n'; + result += allMissingSkills.map(skill => `- ${skill}`).join('\n'); + + if (missingSkills.length > 0) { + result += '\n##Relevant skills:\n' + await this.agent.prompter.skill_libary.getRelevantSkillDocs(missingSkills.map(skill => `- ${skill}`).join('\n'), 2) + '\n'; + } + + if (missingLearnedSkills.length > 0) { + const availableLearnedSkills = await this.learnedSkillsManager.getLearnedSkillsForBot(this.agent.name); + const skillNames = Object.keys(availableLearnedSkills); + if (skillNames.length > 0) { + result += '\n##Available learned skills:\n'; + result += skillNames.map(name => `- learnedSkills.${name}`).join('\n') + '\n'; + } else { + result += '\n##No learned skills available. Create skills in learnedSkills folder first.\n'; + } + } + + return result; + } + + const eslint = new ESLint({ + overrideConfigFile: true, + overrideConfig: [ + { + languageOptions: { + ecmaVersion: 2022, + sourceType: 'module', + globals: { + global: 'readonly', + process: 'readonly', + Buffer: 'readonly', + console: 'readonly', + bot: 'readonly', + skills: 'readonly', + world: 'readonly', + Vec3: 'readonly', + log: 'readonly', + learnedSkills: 'readonly' + } + }, + rules: { + 'no-unused-vars': 'off', + 'no-undef': 'off' + } + } + ] + }); + + const results = await eslint.lintText(originalCode); + const originalCodeLines = originalCode.split('\n'); + const exceptions = results.map(r => r.messages).flat(); + + if (exceptions.length > 0) { + exceptions.forEach((exc, index) => { + if (exc.line && exc.column) { + const errorLine = originalCodeLines[exc.line - 1]?.trim() || 'Unable to retrieve error line content'; + result += `**Line ${exc.line}, Column ${exc.column}:** ${exc.message}\n`; + result += `Code: \`${errorLine}\`\n`; + if (exc.severity === 2) { + result += `Severity: Error\n\n`; + } + } else { + result += `**${exc.message}**\n`; + if (exc.severity === 2) { + result += `Severity: Error\n\n`; + } + } + }); + result += 'The code contains exceptions and cannot continue execution.'; + } else { + return null; + } + + return result; + } catch (error) { + console.error('Lint code error:', error); + return `#### CODE ERROR INFO ###\nLint processing failed: ${error.message}`; + } + } +} + +export default LintTool; diff --git a/src/agent/tools/ls.js b/src/agent/tools/ls.js new file mode 100644 index 000000000..eb2dd97e8 --- /dev/null +++ b/src/agent/tools/ls.js @@ -0,0 +1,162 @@ +import fs from 'fs'; +import path from 'path'; +import { minimatch } from 'minimatch'; + +//LS Tool - Lists directory contents with detailed metadata +export class LSTool { + static description = 'List files and directories in a path with detailed metadata'; + static inputSchema = { + type: "object", + properties: { + path: { + type: "string", + description: "Absolute path to the directory to list" + }, + ignore: { + type: "array", + description: "Array of glob patterns to ignore", + items: { type: "string" } + } + }, + required: ["path"] + }; + + constructor(agent = null) { + this.name = 'LS'; + this.agent = agent; + } + + /** + * Execute the ls operation + * @param {Object} params - The ls parameters + * @param {string} params.path - Absolute path to the directory + * @param {Array} params.ignore - Array of glob patterns to ignore + * @returns {Object} Result object + */ + async execute(params) { + try { + const { path: dirPath, ignore = [] } = params; + + if (!dirPath) { + throw new Error('Missing required parameter: path'); + } + if (!fs.existsSync(dirPath)) { + throw new Error(`Directory does not exist: ${dirPath}`); + } + + const stats = fs.statSync(dirPath); + if (!stats.isDirectory()) { + throw new Error(`Path is not a directory: ${dirPath}`); + } + const entries = fs.readdirSync(dirPath); + const results = []; + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry); + + if (this.shouldIgnore(entry, ignore)) { + continue; + } + + try { + const entryStats = fs.statSync(fullPath); + const isDirectory = entryStats.isDirectory(); + + let size; + if (isDirectory) { + size = this.countDirectoryItems(fullPath); + } else { + size = entryStats.size; + } + + results.push({ + name: entry, + path: fullPath, + relativePath: entry, + type: isDirectory ? 'directory' : 'file', + size, + modified: entryStats.mtime, + permissions: this.getPermissions(entryStats.mode) + }); + } catch (error) { + continue; + } + } + + results.sort((a, b) => { + if (a.type !== b.type) { + return a.type === 'directory' ? -1 : 1; + } + return a.name.localeCompare(b.name); + }); + + return { + success: true, + message: `Listed ${results.length} items in ${path.basename(dirPath)}`, + path: dirPath, + totalItems: results.length, + directories: results.filter(item => item.type === 'directory').length, + files: results.filter(item => item.type === 'file').length, + items: results + }; + + } catch (error) { + return { + success: false, + message: `## List Tool Error ##\n**Error:** ${error.message}` + }; + } + } + + shouldIgnore(entry, ignorePatterns) { + for (const pattern of ignorePatterns) { + if (minimatch(entry, pattern)) { + return true; + } + } + return false; + } + + countDirectoryItems(dirPath) { + try { + const entries = fs.readdirSync(dirPath); + let count = entries.length; + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry); + try { + const stats = fs.statSync(fullPath); + if (stats.isDirectory()) { + count += this.countDirectoryItems(fullPath); + } + } catch (error) { + continue; + } + } + + return count; + } catch (error) { + return 0; + } + } + + getPermissions(mode) { + const permissions = []; + + permissions.push((mode & 0o400) ? 'r' : '-'); + permissions.push((mode & 0o200) ? 'w' : '-'); + permissions.push((mode & 0o100) ? 'x' : '-'); + + permissions.push((mode & 0o040) ? 'r' : '-'); + permissions.push((mode & 0o020) ? 'w' : '-'); + permissions.push((mode & 0o010) ? 'x' : '-'); + + permissions.push((mode & 0o004) ? 'r' : '-'); + permissions.push((mode & 0o002) ? 'w' : '-'); + permissions.push((mode & 0o001) ? 'x' : '-'); + + return permissions.join(''); + } +} + +export default LSTool; diff --git a/src/agent/tools/multiEdit.js b/src/agent/tools/multiEdit.js new file mode 100644 index 000000000..67c0ea8c2 --- /dev/null +++ b/src/agent/tools/multiEdit.js @@ -0,0 +1,129 @@ +import fs from 'fs'; +import path from 'path'; +import { EditTool } from './edit.js'; + +/** + * MultiEdit Tool - Performs multiple edits on a single file in one atomic operation + */ +export class MultiEditTool { + static description = 'Perform multiple edits on a single file in one atomic operation'; + static inputSchema = { + type: "object", + properties: { + file_path: { + type: "string", + description: "Absolute path to the file to edit" + }, + edits: { + type: "array", + description: "Array of edit operations to perform sequentially", + items: { + type: "object", + properties: { + old_string: { type: "string", description: "Text to replace" }, + new_string: { type: "string", description: "Replacement text" }, + replace_all: { type: "boolean", description: "Replace all occurrences" } + }, + required: ["old_string", "new_string"] + } + } + }, + required: ["file_path", "edits"] + }; + + constructor(agent = null) { + this.name = 'MultiEdit'; + this.agent = agent; + this.editTool = new EditTool(); + } + + /** + * Execute multiple edits atomically on a single file + * @param {Object} params - The edit parameters + * @param {string} params.file_path - Absolute path to the file + * @param {Array} params.edits - Array of edit operations + * @returns {Object} Result object + */ + async execute(params) { + try { + const { file_path, edits } = params; + + if (!file_path || !edits || !Array.isArray(edits) || edits.length === 0) { + throw new Error('[MultiEdit Tool] Missing required parameters: file_path and edits array'); + } + + if (!fs.existsSync(file_path)) { + throw new Error(`[MultiEdit Tool] File does not exist: ${file_path}`); + } + for (let i = 0; i < edits.length; i++) { + const edit = edits[i]; + if (!edit.old_string || edit.new_string === undefined) { + throw new Error(`[MultiEdit Tool] Edit ${i + 1}: Missing required parameters old_string or new_string`); + } + if (edit.old_string === edit.new_string) { + throw new Error(`[MultiEdit Tool] Edit ${i + 1}: old_string and new_string must be different`); + } + } + + let content = fs.readFileSync(file_path, 'utf8'); + const originalContent = content; + const results = []; + for (let i = 0; i < edits.length; i++) { + const edit = edits[i]; + const { old_string, new_string, replace_all = false } = edit; + + if (!content.includes(old_string)) { + throw new Error(`[MultiEdit Tool] Edit ${i + 1}: String not found in file: "${old_string}"`); + } + const escapedOld = old_string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + if (!replace_all) { + const occurrences = (content.match(new RegExp(escapedOld, 'g')) || []).length; + if (occurrences > 1) { + throw new Error(`[MultiEdit Tool] Edit ${i + 1}: String "${old_string}" appears ${occurrences} times. Use replace_all=true or provide more context to make it unique`); + } + } + + const beforeLength = content.length; + if (replace_all) { + content = content.replaceAll(old_string, new_string); + } else { + content = content.replace(old_string, new_string); + } + + const replacements = replace_all + ? (originalContent.match(new RegExp(escapedOld, 'g')) || []).length + : 1; + + results.push({ + edit: i + 1, + old_string: old_string.substring(0, 50) + (old_string.length > 50 ? '...' : ''), + new_string: new_string.substring(0, 50) + (new_string.length > 50 ? '...' : ''), + replacements, + success: true + }); + } + + fs.writeFileSync(file_path, content, 'utf8'); + + const totalReplacements = results.reduce((sum, result) => sum + result.replacements, 0); + + return { + success: true, + message: `Successfully applied ${edits.length} edits with ${totalReplacements} total replacements in ${path.basename(file_path)}`, + file_path, + edits_applied: edits.length, + total_replacements: totalReplacements, + results + }; + + } catch (error) { + return { + success: false, + message: `## MultiEdit Tool Error ##\n**Error:** ${error.message}`, + file_path: params.file_path + }; + } + } +} + +export default MultiEditTool; diff --git a/src/agent/tools/read.js b/src/agent/tools/read.js new file mode 100644 index 000000000..75cf730c4 --- /dev/null +++ b/src/agent/tools/read.js @@ -0,0 +1,111 @@ +import fs from 'fs'; +import path from 'path'; + +export class ReadTool { + static description = 'Read and display the contents of a file'; + static inputSchema = { + type: "object", + properties: { + file_path: { + type: "string", + description: "Absolute path to the file to read" + }, + offset: { + type: "number", + description: "Line number to start reading from (1-indexed, optional)" + }, + limit: { + type: "number", + description: "Number of lines to read (optional)" + } + }, + required: ["file_path"] + }; + + constructor(agent = null) { + this.name = 'Read'; + this.agent = agent; + } + + /** + * Execute the read operation + * @param {Object} params - The read parameters + * @param {string} params.file_path - Absolute path to the file + * @param {number} params.offset - Line offset to start reading from (1-indexed) + * @param {number} params.limit - Number of lines to read + * @returns {Object} Result object + */ + async execute(params) { + try { + const { file_path, offset, limit } = params; + + if (!file_path) { + throw new Error('[Read Tool] Missing required parameter: file_path'); + } + if (!fs.existsSync(file_path)) { + throw new Error(`[Read Tool] File does not exist: ${file_path}`); + } + const stats = fs.statSync(file_path); + if (!stats.isFile()) { + throw new Error(`[Read Tool] Path is not a file: ${file_path}`); + } + const content = fs.readFileSync(file_path, 'utf8'); + const lines = content.split('\n'); + + let displayLines = lines; + let startLine = 1; + let endLine = lines.length; + + if (offset !== undefined) { + startLine = Math.max(1, offset); + displayLines = lines.slice(startLine - 1); + } + + if (limit !== undefined) { + displayLines = displayLines.slice(0, limit); + endLine = Math.min(startLine + limit - 1, lines.length); + } else if (offset !== undefined) { + endLine = lines.length; + } + + const formattedContent = displayLines + .map((line, index) => { + const lineNumber = startLine + index; + return ` ${lineNumber}→${line}`; + }) + .join('\n'); + + + const truncated = offset !== undefined || limit !== undefined; + const fullLength = lines.length; + + const fileName = path.basename(file_path); + const sizeInfo = `${stats.size} bytes`; + const lineInfo = truncated ? + `lines ${startLine}-${endLine} of ${fullLength}` : + `${fullLength} lines`; + + const message = `\n${formattedContent}\n`; + + return { + success: true, + message: message, + file_path, + size: stats.size, + start_line: startLine, + end_line: endLine, + full_length: fullLength, + truncated, + content: formattedContent + }; + + } catch (error) { + return { + success: false, + message: `## Read Tool Error ##\n**Error:** ${error.message}` + }; + } + } +} + +export default ReadTool; diff --git a/src/agent/tools/todoWrite.js b/src/agent/tools/todoWrite.js new file mode 100644 index 000000000..3792375f2 --- /dev/null +++ b/src/agent/tools/todoWrite.js @@ -0,0 +1,182 @@ +import fs from 'fs'; +import path from 'path'; +import process from 'process'; + +/** + * TodoWrite Tool - Creates and manages structured task lists for coding sessions + */ +export class TodoWriteTool { + static description = 'Create or update TODO list for task planning and tracking progress'; + static inputSchema = { + type: "object", + properties: { + todos: { + type: "array", + description: "Array of todo items with content, status, and id", + items: { + type: "object", + properties: { + content: { type: "string", description: "Todo item description" }, + status: { + type: "string", + enum: ["pending", "in_progress", "completed"], + description: "Current status of the todo item" + }, + id: { type: "string", description: "Unique identifier for the todo item" } + }, + required: ["content", "status", "id"] + } + } + }, + required: ["todos"] + }; + + constructor(agent = null) { + this.name = 'TodoWrite'; + this.agent = agent; + } + + /** + * Execute the TodoWrite tool + * @param {Object} params - Tool parameters + * @returns {Object} Execution result + */ + execute(params) { + let message = ''; + try { + // Validate input + if (!params.todos || !Array.isArray(params.todos)) { + return { + success: false, + message: "todos parameter must be an array", + file_path: this.getTodoFilePath() + }; + } + + // Validate each todo item + for (const todo of params.todos) { + if (!todo.content || !todo.status || !todo.id) { + return { + success: false, + message: "Each todo must have content, status, and id", + file_path: this.getTodoFilePath() + }; + } + + if (!['pending', 'in_progress', 'completed'].includes(todo.status)) { + return { + success: false, + message: `Invalid status: ${todo.status}. Must be pending, in_progress, or completed`, + file_path: this.getTodoFilePath() + }; + } + } + + // Check for multiple in_progress tasks + const inProgressTasks = params.todos.filter(todo => todo.status === "in_progress"); + if (inProgressTasks.length > 1) { + return { + success: false, + message: "Only one task can be in_progress at a time", + file_path: this.getTodoFilePath() + }; + } + + // Generate markdown content + const markdownContent = this.generateMarkdown(params.todos); + + // Determine file path + const todoFilePath = this.getTodoFilePath(); + + // Write to file + const dir = path.dirname(todoFilePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + fs.writeFileSync(todoFilePath, markdownContent, 'utf8'); + + // Generate summary + const summary = this.generateSummary(params.todos); + message = `TodoList updated successfully: ${summary}`; + + return { + success: true, + message: message, + file_path: todoFilePath + }; + + } catch (error) { + return { + success: false, + message: `TodoWrite execution failed: ${error.message}`, + file_path: this.getTodoFilePath() + }; + } + } + + /** + * Generate markdown content from todos + * @param {Array} todos - Array of todo items + * @returns {string} Markdown content + */ + generateMarkdown(todos) { + let content = "# TODO LIST\n\n"; + + const pendingTasks = todos.filter(todo => todo.status === "pending"); + const inProgressTasks = todos.filter(todo => todo.status === "in_progress"); + const completedTasks = todos.filter(todo => todo.status === "completed"); + + if (inProgressTasks.length > 0) { + content += "## In Progress\n"; + inProgressTasks.forEach(todo => { + content += `- [x] **${todo.content}** (ID: ${todo.id})\n`; + }); + content += "\n"; + } + + if (pendingTasks.length > 0) { + content += "## Pending\n"; + pendingTasks.forEach(todo => { + content += `- [ ] ${todo.content} (ID: ${todo.id})\n`; + }); + content += "\n"; + } + + if (completedTasks.length > 0) { + content += "## Completed\n"; + completedTasks.forEach(todo => { + content += `- [x] ~~${todo.content}~~ (ID: ${todo.id})\n`; + }); + content += "\n"; + } + + content += `\n---\n*Last updated: ${new Date().toISOString()}*\n`; + + return content; + } + + /** + * Generate summary of todo list changes + * @param {Array} todos - Array of todo items + * @returns {string} Summary text + */ + generateSummary(todos) { + const pendingCount = todos.filter(todo => todo.status === "pending").length; + const inProgressCount = todos.filter(todo => todo.status === "in_progress").length; + const completedCount = todos.filter(todo => todo.status === "completed").length; + + return `${pendingCount} pending, ${inProgressCount} in progress, ${completedCount} completed`; + } + + + getTodoFilePath() { + const projectRoot = process.cwd(); + if (this.agent && this.agent.name) { + return path.join(projectRoot, 'bots', this.agent.name, 'TODOLIST.md'); + } + return path.join(projectRoot, 'bots', 'default', 'TODOLIST.md'); + } +} + +export default TodoWriteTool; diff --git a/src/agent/tools/toolManager.js b/src/agent/tools/toolManager.js new file mode 100644 index 000000000..8c085ce6f --- /dev/null +++ b/src/agent/tools/toolManager.js @@ -0,0 +1,497 @@ +import { EditTool } from './edit.js'; +import { MultiEditTool } from './multiEdit.js'; +import { WriteTool } from './write.js'; +import { GlobTool } from './glob.js'; +import { GrepTool } from './grep.js'; +import { LSTool } from './ls.js'; +import { ReadTool } from './read.js'; +import { ExecuteTool } from './execute.js'; +import { LintTool } from './lint.js'; +import { TodoWriteTool } from './todoWrite.js'; +import { FinishCodingTool } from './finishCoding.js'; +import fs from 'fs'; +import path from 'path'; + +const TOOL_CLASSES = [ + ['Edit', EditTool], ['MultiEdit', MultiEditTool], ['Write', WriteTool], + ['Execute', ExecuteTool], ['Lint', LintTool], ['Glob', GlobTool], + ['Grep', GrepTool], ['LS', LSTool], ['Read', ReadTool], ['TodoWrite', TodoWriteTool], + ['FinishCoding', FinishCodingTool] +]; +const REMINDER_THRESHOLD = 60000; + +//Tool Manager - Manages all available tools and executes Tools with workspace validation +export class ToolManager { + constructor(agent = null) { + this.agent = agent; + this.tools = new Map(); + this.workspaces = []; + this.promptCache = null; + this.promptCacheTime = 0; + + this.initializeTools(); + this.initializeWorkspaces(); + } + + initializeTools() { + for (const [name, ToolClass] of TOOL_CLASSES) { + const tool = new ToolClass(this.agent); + this.tools.set(name, tool); + } + } + + initializeWorkspaces() { + if (!this.agent?.name) { + this.workspaces = []; + return; + } + + if (this.agent.code_workspaces && Array.isArray(this.agent.code_workspaces)) { + this.workspaces = this.agent.code_workspaces + .map(ws => ws.replace('{BOT_NAME}', this.agent.name)) + .map(ws => ws.startsWith('/') ? ws.substring(1) : ws); // Remove leading slash for internal processing + } else { + console.error(`${COLORS.brightRed}SECURITY: No code_workspaces configured for bot ${this.agent.name}. File operations will be blocked.${COLORS.reset}`); + this.workspaces = []; + } + } + + + async executeTool(Tool) { + const startTime = Date.now(); + const { tool: toolName, params = {} } = Tool; + + if (!toolName) { + return this.createErrorResult('unknown', 'Missing tool name in Tool', startTime); + } + + const toolInstance = this.tools.get(toolName); + if (!toolInstance) { + const availableTools = Array.from(this.tools.keys()).join(', '); + return this.createErrorResult(toolName, `Unknown tool: ${toolName}. Available: ${availableTools}`, startTime); + } + + try { + console.log(`${COLORS.brightBlue}[ToolManager]${COLORS.reset} Executing ${COLORS.brightYellow}${toolName}${COLORS.reset} tool...`); + const result = await toolInstance.execute(params); + + if (result.success !== false) + console.log(`${COLORS.brightGreen}✓ [ToolManager]${COLORS.reset} ${COLORS.brightYellow}${toolName}${COLORS.reset} executed successfully`); + else + console.log(`${COLORS.brightRed}✗ [ToolManager]${COLORS.reset} ${COLORS.brightYellow}${toolName}${COLORS.reset} execution failed: ${result.error || result.message}`); + + return { tool: toolName, timestamp: new Date().toISOString(), ...result }; + } catch (error) { + console.log(`${COLORS.brightRed}✗ [ToolManager]${COLORS.reset} ${COLORS.brightYellow}${toolName || 'unknown'}${COLORS.reset} execution error: ${error.message}`); + return this.createErrorResult(toolName, error.message, startTime); + } + } + + createErrorResult(tool, message, startTime) { + return { + tool, + timestamp: new Date().toISOString(), + success: false, + error: message + }; + } + async runTools(tools, options = {}) { + const { validateWorkspaces = false, aggregate = true } = options; + + if (!Array.isArray(tools)) { + console.log(`${COLORS.brightYellow}⚠ [ToolManager]${COLORS.reset} executeTools: tools parameter is not a valid array`); + return []; + } + + if (validateWorkspaces) { + const validation = this.validateWorkspaces(tools); + if (!validation.valid) { + throw new Error(`Workspace validation failed: ${validation.error}`); + } + } + + console.log(`${COLORS.brightBlue}[ToolManager]${COLORS.reset} Executing ${COLORS.brightMagenta}${tools.length}${COLORS.reset} Tool(s)...`); + const results = []; + + for (let i = 0; i < tools.length; i++) { + console.log(`${COLORS.brightBlue}[ToolManager]${COLORS.reset} Tool ${COLORS.brightMagenta}${i + 1}/${tools.length}${COLORS.reset}:`); + const result = await this.executeTool(tools[i]); + results.push(result); + + if (!result.success) + console.log(`${COLORS.brightRed}✗ [ToolManager]${COLORS.reset} Tool ${i + 1} failed, continuing with next Tool...`); + } + + if (aggregate) { + const successCount = results.filter(r => r.success !== false).length; + const failureCount = results.length - successCount; + + if (failureCount === 0) + console.log(`${COLORS.brightGreen}[OK] [ToolManager]${COLORS.reset} All ${COLORS.brightMagenta}${results.length}${COLORS.reset} tools executed successfully`); + else + console.log(`${COLORS.brightYellow}⚠ [ToolManager]${COLORS.reset} Tools completed: ${COLORS.brightGreen}${successCount} success${COLORS.reset}, ${COLORS.brightRed}${failureCount} failed${COLORS.reset}`); + } + + return results; + } + + async executeJSONTools(tools) { + try { + console.log(`${COLORS.brightBlue}[ToolManager]${COLORS.reset} Starting JSON tools execution...`); + + const results = await this.runTools(tools, { validateWorkspaces: true, aggregate: true }); + const failedResults = results.filter(r => r.success === false); + + if (failedResults.length > 0) { + const failedToolNames = failedResults.map(r => r.tool).join(', '); + const errorMessage = `${failedResults.length} tools failed: ${failedResults.map(r => r.error).join(', ')}`; + console.log(`${COLORS.brightRed}✗ [ToolManager]${COLORS.reset} JSON tools execution failed: ${failedResults.length} Tool(s) failed`); + return { + success: false, + message: errorMessage, + results, + operations: results.map(r => ({ + tool: r.tool, + path: r.file_path + })) + }; + } + + const successMessage = results.map(r => `${r.tool}: ${r.file_path || 'executed'}`).join(', '); + console.log(`${COLORS.brightGreen}[>] [ToolManager]${COLORS.reset} JSON tools execution completed successfully`); + + return { + success: true, + message: `Workspace validation passed. ${successMessage}`, + results, + operations: results.map(r => ({ + tool: r.tool, + path: r.file_path + })) + }; + } catch (error) { + console.log(`${COLORS.brightRed}✗ [ToolManager]${COLORS.reset} JSON tools execution error: ${error.message}`); + return { success: false, message: `Execution error: ${error.message}` }; + } + } + + // JSON Tool Processing + async processResponse(response) { + const parseResult = this.parseJSONTools(response); + + if (!parseResult.hasTools) { + return { success: true, message: 'No JSON tools found in response' }; + } + + if (parseResult.tools.length === 0) { + return { success: false, message: 'Failed to extract valid JSON tools' }; + } + + console.log(`Detected ${parseResult.tools.length} JSON tool(s) in response`); + return await this.executeJSONTools(parseResult.tools); + } + + parseJSONTools(response) { + if (!response || typeof response !== 'string') { + return { hasTools: false, tools: [] }; + } + + const strategies = [ + () => this.parseDirectJSON(response), + () => this.parseEmbeddedJSON(response), + () => this.parseCodeBlockJSON(response) + ]; + + for (const strategy of strategies) { + const result = strategy(); + if (result.tools.length > 0) { + return { hasTools: true, tools: result.tools }; + } + } + + return { hasTools: false, tools: [] }; + } + + parseDirectJSON(response) { + try { + const parsed = JSON.parse(response.trim()); + const tools = this.extractToolsFromParsed(parsed); + return { tools, strategy: 'direct parsing' }; + } catch { + try { + const fixed = this._fixJSONNewlines(response.trim()); + const tools = this.extractToolsFromParsed(JSON.parse(fixed)); + return tools.length > 0 ? { tools, strategy: 'direct parsing' } : { tools: [], strategy: 'direct parsing' }; + } catch { + return { tools: [], strategy: 'direct parsing' }; + } + } + } + + _fixJSONNewlines(str) { + let result = '', inString = false, escape = false; + for (const char of str) { + if (escape) { result += char; escape = false; continue; } + if (char === '\\') { result += char; escape = true; continue; } + if (char === '"') { inString = !inString; result += char; continue; } + if (inString && char === '\n') { result += '\\n'; continue; } + if (inString && char === '\r') { result += '\\r'; continue; } + if (inString && char === '\t') { result += '\\t'; continue; } + result += char; + } + return result; + } + + parseEmbeddedJSON(response) { + const tools = []; + + // Find JSON objects by looking for balanced braces + let braceCount = 0; + let jsonStart = -1; + + for (let i = 0; i < response.length; i++) { + const char = response[i]; + + if (char === '{') { + if (braceCount === 0) { + jsonStart = i; + } + braceCount++; + } else if (char === '}') { + braceCount--; + + if (braceCount === 0 && jsonStart !== -1) { + const jsonStr = response.substring(jsonStart, i + 1); + try { + const parsed = JSON.parse(jsonStr.trim()); + tools.push(...this.extractToolsFromParsed(parsed)); + } catch { + // Continue looking for other JSON objects + } + jsonStart = -1; + } + } + } + + return { tools, strategy: 'embedded JSON parsing' }; + } + + parseCodeBlockJSON(response) { + const tools = []; + const jsonBlockRegex = /```json\s*([\s\S]*?)```/gi; + let match; + + while ((match = jsonBlockRegex.exec(response)) !== null) { + try { + const parsed = JSON.parse(match[1].trim()); + tools.push(...this.extractToolsFromParsed(parsed)); + } catch { + continue; + } + } + + return { tools, strategy: 'code block parsing' }; + } + + extractToolsFromParsed(parsed) { + const tools = []; + + if (parsed?.tools && Array.isArray(parsed.tools)) { + for (const cmd of parsed.tools) { + if (cmd?.name) { + const { name, ...params } = cmd; + tools.push({ tool: name, params }); + } + } + } + else if (Array.isArray(parsed)) { + tools.push(...parsed.filter(cmd => cmd?.tool)); + } else if (parsed?.tool) { + tools.push(parsed); + } + + return tools; + } + + validateWorkspaces(tools) { + try { + if (!Array.isArray(tools)) { + return { valid: false, error: 'Tools must be an array' }; + } + + for (const Tool of tools) { + const filePath = Tool.params?.file_path || Tool.params?.path; + if (!filePath) continue; + + if (!filePath.startsWith('/')) { + return { + valid: false, + error: `File access denied: Only absolute paths allowed, got: ${filePath}` + }; + } + + const normalizedPath = filePath.substring(1); + const isAllowed = (this.workspaces || []).some(workspace => { + const cleanWorkspace = workspace.endsWith('/') ? workspace.slice(0, -1) : workspace; + return normalizedPath.startsWith(cleanWorkspace + '/') || normalizedPath === cleanWorkspace; + }); + + if (!isAllowed) { + return { + valid: false, + error: `File access denied: ${filePath} is outside allowed workspaces: ${this.workspaces.join(', ')}` + }; + } + } + + return { valid: true }; + } catch (error) { + console.error(`${COLORS.brightRed}SECURITY: Workspace validation error: ${error.message}${COLORS.reset}`); + return { valid: false, error: `Workspace validation failed: ${error.message}` }; + } + } + + generateSystemReminders() { + const reminders = []; + + if (this.isTodoListEmpty()) { + reminders.push('This is a reminder that your todo list is currently empty. DO NOT mention this to the user explicitly because they are already aware. If you are working on tasks that would benefit from a todo list please use the TodoWrite tool to create one. If not, please feel free to ignore. Again do not mention this message to the user.'); + } + + if (this.shouldShowLearnedSkillsReminder()) { + reminders.push('You haven\'t learned any new skills in the past minute. If you have developed useful code patterns or solutions, consider saving them as reusable skills in the learnedSkills folder using the Write tool. If you haven\'t learned anything new, feel free to ignore this message. DO NOT mention this reminder to the user.'); + } + + return reminders.length > 0 ? '\n\n' + reminders.join('\n\n') : ''; + } + + isTodoListEmpty() { + if (!this.agent?.name) return true; + + try { + const projectRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); + const todoFilePath = path.join(projectRoot, 'bots', this.agent.name, 'TODOLIST.md'); + + if (!fs.existsSync(todoFilePath)) return true; + + const content = fs.readFileSync(todoFilePath, 'utf8').trim(); + if (!content) return true; + + const todoLines = content.split('\n').filter(line => line.trim().startsWith('- [')); + return todoLines.length === 0; + } catch { + return true; + } + } + + shouldShowLearnedSkillsReminder() { + if (!this.agent?.name) return false; + + try { + const projectRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..'); + const learnedSkillsPath = path.join(projectRoot, 'bots', this.agent.name, 'learnedSkills'); + + if (!fs.existsSync(learnedSkillsPath)) return true; + + const files = fs.readdirSync(learnedSkillsPath).filter(file => file.endsWith('.js')); + if (files.length === 0) return true; + + const threshold = Date.now() - REMINDER_THRESHOLD; + return !files.some(file => { + const filePath = path.join(learnedSkillsPath, file); + const stats = fs.statSync(filePath); + return stats.mtime.getTime() > threshold; + }); + } catch (error) { + console.warn('Error checking learnedSkills folder:', error.message); + return false; + } + } + + getToolDefinitions() { + const toolDefinitions = []; + + for (const [name, toolInstance] of this.tools) { + const ToolClass = toolInstance.constructor; + + if (!ToolClass.description || !ToolClass.inputSchema) { + console.warn(`Tool ${name} missing description or inputSchema, skipping`); + continue; + } + + const definition = { + type: "function", + function: { + name: name, + description: ToolClass.description, + parameters: ToolClass.inputSchema + } + }; + + toolDefinitions.push(definition); + } + + console.log(`Generated ${toolDefinitions.length} tool definitions`); + return toolDefinitions; + } + + parseToolCalls(toolCalls) { + if (!Array.isArray(toolCalls)) { + console.warn('parseToolCalls: toolCalls is not an array'); + return []; + } + + return toolCalls.map(toolCall => { + try { + const params = typeof toolCall.function.arguments === 'string' + ? JSON.parse(toolCall.function.arguments) + : toolCall.function.arguments; + + // Convert to JSON tool format: { name: "ToolName", param1: value1, param2: value2 } + return { + name: toolCall.function.name, + ...params + }; + } catch (error) { + console.error(`Failed to parse tool call ${toolCall.function.name}:`, error); + return null; + } + }).filter(tool => tool !== null); + } + + buildToolsPrompt(toolsUsageManual = '') { + const toolDefinitions = this.getToolDefinitions(); + const toolsContent = toolDefinitions + .map(tool => JSON.stringify(tool, null, 2)) + .join('\n'); + + return [ + '', + 'You are provided with function signatures within XML tags:', + '', + toolsContent, + '', + '', + 'For your response, return a JSON object with a tools array within XML tags:', + '', + '{"tools": [', + ' {"name": "ToolName", "param1": "value1", "param2": "value2"},', + ' {"name": "AnotherTool", "param1": "value1"}', + ']}', + '', + '', + '# Tool Usage Guidelines', + '', + toolsUsageManual + ].join('\n'); + } +} + +const COLORS = { + reset: '\x1b[0m', red: '\x1b[31m', green: '\x1b[32m', yellow: '\x1b[33m', + blue: '\x1b[34m', magenta: '\x1b[35m', cyan: '\x1b[36m', white: '\x1b[37m', + brightRed: '\x1b[91m', brightGreen: '\x1b[92m', brightYellow: '\x1b[93m', + brightBlue: '\x1b[94m', brightMagenta: '\x1b[95m', brightCyan: '\x1b[96m' +}; + +export default ToolManager; \ No newline at end of file diff --git a/src/agent/tools/tools-prompt.md b/src/agent/tools/tools-prompt.md new file mode 100644 index 000000000..57b5381f2 --- /dev/null +++ b/src/agent/tools/tools-prompt.md @@ -0,0 +1,308 @@ +# Tools operations + +Tool name: FinishCoding +Tool description: Finish the current coding session and return to normal mode. Use this tool when you have completed all the required coding tasks and want to provide a summary of what was accomplished during the coding session. + +Usage: +- Call this tool only when you have finished all coding tasks +- Provide a comprehensive summary of what was accomplished +- This will gracefully exit the coding mode and return control to the main agent +- The summary will be returned as the result of the newAction command + +--- + +Tool name: LS +Tool description: Lists files and directories in a given path. The path parameter must be an absolute path, not a relative path. You can optionally provide an array of glob patterns to ignore with the ignore parameter. You should generally prefer the Glob and Grep tools, if you know which directories to search. +--- + +Tool name: Read +Tool description: Reads a file from the local filesystem. You can access any file directly by using this tool. +Assume this tool is able to read all files on the machine. If the User provides a path to a file assume that path is valid. It is okay to read a file that does not exist; an error will be returned. + +Usage: +- The file_path parameter must be an absolute path, not a relative path +- By default, it reads up to 2000 lines starting from the beginning of the file +- You can optionally specify a line offset and limit (especially handy for long files), but it's recommended to read the whole file by not providing these parameters +- Any lines longer than 2000 characters will be truncated +- Results are returned using cat -n format, with line numbers starting at 1 +- This tool allows Claude Code to read images (eg PNG, JPG, etc). When reading an image file the contents are presented visually as Claude Code is a multimodal LLM. +- This tool can read PDF files (.pdf). PDFs are processed page by page, extracting both text and visual content for analysis. +- This tool can read Jupyter notebooks (.ipynb files) and returns all cells with their outputs, combining code, text, and visualizations. +- You have the capability to call multiple tools in a single response. It is always better to speculatively read multiple files as a batch that are potentially useful. +- You will regularly be asked to read screenshots. If the user provides a path to a screenshot ALWAYS use this tool to view the file at the path. This tool will work with all temporary file paths like /var/folders/123/abc/T/TemporaryItems/NSIRD_screencaptureui_ZfB1tD/Screenshot.png +- If you read a file that exists but has empty contents you will receive a system reminder warning in place of file contents. +--- + + +Tool name: Edit +Tool description: Performs exact string replacements in files. + +Usage: +- You must use your `Read` tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file. +- When editing text from Read tool output, ensure you preserve the exact indentation (tabs/spaces) as it appears AFTER the line number prefix. The line number prefix format is: spaces + line number + tab. Everything after that tab is the actual file content to match. Never include any part of the line number prefix in the old_string or new_string. +- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. +- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. +- The edit will FAIL if `old_string` is not unique in the file. Either provide a larger string with more surrounding context to make it unique or use `replace_all` to change every instance of `old_string`. +- Use `replace_all` for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance. +--- + + +Tool name: MultiEdit +Tool description: This is a tool for making multiple edits to a single file in one operation. It is built on top of the Edit tool and allows you to perform multiple find-and-replace operations efficiently. Prefer this tool over the Edit tool when you need to make multiple edits to the same file. + +Before using this tool: + +1. Use the Read tool to understand the file's contents and context +2. Verify the directory path is correct + +To make multiple file edits, provide the following: +1. file_path: The absolute path to the file to modify (must be absolute, not relative) +2. edits: An array of edit operations to perform, where each edit contains: + - old_string: The text to replace (must match the file contents exactly, including all whitespace and indentation) + - new_string: The edited text to replace the old_string + - replace_all: Replace all occurences of old_string. This parameter is optional and defaults to false. + +IMPORTANT: +- All edits are applied in sequence, in the order they are provided +- Each edit operates on the result of the previous edit +- All edits must be valid for the operation to succeed - if any edit fails, none will be applied +- This tool is ideal when you need to make several changes to different parts of the same file +- For Jupyter notebooks (.ipynb files), use the NotebookEdit instead + +CRITICAL REQUIREMENTS: +1. All edits follow the same requirements as the single Edit tool +2. The edits are atomic - either all succeed or none are applied +3. Plan your edits carefully to avoid conflicts between sequential operations + +WARNING: +- The tool will fail if edits.old_string doesn't match the file contents exactly (including whitespace) +- The tool will fail if edits.old_string and edits.new_string are the same +- Since edits are applied in sequence, ensure that earlier edits don't affect the text that later edits are trying to find + +When making edits: +- Ensure all edits result in idiomatic, correct code +- Do not leave the code in a broken state +- Always use absolute file paths (starting with /) +- Only use emojis if the user explicitly requests it. Avoid adding emojis to files unless asked. +- Use replace_all for replacing and renaming strings across the file. This parameter is useful if you want to rename a variable for instance. + +If you want to create a new file, use: +- A new file path, including dir name if needed +- First edit: empty old_string and the new file's contents as new_string +- Subsequent edits: normal edit operations on the created content +--- + + +Tool name: Write +Tool description: Writes a file to the local filesystem. + +Usage: +- This tool will overwrite the existing file if there is one at the provided path. +- If this is an existing file, you MUST use the Read tool first to read the file's contents. This tool will fail if you did not read the file first. +- ALWAYS prefer editing existing files in the codebase. NEVER write new files unless explicitly required. +- NEVER proactively create documentation files (*.md) or README files. Only create documentation files if explicitly requested by the User. +- Only use emojis if the user explicitly requests it. Avoid writing emojis to files unless asked. +--- + +Tool name: TodoWrite +Tool description: Use this tool to create and manage a structured task list for your current coding session. This helps you track progress, organize complex tasks, and demonstrate thoroughness to the user. +It also helps the user understand the progress of the task and overall progress of their requests. + +## When to Use This Tool +Use this tool proactively in these scenarios: + +1. Complex multi-step tasks - When a task requires 3 or more distinct steps or actions +2. Non-trivial and complex tasks - Tasks that require careful planning or multiple operations +3. User explicitly requests todo list - When the user directly asks you to use the todo list +4. User provides multiple tasks - When users provide a list of things to be done (numbered or comma-separated) +5. After receiving new instructions - Immediately capture user requirements as todos +6. When you start working on a task - Mark it as in_progress BEFORE beginning work. Ideally you should only have one todo as in_progress at a time +7. After completing a task - Mark it as completed and add any new follow-up tasks discovered during implementation + +## When NOT to Use This Tool + +Skip using this tool when: +1. There is only a single, straightforward task +2. The task is trivial and tracking it provides no organizational benefit +3. The task can be completed in less than 3 trivial steps +4. The task is purely conversational or informational + +NOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly. + +## Examples of When to Use the Todo List + + +User: I want to add a dark mode toggle to the application settings. Make sure you run the tests and build when you're done! +Assistant: I'll help add a dark mode toggle to your application settings. Let me create a todo list to track this implementation. +*Creates todo list with the following items:* +1. Create dark mode toggle component in Settings page +2. Add dark mode state management (context/store) +3. Implement CSS-in-JS styles for dark theme +4. Update existing components to support theme switching +5. Run tests and build process, addressing any failures or errors that occur +*Begins working on the first task* + + +The assistant used the todo list because: +1. Adding dark mode is a multi-step feature requiring UI, state management, and styling changes +2. The user explicitly requested tests and build be run afterward +3. The assistant inferred that tests and build need to pass by adding "Ensure tests and build succeed" as the final task + + + + +User: Help me rename the function getCwd to getCurrentWorkingDirectory across my project +Assistant: Let me first search through your codebase to find all occurrences of 'getCwd'. +*Uses grep or search tools to locate all instances of getCwd in the codebase* +Assistant: I've found 15 instances of 'getCwd' across 8 different files. Let me create a todo list to track these changes. +*Creates todo list with specific items for each file that needs updating* + + +The assistant used the todo list because: +1. First, the assistant searched to understand the scope of the task +2. Upon finding multiple occurrences across different files, it determined this was a complex task with multiple steps +3. The todo list helps ensure every instance is tracked and updated systematically +4. This approach prevents missing any occurrences and maintains code consistency + + + + + +User: I need to implement these features for my e-commerce site: user registration, product catalog, shopping cart, and checkout flow. +Assistant: I'll help implement these features. First, let's add all the features to the todo list. +*Creates a todo list breaking down each feature into specific tasks based on the project architecture* +Assistant: Let's start with user registration. This will involve creating database models, API endpoints, and frontend forms that integrate with your existing authentication system. + + +The assistant used the todo list because: +1. The user provided multiple complex features to implement in a comma separated list +2. The todo list helps organize these large features into manageable tasks +3. This approach allows for tracking progress across the entire implementation + + + + +User: Can you help optimize my React application? It's rendering slowly and has performance issues. +Assistant: I'll help optimize your React application. First, let me examine your codebase to identify potential performance bottlenecks. +*Reviews component structure, render patterns, state management, and data fetching* +Assistant: After analyzing your codebase, I've identified several performance issues. Let me create a todo list to track our optimization efforts. +*Creates todo list with items like: 1) Implement memoization for expensive calculations in ProductList, 2) Add virtualization for long lists in Dashboard, 3) Optimize image loading in Gallery component, 4) Fix state update loops in ShoppingCart, 5) Review bundle size and implement code splitting* +Let's start by implementing memoization for the expensive calculations in your ProductList component. + + +The assistant used the todo list because: +1. First, the assistant examined the codebase to identify specific performance issues +2. Based on this analysis, it identified multiple optimization opportunities +3. Performance optimization is a non-trivial task requiring multiple steps +4. The todo list helps methodically track improvements across different components +5. This systematic approach ensures all performance bottlenecks are addressed + + + +## Examples of When NOT to Use the Todo List + + +User: How do I print 'Hello World' in Python? +Assistant: In Python, you can print "Hello World" with this simple code: + +python +print("Hello World") + +This will output the text "Hello World" to the console when executed. + + +The assistant did not use the todo list because this is a single, trivial task that can be completed in one step. There's no need to track multiple tasks or steps for such a straightforward request. + + + + +User: What does the git status command do? +Assistant: The git status command shows the current state of your working directory and staging area. It displays which changes have been staged, which haven't, and which files aren't being tracked by Git. This is a fundamental command for understanding the current state of your repository before making commits. + + +The assistant did not use the todo list because this is an informational request with no actual coding task to complete. The user is simply asking for an explanation, not for the assistant to perform multiple steps or tasks. + + + + +User: Can you add a comment to the calculateTotal function to explain what it does? +Assistant: Sure, let me add a comment to the calculateTotal function to explain what it does. +* Uses the Edit tool to add a comment to the calculateTotal function * + + +The assistant did not use the todo list because this is a single, straightforward task confined to one location in the code. Adding a comment doesn't require tracking multiple steps or systematic organization. + + + + +User: Run npm install for me and tell me what happens. +Assistant: I'll run the npm install command for you. + +*Executes: npm install* + +The command completed successfully. Here's the output: +[Output of npm install command] + +All dependencies have been installed according to your package.json file. + + +The assistant did not use the todo list because this is a single command execution with immediate results. There are no multiple steps to track or organize, making the todo list unnecessary for this straightforward task. + + + +## Task States and Management + +1. **Task States**: Use these states to track progress: + - pending: Task not yet started + - in_progress: Currently working on (limit to ONE task at a time) + - completed: Task finished successfully + +2. **Task Management**: + - Update task status in real-time as you work + - Mark tasks complete IMMEDIATELY after finishing (don't batch completions) + - Only have ONE task in_progress at any time + - Complete current tasks before starting new ones + - Remove tasks that are no longer relevant from the list entirely + +3. **Task Completion Requirements**: + - ONLY mark a task as completed when you have FULLY accomplished it + - If you encounter errors, blockers, or cannot finish, keep the task as in_progress + - When blocked, create a new task describing what needs to be resolved + - Never mark a task as completed if: + - Tests are failing + - Implementation is partial + - You encountered unresolved errors + - You couldn't find necessary files or dependencies + +4. **Task Breakdown**: + - Create specific, actionable items + - Break complex tasks into smaller, manageable steps + - Use clear, descriptive task names + +When in doubt, use this tool. Being proactive with task management demonstrates attentiveness and ensures you complete all requirements successfully. + +--- + + +Tool name: Glob +Tool description: - Fast file pattern matching tool that works with any codebase size +- Supports glob patterns like "**/*.js" or "src/**/*.ts" +- Returns matching file paths sorted by modification time +- Use this tool when you need to find files by name patterns +- When you are doing an open ended search that may require multiple rounds of globbing and grepping, use the Agent tool instead +- You have the capability to call multiple tools in a single response. It is always better to speculatively perform multiple searches as a batch that are potentially useful. +--- + + +Tool name: Grep +Tool description: A powerful search tool built on ripgrep + + Usage: + - ALWAYS use Grep for search tasks. NEVER invoke `grep` or `rg` as a Bash command. The Grep tool has been optimized for correct permissions and access. + - Supports full regex syntax (e.g., "log.*Error", "function\s+\w+") + - Filter files with glob parameter (e.g., "*.js", "**/*.tsx") or type parameter (e.g., "js", "py", "rust") + - Output modes: "content" shows matching lines, "files_with_matches" shows only file paths (default), "count" shows match counts + - Use Task tool for open-ended searches requiring multiple rounds + - Pattern syntax: Uses ripgrep (not grep) - literal braces need escaping (use `interface\{\}` to find `interface{}` in Go code) + - Multiline matching: By default patterns match within single lines only. For cross-line patterns like `struct \{[\s\S]*?field`, use `multiline: true` +--- \ No newline at end of file diff --git a/src/agent/tools/write.js b/src/agent/tools/write.js new file mode 100644 index 000000000..4809f2b99 --- /dev/null +++ b/src/agent/tools/write.js @@ -0,0 +1,70 @@ +import fs from 'fs'; +import path from 'path'; + +export class WriteTool { + static description = 'Write or overwrite content to a file at the specified workspace absolute path'; + static inputSchema = { + type: "object", + properties: { + file_path: { + type: "string", + description: "Absolute path to the file" + }, + content: { + type: "string", + description: "Content to write to the file" + } + }, + required: ["file_path", "content"] + }; + + constructor(agent = null) { + this.name = 'Write'; + this.agent = agent; + } + + /** + * Execute the write operation + * @param {Object} params - The write parameters + * @param {string} params.file_path - Absolute path to the file + * @param {string} params.content - Content to write to the file + * @returns {Object} Result object + */ + execute(params) { + try { + const { file_path, content } = params; + + if (!file_path || content === undefined) { + throw new Error('[Write Tool] Missing required parameters: file_path, content'); + } + const fileExists = fs.existsSync(file_path); + + const dir = path.dirname(file_path); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + fs.writeFileSync(file_path, content, 'utf8'); + const stats = fs.statSync(file_path); + const action = fileExists ? 'overwritten' : 'created'; + + return { + success: true, + message: `Successfully ${action} ${path.basename(file_path)} (${stats.size} bytes)`, + file_path, + size: stats.size, + action + }; + + } catch (error) { + return { + success: false, + message: `## Write Tool Error ##\n**Error:** ${error.message}`, + file_path: params.file_path + }; + } + } + +} + +export default WriteTool; diff --git a/src/models/azure.js b/src/models/azure.js index b6be3e006..a4b6fbd3f 100644 --- a/src/models/azure.js +++ b/src/models/azure.js @@ -5,28 +5,29 @@ import { GPT } from './gpt.js' export class AzureGPT extends GPT { static prefix = 'azure'; constructor(model_name, url, params) { - super(model_name, url) - - this.model_name = model_name; + super(model_name, url, params); this.params = params || {}; + if (this.params.apiVersion) { + delete this.params.apiVersion; + } + } + initClient() { const config = {}; - - if (url) - config.endpoint = url; - + if (this.url) + config.endpoint = this.url; config.apiKey = hasKey('AZURE_OPENAI_API_KEY') ? getKey('AZURE_OPENAI_API_KEY') : getKey('OPENAI_API_KEY'); - - config.deployment = model_name; - - if (this.params.apiVersion) { + config.deployment = this.model_name; + if (this.params && this.params.apiVersion) { config.apiVersion = this.params.apiVersion; - delete this.params.apiVersion; // remove from params for later use in requests - } - else { + } else { throw new Error('apiVersion is required in params for azure!'); } - - this.openai = new AzureOpenAI(config) + this.openai = new AzureOpenAI(config); + } + // Override sendRequest to set stop_seq default to null + // Some Azure models (e.g., gpt-5-nano) do not support the 'stop' parameter + async sendRequest(turns, systemMessage, stop_seq=null, tools=null) { + return super.sendRequest(turns, systemMessage, stop_seq, tools); } } \ No newline at end of file diff --git a/src/models/cerebras.js b/src/models/cerebras.js index be902a649..dfde25443 100644 --- a/src/models/cerebras.js +++ b/src/models/cerebras.js @@ -13,8 +13,7 @@ export class Cerebras { this.client = new CerebrasSDK({ apiKey: getKey('CEREBRAS_API_KEY') }); } - async sendRequest(turns, systemMessage, stop_seq = '***') { - // Format messages array + async sendRequest(turns, systemMessage, stop_seq = '<|EOT|>', tools=null) { const messages = strictFormat(turns); messages.unshift({ role: 'system', content: systemMessage }); @@ -25,16 +24,40 @@ export class Cerebras { ...(this.params || {}), }; - let res; + if (tools && Array.isArray(tools) && tools.length > 0) { + console.log(`Using native tool calling with ${tools.length} tools`); + pack.tools = tools; + // Cerebras only supports 'auto' or 'none', not 'required' + pack.tool_choice = 'auto'; + } + try { + const logMessage = tools + ? `Awaiting Cerebras API response with native tool calling (${tools.length} tools)...` + : 'Awaiting Cerebras API response...'; + console.log(logMessage); + const completion = await this.client.chat.completions.create(pack); - // OpenAI-compatible shape - res = completion.choices?.[0]?.message?.content || ''; + + if (!completion?.choices?.[0]) { + console.error('No completion or choices returned'); + return 'No response received.'; + } + + const message = completion.choices[0].message; + if (message.tool_calls && message.tool_calls.length > 0) { + console.log(`Received ${message.tool_calls.length} tool call(s) from API`); + return JSON.stringify({ + _native_tool_calls: true, + tool_calls: message.tool_calls + }); + } + + return message.content || ''; } catch (err) { console.error('Cerebras API error:', err); - res = 'My brain disconnected, try again.'; + return 'My brain disconnected, try again.'; } - return res; } async sendVisionRequest(messages, systemMessage, imageBuffer) { diff --git a/src/models/claude.js b/src/models/claude.js index 271c6b214..1a7546ed6 100644 --- a/src/models/claude.js +++ b/src/models/claude.js @@ -17,45 +17,77 @@ export class Claude { this.anthropic = new Anthropic(config); } - async sendRequest(turns, systemMessage) { + async sendRequest(turns, systemMessage, stop_seq='<|EOT|>', tools=null) { const messages = strictFormat(turns); - let res = null; + try { - console.log(`Awaiting anthropic response from ${this.model_name}...`) + const logMessage = tools + ? `Awaiting anthropic response with native tool calling (${tools.length} tools) from ${this.model_name}...` + : `Awaiting anthropic response from ${this.model_name}...`; + console.log(logMessage); + if (!this.params.max_tokens) { if (this.params.thinking?.budget_tokens) { this.params.max_tokens = this.params.thinking.budget_tokens + 1000; - // max_tokens must be greater than thinking.budget_tokens } else { this.params.max_tokens = 4096; } } - const resp = await this.anthropic.messages.create({ + + const requestConfig = { model: this.model_name || "claude-sonnet-4-20250514", system: systemMessage, messages: messages, ...(this.params || {}) - }); - + }; + + if (tools && Array.isArray(tools) && tools.length > 0) { + console.log(`Using native tool calling with ${tools.length} tools`); + requestConfig.tools = tools.map(tool => ({ + name: tool.function.name, + description: tool.function.description, + input_schema: tool.function.parameters + })); + } + + const resp = await this.anthropic.messages.create(requestConfig); console.log('Received.') - // get first content of type text + + // Check for tool use + const toolUse = resp.content.find(content => content.type === 'tool_use'); + if (toolUse) { + console.log(`Received tool call from API`); + const tool_calls = resp.content + .filter(item => item.type === 'tool_use') + .map((item, index) => ({ + id: item.id || `call_${Date.now()}_${index}`, + type: 'function', + function: { + name: item.name, + arguments: JSON.stringify(item.input || {}) + } + })); + return JSON.stringify({ + _native_tool_calls: true, + tool_calls + }); + } + const textContent = resp.content.find(content => content.type === 'text'); if (textContent) { - res = textContent.text; - } else { - console.warn('No text content found in the response.'); - res = 'No response from Claude.'; + return textContent.text; } + + console.warn('No text content found in the response.'); + return 'No response from Claude.'; } catch (err) { if (err.message.includes("does not support image input")) { - res = "Vision is only supported by certain models."; - } else { - res = "My brain disconnected, try again."; + return "Vision is only supported by certain models."; } console.log(err); + return "My brain disconnected, try again."; } - return res; } async sendVisionRequest(turns, systemMessage, imageBuffer) { diff --git a/src/models/deepseek.js b/src/models/deepseek.js index 5596fa8fc..58de6e041 100644 --- a/src/models/deepseek.js +++ b/src/models/deepseek.js @@ -1,55 +1,20 @@ import OpenAIApi from 'openai'; -import { getKey, hasKey } from '../utils/keys.js'; -import { strictFormat } from '../utils/text.js'; +import { getKey } from '../utils/keys.js'; +import { GPT } from './gpt.js'; -export class DeepSeek { +export class DeepSeek extends GPT { static prefix = 'deepseek'; constructor(model_name, url, params) { - this.model_name = model_name; - this.params = params; + super(model_name, url, params); + } + initClient() { let config = {}; - - config.baseURL = url || 'https://api.deepseek.com'; + config.baseURL = this.url || 'https://api.deepseek.com'; config.apiKey = getKey('DEEPSEEK_API_KEY'); - this.openai = new OpenAIApi(config); } - async sendRequest(turns, systemMessage, stop_seq='***') { - let messages = [{'role': 'system', 'content': systemMessage}].concat(turns); - - messages = strictFormat(messages); - - const pack = { - model: this.model_name || "deepseek-chat", - messages, - stop: stop_seq, - ...(this.params || {}) - }; - - let res = null; - try { - console.log('Awaiting deepseek api response...') - // console.log('Messages:', messages); - let completion = await this.openai.chat.completions.create(pack); - if (completion.choices[0].finish_reason == 'length') - throw new Error('Context length exceeded'); - console.log('Received.') - res = completion.choices[0].message.content; - } - catch (err) { - if ((err.message == 'Context length exceeded' || err.code == 'context_length_exceeded') && turns.length > 1) { - console.log('Context length exceeded, trying again with shorter context.'); - return await this.sendRequest(turns.slice(1), systemMessage, stop_seq); - } else { - console.log(err); - res = 'My brain disconnected, try again.'; - } - } - return res; - } - async embed(text) { throw new Error('Embeddings are not supported by Deepseek.'); } diff --git a/src/models/gemini.js b/src/models/gemini.js index 178ffff37..29d5630b3 100644 --- a/src/models/gemini.js +++ b/src/models/gemini.js @@ -34,8 +34,11 @@ export class Gemini { this.genAI = new GoogleGenAI({apiKey: getKey('GEMINI_API_KEY')}); } - async sendRequest(turns, systemMessage) { - console.log('Awaiting Google API response...'); + async sendRequest(turns, systemMessage, stop_seq='<|EOT|>', tools=null) { + const logMessage = tools + ? `Awaiting Google API response with native tool calling (${tools.length} tools)...` + : 'Awaiting Google API response...'; + console.log(logMessage); turns = strictFormat(turns); let contents = []; @@ -46,7 +49,7 @@ export class Gemini { }); } - const result = await this.genAI.models.generateContent({ + const requestConfig = { model: this.model_name || "gemini-2.5-flash", contents: contents, safetySettings: this.safetySettings, @@ -54,14 +57,53 @@ export class Gemini { systemInstruction: systemMessage, ...(this.params || {}) } - }); - const response = await result.text; + }; - console.log('Received.'); + if (tools && Array.isArray(tools) && tools.length > 0) { + console.log(`Using native tool calling with ${tools.length} tools`); + requestConfig.tools = [{ functionDeclarations: this._convertToGeminiTools(tools) }]; + } + + const result = await this.genAI.models.generateContent(requestConfig); + + const candidate = result.candidates?.[0]; + if (candidate?.content?.parts) { + const functionCall = candidate.content.parts.find(part => part.functionCall); + if (functionCall) { + console.log(`Received tool call from API`); + return JSON.stringify({ + _native_tool_calls: true, + tool_calls: this._convertFromGeminiToolCalls(candidate.content.parts) + }); + } + } + const response = await result.text; + console.log('Received.'); return response; } + _convertToGeminiTools(openaiTools) { + return openaiTools.map(tool => ({ + name: tool.function.name, + description: tool.function.description, + parameters: tool.function.parameters + })); + } + + _convertFromGeminiToolCalls(parts) { + return parts + .filter(part => part.functionCall) + .map((part, index) => ({ + id: `call_${Date.now()}_${index}`, + type: 'function', + function: { + name: part.functionCall.name, + arguments: JSON.stringify(part.functionCall.args || {}) + } + })); + } + async sendVisionRequest(turns, systemMessage, imageBuffer) { const imagePart = { inlineData: { diff --git a/src/models/glhf.js b/src/models/glhf.js index b237c8d74..38387e3d0 100644 --- a/src/models/glhf.js +++ b/src/models/glhf.js @@ -1,29 +1,25 @@ import OpenAIApi from 'openai'; import { getKey } from '../utils/keys.js'; +import { GPT } from './gpt.js'; -export class GLHF { +export class GLHF extends GPT { static prefix = 'glhf'; - constructor(model_name, url) { - this.model_name = model_name; + constructor(model_name, url, params) { + super(model_name, url, params); + } + + initClient() { const apiKey = getKey('GHLF_API_KEY'); if (!apiKey) { throw new Error('API key not found. Please check keys.json and ensure GHLF_API_KEY is defined.'); } this.openai = new OpenAIApi({ apiKey, - baseURL: url || "https://glhf.chat/api/openai/v1" + baseURL: this.url || "https://glhf.chat/api/openai/v1" }); } - async sendRequest(turns, systemMessage, stop_seq = '***') { - // Construct the message array for the API request. - let messages = [{ role: 'system', content: systemMessage }].concat(turns); - const pack = { - model: this.model_name || "hf:meta-llama/Llama-3.1-405B-Instruct", - messages, - stop: [stop_seq] - }; - + async sendRequest(turns, systemMessage, stop_seq = '<|EOT|>', tools=null) { const maxAttempts = 5; let attempt = 0; let finalRes = null; @@ -31,12 +27,15 @@ export class GLHF { while (attempt < maxAttempts) { attempt++; console.log(`Awaiting glhf.chat API response... (attempt: ${attempt})`); + try { - let completion = await this.openai.chat.completions.create(pack); - if (completion.choices[0].finish_reason === 'length') { - throw new Error('Context length exceeded'); + let res = await super.sendRequest(turns, systemMessage, stop_seq, tools); + + // If it's a tool calling response, return directly without processing + if (res.startsWith('{') && res.includes('_native_tool_calls')) { + return res; } - let res = completion.choices[0].message.content; + // If there's an open tag without a corresponding , retry. if (res.includes("") && !res.includes("")) { console.warn("Partial block detected. Re-generating..."); @@ -47,11 +46,11 @@ export class GLHF { res = "" + res; } finalRes = res.replace(/<\|separator\|>/g, '*no response*'); - break; // Valid response obtained. + break; } catch (err) { if ((err.message === 'Context length exceeded' || err.code === 'context_length_exceeded') && turns.length > 1) { console.log('Context length exceeded, trying again with shorter context.'); - return await this.sendRequest(turns.slice(1), systemMessage, stop_seq); + return await this.sendRequest(turns.slice(1), systemMessage, stop_seq, tools); } else { console.error(err); finalRes = 'My brain disconnected, try again.'; diff --git a/src/models/gpt.js b/src/models/gpt.js index 63bdaa1af..db6017b86 100644 --- a/src/models/gpt.js +++ b/src/models/gpt.js @@ -4,14 +4,21 @@ import { strictFormat } from '../utils/text.js'; export class GPT { static prefix = 'openai'; + constructor(model_name, url, params) { this.model_name = model_name; this.params = params; this.url = url; // store so that we know whether a custom URL has been set + // Template method pattern: subclasses can override initClient() + this.initClient(); + } + + // Initialize OpenAI client - subclasses can override this method + initClient() { let config = {}; - if (url) - config.baseURL = url; + if (this.url) + config.baseURL = this.url; if (hasKey('OPENAI_ORG_ID')) config.organization = getKey('OPENAI_ORG_ID'); @@ -21,53 +28,88 @@ export class GPT { this.openai = new OpenAIApi(config); } - async sendRequest(turns, systemMessage, stop_seq='***') { - let messages = strictFormat(turns); - messages = messages.map(message => { - message.content += stop_seq; - return message; - }); + async sendRequest(turns, systemMessage, stop_seq='<|EOT|>', tools=null) { let model = this.model_name || "gpt-4o-mini"; - let res = null; try { - console.log('Awaiting openai api response from model', model); // if a custom URL is set, use chat.completions // because custom "OpenAI-compatible" endpoints likely do not have responses endpoint if (this.url) { let messages = [{'role': 'system', 'content': systemMessage}].concat(turns); messages = strictFormat(messages); + const pack = { model: model, messages, - stop: stop_seq, ...(this.params || {}) }; + + // Handle tool calling + if (tools && Array.isArray(tools) && tools.length > 0) { + console.log(`Using native tool calling with ${tools.length} tools`); + pack.tools = tools; + pack.tool_choice = 'required'; + } else if (stop_seq) { + pack.stop = Array.isArray(stop_seq) ? stop_seq : [stop_seq]; + } + + // o1, o3, and 5 series models don't support stop parameter if (model.includes('o1') || model.includes('o3') || model.includes('5')) { delete pack.stop; } - let completion = await this.openai.chat.completions.create(pack); + + const logMessage = tools + ? `Awaiting openai api response with native tool calling (${tools.length} tools) from model ${model}` + : `Awaiting openai api response from model ${model}`; + console.log(logMessage); + + const completion = await this.openai.chat.completions.create(pack); + + if (!completion?.choices?.[0]) { + console.error('No completion or choices returned:', completion); + return 'No response received.'; + } + if (completion.choices[0].finish_reason == 'length') - throw new Error('Context length exceeded'); + throw new Error('Context length exceeded'); + console.log('Received.'); - res = completion.choices[0].message.content; + + const message = completion.choices[0].message; + + // Handle tool calls response + if (message.tool_calls && message.tool_calls.length > 0) { + console.log(`Received ${message.tool_calls.length} tool call(s) from API`); + return JSON.stringify({ + _native_tool_calls: true, + tool_calls: message.tool_calls + }); + } + + res = message.content; } - // otherwise, use responses + // otherwise, use responses API else { + console.log('Awaiting openai api response from model', model); + let messages = strictFormat(turns); messages = messages.map(message => { message.content += stop_seq; return message; }); + const response = await this.openai.responses.create({ model: model, instructions: systemMessage, input: messages, ...(this.params || {}) }); + console.log('Received.'); res = response.output_text; + + // Remove stop sequence from response let stop_seq_index = res.indexOf(stop_seq); res = stop_seq_index !== -1 ? res.slice(0, stop_seq_index) : res; } @@ -75,7 +117,7 @@ export class GPT { catch (err) { if ((err.message == 'Context length exceeded' || err.code == 'context_length_exceeded') && turns.length > 1) { console.log('Context length exceeded, trying again with shorter context.'); - return await this.sendRequest(turns.slice(1), systemMessage, stop_seq); + return await this.sendRequest(turns.slice(1), systemMessage, stop_seq, tools); } else if (err.message.includes('image_url')) { console.log(err); res = 'Vision is only supported by certain models.'; @@ -84,6 +126,7 @@ export class GPT { res = 'My brain disconnected, try again.'; } } + return res; } @@ -106,14 +149,15 @@ export class GPT { async embed(text) { if (text.length > 8191) text = text.slice(0, 8191); + const embedding = await this.openai.embeddings.create({ model: this.model_name || "text-embedding-3-small", input: text, encoding_format: "float", }); + return embedding.data[0].embedding; } - } const sendAudioRequest = async (text, model, voice, url) => { @@ -121,7 +165,7 @@ const sendAudioRequest = async (text, model, voice, url) => { model: model, voice: voice, input: text - } + }; let config = {}; @@ -138,10 +182,11 @@ const sendAudioRequest = async (text, model, voice, url) => { const mp3 = await openai.audio.speech.create(payload); const buffer = Buffer.from(await mp3.arrayBuffer()); const base64 = buffer.toString("base64"); + return base64; -} +}; export const TTSConfig = { sendAudioRequest: sendAudioRequest, baseUrl: 'https://api.openai.com/v1', -} +}; diff --git a/src/models/grok.js b/src/models/grok.js index 40c63ce1c..fc3dc2b18 100644 --- a/src/models/grok.js +++ b/src/models/grok.js @@ -1,78 +1,35 @@ import OpenAIApi from 'openai'; import { getKey } from '../utils/keys.js'; +import { GPT } from './gpt.js'; // xAI doesn't supply a SDK for their models, but fully supports OpenAI and Anthropic SDKs -export class Grok { +export class Grok extends GPT { static prefix = 'xai'; constructor(model_name, url, params) { - this.model_name = model_name; - this.url = url; - this.params = params; + super(model_name, url, params); + } + initClient() { let config = {}; - if (url) - config.baseURL = url; - else - config.baseURL = "https://api.x.ai/v1" - + config.baseURL = this.url || 'https://api.x.ai/v1'; config.apiKey = getKey('XAI_API_KEY'); - this.openai = new OpenAIApi(config); } - async sendRequest(turns, systemMessage) { - let messages = [{'role': 'system', 'content': systemMessage}].concat(turns); - - const pack = { - model: this.model_name || "grok-3-mini-latest", - messages, - ...(this.params || {}) - }; - - let res = null; - try { - console.log('Awaiting xai api response...') - ///console.log('Messages:', messages); - let completion = await this.openai.chat.completions.create(pack); - if (completion.choices[0].finish_reason == 'length') - throw new Error('Context length exceeded'); - console.log('Received.') - res = completion.choices[0].message.content; - } - catch (err) { - if ((err.message == 'Context length exceeded' || err.code == 'context_length_exceeded') && turns.length > 1) { - console.log('Context length exceeded, trying again with shorter context.'); - return await this.sendRequest(turns.slice(1), systemMessage); - } else if (err.message.includes('The model expects a single `text` element per message.')) { - console.log(err); - res = 'Vision is only supported by certain models.'; - } else { - console.log(err); - res = 'My brain disconnected, try again.'; - } + async sendRequest(turns, systemMessage, stop_seq='<|EOT|>', tools=null) { + // Grok doesn't support stop parameter, pass null to disable it + // Official docs: "stop parameters are not supported by reasoning models" + const res = await super.sendRequest(turns, systemMessage, null, tools); + + // If it's a tool calling response, return directly without processing + if (res.startsWith('{') && res.includes('_native_tool_calls')) { + return res; } + // sometimes outputs special token <|separator|>, just replace it return res.replace(/<\|separator\|>/g, '*no response*'); } - async sendVisionRequest(messages, systemMessage, imageBuffer) { - const imageMessages = [...messages]; - imageMessages.push({ - role: "user", - content: [ - { type: "text", text: systemMessage }, - { - type: "image_url", - image_url: { - url: `data:image/jpeg;base64,${imageBuffer.toString('base64')}` - } - } - ] - }); - - return this.sendRequest(imageMessages, systemMessage); - } - async embed(text) { throw new Error('Embeddings are not supported by Grok.'); } diff --git a/src/models/groq.js b/src/models/groq.js index 85a913e8c..2a3bda9d2 100644 --- a/src/models/groq.js +++ b/src/models/groq.js @@ -9,33 +9,24 @@ export class GroqCloudAPI { static prefix = 'groq'; constructor(model_name, url, params) { - this.model_name = model_name; this.url = url; this.params = params || {}; - // Remove any mention of "tools" from params: - if (this.params.tools) - delete this.params.tools; - // This is just a bit of future-proofing in case we drag Mindcraft in that direction. - - // I'm going to do a sneaky ReplicateAPI theft for a lot of this, aren't I? if (this.url) console.warn("Groq Cloud has no implementation for custom URLs. Ignoring provided URL."); this.groq = new Groq({ apiKey: getKey('GROQCLOUD_API_KEY') }); - - } - async sendRequest(turns, systemMessage, stop_seq = null) { - // Construct messages array + async sendRequest(turns, systemMessage, stop_seq = null, tools=null) { let messages = [{"role": "system", "content": systemMessage}].concat(turns); - let res = null; - try { - console.log("Awaiting Groq response..."); + const logMessage = tools + ? `Awaiting Groq response with native tool calling (${tools.length} tools)...` + : 'Awaiting Groq response...'; + console.log(logMessage); // Handle deprecated max_tokens parameter if (this.params.max_tokens) { @@ -48,27 +39,47 @@ export class GroqCloudAPI { this.params.max_completion_tokens = 4000; } - let completion = await this.groq.chat.completions.create({ - "messages": messages, - "model": this.model_name || "qwen/qwen3-32b", - "stream": false, - "stop": stop_seq, + const pack = { + messages: messages, + model: this.model_name || "qwen/qwen3-32b", + stream: false, + stop: stop_seq, ...(this.params || {}) - }); + }; - res = completion.choices[0].message.content; + if (tools && Array.isArray(tools) && tools.length > 0) { + console.log(`Using native tool calling with ${tools.length} tools`); + pack.tools = tools; + pack.tool_choice = 'required'; + delete pack.stop; + } + + let completion = await this.groq.chat.completions.create(pack); + + if (!completion?.choices?.[0]) { + return 'No response received.'; + } + + const message = completion.choices[0].message; + if (message.tool_calls && message.tool_calls.length > 0) { + console.log(`Received ${message.tool_calls.length} tool call(s) from API`); + return JSON.stringify({ + _native_tool_calls: true, + tool_calls: message.tool_calls + }); + } + let res = message.content; res = res.replace(/[\s\S]*?<\/think>/g, '').trim(); + return res; } catch(err) { if (err.message.includes("content must be a string")) { - res = "Vision is only supported by certain models."; - } else { - res = "My brain disconnected, try again."; + return "Vision is only supported by certain models."; } console.log(err); + return "My brain disconnected, try again."; } - return res; } async sendVisionRequest(messages, systemMessage, imageBuffer) { diff --git a/src/models/huggingface.js b/src/models/huggingface.js index 91fbdfd77..08b7f0a69 100644 --- a/src/models/huggingface.js +++ b/src/models/huggingface.js @@ -1,6 +1,6 @@ import { toSinglePrompt } from '../utils/text.js'; import { getKey } from '../utils/keys.js'; -import { HfInference } from "@huggingface/inference"; +import { InferenceClient } from "@huggingface/inference"; export class HuggingFace { static prefix = 'huggingface'; @@ -14,70 +14,71 @@ export class HuggingFace { console.warn("Hugging Face doesn't support custom urls!"); } - this.huggingface = new HfInference(getKey('HUGGINGFACE_API_KEY')); + this.huggingface = new InferenceClient(getKey('HUGGINGFACE_API_KEY')); } - async sendRequest(turns, systemMessage) { - const stop_seq = '***'; - // Build a single prompt from the conversation turns - const prompt = toSinglePrompt(turns, null, stop_seq); - // Fallback model if none was provided - const model_name = this.model_name || 'meta-llama/Meta-Llama-3-8B'; - // Combine system message with the prompt - const input = systemMessage + "\n" + prompt; - - // We'll try up to 5 times in case of partial blocks for DeepSeek-R1 models. + async sendRequest(turns, systemMessage, stop_seq = '<|EOT|>', tools=null) { + const model_name = this.model_name || 'openai/gpt-oss-120b'; const maxAttempts = 5; - let attempt = 0; - let finalRes = null; - while (attempt < maxAttempts) { - attempt++; - console.log(`Awaiting Hugging Face API response... (model: ${model_name}, attempt: ${attempt})`); - let res = ''; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + console.log(tools + ? `Awaiting Hugging Face API response with tool calling (${tools.length} tools)... (model: ${model_name}, attempt: ${attempt})` + : `Awaiting Hugging Face API response... (model: ${model_name}, attempt: ${attempt})`); + try { - // Consume the streaming response chunk by chunk - for await (const chunk of this.huggingface.chatCompletionStream({ + const messages = [{ role: "system", content: systemMessage }, ...turns]; + const requestParams = { model: model_name, - messages: [{ role: "user", content: input }], + messages: messages, ...(this.params || {}) - })) { - res += (chunk.choices[0]?.delta?.content || ""); + }; + + if (tools?.length > 0) { + console.log(`Using tool calling with ${tools.length} tools`); + requestParams.tools = tools; + requestParams.tool_choice = 'auto'; + } + + const response = await this.huggingface.chatCompletion(requestParams); + const message = response.choices[0].message; + + // Handle native tool calls + if (message.tool_calls?.length > 0) { + console.log(`Received ${message.tool_calls.length} tool call(s) from API`); + return JSON.stringify({ + _native_tool_calls: true, + tool_calls: message.tool_calls + }); } - } catch (err) { - console.log(err); - res = 'My brain disconnected, try again.'; - // Break out immediately; we only retry when handling partial tags. - break; - } - // If the model is DeepSeek-R1, check for mismatched blocks. + let res = message.content || ''; + + // Handle blocks const hasOpenTag = res.includes(""); const hasCloseTag = res.includes(""); - // If there's a partial mismatch, warn and retry the entire request. - if ((hasOpenTag && !hasCloseTag)) { + if (hasOpenTag && !hasCloseTag) { console.warn("Partial block detected. Re-generating..."); continue; } - // If both tags are present, remove the block entirely. if (hasOpenTag && hasCloseTag) { res = res.replace(/[\s\S]*?<\/think>/g, '').trim(); } - finalRes = res; - break; // Exit loop if we got a valid response. - } + console.log('Received.'); + return res; - // If no valid response was obtained after max attempts, assign a fallback. - if (finalRes == null) { - console.warn("Could not get a valid block or normal response after max attempts."); - finalRes = 'I thought too hard, sorry, try again.'; + } catch (err) { + console.error('HuggingFace API error:', err.message || err); + if (attempt >= maxAttempts) { + return 'My brain disconnected, try again.'; + } + } } - console.log('Received.'); - console.log(finalRes); - return finalRes; + + return 'I thought too hard, sorry, try again.'; } async embed(text) { diff --git a/src/models/hyperbolic.js b/src/models/hyperbolic.js index f483b6980..2ac2ef663 100644 --- a/src/models/hyperbolic.js +++ b/src/models/hyperbolic.js @@ -18,14 +18,12 @@ export class Hyperbolic { * * @param {Array} turns - An array of message objects, e.g. [{role: 'user', content: 'Hi'}]. * @param {string} systemMessage - The system prompt or instruction. - * @param {string} stopSeq - A stopping sequence, default '***'. + * @param {string} stopSeq - A stopping sequence, default '<|EOT|>'. * @returns {Promise} - The model's reply. */ - async sendRequest(turns, systemMessage, stopSeq = '***') { - // Prepare the messages with a system prompt at the beginning + async sendRequest(turns, systemMessage, stopSeq = '<|EOT|>', tools=null) { const messages = [{ role: 'system', content: systemMessage }, ...turns]; - // Build the request payload const payload = { model: this.modelName, messages: messages, @@ -35,14 +33,22 @@ export class Hyperbolic { stream: false }; + if (tools && Array.isArray(tools) && tools.length > 0) { + console.log(`Using native tool calling with ${tools.length} tools`); + payload.tools = tools; + payload.tool_choice = 'required'; + } + const maxAttempts = 5; let attempt = 0; let finalRes = null; while (attempt < maxAttempts) { attempt++; - console.log(`Awaiting Hyperbolic API response... (attempt: ${attempt})`); - console.log('Messages:', messages); + const logMessage = tools + ? `Awaiting Hyperbolic API response with native tool calling (${tools.length} tools)... (attempt: ${attempt})` + : `Awaiting Hyperbolic API response... (attempt: ${attempt})`; + console.log(logMessage); let completionContent = null; @@ -65,7 +71,16 @@ export class Hyperbolic { throw new Error('Context length exceeded'); } - completionContent = data?.choices?.[0]?.message?.content || ''; + const message = data?.choices?.[0]?.message; + if (message?.tool_calls && message.tool_calls.length > 0) { + console.log(`Received ${message.tool_calls.length} tool call(s) from API`); + return JSON.stringify({ + _native_tool_calls: true, + tool_calls: message.tool_calls + }); + } + + completionContent = message?.content || ''; console.log('Received response from Hyperbolic.'); } catch (err) { if ( @@ -73,7 +88,7 @@ export class Hyperbolic { turns.length > 1 ) { console.log('Context length exceeded, trying again with a shorter context...'); - return await this.sendRequest(turns.slice(1), systemMessage, stopSeq); + return await this.sendRequest(turns.slice(1), systemMessage, stopSeq, tools); } else { console.error(err); completionContent = 'My brain disconnected, try again.'; @@ -86,7 +101,7 @@ export class Hyperbolic { if ((hasOpenTag && !hasCloseTag)) { console.warn("Partial block detected. Re-generating..."); - continue; // Retry the request + continue; } if (hasCloseTag && !hasOpenTag) { @@ -96,9 +111,16 @@ export class Hyperbolic { if (hasOpenTag && hasCloseTag) { completionContent = completionContent.replace(/[\s\S]*?<\/think>/g, '').trim(); } + // Extract content from <|channel|>final<|message|>... + const finalChannelMatch = completionContent.match(/.*<\|channel\|>final<\|message\|>([\s\S]*)$/); + if (finalChannelMatch) { + completionContent = finalChannelMatch[1].trim(); + } else { + completionContent = completionContent.replace(/<\|channel\|>[\s\S]*?<\|message\|>[\s\S]*?(?:<\|end\|>|$)/g, '').trim(); + } finalRes = completionContent.replace(/<\|separator\|>/g, '*no response*'); - break; // Valid response obtained—exit loop + break; } if (finalRes == null) { diff --git a/src/models/mercury.js b/src/models/mercury.js index 74cd64e63..49742cb21 100644 --- a/src/models/mercury.js +++ b/src/models/mercury.js @@ -1,94 +1,19 @@ import OpenAIApi from 'openai'; -import { getKey, hasKey } from '../utils/keys.js'; -import { strictFormat } from '../utils/text.js'; +import { getKey } from '../utils/keys.js'; +import { GPT } from './gpt.js'; -export class Mercury { +export class Mercury extends GPT { static prefix = 'mercury'; constructor(model_name, url, params) { - this.model_name = model_name; - this.params = params; - let config = {}; - if (url) - config.baseURL = url; - else - config.baseURL = "https://api.inceptionlabs.ai/v1"; + super(model_name, url, params); + } + initClient() { + let config = {}; + config.baseURL = this.url || 'https://api.inceptionlabs.ai/v1'; config.apiKey = getKey('MERCURY_API_KEY'); - this.openai = new OpenAIApi(config); } - - async sendRequest(turns, systemMessage, stop_seq='***') { - if (typeof stop_seq === 'string') { - stop_seq = [stop_seq]; - } else if (!Array.isArray(stop_seq)) { - stop_seq = []; - } - let messages = [{'role': 'system', 'content': systemMessage}].concat(turns); - messages = strictFormat(messages); - const pack = { - model: this.model_name || "mercury-coder-small", - messages, - stop: stop_seq, - ...(this.params || {}) - }; - - - let res = null; - - try { - console.log('Awaiting mercury api response from model', this.model_name) - // console.log('Messages:', messages); - let completion = await this.openai.chat.completions.create(pack); - if (completion.choices[0].finish_reason == 'length') - throw new Error('Context length exceeded'); - console.log('Received.') - res = completion.choices[0].message.content; - } - catch (err) { - if ((err.message == 'Context length exceeded' || err.code == 'context_length_exceeded') && turns.length > 1) { - console.log('Context length exceeded, trying again with shorter context.'); - return await this.sendRequest(turns.slice(1), systemMessage, stop_seq); - } else if (err.message.includes('image_url')) { - console.log(err); - res = 'Vision is only supported by certain models.'; - } else { - console.log(err); - res = 'My brain disconnected, try again.'; - } - } - return res; - } - - async sendVisionRequest(messages, systemMessage, imageBuffer) { - const imageMessages = [...messages]; - imageMessages.push({ - role: "user", - content: [ - { type: "text", text: systemMessage }, - { - type: "image_url", - image_url: { - url: `data:image/jpeg;base64,${imageBuffer.toString('base64')}` - } - } - ] - }); - - return this.sendRequest(imageMessages, systemMessage); - } - - async embed(text) { - if (text.length > 8191) - text = text.slice(0, 8191); - const embedding = await this.openai.embeddings.create({ - model: this.model_name || "text-embedding-3-small", - input: text, - encoding_format: "float", - }); - return embedding.data[0].embedding; - } - } diff --git a/src/models/mistral.js b/src/models/mistral.js index 536b386de..ebbfeafb9 100644 --- a/src/models/mistral.js +++ b/src/models/mistral.js @@ -36,10 +36,7 @@ export class Mistral { } } - async sendRequest(turns, systemMessage) { - - let result; - + async sendRequest(turns, systemMessage, stop_seq='<|EOT|>', tools=null) { try { const model = this.model_name || "mistral-large-latest"; @@ -48,24 +45,42 @@ export class Mistral { ]; messages.push(...strictFormat(turns)); - console.log('Awaiting mistral api response...') - const response = await this.#client.chat.complete({ + const requestConfig = { model, messages, ...(this.params || {}) - }); + }; + + if (tools && Array.isArray(tools) && tools.length > 0) { + // Tools are already in correct format from ToolManager: { type: "function", function: {...} } + requestConfig.tools = tools; + // Force tool usage - 'any' requires at least one tool call per response + requestConfig.tool_choice = 'any'; + } + + const logMessage = tools + ? `Awaiting mistral api response with native tool calling (${tools.length} tools)...` + : 'Awaiting mistral api response...'; + console.log(logMessage); - result = response.choices[0].message.content; + const response = await this.#client.chat.complete(requestConfig); + + const message = response.choices[0].message; + if (message.toolCalls && message.toolCalls.length > 0) { + return JSON.stringify({ + _native_tool_calls: true, + tool_calls: message.toolCalls + }); + } + + return message.content; } catch (err) { if (err.message.includes("A request containing images has been given to a model which does not have the 'vision' capability.")) { - result = "Vision is only supported by certain models."; - } else { - result = "My brain disconnected, try again."; + return "Vision is only supported by certain models."; } console.log(err); + return "My brain disconnected, try again."; } - - return result; } async sendVisionRequest(messages, systemMessage, imageBuffer) { diff --git a/src/models/novita.js b/src/models/novita.js index 18e1fc454..28b2b43dd 100644 --- a/src/models/novita.js +++ b/src/models/novita.js @@ -1,69 +1,45 @@ import OpenAIApi from 'openai'; import { getKey } from '../utils/keys.js'; -import { strictFormat } from '../utils/text.js'; +import { GPT } from './gpt.js'; // llama, mistral -export class Novita { +export class Novita extends GPT { static prefix = 'novita'; constructor(model_name, url, params) { - this.model_name = model_name; - this.url = url || 'https://api.novita.ai/v3/openai'; - this.params = params; - - - let config = { - baseURL: this.url - }; - config.apiKey = getKey('NOVITA_API_KEY'); - - this.openai = new OpenAIApi(config); - } - - async sendRequest(turns, systemMessage, stop_seq='***') { - let messages = [{'role': 'system', 'content': systemMessage}].concat(turns); + super(model_name, url, params); + } - - messages = strictFormat(messages); - - const pack = { - model: this.model_name || "meta-llama/llama-4-scout-17b-16e-instruct", - messages, - stop: [stop_seq], - ...(this.params || {}) - }; + initClient() { + let config = { + baseURL: this.url || 'https://api.novita.ai/v3/openai' + }; + config.apiKey = getKey('NOVITA_API_KEY'); + this.openai = new OpenAIApi(config); + } - let res = null; - try { - console.log('Awaiting novita api response...') - let completion = await this.openai.chat.completions.create(pack); - if (completion.choices[0].finish_reason == 'length') - throw new Error('Context length exceeded'); - console.log('Received.') - res = completion.choices[0].message.content; - } - catch (err) { - if ((err.message == 'Context length exceeded' || err.code == 'context_length_exceeded') && turns.length > 1) { - console.log('Context length exceeded, trying again with shorter context.'); - return await sendRequest(turns.slice(1), systemMessage, stop_seq); - } else { - console.log(err); - res = 'My brain disconnected, try again.'; - } - } - if (res.includes('')) { - let start = res.indexOf(''); - let end = res.indexOf('') + 8; - if (start != -1) { - if (end != -1) { - res = res.substring(0, start) + res.substring(end); - } else { - res = res.substring(0, start+7); - } - } - res = res.trim(); - } - return res; - } + async sendRequest(turns, systemMessage, stop_seq='<|EOT|>', tools=null) { + let res = await super.sendRequest(turns, systemMessage, stop_seq, tools); + + // If it's a tool calling response, return directly without processing + if (res.startsWith('{') && res.includes('_native_tool_calls')) { + return res; + } + + // Remove blocks from text responses + if (res.includes('')) { + let start = res.indexOf(''); + let end = res.indexOf('') + 8; + if (start != -1) { + if (end != -1) { + res = res.substring(0, start) + res.substring(end); + } else { + res = res.substring(0, start+7); + } + } + res = res.trim(); + } + return res; + } async embed(text) { throw new Error('Embeddings are not supported by Novita AI.'); diff --git a/src/models/ollama.js b/src/models/ollama.js index d5b2891b6..4dc5b5e36 100644 --- a/src/models/ollama.js +++ b/src/models/ollama.js @@ -10,7 +10,7 @@ export class Ollama { this.embedding_endpoint = '/api/embeddings'; } - async sendRequest(turns, systemMessage) { + async sendRequest(turns, systemMessage, stop_seq='<|EOT|>', tools=null) { let model = this.model_name || 'sweaterdog/andy-4:micro-q8_0'; let messages = strictFormat(turns); messages.unshift({ role: 'system', content: systemMessage }); @@ -20,24 +20,42 @@ export class Ollama { while (attempt < maxAttempts) { attempt++; - console.log(`Awaiting local response... (model: ${model}, attempt: ${attempt})`); + const logMessage = tools + ? `Awaiting local response with tool calling (${tools.length} tools)... (model: ${model}, attempt: ${attempt})` + : `Awaiting local response... (model: ${model}, attempt: ${attempt})`; + console.log(logMessage); + let res = null; try { - let apiResponse = await this.send(this.chat_endpoint, { + const requestBody = { model: model, messages: messages, stream: false, ...(this.params || {}) - }); - if (apiResponse) { - res = apiResponse['message']['content']; - } else { + }; + + if (tools && Array.isArray(tools) && tools.length > 0) { + console.log(`Using tool calling with ${tools.length} tools`); + requestBody.tools = tools; + } + + let apiResponse = await this.send(this.chat_endpoint, requestBody); + + if (!apiResponse) { res = 'No response data.'; + } else if (apiResponse.message?.tool_calls && apiResponse.message.tool_calls.length > 0) { + console.log(`Received ${apiResponse.message.tool_calls.length} tool call(s) from API`); + return JSON.stringify({ + _native_tool_calls: true, + tool_calls: apiResponse.message.tool_calls + }); + } else { + res = apiResponse['message']['content']; } } catch (err) { if (err.message.toLowerCase().includes('context length') && turns.length > 1) { console.log('Context length exceeded, trying again with shorter context.'); - return await this.sendRequest(turns.slice(1), systemMessage); + return await this.sendRequest(turns.slice(1), systemMessage, stop_seq, tools); } else { console.log(err); res = 'My brain disconnected, try again.'; diff --git a/src/models/openrouter.js b/src/models/openrouter.js index ca0782bc4..374de5607 100644 --- a/src/models/openrouter.js +++ b/src/models/openrouter.js @@ -1,76 +1,24 @@ import OpenAIApi from 'openai'; -import { getKey, hasKey } from '../utils/keys.js'; -import { strictFormat } from '../utils/text.js'; +import { getKey } from '../utils/keys.js'; +import { GPT } from './gpt.js'; -export class OpenRouter { +export class OpenRouter extends GPT { static prefix = 'openrouter'; - constructor(model_name, url) { - this.model_name = model_name; + constructor(model_name, url, params) { + super(model_name, url, params); + } + initClient() { let config = {}; - config.baseURL = url || 'https://openrouter.ai/api/v1'; - + config.baseURL = this.url || 'https://openrouter.ai/api/v1'; const apiKey = getKey('OPENROUTER_API_KEY'); if (!apiKey) { console.error('Error: OPENROUTER_API_KEY not found. Make sure it is set properly.'); } - - // Pass the API key to OpenAI compatible Api - config.apiKey = apiKey; - + config.apiKey = apiKey; this.openai = new OpenAIApi(config); } - async sendRequest(turns, systemMessage, stop_seq='*') { - let messages = [{ role: 'system', content: systemMessage }, ...turns]; - messages = strictFormat(messages); - - // Choose a valid model from openrouter.ai (for example, "openai/gpt-4o") - const pack = { - model: this.model_name, - messages, - stop: stop_seq - }; - - let res = null; - try { - console.log('Awaiting openrouter api response...'); - let completion = await this.openai.chat.completions.create(pack); - if (!completion?.choices?.[0]) { - console.error('No completion or choices returned:', completion); - return 'No response received.'; - } - if (completion.choices[0].finish_reason === 'length') { - throw new Error('Context length exceeded'); - } - console.log('Received.'); - res = completion.choices[0].message.content; - } catch (err) { - console.error('Error while awaiting response:', err); - // If the error indicates a context-length problem, we can slice the turns array, etc. - res = 'My brain disconnected, try again.'; - } - return res; - } - - async sendVisionRequest(messages, systemMessage, imageBuffer) { - const imageMessages = [...messages]; - imageMessages.push({ - role: "user", - content: [ - { type: "text", text: systemMessage }, - { - type: "image_url", - image_url: { - url: `data:image/jpeg;base64,${imageBuffer.toString('base64')}` - } - } - ] - }); - - return this.sendRequest(imageMessages, systemMessage); - } - async embed(text) { throw new Error('Embeddings are not supported by Openrouter.'); } diff --git a/src/models/prompter.js b/src/models/prompter.js index 6ee93b2e7..755b4aea3 100644 --- a/src/models/prompter.js +++ b/src/models/prompter.js @@ -9,6 +9,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { selectAPI, createModel } from './_model_map.js'; +import process from 'process'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -39,6 +40,14 @@ export class Prompter { for (let key in base_profile) { if (this.profile[key] === undefined) this.profile[key] = base_profile[key]; + // Load md file content if the config value contains 'prompt' + if (typeof this.profile[key] === 'string' && this.profile[key].includes('prompt')) { + try { + this.profile[key] = readFileSync(this.profile[key], 'utf8'); + } catch (err) { + console.warn(`Failed to read prompt file: ${this.profile[key]}, keeping original config`); + } + } } // base overrides default, individual overrides base @@ -49,6 +58,7 @@ export class Prompter { this.cooldown = this.profile.cooldown ? this.profile.cooldown : 0; this.last_prompt_time = 0; this.awaiting_coding = false; + this.max_messages = settings.max_messages; //TODO:remove after test // for backwards compatibility, move max_tokens to params let max_tokens = null; @@ -113,10 +123,17 @@ export class Prompter { this.convo_examples = new Examples(this.embedding_model, settings.num_examples); this.coding_examples = new Examples(this.embedding_model, settings.num_examples); + const processedCodingExamples = this.profile.coding_examples.map(example => + example.map(turn => ({ + ...turn, + content: turn.content.replaceAll('$NAME', this.agent.name) + })) + ); + // Wait for both examples to load before proceeding await Promise.all([ this.convo_examples.load(this.profile.conversation_examples), - this.coding_examples.load(this.profile.coding_examples), + this.coding_examples.load(processedCodingExamples), this.skill_libary.initSkillLibrary() ]).catch(error => { // Preserve error details @@ -155,11 +172,14 @@ export class Prompter { const code_task_content = messages.slice().reverse().find(msg => msg.role !== 'system' && msg.content.includes('!newAction(') )?.content?.match(/!newAction\((.*?)\)/)?.[1] || ''; - + // prompt = prompt.replaceAll( + // '$CODE_DOCS',await this.skill_libary.getAllSkillDocs() + // ); prompt = prompt.replaceAll( '$CODE_DOCS', await this.skill_libary.getRelevantSkillDocs(code_task_content, settings.relevant_docs_count) ); + } if (prompt.includes('$EXAMPLES') && examples !== null) prompt = prompt.replaceAll('$EXAMPLES', await examples.createExampleMessage(messages)); @@ -178,12 +198,15 @@ export class Prompter { let goal_text = ''; for (let goal in last_goals) { if (last_goals[goal]) - goal_text += `You recently successfully completed the goal ${goal}.\n` + goal_text += `You recently successfully completed the goal ${goal}.\n`; else - goal_text += `You recently failed to complete the goal ${goal}.\n` + goal_text += `You recently failed to complete the goal ${goal}.\n`; } prompt = prompt.replaceAll('$LAST_GOALS', goal_text.trim()); } + if (prompt.includes('$TOOLS')) { + prompt = prompt.replaceAll('$TOOLS', this.profile.tools_manual); + } if (prompt.includes('$BLUEPRINTS')) { if (this.agent.npc.constructions) { let blueprints = ''; @@ -193,12 +216,34 @@ export class Prompter { prompt = prompt.replaceAll('$BLUEPRINTS', blueprints.slice(0, -2)); } } + if (prompt.includes('$ABSOLUTE_PATH_PREFIX')) { + const absolutePathPrefix = process.cwd(); + prompt = prompt.replaceAll('$ABSOLUTE_PATH_PREFIX', absolutePathPrefix); + } // check if there are any remaining placeholders with syntax $ let remaining = prompt.match(/\$[A-Z_]+/g); if (remaining !== null) { console.warn('Unknown prompt placeholders:', remaining.join(', ')); } + // Write prompt to log file with proper formatting + try { + const fs = await import('fs'); + const path = await import('path'); + + const logsDir = path.default.join(__dirname, '../../logs'); + if (!fs.default.existsSync(logsDir)) { + fs.default.mkdirSync(logsDir, { recursive: true }); + } + + const timestamp = new Date().toISOString(); + // Convert \n escape sequences to actual newlines for better readability + const formattedPrompt = prompt.replace(/\\n/g, '\n'); + const logEntry = `\n## Prompt Generated at ${timestamp}\n\n\`\`\`\n${formattedPrompt}\n\`\`\`\n\n---\n`; + + } catch (error) { + console.warn('Failed to write prompt to log file:', error.message); + } return prompt; } @@ -250,8 +295,8 @@ export class Prompter { } if (generation?.includes('')) { - const [_, afterThink] = generation.split('') - generation = afterThink + const [_, afterThink] = generation.split(''); + generation = afterThink; } return generation; @@ -260,20 +305,89 @@ export class Prompter { return ''; } - async promptCoding(messages) { + + async promptCoding(messages, codingGoal) { if (this.awaiting_coding) { console.warn('Already awaiting coding response, returning no response.'); return '```//no response```'; } + this.awaiting_coding = true; - await this.checkCooldown(); - let prompt = this.profile.coding; - prompt = await this.replaceStrings(prompt, messages, this.coding_examples); + try { + await this.checkCooldown(); + + while (messages.length > this.max_messages && messages.length > 1) { + messages.shift(); + console.log(`Trimmed oldest message, current length: ${messages.length}`); + } - let resp = await this.code_model.sendRequest(messages, prompt); - this.awaiting_coding = false; - await this._saveLog(prompt, messages, resp, 'coding'); - return resp; + const toolManager = this.agent.coder?.codeToolsManager; + const useNativeTools = this.profile.use_native_tools !== false; + + let prompt = this.profile.coding; + let toolsForAPI = null; + + if (useNativeTools) { + toolsForAPI = toolManager.getToolDefinitions(); + prompt = prompt.replace('$TOOLS', this.profile.tools_manual || ''); + console.log(`Native tools enabled: ${toolsForAPI.length} tools available`); + } else { + const toolsPrompt = toolManager.buildToolsPrompt(this.profile.tools_manual || ''); + prompt = prompt.replace('$TOOLS', toolsPrompt); + console.log(`Prompt-based tools enabled: ${toolManager.getToolDefinitions().length} tools available`); + } + + prompt = prompt.replaceAll('$CODING_GOAL', codingGoal); + prompt = await this.replaceStrings(prompt, messages, this.coding_examples); + + const apiResponse = await this.code_model.sendRequest(messages, prompt, '<|EOT|>', toolsForAPI); + + let finalResponse = apiResponse; + + if (useNativeTools && typeof apiResponse === 'string' && apiResponse.includes('_native_tool_calls')) { + try { + const parsed = JSON.parse(apiResponse); + if (parsed._native_tool_calls && parsed.tool_calls) { + const convertedTools = toolManager.parseToolCalls(parsed.tool_calls); + finalResponse = JSON.stringify({ tools: convertedTools }, null, 2); + console.log(`Converted ${convertedTools.length} native tool calls to JSON format`); + } + } catch (e) { + console.error('Failed to parse native tool calls:', e); + } + } + + if (!useNativeTools) { + const toolsMatch = apiResponse.match(/([\s\S]*?)<\/tools>/); + if (toolsMatch) { + finalResponse = toolsMatch[1].trim(); + } + } + + await this._saveLog(prompt, messages, finalResponse, 'coding'); + this.max_messages++; + + return finalResponse; + } catch (error) { + console.error('Error in promptCoding:', error.message); + if (error.message?.includes('Range of input length should be')) { + console.log('Input length exceeded, trimming messages and adjusting max_messages'); + if (messages.length > 2) { + messages.shift(); + console.log(`Removed oldest message, new length: ${messages.length}`); + this.max_messages = messages.length - 2; + console.log(`Adjusted max_messages to: ${this.max_messages}`); + } else { + console.log('Messages too few, clearing all messages and resetting max_messages to default'); + messages.length = 0; + this.max_messages = 15; + console.log('Cleared messages and reset max_messages to 15'); + } + } + throw error; + } finally { + this.awaiting_coding = false; + } } async promptMemSaving(to_summarize) { @@ -283,7 +397,7 @@ export class Prompter { let resp = await this.chat_model.sendRequest([], prompt); await this._saveLog(prompt, to_summarize, resp, 'memSaving'); if (resp?.includes('')) { - const [_, afterThink] = resp.split('') + const [_, afterThink] = resp.split(''); resp = afterThink; } return resp; @@ -312,7 +426,7 @@ export class Prompter { system_message = await this.replaceStrings(system_message, messages); let user_message = 'Use the below info to determine what goal to target next\n\n'; - user_message += '$LAST_GOALS\n$STATS\n$INVENTORY\n$CONVO' + user_message += '$LAST_GOALS\n$STATS\n$INVENTORY\n$CONVO'; user_message = await this.replaceStrings(user_message, messages, null, null, last_goals); let user_messages = [{role: 'user', content: user_message}]; diff --git a/src/models/qwen.js b/src/models/qwen.js index a768b5b07..3776d6376 100644 --- a/src/models/qwen.js +++ b/src/models/qwen.js @@ -1,54 +1,20 @@ import OpenAIApi from 'openai'; -import { getKey, hasKey } from '../utils/keys.js'; -import { strictFormat } from '../utils/text.js'; +import { getKey } from '../utils/keys.js'; +import { GPT } from './gpt.js'; -export class Qwen { +export class Qwen extends GPT { static prefix = 'qwen'; constructor(model_name, url, params) { - this.model_name = model_name; - this.params = params; - let config = {}; + super(model_name, url, params); + } - config.baseURL = url || 'https://dashscope.aliyuncs.com/compatible-mode/v1'; + initClient() { + let config = {}; + config.baseURL = this.url || 'https://dashscope.aliyuncs.com/compatible-mode/v1'; config.apiKey = getKey('QWEN_API_KEY'); - this.openai = new OpenAIApi(config); } - async sendRequest(turns, systemMessage, stop_seq='***') { - let messages = [{'role': 'system', 'content': systemMessage}].concat(turns); - - messages = strictFormat(messages); - - const pack = { - model: this.model_name || "qwen-plus", - messages, - stop: stop_seq, - ...(this.params || {}) - }; - - let res = null; - try { - console.log('Awaiting Qwen api response...'); - // console.log('Messages:', messages); - let completion = await this.openai.chat.completions.create(pack); - if (completion.choices[0].finish_reason == 'length') - throw new Error('Context length exceeded'); - console.log('Received.'); - res = completion.choices[0].message.content; - } - catch (err) { - if ((err.message == 'Context length exceeded' || err.code == 'context_length_exceeded') && turns.length > 1) { - console.log('Context length exceeded, trying again with shorter context.'); - return await this.sendRequest(turns.slice(1), systemMessage, stop_seq); - } else { - console.log(err); - res = 'My brain disconnected, try again.'; - } - } - return res; - } - // Why random backoff? // With a 30 requests/second limit on Alibaba Qwen's embedding service, // random backoff helps maximize bandwidth utilization. diff --git a/src/models/replicate.js b/src/models/replicate.js index aa296c57d..991b7d4cc 100644 --- a/src/models/replicate.js +++ b/src/models/replicate.js @@ -19,17 +19,54 @@ export class ReplicateAPI { }); } - async sendRequest(turns, systemMessage) { - const stop_seq = '***'; - const prompt = toSinglePrompt(turns, null, stop_seq); + async sendRequest(turns, systemMessage, stop_seq = '<|EOT|>', tools=null) { let model_name = this.model_name || 'meta/meta-llama-3-70b-instruct'; + // If tools are provided, use non-streaming API for tool calling + if (tools && Array.isArray(tools) && tools.length > 0) { + console.log(`Using tool calling with ${tools.length} tools`); + console.log('Awaiting Replicate API response with tool calling...'); + + try { + const messages = [ + { role: "system", content: systemMessage }, + ...turns + ]; + + const output = await this.replicate.run(model_name, { + input: { + messages: messages, + tools: tools, + tool_choice: 'auto', + ...(this.params || {}) + } + }); + + // Check if output contains tool calls + if (output?.tool_calls && output.tool_calls.length > 0) { + console.log(`Received ${output.tool_calls.length} tool call(s) from API`); + return JSON.stringify({ + _native_tool_calls: true, + tool_calls: output.tool_calls + }); + } + + console.log('Received.'); + return output?.content || output || ''; + } catch (err) { + console.log(err); + return 'My brain disconnected, try again.'; + } + } + + // Original streaming logic for non-tool calls + const prompt = toSinglePrompt(turns, null, stop_seq); const input = { prompt, system_prompt: systemMessage, ...(this.params || {}) }; - let res = null; + try { console.log('Awaiting Replicate API response...'); let result = ''; @@ -41,13 +78,12 @@ export class ReplicateAPI { break; } } - res = result; + console.log('Received.'); + return result; } catch (err) { console.log(err); - res = 'My brain disconnected, try again.'; + return 'My brain disconnected, try again.'; } - console.log('Received.'); - return res; } async embed(text) { diff --git a/src/models/vllm.js b/src/models/vllm.js index d821983bb..2f8528292 100644 --- a/src/models/vllm.js +++ b/src/models/vllm.js @@ -2,77 +2,22 @@ // Qwen is also compatible with the OpenAI API format; import OpenAIApi from 'openai'; -import { getKey, hasKey } from '../utils/keys.js'; -import { strictFormat } from '../utils/text.js'; +import { GPT } from './gpt.js'; -export class VLLM { +export class VLLM extends GPT { static prefix = 'vllm'; - constructor(model_name, url) { - this.model_name = model_name; - - // Currently use self-hosted SGLang API for text generation; use OpenAI text-embedding-3-small model for simple embedding. - let vllm_config = {}; - if (url) - vllm_config.baseURL = url; - else - vllm_config.baseURL = 'http://0.0.0.0:8000/v1'; - - vllm_config.apiKey = "" - - this.vllm = new OpenAIApi(vllm_config); + constructor(model_name, url, params) { + super(model_name, url, params); } - async sendRequest(turns, systemMessage, stop_seq = '***') { - let messages = [{ 'role': 'system', 'content': systemMessage }].concat(turns); - let model = this.model_name || "deepseek-ai/DeepSeek-R1-Distill-Qwen-32B"; - - if (model.includes('deepseek') || model.includes('qwen')) { - messages = strictFormat(messages); - } - - const pack = { - model: model, - messages, - stop: stop_seq, - }; - - let res = null; - try { - console.log('Awaiting openai api response...') - // console.log('Messages:', messages); - // todo set max_tokens, temperature, top_p, etc. in pack - let completion = await this.vllm.chat.completions.create(pack); - if (completion.choices[0].finish_reason == 'length') - throw new Error('Context length exceeded'); - console.log('Received.') - res = completion.choices[0].message.content; - } - catch (err) { - if ((err.message == 'Context length exceeded' || err.code == 'context_length_exceeded') && turns.length > 1) { - console.log('Context length exceeded, trying again with shorter context.'); - return await this.sendRequest(turns.slice(1), systemMessage, stop_seq); - } else { - console.log(err); - res = 'My brain disconnected, try again.'; - } - } - return res; + initClient() { + let vllm_config = {}; + vllm_config.baseURL = this.url || 'http://0.0.0.0:8000/v1'; + vllm_config.apiKey = ""; + this.openai = new OpenAIApi(vllm_config); } - async saveToFile(logFile, logEntry) { - let task_id = this.agent.task.task_id; - console.log(task_id) - let logDir; - if (this.task_id === null) { - logDir = path.join(__dirname, `../../bots/${this.agent.name}/logs`); - } else { - logDir = path.join(__dirname, `../../bots/${this.agent.name}/logs/${task_id}`); - } - - await fs.mkdir(logDir, { recursive: true }); - - logFile = path.join(logDir, logFile); - await fs.appendFile(logFile, String(logEntry), 'utf-8'); + async embed(text) { + throw new Error('Embeddings are not supported by VLLM. Use OpenAI text-embedding-3-small model for simple embedding.'); } - } \ No newline at end of file diff --git a/src/utils/text.js b/src/utils/text.js index 08a3b4e60..9e40e4390 100644 --- a/src/utils/text.js +++ b/src/utils/text.js @@ -13,7 +13,7 @@ export function stringifyTurns(turns) { return res.trim(); } -export function toSinglePrompt(turns, system=null, stop_seq='***', model_nickname='assistant') { +export function toSinglePrompt(turns, system=null, stop_seq='<|EOT|>', model_nickname='assistant') { let prompt = system ? `${system}${stop_seq}` : ''; let role = ''; turns.forEach((message) => {