From 5ba8a4422aaabe7ff20f15ff86bbc6e5d0a91249 Mon Sep 17 00:00:00 2001 From: Sweaterdog Date: Thu, 26 Mar 2026 02:17:38 -0700 Subject: [PATCH 01/56] Revise local model usage instructions in README Updated local model recommendations and installation instructions. --- README.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 5c768a38d..0fdc0250b 100644 --- a/README.md +++ b/README.md @@ -81,11 +81,8 @@ You can configure the agent's name, model, and prompts in their profile like `an For more comprehensive model configuration and syntax, see [Model Specifications](#model-specifications). -For local models we support [ollama](https://ollama.com/) and we provide our own finetuned models for you to use. -To install our models, install ollama and run the following terminal command: -```bash -ollama pull sweaterdog/andy-4:micro-q8_0 && ollama pull embeddinggemma -``` +For local models, we recommend you use **LM Studio** for the Andy series of models. +Please see our [huggingface page for more info.](https://huggingface.co/collections/Mindcraft-CE) ## Online Servers To connect to online servers your bot will need an official Microsoft/Minecraft account. You can use your own personal one, but will need another account if you want to connect too and play with it. To connect, change these lines in `settings.js`: From eebd757b44d307487ce5cc026d930d55fe355304 Mon Sep 17 00:00:00 2001 From: Sweaterdog Date: Thu, 26 Mar 2026 02:21:04 -0700 Subject: [PATCH 02/56] Delete profiles/andy-4-reasoning.json --- profiles/andy-4-reasoning.json | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 profiles/andy-4-reasoning.json diff --git a/profiles/andy-4-reasoning.json b/profiles/andy-4-reasoning.json deleted file mode 100644 index f6e2939ea..000000000 --- a/profiles/andy-4-reasoning.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "name": "andy_4_thinking", - - "model": "ollama/sweaterdog/andy-4:micro-q8_0", - - "conversing": "You are a playful Minecraft bot named $NAME that can converse with players, see, move, mine, build, and interact with the world by using commands.\n$SELF_PROMPT Act human-like as if you were a typical Minecraft player, rather than an AI. Be very brief in your responses, don't apologize constantly, don't give instructions or make lists unless asked, and don't refuse requests. Think in high amounts before responding. Don't pretend to act, use commands immediately when requested. Do NOT say this: 'Sure, I've stopped.', instead say this: 'Sure, I'll stop. !stop'. Do NOT say this: 'On my way! Give me a moment.', instead say this: 'On my way! !goToPlayer(\"playername\", 3)'. 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\nReason before responding. Conversation Begin:", - - "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. Think deeply before responding. 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": "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:" - -} From c6acdf43f6336b7e61701fb3280c1c3714058b82 Mon Sep 17 00:00:00 2001 From: Sweaterdog Date: Thu, 26 Mar 2026 02:22:50 -0700 Subject: [PATCH 03/56] Update profile name and model structure for andy-4.2 --- profiles/andy-4.2.json | 9 +++++++++ profiles/andy-4.json | 7 ------- 2 files changed, 9 insertions(+), 7 deletions(-) create mode 100644 profiles/andy-4.2.json delete mode 100644 profiles/andy-4.json diff --git a/profiles/andy-4.2.json b/profiles/andy-4.2.json new file mode 100644 index 000000000..dd77b283d --- /dev/null +++ b/profiles/andy-4.2.json @@ -0,0 +1,9 @@ +{ + "name": "andy_4.2", + + "model": { + "api": "openai", + "model": "andy-4.2", + "url": "http://127.0.0.1:1234/v1" + } +} diff --git a/profiles/andy-4.json b/profiles/andy-4.json deleted file mode 100644 index 2048a80d9..000000000 --- a/profiles/andy-4.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "andy_4", - - "model": "ollama/sweaterdog/andy-4:micro-q8_0", - - "embedding": "ollama" -} From 6d770243da73caf5a58b4ab88a1035b8e5fa3f65 Mon Sep 17 00:00:00 2001 From: Sweaterdog Date: Thu, 26 Mar 2026 02:23:13 -0700 Subject: [PATCH 04/56] Update profile reference in settings.js --- settings.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/settings.js b/settings.js index e59457db6..b6724316b 100644 --- a/settings.js +++ b/settings.js @@ -20,7 +20,7 @@ const settings = { // "./profiles/mistral.json", // "./profiles/deepseek.json", // "./profiles/mercury.json", - // "./profiles/andy-4.json", // Supports up to 75 messages! + // "./profiles/andy-4.2.json", // using more than 1 profile requires you to /msg each bot indivually // individual profiles override values from the base profile From d18647c7350f0d08a95ab427829133abab83f159 Mon Sep 17 00:00:00 2001 From: Sweaterdog Date: Thu, 26 Mar 2026 02:24:12 -0700 Subject: [PATCH 05/56] Update recommendation for local models usage Added a note to avoid using Ollama with local models. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0fdc0250b..479b050b2 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ You can configure the agent's name, model, and prompts in their profile like `an For more comprehensive model configuration and syntax, see [Model Specifications](#model-specifications). -For local models, we recommend you use **LM Studio** for the Andy series of models. +For local models, we recommend you use **LM Studio** for the Andy series of models. Ollama breaks current models, and should be avoided. Please see our [huggingface page for more info.](https://huggingface.co/collections/Mindcraft-CE) ## Online Servers From bbeb003d45d3b019204610012917372ad1ac7c3f Mon Sep 17 00:00:00 2001 From: Sweaterdog Date: Sun, 29 Mar 2026 22:45:31 -0700 Subject: [PATCH 06/56] Update profiles/andy-4.2.json Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- profiles/andy-4.2.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/profiles/andy-4.2.json b/profiles/andy-4.2.json index dd77b283d..e6e68f927 100644 --- a/profiles/andy-4.2.json +++ b/profiles/andy-4.2.json @@ -1,5 +1,5 @@ { - "name": "andy_4.2", + "name": "andy_4_2", "model": { "api": "openai", From 8c916ae54f70284a93e2ba5d06e40c2afaa16267 Mon Sep 17 00:00:00 2001 From: Alex Taylor Date: Thu, 2 Apr 2026 17:42:43 +0700 Subject: [PATCH 07/56] feat: add 8 more model providers --- src/models/ai21.js | 70 ++++++++++++++++++++++++++++++++++++++++ src/models/anyscale.js | 70 ++++++++++++++++++++++++++++++++++++++++ src/models/cohere.js | 70 ++++++++++++++++++++++++++++++++++++++++ src/models/deepinfra.js | 70 ++++++++++++++++++++++++++++++++++++++++ src/models/fireworks.js | 70 ++++++++++++++++++++++++++++++++++++++++ src/models/nvidia.js | 70 ++++++++++++++++++++++++++++++++++++++++ src/models/perplexity.js | 70 ++++++++++++++++++++++++++++++++++++++++ src/models/together.js | 70 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 560 insertions(+) create mode 100644 src/models/ai21.js create mode 100644 src/models/anyscale.js create mode 100644 src/models/cohere.js create mode 100644 src/models/deepinfra.js create mode 100644 src/models/fireworks.js create mode 100644 src/models/nvidia.js create mode 100644 src/models/perplexity.js create mode 100644 src/models/together.js diff --git a/src/models/ai21.js b/src/models/ai21.js new file mode 100644 index 000000000..7097cc3c8 --- /dev/null +++ b/src/models/ai21.js @@ -0,0 +1,70 @@ +import OpenAIApi from 'openai'; +import { getKey } from '../utils/keys.js'; +import { strictFormat } from '../utils/text.js'; + +export class AI21 { + static prefix = 'ai21'; + constructor(model_name, url, params) { + this.model_name = model_name; + this.url = url; + this.params = params; + + let config = {}; + if (url) + config.baseURL = url; + else + config.baseURL = "https://api.ai21.com/studio/v1"; + + config.apiKey = getKey('AI21_API_KEY'); + + this.openai = new OpenAIApi(config); + } + + async sendRequest(turns, systemMessage) { + let messages = [{'role': 'system', 'content': systemMessage}].concat(turns); + messages = strictFormat(messages); + + const pack = { + model: this.model_name || "jamba-1.5-large", + messages, + ...(this.params || {}) + }; + + let res = null; + try { + let completion = await this.openai.chat.completions.create(pack); + if (completion.choices[0].finish_reason == 'length') + throw new Error('Context length exceeded'); + res = completion.choices[0].message.content; + } + catch (err) { + if ((err.message == 'Context length exceeded' || err.code == 'context_length_exceeded') && turns.length > 1) { + return await this.sendRequest(turns.slice(1), systemMessage); + } else { + 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 AI21.'); + } +} \ No newline at end of file diff --git a/src/models/anyscale.js b/src/models/anyscale.js new file mode 100644 index 000000000..5de63b9f1 --- /dev/null +++ b/src/models/anyscale.js @@ -0,0 +1,70 @@ +import OpenAIApi from 'openai'; +import { getKey } from '../utils/keys.js'; +import { strictFormat } from '../utils/text.js'; + +export class Anyscale { + static prefix = 'anyscale'; + constructor(model_name, url, params) { + this.model_name = model_name; + this.url = url; + this.params = params; + + let config = {}; + if (url) + config.baseURL = url; + else + config.baseURL = "https://api.endpoints.anyscale.com/v1"; + + config.apiKey = getKey('ANYSCALE_API_KEY'); + + this.openai = new OpenAIApi(config); + } + + async sendRequest(turns, systemMessage) { + let messages = [{'role': 'system', 'content': systemMessage}].concat(turns); + messages = strictFormat(messages); + + const pack = { + model: this.model_name || "meta-llama/Meta-Llama-3-70B-Instruct", + messages, + ...(this.params || {}) + }; + + let res = null; + try { + let completion = await this.openai.chat.completions.create(pack); + if (completion.choices[0].finish_reason == 'length') + throw new Error('Context length exceeded'); + res = completion.choices[0].message.content; + } + catch (err) { + if ((err.message == 'Context length exceeded' || err.code == 'context_length_exceeded') && turns.length > 1) { + return await this.sendRequest(turns.slice(1), systemMessage); + } else { + 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 Anyscale.'); + } +} \ No newline at end of file diff --git a/src/models/cohere.js b/src/models/cohere.js new file mode 100644 index 000000000..a77089e90 --- /dev/null +++ b/src/models/cohere.js @@ -0,0 +1,70 @@ +import OpenAIApi from 'openai'; +import { getKey } from '../utils/keys.js'; +import { strictFormat } from '../utils/text.js'; + +export class Cohere { + static prefix = 'cohere'; + constructor(model_name, url, params) { + this.model_name = model_name; + this.url = url; + this.params = params; + + let config = {}; + if (url) + config.baseURL = url; + else + config.baseURL = "https://api.cohere.com/v1"; + + config.apiKey = getKey('COHERE_API_KEY'); + + this.openai = new OpenAIApi(config); + } + + async sendRequest(turns, systemMessage) { + let messages = [{'role': 'system', 'content': systemMessage}].concat(turns); + messages = strictFormat(messages); + + const pack = { + model: this.model_name || "command-r-plus", + messages, + ...(this.params || {}) + }; + + let res = null; + try { + let completion = await this.openai.chat.completions.create(pack); + if (completion.choices[0].finish_reason == 'length') + throw new Error('Context length exceeded'); + res = completion.choices[0].message.content; + } + catch (err) { + if ((err.message == 'Context length exceeded' || err.code == 'context_length_exceeded') && turns.length > 1) { + return await this.sendRequest(turns.slice(1), systemMessage); + } else { + 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 Cohere.'); + } +} \ No newline at end of file diff --git a/src/models/deepinfra.js b/src/models/deepinfra.js new file mode 100644 index 000000000..e1d6858a4 --- /dev/null +++ b/src/models/deepinfra.js @@ -0,0 +1,70 @@ +import OpenAIApi from 'openai'; +import { getKey } from '../utils/keys.js'; +import { strictFormat } from '../utils/text.js'; + +export class DeepInfra { + static prefix = 'deepinfra'; + constructor(model_name, url, params) { + this.model_name = model_name; + this.url = url; + this.params = params; + + let config = {}; + if (url) + config.baseURL = url; + else + config.baseURL = "https://api.deepinfra.com/v1/openai"; + + config.apiKey = getKey('DEEPINFRA_API_KEY'); + + this.openai = new OpenAIApi(config); + } + + async sendRequest(turns, systemMessage) { + let messages = [{'role': 'system', 'content': systemMessage}].concat(turns); + messages = strictFormat(messages); + + const pack = { + model: this.model_name || "meta-llama/Meta-Llama-3-70B-Instruct", + messages, + ...(this.params || {}) + }; + + let res = null; + try { + let completion = await this.openai.chat.completions.create(pack); + if (completion.choices[0].finish_reason == 'length') + throw new Error('Context length exceeded'); + res = completion.choices[0].message.content; + } + catch (err) { + if ((err.message == 'Context length exceeded' || err.code == 'context_length_exceeded') && turns.length > 1) { + return await this.sendRequest(turns.slice(1), systemMessage); + } else { + 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 DeepInfra.'); + } +} \ No newline at end of file diff --git a/src/models/fireworks.js b/src/models/fireworks.js new file mode 100644 index 000000000..4367e286e --- /dev/null +++ b/src/models/fireworks.js @@ -0,0 +1,70 @@ +import OpenAIApi from 'openai'; +import { getKey } from '../utils/keys.js'; +import { strictFormat } from '../utils/text.js'; + +export class Fireworks { + static prefix = 'fireworks'; + constructor(model_name, url, params) { + this.model_name = model_name; + this.url = url; + this.params = params; + + let config = {}; + if (url) + config.baseURL = url; + else + config.baseURL = "https://api.fireworks.ai/inference/v1"; + + config.apiKey = getKey('FIREWORKS_API_KEY'); + + this.openai = new OpenAIApi(config); + } + + async sendRequest(turns, systemMessage) { + let messages = [{'role': 'system', 'content': systemMessage}].concat(turns); + messages = strictFormat(messages); + + const pack = { + model: this.model_name || "accounts/fireworks/models/llama-v3p1-70b-instruct", + messages, + ...(this.params || {}) + }; + + let res = null; + try { + let completion = await this.openai.chat.completions.create(pack); + if (completion.choices[0].finish_reason == 'length') + throw new Error('Context length exceeded'); + res = completion.choices[0].message.content; + } + catch (err) { + if ((err.message == 'Context length exceeded' || err.code == 'context_length_exceeded') && turns.length > 1) { + return await this.sendRequest(turns.slice(1), systemMessage); + } else { + 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 Fireworks.'); + } +} \ No newline at end of file diff --git a/src/models/nvidia.js b/src/models/nvidia.js new file mode 100644 index 000000000..1f1785173 --- /dev/null +++ b/src/models/nvidia.js @@ -0,0 +1,70 @@ +import OpenAIApi from 'openai'; +import { getKey } from '../utils/keys.js'; +import { strictFormat } from '../utils/text.js'; + +export class Nvidia { + static prefix = 'nvidia'; + constructor(model_name, url, params) { + this.model_name = model_name; + this.url = url; + this.params = params; + + let config = {}; + if (url) + config.baseURL = url; + else + config.baseURL = "https://integrate.api.nvidia.com/v1"; + + config.apiKey = getKey('NVIDIA_API_KEY'); + + this.openai = new OpenAIApi(config); + } + + async sendRequest(turns, systemMessage) { + let messages = [{'role': 'system', 'content': systemMessage}].concat(turns); + messages = strictFormat(messages); + + const pack = { + model: this.model_name || "meta/llama3-70b-instruct", + messages, + ...(this.params || {}) + }; + + let res = null; + try { + let completion = await this.openai.chat.completions.create(pack); + if (completion.choices[0].finish_reason == 'length') + throw new Error('Context length exceeded'); + res = completion.choices[0].message.content; + } + catch (err) { + if ((err.message == 'Context length exceeded' || err.code == 'context_length_exceeded') && turns.length > 1) { + return await this.sendRequest(turns.slice(1), systemMessage); + } else { + 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 Nvidia.'); + } +} \ No newline at end of file diff --git a/src/models/perplexity.js b/src/models/perplexity.js new file mode 100644 index 000000000..11beb89c3 --- /dev/null +++ b/src/models/perplexity.js @@ -0,0 +1,70 @@ +import OpenAIApi from 'openai'; +import { getKey } from '../utils/keys.js'; +import { strictFormat } from '../utils/text.js'; + +export class Perplexity { + static prefix = 'perplexity'; + constructor(model_name, url, params) { + this.model_name = model_name; + this.url = url; + this.params = params; + + let config = {}; + if (url) + config.baseURL = url; + else + config.baseURL = "https://api.perplexity.ai"; + + config.apiKey = getKey('PERPLEXITY_API_KEY'); + + this.openai = new OpenAIApi(config); + } + + async sendRequest(turns, systemMessage) { + let messages = [{'role': 'system', 'content': systemMessage}].concat(turns); + messages = strictFormat(messages); + + const pack = { + model: this.model_name || "llama-3-sonar-large-32k-online", + messages, + ...(this.params || {}) + }; + + let res = null; + try { + let completion = await this.openai.chat.completions.create(pack); + if (completion.choices[0].finish_reason == 'length') + throw new Error('Context length exceeded'); + res = completion.choices[0].message.content; + } + catch (err) { + if ((err.message == 'Context length exceeded' || err.code == 'context_length_exceeded') && turns.length > 1) { + return await this.sendRequest(turns.slice(1), systemMessage); + } else { + 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 Perplexity.'); + } +} \ No newline at end of file diff --git a/src/models/together.js b/src/models/together.js new file mode 100644 index 000000000..ec3e37cc2 --- /dev/null +++ b/src/models/together.js @@ -0,0 +1,70 @@ +import OpenAIApi from 'openai'; +import { getKey } from '../utils/keys.js'; +import { strictFormat } from '../utils/text.js'; + +export class Together { + static prefix = 'together'; + constructor(model_name, url, params) { + this.model_name = model_name; + this.url = url; + this.params = params; + + let config = {}; + if (url) + config.baseURL = url; + else + config.baseURL = "https://api.together.xyz/v1"; + + config.apiKey = getKey('TOGETHER_API_KEY'); + + this.openai = new OpenAIApi(config); + } + + async sendRequest(turns, systemMessage) { + let messages = [{'role': 'system', 'content': systemMessage}].concat(turns); + messages = strictFormat(messages); + + const pack = { + model: this.model_name || "meta-llama/Llama-3-70b-chat-hf", + messages, + ...(this.params || {}) + }; + + let res = null; + try { + let completion = await this.openai.chat.completions.create(pack); + if (completion.choices[0].finish_reason == 'length') + throw new Error('Context length exceeded'); + res = completion.choices[0].message.content; + } + catch (err) { + if ((err.message == 'Context length exceeded' || err.code == 'context_length_exceeded') && turns.length > 1) { + return await this.sendRequest(turns.slice(1), systemMessage); + } else { + 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 Together.'); + } +} \ No newline at end of file From 8381299676bc4134eafad1b940bf22afcd8db46a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?TheHat=E2=84=A2?= <236301863+masterpatrickpl-coder@users.noreply.github.com> Date: Tue, 14 Apr 2026 14:54:49 +0800 Subject: [PATCH 08/56] Big Change typo? --- src/models/glhf.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/glhf.js b/src/models/glhf.js index b237c8d74..5256a33af 100644 --- a/src/models/glhf.js +++ b/src/models/glhf.js @@ -5,7 +5,7 @@ export class GLHF { static prefix = 'glhf'; constructor(model_name, url) { this.model_name = model_name; - const apiKey = getKey('GHLF_API_KEY'); + const apiKey = getKey('GLHF_API_KEY'); if (!apiKey) { throw new Error('API key not found. Please check keys.json and ensure GHLF_API_KEY is defined.'); } From 4f07e90d5189ef8f6ec9d55131d665d6d3b66ecc Mon Sep 17 00:00:00 2001 From: riqvip <95001376+riqvip@users.noreply.github.com> Date: Wed, 15 Apr 2026 11:38:41 -0700 Subject: [PATCH 09/56] Remove GLHF --- README.md | 1 - keys.example.json | 1 - src/models/glhf.js | 71 ---------------------------------------------- 3 files changed, 73 deletions(-) delete mode 100644 src/models/glhf.js diff --git a/README.md b/README.md index bcdceef70..4e7f84401 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,6 @@ You can configure the agent's name, model, and prompts in their profile like `an | `huggingface` | `HUGGINGFACE_API_KEY` | [docs](https://huggingface.co/models) | | `novita` | `NOVITA_API_KEY` | [docs](https://novita.ai/model-api/product/llm-api?utm_source=github_mindcraft&utm_medium=github_readme&utm_campaign=link) | | `openrouter` | `OPENROUTER_API_KEY` | [docs](https://openrouter.ai/models) | -| `glhf` | `GHLF_API_KEY` | [docs](https://glhf.chat/user-settings/api) | | `hyperbolic` | `HYPERBOLIC_API_KEY` | [docs](https://docs.hyperbolic.xyz/docs/getting-started) | | `vllm` | n/a | n/a | | `cerebras` | `CEREBRAS_API_KEY` | [docs](https://inference-docs.cerebras.ai/introduction) | diff --git a/keys.example.json b/keys.example.json index fe6812888..bcdfd9442 100644 --- a/keys.example.json +++ b/keys.example.json @@ -10,7 +10,6 @@ "XAI_API_KEY": "", "MISTRAL_API_KEY": "", "DEEPSEEK_API_KEY": "", - "GHLF_API_KEY": "", "HYPERBOLIC_API_KEY": "", "NOVITA_API_KEY": "", "OPENROUTER_API_KEY": "", diff --git a/src/models/glhf.js b/src/models/glhf.js deleted file mode 100644 index 5256a33af..000000000 --- a/src/models/glhf.js +++ /dev/null @@ -1,71 +0,0 @@ -import OpenAIApi from 'openai'; -import { getKey } from '../utils/keys.js'; - -export class GLHF { - static prefix = 'glhf'; - constructor(model_name, url) { - this.model_name = model_name; - const apiKey = getKey('GLHF_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" - }); - } - - 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] - }; - - const maxAttempts = 5; - let attempt = 0; - let finalRes = null; - - 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 = 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..."); - continue; - } - // If there's a closing tag but no opening , prepend one. - if (res.includes("") && !res.includes("")) { - res = "" + res; - } - finalRes = res.replace(/<\|separator\|>/g, '*no response*'); - break; // Valid response obtained. - } 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.error(err); - finalRes = 'My brain disconnected, try again.'; - break; - } - } - } - if (finalRes === null) { - finalRes = "I thought too hard, sorry, try again"; - } - return finalRes; - } - - async embed(text) { - throw new Error('Embeddings are not supported by glhf.'); - } -} From af4dcb9ad761b52660a865177faddd734c763135 Mon Sep 17 00:00:00 2001 From: NinotQuyi Date: Fri, 1 May 2026 19:53:24 +0800 Subject: [PATCH 10/56] Make provider configuration portable Model selection now resolves provider ids through a project registry instead of baking endpoint and key details into many one-off model classes. The example registry, profiles, shared transports, and tests move the integration surface toward OpenClaw-style format names while keeping local key material ignored. Constraint: Real provider credentials must stay in ignored llm_providers.json, not in committed examples Rejected: Keep per-provider class copies | duplicated OpenAI-compatible transports made stale providers harder to remove Confidence: medium Scope-risk: broad Directive: Do not reintroduce provider-specific OpenAI-compatible classes unless a provider needs a genuinely different protocol Tested: git diff --cached --check; git grep --cached secret-pattern scan only found placeholders/test refresh_token strings; npm test passed before commit split Not-tested: Live provider matrix after commit split Co-authored-by: OmX --- .dockerignore | 3 +- .gitignore | 2 + docs/enhanced-coding-system-diff-analysis.md | 397 ++++++++++ docs/llm-provider-config.md | 156 ++++ docs/provider-unification-openclaw-notes.md | 197 +++++ eslint.config.js | 9 +- keys.example.json | 14 +- llm_providers.example.json | 227 ++++++ package.json | 7 +- profiles/andy-4-reasoning.json | 11 +- profiles/andy-4.json | 12 +- profiles/azure.json | 18 +- profiles/cerebras.json | 7 + profiles/claude.json | 10 +- profiles/claude_thinker.json | 8 +- profiles/codex.json | 12 + profiles/deepseek.json | 10 +- profiles/freeguy.json | 9 +- profiles/gemini.json | 8 +- profiles/gpt.json | 8 +- profiles/grok.json | 10 +- profiles/groq.json | 7 + profiles/kimi.json | 7 + profiles/llama.json | 14 +- profiles/mercury.json | 11 +- profiles/minimax-cn.json | 7 + profiles/minimax-intl.json | 7 + profiles/mistral.json | 8 +- profiles/novita.json | 7 + profiles/ollama.json | 7 + profiles/openrouter.json | 7 + profiles/qwen-cn.json | 12 + profiles/qwen.json | 17 - profiles/replicate.json | 11 + profiles/siliconflow.json | 7 + profiles/vllm.json | 11 +- src/agent/library/skill_library.js | 5 + src/agent/speak.js | 32 +- src/models/_model_map.js | 173 ++++- src/models/anthropic_messages.js | 102 +++ src/models/azure.js | 32 - src/models/azure_openai_responses.js | 36 + src/models/cerebras.js | 61 -- src/models/claude.js | 87 --- src/models/codex_chatgpt.js | 756 +++++++++++++++++++ src/models/deepseek.js | 59 -- src/models/gemini.js | 176 ----- src/models/google_generative_ai.js | 174 +++++ src/models/gpt.js | 147 ---- src/models/grok.js | 82 -- src/models/groq.js | 95 --- src/models/huggingface.js | 86 --- src/models/hyperbolic.js | 114 --- src/models/lmstudio.js | 74 -- src/models/mercury.js | 95 --- src/models/mistral.js | 94 --- src/models/native_tools.js | 155 ++++ src/models/novita.js | 71 -- src/models/ollama.js | 115 --- src/models/openai_compatible.js | 190 +++++ src/models/openai_responses.js | 75 ++ src/models/openrouter.js | 77 -- src/models/qwen.js | 80 -- src/models/replicate.js | 312 ++++---- src/models/vllm.js | 78 -- src/utils/keys.js | 40 +- tests/codex_chatgpt.test.js | 321 ++++++++ tests/llm_providers_config.test.js | 98 +++ tests/native_tools.test.js | 81 ++ tests/openai_compatible.test.js | 350 +++++++++ tests/profile_shape.test.js | 52 ++ tests/smoke/live_function_call_smoke.js | 169 +++++ tests/smoke/live_model_matrix.js | 281 +++++++ 73 files changed, 4387 insertions(+), 1913 deletions(-) create mode 100644 docs/enhanced-coding-system-diff-analysis.md create mode 100644 docs/llm-provider-config.md create mode 100644 docs/provider-unification-openclaw-notes.md create mode 100644 llm_providers.example.json create mode 100644 profiles/cerebras.json create mode 100644 profiles/codex.json create mode 100644 profiles/groq.json create mode 100644 profiles/kimi.json create mode 100644 profiles/minimax-cn.json create mode 100644 profiles/minimax-intl.json create mode 100644 profiles/novita.json create mode 100644 profiles/ollama.json create mode 100644 profiles/openrouter.json create mode 100644 profiles/qwen-cn.json delete mode 100644 profiles/qwen.json create mode 100644 profiles/replicate.json create mode 100644 profiles/siliconflow.json create mode 100644 src/models/anthropic_messages.js delete mode 100644 src/models/azure.js create mode 100644 src/models/azure_openai_responses.js delete mode 100644 src/models/cerebras.js delete mode 100644 src/models/claude.js create mode 100644 src/models/codex_chatgpt.js delete mode 100644 src/models/deepseek.js delete mode 100644 src/models/gemini.js create mode 100644 src/models/google_generative_ai.js delete mode 100644 src/models/gpt.js delete mode 100644 src/models/grok.js delete mode 100644 src/models/groq.js delete mode 100644 src/models/huggingface.js delete mode 100644 src/models/hyperbolic.js delete mode 100644 src/models/lmstudio.js delete mode 100644 src/models/mercury.js delete mode 100644 src/models/mistral.js create mode 100644 src/models/native_tools.js delete mode 100644 src/models/novita.js delete mode 100644 src/models/ollama.js create mode 100644 src/models/openai_compatible.js create mode 100644 src/models/openai_responses.js delete mode 100644 src/models/openrouter.js delete mode 100644 src/models/qwen.js delete mode 100644 src/models/vllm.js create mode 100644 tests/codex_chatgpt.test.js create mode 100644 tests/llm_providers_config.test.js create mode 100644 tests/native_tools.test.js create mode 100644 tests/openai_compatible.test.js create mode 100644 tests/profile_shape.test.js create mode 100644 tests/smoke/live_function_call_smoke.js create mode 100644 tests/smoke/live_model_matrix.js diff --git a/.dockerignore b/.dockerignore index 9f56aa826..802e45b18 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,4 +2,5 @@ node_modules bots/*/ !bots/* -keys.json \ No newline at end of file +keys.json +llm_providers.json diff --git a/.gitignore b/.gitignore index d838f969d..be861317c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ scratch.js bots/**/action-code/** bots/**/ keys.json +llm_providers.json services/viaproxy/jars/** services/viaproxy/logs/** services/viaproxy/plugins/** @@ -21,6 +22,7 @@ jill_*.json src/models/logs/* server_data/* results/* +tests/results/* tasks/construction_tasks/test_multiagent_construction_tasks.json tasks/construction_tasks/train_multiagent_construction_tasks.json tasks/construction_tasks/test/** diff --git a/docs/enhanced-coding-system-diff-analysis.md b/docs/enhanced-coding-system-diff-analysis.md new file mode 100644 index 000000000..b6a95405a --- /dev/null +++ b/docs/enhanced-coding-system-diff-analysis.md @@ -0,0 +1,397 @@ +# enhanced-coding-system 相对 develop 的改动分析 + +记录日期:2026-04-29 +仓库:`/Users/Ninot/NinotQuyi/mindcraft` +分析 worktree:`/Users/Ninot/NinotQuyi/mindcraft-enhanced-coding-system` + +## 分析范围 + +已执行远端同步: + +```bash +git fetch --all --prune --tags +``` + +对比对象: + +```text +origin/develop: 8acbd90 Merge pull request #761 from domdomegg/update-model-ids +origin/enhanced-coding-system: ec5bd0f refactor: use template method pattern to decouple API key dependencies +merge-base: d54eec6 +``` + +分支关系: + +```text +develop 相比 enhanced 多 55 个提交 +enhanced 相比 develop 分叉点多 99 个提交 +``` + +主要使用 PR/feature-branch 语义的三点 diff: + +```bash +git diff origin/develop...HEAD +``` + +统计结果: + +```text +79 files changed, 5262 insertions(+), 1017 deletions(-) +``` + +补充说明:原始工作目录 `/Users/Ninot/NinotQuyi/mindcraft` 存在未提交修改和未跟踪文件;本文档记录的是干净 worktree 中 `origin/enhanced-coding-system` 相对 `origin/develop` 的分支 diff,不包含本地未提交内容。 + +--- + +## 总体结论 + +`enhanced-coding-system` 是一次围绕 **Coding Agent 工具化、Native Tool Calling、多模型工具调用适配、Prompt/Profile 重构、执行安全边界、Learned Skills、以及 Minecraft 行为可靠性增强** 的大规模功能分支。 + +可以划分为以下主要功能模块: + +1. Coding Agent 工具化重构 +2. Native Tool Calling 支持 +3. Prompt/Profile 外置与重构 +4. Coding 安全边界与 Workspace 限制 +5. Learned Skills 持久化技能系统 +6. Minecraft 生存/建造/查询能力增强 +7. Provider/Profile 生态扩展 +8. 依赖与 SDK 更新 + +--- + +## 1. Coding Agent 工具化重构 + +核心新增目录: + +```text +src/agent/tools/ +``` + +新增工具文件: + +```text +edit.js +execute.js +finishCoding.js +glob.js +grep.js +lint.js +ls.js +multiEdit.js +read.js +todoWrite.js +toolManager.js +tools-prompt.md +write.js +``` + +主要变化: + +- 将原本“直接生成 JavaScript codeblock”的 coding 模式改造成工具调用式工作流。 +- 新增 `Read / Write / Edit / MultiEdit / Execute / Lint / Grep / Glob / LS / TodoWrite / FinishCoding` 等工具。 +- `ToolManager` 负责: + - JSON tool response 解析; + - native tool calls 转换; + - 工具执行调度; + - 工作区路径校验; + - todo / learned skills 提醒。 +- `ExecuteTool` 负责: + - 执行 bot action-code JavaScript 文件; + - 执行前 lint 检查; + - IIFE 格式校验; + - 超时处理; + - 错误栈映射; + - 捕获 bot chat/output; + - 执行失败时返回更详细错误信息。 + +代表文件: + +```text +src/agent/coder.js +src/agent/tools/toolManager.js +src/agent/tools/execute.js +src/models/prompter.js +profiles/defaults/prompts/coding.md +``` + +--- + +## 2. Native Tool Calling 模型适配层 + +大量模型的 `sendRequest` 签名被扩展为支持 tools 参数: + +```js +sendRequest(turns, systemMessage, stop_seq, tools) +``` + +受影响模型: + +```text +src/models/gpt.js +src/models/claude.js +src/models/gemini.js +src/models/groq.js +src/models/grok.js +src/models/cerebras.js +src/models/mistral.js +src/models/huggingface.js +src/models/ollama.js +src/models/replicate.js +src/models/hyperbolic.js +src/models/novita.js +src/models/azure.js +src/models/openrouter.js +src/models/mercury.js +src/models/qwen.js +src/models/vllm.js +``` + +主要变化: + +- OpenAI-compatible 模型支持 `tools`。 +- 模型返回 `_native_tool_calls` JSON。 +- `Prompter.promptCoding()` 将 native tool calls 转成内部 JSON tools 格式。 +- 支持在 profile 中切换: + - native tools API; + - prompt-engineering tools 格式。 +- `GPT` 引入 template method pattern: + - `initClient()`; + - 子类只覆盖 client 初始化,减少 provider 间重复逻辑。 + +代表文件: + +```text +src/models/gpt.js +src/models/prompter.js +src/models/azure.js +src/models/openrouter.js +src/models/mercury.js +``` + +--- + +## 3. Coding Prompt / Profile 配置重构 + +默认 profile 从内联长 prompt 改成外部 Markdown 文件: + +```text +profiles/defaults/prompts/coding.md +profiles/defaults/prompts/conversing.md +profiles/defaults/prompts/bot_responder.md +profiles/defaults/prompts/image_analysis.md +profiles/defaults/prompts/saving_memory.md +``` + +`profiles/defaults/_default.json` 改为引用 prompt 文件路径: + +```json +"coding": "profiles/defaults/prompts/coding.md", +"conversing": "profiles/defaults/prompts/conversing.md", +"tools_manual": "src/agent/tools/tools-prompt.md" +``` + +主要作用: + +- 提升 coding prompt 的可维护性。 +- 将 tool 使用规范独立成文档。 +- 新增 `$TOOLS`、`$CODING_GOAL` 等占位符。 +- coding examples 从 JavaScript codeblock 改成 JSON tools 调用示例。 + +代表文件: + +```text +profiles/defaults/_default.json +profiles/defaults/prompts/coding.md +src/agent/tools/tools-prompt.md +src/models/prompter.js +``` + +--- + +## 4. Coding 安全边界 / Workspace 限制 + +新增配置: + +```js +"code_workspaces": [ + "bots/{BOT_NAME}/action-code", + "bots/{BOT_NAME}/learnedSkills", + "bots/{BOT_NAME}/" +] +``` + +主要作用: + +- 限制 AI 只能在允许目录中读写/执行文件。 +- `ToolManager.validateWorkspaces()` 对 `file_path` 做校验。 +- coding 生成的动作代码主要放到: + +```text +bots/{BOT_NAME}/action-code +``` + +代表文件: + +```text +settings.js +src/agent/tools/toolManager.js +src/agent/tools/write.js +src/agent/tools/edit.js +src/agent/tools/execute.js +``` + +--- + +## 5. Learned Skills 动态技能系统 + +新增文件: + +```text +src/agent/library/learnedSkillsManager.js +``` + +主要能力: + +- 从每个 bot 的目录加载自定义 learned skills: + +```text +bots/{botName}/learnedSkills/ +``` + +- 校验 learned skill 文件内容。 +- 提取 JSDoc / function signature 作为 skill docs。 +- 合并到原有 `SkillLibrary` 的相关技能检索中。 +- coding prompt 中新增 learned skills 规范。 + +代表文件: + +```text +src/agent/library/learnedSkillsManager.js +src/agent/library/skill_library.js +profiles/defaults/prompts/coding.md +``` + +--- + +## 6. Minecraft 行为能力增强与可靠性修复 + +改动集中在: + +```text +src/agent/library/skills.js +src/agent/library/world.js +src/agent/modes.js +src/agent/commands/actions.js +src/agent/commands/queries.js +``` + +主要内容: + +- 新增 `world.getBuildingStructure()`,让 LLM 能读取建筑结构。 +- `!nearbyBlocks` 输出更丰富的水、岩浆和环境状态。 +- `getNearestBlocksWhere()` 支持 function 或 block id array。 +- 多处循环增加 `bot.interrupt_code` 检查。 +- `unstuck` 模式改成随机附近位置多次尝试。 +- `self_preservation` 对火、岩浆、水、下落方块处理更强。 +- action/mode 执行增加 catch,避免 floating promise 崩溃。 +- `!newAction` 描述改为工具式 coding 工作流。 +- 增加 cheat mode 支持和部分动作返回值细化。 + +代表文件: + +```text +src/agent/library/world.js +src/agent/library/skills.js +src/agent/modes.js +src/agent/commands/actions.js +src/agent/commands/queries.js +``` + +--- + +## 7. Provider / Profile 扩展 + +新增或扩展多个 profile: + +```text +profiles/cerebras.json +profiles/groq.json +profiles/huggingface.json +profiles/hyperbolic.json +profiles/novita.json +profiles/ollama.json +profiles/openrouter.json +``` + +同时修改: + +```text +profiles/gpt.json +profiles/claude.json +profiles/gemini.json +profiles/qwen.json +profiles/grok.json +profiles/mistral.json +profiles/deepseek.json +profiles/azure.json +... +``` + +主要内容: + +- 新增更多 LLM provider profile。 +- 给不同 provider 配置 tool calling / model 参数。 +- 调整部分 stop sequence,避免和 patch/tool 格式冲突。 +- 默认 profile 中降低部分自保/战斗模式默认开启程度。 + +--- + +## 8. 依赖与 SDK 更新 + +`package.json` 新增/更新依赖: + +```text +@huggingface/inference: ^4.11.3 +axios +diff +glob +minimatch +tree-sitter +tree-sitter-bash +``` + +大致用途: + +- HuggingFace 新 SDK。 +- 文件 glob / minimatch。 +- diff/edit 工具。 +- lint / shell 分析相关能力。 +- HTTP provider 支持。 + +--- + +## 重要文件变更概览 + +按 churn 排名前列的文件包括: + +```text +src/agent/tools/execute.js 755 lines added +src/agent/tools/toolManager.js 497 lines added +profiles/defaults/prompts/coding.md 412 lines added +src/agent/coder.js 345 lines changed +src/agent/tools/tools-prompt.md 308 lines added +src/agent/library/skills.js 261 lines changed +src/agent/library/learnedSkillsManager.js 232 lines added +src/agent/tools/lint.js 225 lines added +src/agent/tools/todoWrite.js 182 lines added +src/agent/tools/grep.js 173 lines added +src/agent/library/world.js 166 lines changed +src/models/prompter.js 146 lines changed +``` + +--- + +## 一句话总结 + +`enhanced-coding-system` 的核心价值是:把 Mindcraft 的 coding 模式从“一次性代码块生成器”升级为“带工具调用、文件编辑、执行反馈、错误修复、待办管理、learned skills 和多模型 native tool calls 支持的 agentic coding system”,同时增强 Minecraft 世界查询、生存自保、unstuck 和 provider profile 生态。 diff --git a/docs/llm-provider-config.md b/docs/llm-provider-config.md new file mode 100644 index 000000000..018dd0078 --- /dev/null +++ b/docs/llm-provider-config.md @@ -0,0 +1,156 @@ +# LLM Provider 配置说明 + +`llm_providers.json` 是项目级模型注册表,profile 只负责选择注册表里的 provider id 和模型名。 + +## 核心规则 + +- `models.` 注册聊天/代码模型供应商。 +- `embeddings.` 注册嵌入模型供应商,和聊天模型分开。 +- `keys.` 保存密钥值;provider 用 `keyName` 指向它。 +- `format` 写真实 API 协议/端点格式,例如: + - `openai-completions` + - `openai-responses` + - `anthropic-messages` + - `google-generative-ai` + - `openai-embeddings` +- `name` 不需要;provider id 本身就是用户选择时使用的名字。 +- 不需要模型名映射表;profile 里的 `model` 会原样传给对应 provider。 + +## 添加一个 OpenAI Chat Completions 兼容服务 + +在 `keys` 中添加: + +```json +"MY_PROVIDER_API_KEY": "" +``` + +在 `models` 中添加: + +```json +"my_provider": { + "format": "openai-completions", + "baseUrl": "https://api.example.com/v1", + "keyName": "MY_PROVIDER_API_KEY" +} +``` + +profile 中选择: + +```json +"model": { + "provider": "my_provider", + "model": "vendor/model-name" +} +``` + +这里的 `my_provider` 就是用户自定义 provider 名字;`vendor/model-name` 不会被额外映射或改写。 + +## 地区型 Provider 和 Profile + +OpenClaw 的做法是:provider id 保持 canonical(例如 `qwen` / `minimax`),在 onboarding/auth-choice 中选择 China 或 Global,然后把同一个 provider 写成不同 baseUrl。Mindcraft 当前没有交互式 onboarding wizard,所以预置 profile 直接拆成地区版本,避免用户选错 endpoint: + +- `profiles/qwen-cn.json` -> `provider: "qwen_cn"` +- `profiles/minimax-cn.json` -> `provider: "minimax_cn"` +- `profiles/minimax-intl.json` -> `provider: "minimax_intl"` + +规则: + +- 中国区 key/账号通常选 `_cn`。 +- 国际区 key/账号通常选 `_intl`;当前预置只保留 MiniMax 国际区,Qwen 只保留中国区 key。 +- 同一厂商的 CN/Intl key 不一定通用;如果 live test 出现 401,优先检查是否选错地区。 +- 不要用模糊的 `qwen.json` / `minimax.json` 作为预置 profile 名称。 + +## MiniMax + +MiniMax 走 OpenAI Chat Completions 兼容协议,按地区拆成两个 provider: + +```json +"minimax_intl": { + "format": "openai-completions", + "baseUrl": "https://api.minimax.io/v1", + "keyName": "MINIMAX_INTL_API_KEY", + "defaultModel": "MiniMax-M2.7" +}, +"minimax_cn": { + "format": "openai-completions", + "baseUrl": "https://api.minimaxi.com/v1", + "keyName": "MINIMAX_CN_API_KEY", + "defaultModel": "MiniMax-M2.7" +} +``` + +profile 中按地区选择 `minimax_intl` 或 `minimax_cn`。 + +## Kimi Coding + +Kimi Coding 在官方文档中提供两类第三方 Coding Agent 接入方式。实测 OpenAI-compatible coding 入口会校验客户端身份;为了不伪造 User-Agent,这里使用同页 Claude Code 兼容入口,对应真实协议是 Anthropic Messages: + +```json +"kimi": { + "format": "anthropic-messages", + "baseUrl": "https://api.kimi.com/coding/", + "keyName": "KIMI_API_KEY", + "defaultModel": "kimi-for-coding", + "params": { + "max_tokens": 32768, + "provider": "kimi" + } +} +``` + +profile 中选择 `provider: "kimi"`,模型默认可用 `kimi-for-coding`。 + +## 本地 Ollama + +Ollama 使用 OpenAI-compatible 端点即可,不需要单独的 `ollama-chat` 配置格式: + +```json +"ollama": { + "format": "openai-completions", + "baseUrl": "http://127.0.0.1:11434/v1" +} +``` + +Embedding 同样使用 OpenAI embeddings 端点: + +```json +"ollama": { + "format": "openai-embeddings", + "baseUrl": "http://127.0.0.1:11434/v1", + "defaultModel": "embeddinggemma" +} +``` + +本地 Ollama 不需要 `keyName`。 + +## Embedding 配置原则 + +预制 profile 不应该在同一个文件里静默依赖另一个供应商的 embedding。也就是说: + +- 如果模型供应商自己有 embedding provider,profile 可以写同名 embedding,例如 `qwen_cn` + `qwen_cn`。 +- 如果模型供应商没有 embedding provider,预制 profile 先不要写 `embedding` 字段,系统会在 embedding 不可用时回退到词重叠检索。 +- 用户确实想跨供应商使用 embedding 时,可以自己在本地 profile 里显式添加。 + +MiniMax 当前官方文档索引里没有独立 Embeddings API / embedding 模型页面,所以 `profiles/minimax-cn.json` 和 `profiles/minimax-intl.json` 不预设 embedding。 + +## 添加 Embedding 服务 + +在 `embeddings` 中单独注册: + +```json +"my_embedding_provider": { + "format": "openai-embeddings", + "baseUrl": "https://api.example.com/v1", + "defaultModel": "text-embedding-model-name", + "keyName": "MY_EMBEDDING_API_KEY" +} +``` + +profile 中选择: + +```json +"embedding": { + "provider": "my_embedding_provider", + "model": "text-embedding-model-name" +} +``` diff --git a/docs/provider-unification-openclaw-notes.md b/docs/provider-unification-openclaw-notes.md new file mode 100644 index 000000000..f094780f1 --- /dev/null +++ b/docs/provider-unification-openclaw-notes.md @@ -0,0 +1,197 @@ +# Provider / Native Tool Calling 迁移调研记录 + +日期:2026-04-29 +分支:`native-tool`(基于 `develop`) +参考代码:OpenClaw 源码只读调研;未安装 OpenClaw 依赖。 +验证 key 来源:`enhanced-coding-system` 工作区的 `keys.json` / 环境变量(只记录 key 名称,不记录密钥值)。 + +## 结论 + +OpenClaw 不是靠一个“统一 JS 库一键接入所有 API”的简单方式完成多 provider 支持;它的核心是: + +1. **Provider Plugin 架构**:每个 provider 用插件声明 catalog、auth、动态模型解析、transport、tool schema 兼容、stream wrapper、usage/auth 等 hook。 +2. **少量核心 transport family**:OpenAI Responses、OpenAI Chat Completions、Anthropic Messages、Google Generative AI、Azure Responses 等作为主通道;特殊 provider 再用 plugin hook 覆盖。 +3. **Provider-family 兼容层**:tool schema、history/replay、reasoning/thinking、usage、streaming 差异集中在 provider hook,而不是散在业务代码里。 +4. **独立 CLI/Dev 验证面**:`infer` / `models status --probe` 这类无业务环境的探针可验证 auth、模型、基础调用能力。 + +对 Mindcraft 来说,可迁移的不是“复制 OpenClaw 全套”,而是借鉴它的层次: + +- `provider registry`:统一 provider metadata、key 名称、baseURL、默认模型、能力 flags。 +- `transport adapter`:OpenAI-compatible / Anthropic / Gemini / Ollama / special SDK 分开。 +- `tool schema adapter`:从 Mindcraft command/action/query 生成 canonical tool schema,再按 provider family 转换。 +- `tool response normalizer`:所有 provider 的 tool call 输出归一到同一种内部结构。 +- `dev probe mode`:不接 Minecraft,仅跑 `model -> native tool_call -> command adapter/mock tool -> result`。 + +## OpenClaw 源码观察 + +### 关键文件 + +- `src/plugins/types.ts` + - `ProviderPlugin` 定义了 provider 的扩展点:catalog、auth、dynamic model、transport、tool schemas、stream wrapper、usage、cache、reasoning 等。 + - `registerProvider` 是 provider 注册入口。 +- `src/plugins/provider-runtime.ts` + - Provider runtime resolver / hook dispatcher。 + - 统一调用 `normalizeToolSchemas`、`normalizeTransport`、`resolveProviderStreamFn` 等 hook。 +- `src/agents/pi-embedded-runner/model.ts` + - model resolution pipeline:模型解析、auth、provider runtime normalization。 +- `src/agents/provider-stream.ts` + - 选择 provider 自定义 stream function;没有自定义时走 transport-aware stream。 +- `src/agents/provider-transport-stream.ts` + - 内置 transport family:OpenAI Responses、OpenAI Chat Completions、Anthropic Messages、Google Generative AI、Azure Responses。 +- `src/plugin-sdk/provider-entry.ts` + - `defineSingleProviderPluginEntry` 用少量配置快速定义普通 API-key provider。 +- `src/plugin-sdk/provider-tools.ts` + - `buildProviderToolCompatFamilyHooks("openai" | "gemini")`。 + - OpenAI strict schema / Gemini schema cleaning 等兼容逻辑集中在这里。 +- `src/plugin-sdk/provider-model-shared.ts` + - replay/history policy family:`openai-completions`、`anthropic-by-model`、`google-generative-ai` 等。 +- `extensions/openrouter/index.ts` + - 动态模型、OpenRouter 能力探测、stream wrapper、media/image/video/speech provider。 +- `extensions/litellm/index.ts` + - LiteLLM gateway provider,说明 gateway 也按 provider plugin 接入。 +- `extensions/qwen/index.ts` + - DashScope/Qwen provider 多 auth method、catalog、OpenAI-family 兼容、特殊模型限制处理。 +- `extensions/openai/index.ts` + - OpenAI / OpenAI Codex provider,并复用 OpenAI tool compat family hooks。 + +### OpenClaw 多 API 支持方式 + +OpenClaw 支持大量 provider 的原因主要不是 SDK 数量,而是 metadata + hook 分层: + +| 层 | 职责 | Mindcraft 可借鉴点 | +| --- | --- | --- | +| Manifest/catalog | provider id、模型、能力、auth/env key | 把 `profiles/*.json` 中散落的 api/model/url/key 合并成 provider registry | +| Auth resolver | key 从 profile/env/auth store 解析 | 继续兼容 `keys.json`,但 provider 自己声明 key name | +| Transport | OpenAI-compatible / Anthropic / Gemini / Ollama 等 | Mindcraft 先实现 3-4 个 family,不需要一开始支持全部 | +| Tool compat | schema 清洗、provider-native tool 格式 | 把当前各 `src/models/*.js` 内的 tool 转换移出去 | +| Response normalizer | 把各 provider 输出归一 | 统一返回 `{ id, name, arguments }`,不再让模型层拼 `_native_tool_calls` JSON 字符串 | +| Probe/dev CLI | 无业务环境验证模型和 auth | Mindcraft dev mode 不连 Minecraft 也能测试 LLM + tool loop | + +## Mindcraft 当前可迁移基础 + +Enhanced 分支已经有原型,但耦合较重: + +- `src/models/gpt.js`:OpenAI-compatible `tools` / `tool_choice`,返回 typed native tool response object。 +- `src/models/claude.js`:Anthropic `tools` / `tool_use` 转 OpenAI-like tool call。 +- `src/models/gemini.js`:Gemini `functionDeclarations` 与返回转换。 +- `src/models/groq.js`、`src/models/cerebras.js`、`src/models/mistral.js`、`src/models/hyperbolic.js` 等:各自内联 tool calling 差异。 +- `src/models/prompter.js`:根据 `profile.use_native_tools` 决定传原生 tools,或退回 prompt/XML 工具文本。 +- `src/agent/tools/toolManager.js`:coding tools 的 prompt/json 工具系统。 +- `src/agent/commands/actions.js` / `src/agent/commands/queries.js`:Minecraft command 定义已有 `name/description/params/perform`,适合作为 function schema 生成源。 +- `src/utils/keys.js`:只从 `keys.json` 或环境变量取 key,适合被 provider registry 复用。 + +主要问题: + +1. tool schema 转换散落在各 model adapter。 +2. enhanced 原型里 tool call 结果曾用 JSON 字符串伪装成普通 LLM 文本;本分支应改为 typed object,避免调用链不够类型安全。 +3. provider 能力未知;`use_native_tools` 是 profile 级布尔值,缺少 provider/model 级能力探测。 +4. OpenAI-compatible provider 共享逻辑没有统一抽象。 +5. 没有独立 dev/probe surface,导致验证 LLM 能力通常要走 Minecraft agent 流程。 + +## 本地 key 安全发现结果 + +只发现以下可用 key(未输出密钥值): + +| Key 名称 | 来源 | 备注 | +| --- | --- | --- | +| `QWEN_API_KEY` | `enhanced-coding-system/keys.json` | 已做 live probe | + +未发现/未配置,因此本轮跳过: + +- `OPENAI_API_KEY` +- `ANTHROPIC_API_KEY` +- `GEMINI_API_KEY` / `GOOGLE_API_KEY` +- `OPENROUTER_API_KEY` +- `DEEPSEEK_API_KEY` +- `GROQCLOUD_API_KEY` +- `CEREBRAS_API_KEY` +- `XAI_API_KEY` +- `MISTRAL_API_KEY` +- `HUGGINGFACE_API_KEY` +- `HYPERBOLIC_API_KEY` +- `NOVITA_API_KEY` +- `MERCURY_API_KEY` +- `REPLICATE_API_KEY` +- `AZURE_OPENAI_API_KEY` + +## API 验证结果 + +验证方式:不安装 OpenClaw,使用 Mindcraft enhanced 工作区现有依赖 / Node fetch 直接访问 DashScope OpenAI-compatible endpoint。 + +Endpoint:`https://dashscope.aliyuncs.com/compatible-mode/v1` +模型:`qwen-max` +Embedding 模型:`text-embedding-v3` + +| Provider | 能力 | 结果 | 证据 | +| --- | --- | --- | --- | +| Qwen / DashScope | Chat completions | 通过 | `POST /chat/completions`,`qwen-max` 返回 `pong` | +| Qwen / DashScope | OpenAI-compatible native tools | 通过 | `tool_choice` 指定 `report_status`,finish_reason=`tool_calls`,返回 `report_status({status:"ok"})` | +| Qwen / DashScope | Embeddings | 通过 | `POST /embeddings`,`text-embedding-v3` 返回 1024 维向量 | +| Mindcraft `Qwen` adapter | native tools 贯通 | 通过 | `src/models/qwen.js` 继承 `GPT.sendRequest(..., tools)`,本分支归一返回 typed `tool_calls` object | + +## 对 native-tool 分支的建议实现边界 + +### 第一阶段:只抽 native tool 基础,不重写所有 provider + +建议先做薄迁移: + +1. 新增 `src/models/providerRegistry.js` + - provider id、key name、default baseURL、default model、transport family、tool support flags。 +2. 新增 `src/models/toolSchemas.js` + - canonical schema:OpenAI-style function schema。 + - `commands/actions/queries -> tools` 生成器。 +3. 新增 `src/models/toolAdapters.js` + - `openai`:基本 passthrough + strict schema 清理。 + - `anthropic`:`{name, description, input_schema}`。 + - `gemini`:`functionDeclarations` + schema 清理。 +4. 新增 `src/models/toolCalls.js` + - `normalizeOpenAIToolCalls` / `normalizeAnthropicToolUse` / `normalizeGeminiFunctionCall`。 + - 返回结构不要再是 JSON 字符串,改为 typed object,例如: + ```js + { type: 'tool_calls', calls: [{ id, name, arguments }] } + ``` +5. 保留人类 `!command` parser。 + - 人类聊天输入仍可走 `!command`。 + - AI 输出不再鼓励/依赖 `!command`。 +6. 临时 fallback。 + - provider 不支持 native tools 时,允许短期文本/XML fallback,但需要明确 log/标记,后续可关闭。 + +### 第二阶段:dev mode / probe + +新增不接 Minecraft 的验证命令或脚本: + +- `npm run dev:tool-loop`(mock,无 Minecraft)或后续扩展为 `node scripts/dev-tool-loop.js --profile profiles/qwen-cn.json` +- 输入固定 prompt:要求模型调用一个 mock command/tool。 +- 流程:profile -> provider adapter -> native tool call -> mock executor -> normalized result。 +- 验收标准:不用 Minecraft 进程,也能证明 LLM 原生工具调用链可跑通。 + +### 第三阶段:Prompt/Profile 外置到 Markdown + +按已确认需求分两步: + +1. 机械转换:从 `develop` 的 JSON prompt 原文生成 Markdown,机器校验内容等价。 +2. 语义修改:把提示词中“AI 用 `!command` 调工具”的要求改成“AI 使用原生 tools/function call”。 + +这样既满足“不要手写导致漏内容”,又允许迁移后去掉 AI 文本命令指导。 + +## 是否值得引入统一第三方库 + +暂不建议把 Mindcraft 的第一阶段迁移押注在某个“一键统一 API”库上: + +- OpenClaw 自己也不是单库解决,而是 provider plugin + transport family。 +- Mindcraft 已经有多 provider adapter,先抽公共层风险更低。 +- 真正痛点是 tool schema/response/history/prompt 的兼容,不只是 HTTP 调用。 +- Gateway 方案(OpenRouter / LiteLLM / Vercel AI Gateway)可以作为 provider 加入 registry,而不是替代本地 provider abstraction。 + +可评估但不应阻塞第一阶段: + +- LiteLLM gateway:作为“100+ provider”后端入口。 +- OpenRouter:动态模型与多 provider 入口。 +- Vercel AI SDK / AI Gateway:如果未来要统一 JS SDK,可单独做 spike;但 Mindcraft 当前已有 provider 文件,直接重写成本较高。 + +## Skipped / TODO + +- 未安装 OpenClaw 依赖;本轮只读源码。 +- 除 Qwen 外,本地未发现可用 key,未做 live API probe。 +- 未测试 Minecraft agent loop;本轮只验证 LLM/API/tool-call 基础能力。 +- 未测试 streaming/tool-result continuation,多轮 tool loop 需要后续 dev mode 覆盖。 diff --git a/eslint.config.js b/eslint.config.js index b15dcdb2d..73a972f24 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,8 +14,11 @@ export default [ "no-floating-promise": noFloatingPromise, }, languageOptions: { - globals: globals.browser, - ecmaVersion: 2021, + globals: { + ...globals.browser, + ...globals.node, + }, + ecmaVersion: "latest", sourceType: "module", }, rules: { @@ -24,7 +27,7 @@ export default [ "curly": "off", // Do not enforce the use of curly braces around blocks of code. "no-unused-vars": "off", // Disable warnings for unused variables. "no-unreachable": "off", // Disable warnings for unreachable code. - "require-await": "error", // Disallow async functions which have no await expression + "require-await": "off", // Many provider interfaces are intentionally async-compatible. "no-floating-promise/no-floating-promise": "error", // Disallow Promises without error handling or awaiting }, }, diff --git a/keys.example.json b/keys.example.json index bcdfd9442..7ee3a0c2b 100644 --- a/keys.example.json +++ b/keys.example.json @@ -1,5 +1,6 @@ { "OPENAI_API_KEY": "", + "SILICONFLOW_API_KEY": "", "OPENAI_ORG_ID": "", "GEMINI_API_KEY": "", "ANTHROPIC_API_KEY": "", @@ -14,5 +15,16 @@ "NOVITA_API_KEY": "", "OPENROUTER_API_KEY": "", "CEREBRAS_API_KEY": "", - "MERCURY_API_KEY":"" + "MERCURY_API_KEY": "", + "CODEX_CHATGPT_AUTH": { + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "id_token": "", + "access_token": "", + "refresh_token": "", + "account_id": "" + }, + "last_refresh": "" + } } diff --git a/llm_providers.example.json b/llm_providers.example.json new file mode 100644 index 000000000..0dca4e71b --- /dev/null +++ b/llm_providers.example.json @@ -0,0 +1,227 @@ +{ + "keys": { + "OPENAI_API_KEY": "", + "ANTHROPIC_API_KEY": "", + "GEMINI_API_KEY": "", + "GOOGLE_API_KEY": "", + "AZURE_OPENAI_API_KEY": "", + "KIMI_API_KEY": "", + "OPENROUTER_API_KEY": "", + "QWEN_API_KEY": "", + "DEEPSEEK_API_KEY": "", + "SILICONFLOW_API_KEY": "", + "MINIMAX_CN_API_KEY": "", + "MINIMAX_INTL_API_KEY": "", + "XAI_API_KEY": "", + "GROQCLOUD_API_KEY": "", + "MISTRAL_API_KEY": "", + "CEREBRAS_API_KEY": "", + "HUGGINGFACE_API_KEY": "", + "NOVITA_API_KEY": "", + "HYPERBOLIC_API_KEY": "", + "MERCURY_API_KEY": "", + "MERCURY_API_KEY_2": "", + "MY_PROVIDER_API_KEY": "", + "MY_RESPONSES_API_KEY": "", + "MY_EMBEDDING_API_KEY": "", + "OLLAMA_API_KEY": "", + "CODEX_CHATGPT_AUTH": {}, + "REPLICATE_API_KEY": "" + }, + "models": { + "openai": { + "format": "openai-responses", + "keyName": "OPENAI_API_KEY", + "baseUrl": "https://api.openai.com/v1" + }, + "anthropic": { + "format": "anthropic-messages", + "keyName": "ANTHROPIC_API_KEY", + "baseUrl": "https://api.anthropic.com" + }, + "google": { + "format": "google-generative-ai", + "keyName": "GEMINI_API_KEY", + "baseUrl": "https://generativelanguage.googleapis.com" + }, + "replicate": { + "format": "replicate", + "keyName": "REPLICATE_API_KEY", + "defaultModel": "google/gemini-2.5-flash", + "comment": "Replicate Predictions API; not OpenAI-compatible. Model input/output schemas are model-specific." + }, + "azure": { + "format": "azure-openai-responses", + "baseUrl": "https://quyim-mhpzy06q-eastus2.cognitiveservices.azure.com/", + "params": { + "apiVersion": "2025-01-01-preview" + }, + "keyName": "AZURE_OPENAI_API_KEY" + }, + "codex": { + "format": "openai-codex-responses", + "adapter": "codex", + "params": { + "keysPath": "llm_providers.json" + }, + "comment": "When the codex profile is selected, Mindcraft logs in through this project and stores ChatGPT auth in keys.CODEX_CHATGPT_AUTH. No fixed ~/.codex path is read." + }, + "kimi": { + "format": "anthropic-messages", + "baseUrl": "https://api.kimi.com/coding/", + "keyName": "KIMI_API_KEY", + "defaultModel": "kimi-k2.6", + "params": { + "max_tokens": 32768, + "provider": "kimi" + } + }, + "openrouter": { + "format": "openai-completions", + "baseUrl": "https://openrouter.ai/api/v1", + "keyName": "OPENROUTER_API_KEY" + }, + "qwen_cn": { + "format": "openai-completions", + "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "keyName": "QWEN_API_KEY" + }, + "deepseek": { + "format": "openai-completions", + "baseUrl": "https://api.deepseek.com", + "keyName": "DEEPSEEK_API_KEY" + }, + "siliconflow": { + "format": "openai-completions", + "baseUrl": "https://api.siliconflow.cn/v1", + "keyName": "SILICONFLOW_API_KEY", + "defaultModel": "deepseek-ai/DeepSeek-V4-Flash" + }, + "minimax_cn": { + "format": "anthropic-messages", + "baseUrl": "https://api.minimaxi.com/anthropic", + "keyName": "MINIMAX_CN_API_KEY", + "defaultModel": "MiniMax-M2.7" + }, + "minimax_intl": { + "format": "anthropic-messages", + "baseUrl": "https://api.minimax.io/anthropic", + "keyName": "MINIMAX_INTL_API_KEY", + "defaultModel": "MiniMax-M2.7" + }, + "xai": { + "format": "openai-completions", + "keyName": "XAI_API_KEY", + "baseUrl": "https://api.x.ai/v1" + }, + "groq": { + "format": "openai-completions", + "baseUrl": "https://api.groq.com/openai/v1", + "keyName": "GROQCLOUD_API_KEY" + }, + "mistral": { + "format": "openai-completions", + "keyName": "MISTRAL_API_KEY", + "baseUrl": "https://api.mistral.ai/v1" + }, + "cerebras": { + "format": "openai-completions", + "baseUrl": "https://api.cerebras.ai/v1", + "keyName": "CEREBRAS_API_KEY" + }, + "huggingface": { + "format": "openai-completions", + "baseUrl": "https://router.huggingface.co/v1", + "keyName": "HUGGINGFACE_API_KEY" + }, + "novita": { + "format": "openai-completions", + "baseUrl": "https://api.novita.ai/openai/v1", + "keyName": "NOVITA_API_KEY" + }, + "hyperbolic": { + "format": "openai-completions", + "baseUrl": "https://api.hyperbolic.xyz/v1", + "keyName": "HYPERBOLIC_API_KEY", + "defaultModel": "Qwen/Qwen3-Coder-480B-A35B-Instruct" + }, + "mercury": { + "format": "openai-completions", + "baseUrl": "https://api.inceptionlabs.ai/v1", + "keyName": "MERCURY_API_KEY" + }, + "ollama": { + "format": "openai-completions", + "baseUrl": "https://ollama.com/v1", + "keyName": "OLLAMA_API_KEY", + "defaultModel": "gpt-oss:120b-cloud", + "comment": "Cloud usage is prefilled with gpt-oss:120b-cloud. For local Ollama, change baseUrl to http://127.0.0.1:11434/v1 and remove keyName if no API key is required." + }, + "ollama_local": { + "format": "openai-completions", + "baseUrl": "http://127.0.0.1:11434/v1", + "comment": "Local Ollama OpenAI-compatible endpoint. Use this provider for locally installed Ollama models." + }, + "vllm": { + "format": "openai-completions", + "baseUrl": "http://127.0.0.1:8000/v1" + }, + "_example_openai_chat_completions": { + "format": "openai-completions", + "baseUrl": "https://api.example.com/v1", + "keyName": "MY_PROVIDER_API_KEY" + }, + "_example_openai_responses": { + "format": "openai-responses", + "baseUrl": "https://api.example.com/v1", + "keyName": "MY_RESPONSES_API_KEY" + } + }, + "embeddings": { + "openai": { + "format": "openai-embeddings", + "defaultModel": "text-embedding-3-small", + "keyName": "OPENAI_API_KEY" + }, + "azure": { + "format": "azure-openai-responses", + "baseUrl": "https://quyim-mhpzy06q-eastus2.cognitiveservices.azure.com/", + "defaultModel": "text-embedding-v3", + "params": { + "apiVersion": "2025-01-01-preview" + }, + "keyName": "AZURE_OPENAI_API_KEY" + }, + "qwen_cn": { + "format": "openai-embeddings", + "baseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "defaultModel": "text-embedding-v3", + "keyName": "QWEN_API_KEY" + }, + "ollama": { + "format": "openai-embeddings", + "baseUrl": "https://ollama.com/v1", + "keyName": "OLLAMA_API_KEY", + "defaultModel": "embeddinggemma", + "comment": "Ollama Cloud embeddings. For local Ollama use ollama_local." + }, + "_example_openai_embeddings": { + "format": "openai-embeddings", + "baseUrl": "https://api.example.com/v1", + "defaultModel": "text-embedding-model-name", + "keyName": "MY_EMBEDDING_API_KEY" + }, + "ollama_local": { + "format": "openai-embeddings", + "baseUrl": "http://127.0.0.1:11434/v1", + "defaultModel": "embeddinggemma", + "comment": "Local Ollama embeddings endpoint." + }, + "replicate": { + "format": "replicate", + "keyName": "REPLICATE_API_KEY", + "defaultModel": "mark3labs/embeddings-gte-base" + } + }, + "schemaVersion": 1 +} diff --git a/package.json b/package.json index 883f58989..07c158eaa 100644 --- a/package.json +++ b/package.json @@ -38,12 +38,15 @@ "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", "start": "node main.js", + "test": "node --test tests/*.test.js", + "dev:tool-loop": "node tests/smoke/dev_native_tool_loop.js", + "test:live:function-calls": "node tests/smoke/live_function_call_smoke.js", "reinstall": "npm run clean:modules && npm install", "clean:modules": "node -e \"const fs=require('fs');const p=require('path');['node_modules','package-lock.json'].forEach(f=>{if(fs.existsSync(f)){if(fs.lstatSync(f).isDirectory()){fs.rmSync(f,{recursive:true,force:true});}else{fs.unlinkSync(f);}}});\"" }, diff --git a/profiles/andy-4-reasoning.json b/profiles/andy-4-reasoning.json index f6e2939ea..048f7cc32 100644 --- a/profiles/andy-4-reasoning.json +++ b/profiles/andy-4-reasoning.json @@ -1,14 +1,11 @@ { "name": "andy_4_thinking", - - "model": "ollama/sweaterdog/andy-4:micro-q8_0", - + "model": { + "provider": "ollama_local", + "model": "sweaterdog/andy-4:micro-q8_0" + }, "conversing": "You are a playful Minecraft bot named $NAME that can converse with players, see, move, mine, build, and interact with the world by using commands.\n$SELF_PROMPT Act human-like as if you were a typical Minecraft player, rather than an AI. Be very brief in your responses, don't apologize constantly, don't give instructions or make lists unless asked, and don't refuse requests. Think in high amounts before responding. Don't pretend to act, use commands immediately when requested. Do NOT say this: 'Sure, I've stopped.', instead say this: 'Sure, I'll stop. !stop'. Do NOT say this: 'On my way! Give me a moment.', instead say this: 'On my way! !goToPlayer(\"playername\", 3)'. 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\nReason before responding. Conversation Begin:", - "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. Think deeply before responding. 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": "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:" - } diff --git a/profiles/andy-4.json b/profiles/andy-4.json index 2048a80d9..5f2dca42c 100644 --- a/profiles/andy-4.json +++ b/profiles/andy-4.json @@ -1,7 +1,11 @@ { "name": "andy_4", - - "model": "ollama/sweaterdog/andy-4:micro-q8_0", - - "embedding": "ollama" + "model": { + "provider": "ollama_local", + "model": "sweaterdog/andy-4:micro-q8_0" + }, + "embedding": { + "provider": "ollama_local", + "model": "embeddinggemma" + } } diff --git a/profiles/azure.json b/profiles/azure.json index 29b1122d2..d8582f243 100644 --- a/profiles/azure.json +++ b/profiles/azure.json @@ -1,19 +1,7 @@ { "name": "azure", "model": { - "api": "azure", - "url": "https://.openai.azure.com", - "model": "", - "params": { - "apiVersion": "2024-08-01-preview" - } - }, - "embedding": { - "api": "azure", - "url": "https://.openai.azure.com", - "model": "", - "params": { - "apiVersion": "2024-08-01-preview" - } + "provider": "azure", + "model": "gpt-5-nano" } - } \ No newline at end of file +} diff --git a/profiles/cerebras.json b/profiles/cerebras.json new file mode 100644 index 000000000..80cef4e0c --- /dev/null +++ b/profiles/cerebras.json @@ -0,0 +1,7 @@ +{ + "name": "cerebras", + "model": { + "provider": "cerebras", + "model": "gpt-oss-120b" + } +} diff --git a/profiles/claude.json b/profiles/claude.json index 6c8a11384..51c1f2bda 100644 --- a/profiles/claude.json +++ b/profiles/claude.json @@ -1,7 +1,7 @@ { "name": "claude", - - "model": "claude-sonnet-4-6", - - "embedding": "openai" -} \ No newline at end of file + "model": { + "provider": "anthropic", + "model": "claude-sonnet-4-6" + } +} diff --git a/profiles/claude_thinker.json b/profiles/claude_thinker.json index 201a895e5..0388f3ea7 100644 --- a/profiles/claude_thinker.json +++ b/profiles/claude_thinker.json @@ -1,7 +1,7 @@ { "name": "claude_thinker", - "model": { + "provider": "anthropic", "model": "claude-sonnet-4-6", "params": { "thinking": { @@ -9,7 +9,5 @@ "budget_tokens": 4000 } } - }, - - "embedding": "openai" -} \ No newline at end of file + } +} diff --git a/profiles/codex.json b/profiles/codex.json new file mode 100644 index 000000000..822fa1b5e --- /dev/null +++ b/profiles/codex.json @@ -0,0 +1,12 @@ +{ + "name": "codex", + "model": { + "provider": "codex", + "model": "gpt-5.5", + "params": { + "reasoning": { + "effort": "high" + } + } + } +} diff --git a/profiles/deepseek.json b/profiles/deepseek.json index ddae9bb30..6d24357f3 100644 --- a/profiles/deepseek.json +++ b/profiles/deepseek.json @@ -1,7 +1,7 @@ { "name": "deepseek", - - "model": "deepseek-chat", - - "embedding": "openai" -} \ No newline at end of file + "model": { + "provider": "deepseek", + "model": "deepseek-v4-pro" + } +} diff --git a/profiles/freeguy.json b/profiles/freeguy.json index a44ec4c22..282f50b88 100644 --- a/profiles/freeguy.json +++ b/profiles/freeguy.json @@ -1,7 +1,8 @@ { "name": "Freeguy", - - "model": "groq/llama-3.3-70b-versatile", - + "model": { + "provider": "groq", + "model": "llama-3.3-70b-versatile" + }, "max_tokens": 8000 -} \ No newline at end of file +} diff --git a/profiles/gemini.json b/profiles/gemini.json index c60827c5e..ff94259d2 100644 --- a/profiles/gemini.json +++ b/profiles/gemini.json @@ -1,9 +1,9 @@ { "name": "gemini", - - "model": "gemini-flash-latest", - + "model": { + "provider": "google", + "model": "gemini-3-flash-preview" + }, "speak_model": "google/gemini-2.5-flash-preview-tts/Kore", - "cooldown": 2000 } diff --git a/profiles/gpt.json b/profiles/gpt.json index bb2a79b2c..3ae4fa5f7 100644 --- a/profiles/gpt.json +++ b/profiles/gpt.json @@ -1,12 +1,12 @@ { "name": "gpt", - "model": { - "model": "gpt-5.4", + "provider": "openai", + "model": "gpt-5.5", "params": { "reasoning": { - "effort": "low" + "effort": "medium" } } } -} \ No newline at end of file +} diff --git a/profiles/grok.json b/profiles/grok.json index 3942dbb61..ed51fefbb 100644 --- a/profiles/grok.json +++ b/profiles/grok.json @@ -1,7 +1,7 @@ { "name": "Grok", - - "model": "grok-4-fast-reasoning", - - "embedding": "openai" -} \ No newline at end of file + "model": { + "provider": "xai", + "model": "grok-4-fast-reasoning" + } +} diff --git a/profiles/groq.json b/profiles/groq.json new file mode 100644 index 000000000..882a58cef --- /dev/null +++ b/profiles/groq.json @@ -0,0 +1,7 @@ +{ + "name": "groq", + "model": { + "provider": "groq", + "model": "qwen/qwen3-32b" + } +} diff --git a/profiles/kimi.json b/profiles/kimi.json new file mode 100644 index 000000000..6522301bc --- /dev/null +++ b/profiles/kimi.json @@ -0,0 +1,7 @@ +{ + "name": "kimi", + "model": { + "provider": "kimi", + "model": "kimi-k2.6" + } +} diff --git a/profiles/llama.json b/profiles/llama.json index ceb39925b..a7df22727 100644 --- a/profiles/llama.json +++ b/profiles/llama.json @@ -1,10 +1,8 @@ { "name": "LLama", - - "model": "groq/llama-3.3-70b-versatile", - - "max_tokens": 4000, - - "embedding": "openai" - -} \ No newline at end of file + "model": { + "provider": "groq", + "model": "llama-3.3-70b-versatile" + }, + "max_tokens": 4000 +} diff --git a/profiles/mercury.json b/profiles/mercury.json index 482b6011b..4b0de5fbb 100644 --- a/profiles/mercury.json +++ b/profiles/mercury.json @@ -1,9 +1,8 @@ { "name": "Mercury", - "cooldown": 5000, - - "model": "mercury/mercury-coder-small", - - "embedding": "openai" -} \ No newline at end of file + "model": { + "provider": "mercury", + "model": "mercury-coder-small" + } +} diff --git a/profiles/minimax-cn.json b/profiles/minimax-cn.json new file mode 100644 index 000000000..11167b92b --- /dev/null +++ b/profiles/minimax-cn.json @@ -0,0 +1,7 @@ +{ + "name": "minimax", + "model": { + "provider": "minimax_cn", + "model": "MiniMax-M2.7" + } +} diff --git a/profiles/minimax-intl.json b/profiles/minimax-intl.json new file mode 100644 index 000000000..128081cc5 --- /dev/null +++ b/profiles/minimax-intl.json @@ -0,0 +1,7 @@ +{ + "name": "minimax", + "model": { + "provider": "minimax_intl", + "model": "MiniMax-M2.7" + } +} diff --git a/profiles/mistral.json b/profiles/mistral.json index 348692476..08c525ecc 100644 --- a/profiles/mistral.json +++ b/profiles/mistral.json @@ -1,5 +1,7 @@ { "name": "Mistral", - - "model": "mistral/mistral-large-latest" -} \ No newline at end of file + "model": { + "provider": "mistral", + "model": "mistral-medium-latest" + } +} diff --git a/profiles/novita.json b/profiles/novita.json new file mode 100644 index 000000000..8d5519fe4 --- /dev/null +++ b/profiles/novita.json @@ -0,0 +1,7 @@ +{ + "name": "novita", + "model": { + "provider": "novita", + "model": "deepseek/deepseek-v4-flash" + } +} diff --git a/profiles/ollama.json b/profiles/ollama.json new file mode 100644 index 000000000..358a82d3c --- /dev/null +++ b/profiles/ollama.json @@ -0,0 +1,7 @@ +{ + "name": "ollama", + "model": { + "provider": "ollama", + "model": "gpt-oss:120b-cloud" + } +} diff --git a/profiles/openrouter.json b/profiles/openrouter.json new file mode 100644 index 000000000..a8c9df661 --- /dev/null +++ b/profiles/openrouter.json @@ -0,0 +1,7 @@ +{ + "name": "openrouter", + "model": { + "provider": "openrouter", + "model": "moonshotai/kimi-k2.6" + } +} diff --git a/profiles/qwen-cn.json b/profiles/qwen-cn.json new file mode 100644 index 000000000..d73596b8b --- /dev/null +++ b/profiles/qwen-cn.json @@ -0,0 +1,12 @@ +{ + "name": "qwen", + "cooldown": 5000, + "model": { + "provider": "qwen_cn", + "model": "qwen-max" + }, + "embedding": { + "provider": "qwen_cn", + "model": "text-embedding-v3" + } +} diff --git a/profiles/qwen.json b/profiles/qwen.json deleted file mode 100644 index f6a3f461a..000000000 --- a/profiles/qwen.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "name": "qwen", - - "cooldown": 5000, - - "model": { - "api": "qwen", - "url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", - "model": "qwen-max" - }, - - "embedding": { - "api": "qwen", - "url": "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", - "model": "text-embedding-v3" - } -} \ No newline at end of file diff --git a/profiles/replicate.json b/profiles/replicate.json new file mode 100644 index 000000000..149042251 --- /dev/null +++ b/profiles/replicate.json @@ -0,0 +1,11 @@ +{ + "name": "replicate", + "model": { + "provider": "replicate", + "model": "google/gemini-2.5-flash" + }, + "embedding": { + "provider": "replicate", + "model": "mark3labs/embeddings-gte-base" + } +} diff --git a/profiles/siliconflow.json b/profiles/siliconflow.json new file mode 100644 index 000000000..8b3381452 --- /dev/null +++ b/profiles/siliconflow.json @@ -0,0 +1,7 @@ +{ + "name": "siliconflow", + "model": { + "provider": "siliconflow", + "model": "deepseek-ai/DeepSeek-V4-Flash" + } +} diff --git a/profiles/vllm.json b/profiles/vllm.json index a5ab382c3..29ecf2e55 100644 --- a/profiles/vllm.json +++ b/profiles/vllm.json @@ -1,10 +1,7 @@ { "name": "vllm", - "model": { - "api": "vllm", - "model": "Qwen/Qwen2.5-1.5B-Instruct", - "url": "http://127.0.0.1:8000/v1" - }, - "embedding": "openai" -} \ No newline at end of file + "provider": "vllm", + "model": "Qwen/Qwen2.5-1.5B-Instruct" + } +} diff --git a/src/agent/library/skill_library.js b/src/agent/library/skill_library.js index 4470586f1..fc524b7cb 100644 --- a/src/agent/library/skill_library.js +++ b/src/agent/library/skill_library.js @@ -27,6 +27,11 @@ export class SkillLibrary { this.embedding_model = null; } } + if (!this.embedding_model) { + for (const doc of skillDocs) { + this.skill_docs_embeddings[doc] = doc; + } + } this.always_show_skills_docs = {}; for (const skillName of this.always_show_skills) { this.always_show_skills_docs[skillName] = this.skill_docs.find(doc => doc.includes(skillName)); diff --git a/src/agent/speak.js b/src/agent/speak.js index 003655ea8..b4e193959 100644 --- a/src/agent/speak.js +++ b/src/agent/speak.js @@ -2,8 +2,8 @@ import { exec, spawn } from 'child_process'; import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; -import { TTSConfig as gptTTSConfig } from '../models/gpt.js'; -import { TTSConfig as geminiTTSConfig } from '../models/gemini.js'; +import { TTSConfig as gptTTSConfig } from '../models/openai_compatible.js'; +import { TTSConfig as geminiTTSConfig } from '../models/google_generative_ai.js'; let speakingQueue = []; // each item: {text, model, audioData, ready} let isSpeaking = false; @@ -17,13 +17,13 @@ export function speak(text, speak_model) { // no preprocessing needed item.ready = Promise.resolve(); } else { - item.ready = fetchRemoteAudio(text, model) - .then(data => { item.audioData = data; }) - .catch(err => { item.error = err; }); + item.ready = fetchRemoteAudio(text, model) + .then(data => { item.audioData = data; }) + .catch(err => { item.error = err; }); } speakingQueue.push(item); - if (!isSpeaking) processQueue(); + if (!isSpeaking) void processQueue(); } async function fetchRemoteAudio(txt, model) { @@ -64,7 +64,7 @@ async function processQueue() { const { text: txt, model, audioData } = item; if (txt.trim() === '') { isSpeaking = false; - processQueue(); + void processQueue(); return; } @@ -78,7 +78,7 @@ async function processQueue() { } catch (err) { console.error('[TTS] preprocess error', err); isSpeaking = false; - processQueue(); + void processQueue(); return; } @@ -95,7 +95,7 @@ async function processQueue() { exec(cmd, err => { if (err) console.error('TTS error', err); isSpeaking = false; - processQueue(); + void processQueue(); }); } @@ -106,7 +106,7 @@ async function processQueue() { if (!audioData) { console.error('[TTS] No audio data ready'); isSpeaking = false; - processQueue(); + void processQueue(); return; } @@ -120,14 +120,14 @@ async function processQueue() { }); player.on('error', async (err) => { console.error('[TTS] ffplay error', err); - try { await fs.unlink(tmpPath); } catch {} + try { await fs.unlink(tmpPath); } catch (unlinkError) { console.warn('[TTS] cleanup error', unlinkError); } isSpeaking = false; - processQueue(); + void processQueue(); }); player.on('exit', async () => { - try { await fs.unlink(tmpPath); } catch {} + try { await fs.unlink(tmpPath); } catch (unlinkError) { console.warn('[TTS] cleanup error', unlinkError); } isSpeaking = false; - processQueue(); + void processQueue(); }); } else { @@ -138,13 +138,13 @@ async function processQueue() { player.stdin.end(); player.on('exit', () => { isSpeaking = false; - processQueue(); + void processQueue(); }); } } catch (e) { console.error('[TTS] Audio error', e); isSpeaking = false; - processQueue(); + void processQueue(); } } } diff --git a/src/models/_model_map.js b/src/models/_model_map.js index c119f07ed..bed17aa50 100644 --- a/src/models/_model_map.js +++ b/src/models/_model_map.js @@ -1,4 +1,4 @@ -import { promises as fs } from 'fs'; +import { existsSync, readFileSync, promises as fs } from 'fs'; import path from 'path'; import { fileURLToPath, pathToFileURL } from 'url'; @@ -7,6 +7,21 @@ const __dirname = path.dirname(__filename); // Dynamically discover model classes in this directory. // Each model class must export a static `prefix` string. +const providerRegistry = loadRegistry( + process.env.MINDCRAFT_LLM_PROVIDERS_PATH, + process.env.MINDCRAFT_MODEL_PROVIDERS_PATH, + 'model_providers.json', + 'model provider', + 'models' +); +const embeddingProviderRegistry = loadRegistry( + process.env.MINDCRAFT_LLM_PROVIDERS_PATH, + process.env.MINDCRAFT_EMBEDDING_PROVIDERS_PATH, + 'embedding_providers.json', + 'embedding provider', + 'embeddings' +); + const apiMap = await (async () => { const map = {}; const files = (await fs.readdir(__dirname)) @@ -30,53 +45,147 @@ const apiMap = await (async () => { return map; })(); + +function loadRegistry(unifiedPathOverride, registryPathOverride, filename, label, section) { + const unifiedPath = unifiedPathOverride || path.join(process.cwd(), 'llm_providers.json'); + if (existsSync(unifiedPath)) { + try { + const unified = JSON.parse(readFileSync(unifiedPath, 'utf8')); + if (unified?.[section] && typeof unified[section] === 'object') { + return unified[section]; + } + throw new Error(`missing "${section}" section`); + } catch (error) { + throw new Error(`Failed to read unified LLM provider registry ${unifiedPath}: ${error.message}`); + } + } + + const registryPath = registryPathOverride || path.join(process.cwd(), filename); + if (!existsSync(registryPath)) { + return {}; + } + try { + return JSON.parse(readFileSync(registryPath, 'utf8')); + } catch (error) { + throw new Error(`Failed to read ${label} registry ${registryPath}: ${error.message}`); + } +} + +function applyProviderRegistry(profile, registry, label) { + const providerName = profile.provider; + if (!providerName) { + return profile; + } + const providerConfig = registry[providerName]; + if (!providerConfig) { + throw new Error(`Unknown ${label}: ${providerName}`); + } + const provider = normalizeProviderConfig(providerName, providerConfig); + const mergedParams = { + ...(provider.params || {}), + ...(profile.params || {}) + }; + for (const [key, value] of Object.entries(mergedParams)) { + if (value === null || value === undefined) { + delete mergedParams[key]; + } + } + return { + ...provider, + ...profile, + api: profile.api || provider.api, + model: profile.model || provider.model, + url: profile.url || profile.baseUrl || profile.base_url || provider.url, + params: mergedParams + }; +} + +function normalizeProviderConfig(providerName, provider) { + const keyName = provider.keyName || provider.key_name || provider.apiKeyName || provider.api_key_name; + const baseUrl = provider.baseUrl || provider.base_url || provider.baseURL || provider.url; + const format = provider.format || provider.apiFormat || provider.api_format || provider.protocol || provider.api; + const api = provider.adapter || provider.api || apiFromFormat(format); + return { + ...provider, + api, + model: provider.model || provider.defaultModel || provider.default_model, + url: baseUrl, + params: { + ...(keyName ? { apiKeyName: keyName } : {}), + ...(api === 'openai-completions' || api === 'openai-compatible' ? { provider: providerName } : {}), + ...(api === 'anthropic-messages' ? { provider: providerName } : {}), + ...(api === 'replicate' ? { provider: providerName } : {}), + ...(provider.params || {}), + ...(provider.providerName ? { provider: provider.providerName } : {}), + ...(provider.provider_name ? { provider: provider.provider_name } : {}) + } + }; +} + +function apiFromFormat(format) { + const normalized = String(format || '').toLowerCase(); + const aliases = { + // Canonical OpenClaw-style protocol names used by llm_providers.json. + 'openai-completions': 'openai-completions', + 'openai-responses': 'openai-responses', + 'anthropic-messages': 'anthropic-messages', + 'google-generative-ai': 'google-generative-ai', + 'azure-openai-responses': 'azure-openai-responses', + 'openai-codex-responses': 'codex', + 'openai-embeddings': 'openai-completions', + replicate: 'replicate', + // Minimal backward-compatible names still covered by tests or old profiles. + 'openai-compatible': 'openai-compatible', + 'openai-chat-completions': 'openai-completions', + openai: 'openai-completions', + anthropic: 'anthropic-messages', + claude: 'anthropic-messages', + google: 'google-generative-ai', + gemini: 'google-generative-ai', + azure: 'azure-openai-responses', + codex: 'codex' + }; + return aliases[normalized] || format; +} + export function selectAPI(profile) { if (typeof profile === 'string' || profile instanceof String) { profile = {model: profile}; } - // backwards compatibility with local->ollama - if (profile.api?.includes('local') || profile.model?.includes('local')) { - profile.api = 'ollama'; - if (profile.model) { - profile.model = profile.model.replace('local', 'ollama'); - } + profile = applyProviderRegistry(profile, providerRegistry, 'model provider'); + return resolveAPI(profile); +} + +export function selectEmbeddingAPI(profile) { + if (typeof profile === 'string' || profile instanceof String) { + profile = embeddingProviderRegistry[profile] ? {provider: profile} : {model: profile}; + } + profile = applyProviderRegistry(profile, embeddingProviderRegistry, 'embedding provider'); + return resolveAPI(profile); +} + +function resolveAPI(profile) { + if (profile.api) { + profile.api = apiFromFormat(profile.api); } if (!profile.api) { - const api = Object.keys(apiMap).find(key => profile.model?.startsWith(key)); + const api = Object.keys(apiMap).find(key => profile.model?.startsWith(`${key}/`) || profile.model === key); if (api) { profile.api = api; - } - else { - // check for some common models that do not require prefixes - if (profile.model.includes('gpt') || profile.model.includes('o1')|| profile.model.includes('o3')) - profile.api = 'openai'; - else if (profile.model.includes('claude')) - profile.api = 'anthropic'; - else if (profile.model.includes('gemini')) - profile.api = "google"; - else if (profile.model.includes('grok')) - profile.api = 'xai'; - else if (profile.model.includes('mistral')) - profile.api = 'mistral'; - else if (profile.model.includes('deepseek')) - profile.api = 'deepseek'; - else if (profile.model.includes('qwen')) - profile.api = 'qwen'; - } - if (!profile.api) { - throw new Error('Unknown model:', profile.model); + } else { + throw new Error(`Unknown model provider or api for model: ${profile.model}`); } } if (!apiMap[profile.api]) { throw new Error('Unknown api:', profile.api); } - let model_name = profile.model.replace(profile.api + '/', ''); // remove prefix - profile.model = model_name === "" ? null : model_name; // if model is empty, set to null + const model_name = profile.model?.replace(`${profile.api}/`, ''); // remove explicit api prefix + profile.model = model_name === '' ? null : model_name; // if model is empty, set to null return profile; } export function createModel(profile) { - if (!!apiMap[profile.model]) { + if (apiMap[profile.model]) { // if the model value is an api (instead of a specific model name) // then set model to null so it uses the default model for that api profile.model = null; @@ -86,4 +195,4 @@ export function createModel(profile) { } const model = new apiMap[profile.api](profile.model, profile.url, profile.params); return model; -} \ No newline at end of file +} diff --git a/src/models/anthropic_messages.js b/src/models/anthropic_messages.js new file mode 100644 index 000000000..a40c0af1d --- /dev/null +++ b/src/models/anthropic_messages.js @@ -0,0 +1,102 @@ +import Anthropic from '@anthropic-ai/sdk'; +import { strictFormat } from '../utils/text.js'; +import { getKey } from '../utils/keys.js'; +import { createNativeToolResponse, normalizeAnthropicToolUse, toAnthropicTools } from './native_tools.js'; + +// OpenClaw-style Anthropic Messages protocol implementation. +export class AnthropicMessages { + static prefix = 'anthropic-messages'; + + constructor(model_name, url, params) { + this.model_name = model_name; + this.params = params || {}; + + const config = {}; + if (url) config.baseURL = url; + + const apiKeyName = this.params.apiKeyName || this.params.api_key_name || 'ANTHROPIC_API_KEY'; + delete this.params.apiKeyName; + delete this.params.api_key_name; + config.apiKey = getKey(apiKeyName); + + this.anthropic = new Anthropic(config); + this.provider = this.params.provider || this.params.providerName || this.params.provider_name || 'anthropic'; + delete this.params.provider; + delete this.params.providerName; + delete this.params.provider_name; + this.supportsNativeToolCalls = true; + } + + async sendRequest(turns, systemMessage, stop_seq='***', tools=null) { + const messages = strictFormat(turns); + let res = null; + try { + console.log(tools?.length ? `Awaiting anthropic response with native tool calling (${tools.length} tools) from ${this.model_name}...` : `Awaiting anthropic response from ${this.model_name}...`); + if (!this.params.max_tokens) { + if (this.params.thinking?.budget_tokens) { + this.params.max_tokens = this.params.thinking.budget_tokens + 1000; + } else { + this.params.max_tokens = 4096; + } + } + const requestParams = stripToolChoiceParams(this.params); + const requestConfig = { + model: this.model_name || 'claude-sonnet-4-6', + system: systemMessage, + messages, + ...requestParams + }; + if (Array.isArray(tools) && tools.length > 0) { + requestConfig.tools = toAnthropicTools(tools); + } + const resp = await this.anthropic.messages.create(requestConfig); + + console.log('Received.'); + const toolCalls = normalizeAnthropicToolUse(resp.content); + if (toolCalls.length > 0) { + return createNativeToolResponse(toolCalls, this.provider); + } + const textContent = resp.content.find(content => content.type === 'text'); + res = textContent ? textContent.text : '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.'; + } + console.log(err); + } + return res; + } + + async sendVisionRequest(turns, systemMessage, imageBuffer) { + const imageMessages = [...turns]; + imageMessages.push({ + role: 'user', + content: [ + { type: 'text', text: systemMessage }, + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/jpeg', + data: imageBuffer.toString('base64') + } + } + ] + }); + + return this.sendRequest(imageMessages, systemMessage); + } + + async embed() { + throw new Error('Embeddings are not supported by Anthropic Messages. Configure an embedding provider separately.'); + } +} + +function stripToolChoiceParams(params) { + const requestParams = { ...(params || {}) }; + delete requestParams.tool_choice; + delete requestParams.toolChoice; + return requestParams; +} diff --git a/src/models/azure.js b/src/models/azure.js deleted file mode 100644 index b6be3e006..000000000 --- a/src/models/azure.js +++ /dev/null @@ -1,32 +0,0 @@ -import { AzureOpenAI } from "openai"; -import { getKey, hasKey } from '../utils/keys.js'; -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; - this.params = params || {}; - - const config = {}; - - if (url) - config.endpoint = 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.apiVersion = this.params.apiVersion; - delete this.params.apiVersion; // remove from params for later use in requests - } - else { - throw new Error('apiVersion is required in params for azure!'); - } - - this.openai = new AzureOpenAI(config) - } -} \ No newline at end of file diff --git a/src/models/azure_openai_responses.js b/src/models/azure_openai_responses.js new file mode 100644 index 000000000..d016ad585 --- /dev/null +++ b/src/models/azure_openai_responses.js @@ -0,0 +1,36 @@ +import { AzureOpenAI } from 'openai'; +import { getKey } from '../utils/keys.js'; +import { OpenAICompletions } from './openai_compatible.js'; + +// OpenClaw-style Azure OpenAI protocol implementation. +export class AzureOpenAIResponses extends OpenAICompletions { + static prefix = 'azure-openai-responses'; + + initClient() { + this.params = this.params || {}; + this.provider = 'azure'; + this.deployment = this.params.deployment || this.params.deploymentName || this.params.deployment_name || this.model_name; + delete this.params.deployment; + delete this.params.deploymentName; + delete this.params.deployment_name; + this.default_model = this.model_name || this.deployment; + + const config = {}; + if (this.url) config.endpoint = this.url; + + const apiKeyName = this.params.apiKeyName || this.params.api_key_name || 'AZURE_OPENAI_API_KEY'; + delete this.params.apiKeyName; + delete this.params.api_key_name; + config.apiKey = getKey(apiKeyName); + config.deployment = this.deployment; + + if (this.params.apiVersion) { + config.apiVersion = this.params.apiVersion; + delete this.params.apiVersion; + } else { + throw new Error('apiVersion is required in params for azure-openai-responses!'); + } + + this.openai = new AzureOpenAI(config); + } +} diff --git a/src/models/cerebras.js b/src/models/cerebras.js deleted file mode 100644 index be902a649..000000000 --- a/src/models/cerebras.js +++ /dev/null @@ -1,61 +0,0 @@ -import CerebrasSDK from '@cerebras/cerebras_cloud_sdk'; -import { strictFormat } from '../utils/text.js'; -import { getKey } from '../utils/keys.js'; - -export class Cerebras { - static prefix = 'cerebras'; - constructor(model_name, url, params) { - this.model_name = model_name; - this.url = url; - this.params = params; - - // Initialize client with API key - this.client = new CerebrasSDK({ apiKey: getKey('CEREBRAS_API_KEY') }); - } - - async sendRequest(turns, systemMessage, stop_seq = '***') { - // Format messages array - const messages = strictFormat(turns); - messages.unshift({ role: 'system', content: systemMessage }); - - const pack = { - model: this.model_name || 'gpt-oss-120b', - messages, - stream: false, - ...(this.params || {}), - }; - - let res; - try { - const completion = await this.client.chat.completions.create(pack); - // OpenAI-compatible shape - res = completion.choices?.[0]?.message?.content || ''; - } catch (err) { - console.error('Cerebras API error:', 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) { - throw new Error('Embeddings are not supported by Cerebras.'); - } -} diff --git a/src/models/claude.js b/src/models/claude.js deleted file mode 100644 index 51cffee3b..000000000 --- a/src/models/claude.js +++ /dev/null @@ -1,87 +0,0 @@ -import Anthropic from '@anthropic-ai/sdk'; -import { strictFormat } from '../utils/text.js'; -import { getKey } from '../utils/keys.js'; - -export class Claude { - static prefix = 'anthropic'; - constructor(model_name, url, params) { - this.model_name = model_name; - this.params = params || {}; - - let config = {}; - if (url) - config.baseURL = url; - - config.apiKey = getKey('ANTHROPIC_API_KEY'); - - this.anthropic = new Anthropic(config); - } - - async sendRequest(turns, systemMessage) { - const messages = strictFormat(turns); - let res = null; - try { - console.log(`Awaiting anthropic response from ${this.model_name}...`) - 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({ - model: this.model_name || "claude-sonnet-4-6", - system: systemMessage, - messages: messages, - ...(this.params || {}) - }); - - console.log('Received.') - // get first content of type text - 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.'; - } - } - 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."; - } - console.log(err); - } - return res; - } - - async sendVisionRequest(turns, systemMessage, imageBuffer) { - const imageMessages = [...turns]; - imageMessages.push({ - role: "user", - content: [ - { - type: "text", - text: systemMessage - }, - { - type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: imageBuffer.toString('base64') - } - } - ] - }); - - return this.sendRequest(imageMessages, systemMessage); - } - - async embed(text) { - throw new Error('Embeddings are not supported by Claude.'); - } -} diff --git a/src/models/codex_chatgpt.js b/src/models/codex_chatgpt.js new file mode 100644 index 000000000..33a50a233 --- /dev/null +++ b/src/models/codex_chatgpt.js @@ -0,0 +1,756 @@ +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import path from 'path'; +import { createServer } from 'http'; +import { createHash, randomBytes, randomUUID } from 'crypto'; +import open from 'open'; +import { strictFormat } from '../utils/text.js'; +import { createNativeToolResponse } from './native_tools.js'; + +const DEFAULT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex'; +const DEFAULT_KEYS_PATH = 'llm_providers.json'; +const CODEX_REFRESH_TOKEN_URL = 'https://auth.openai.com/oauth/token'; +const CODEX_ISSUER = 'https://auth.openai.com'; +const CODEX_OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; +const CODEX_AUTH_KEY = 'CODEX_CHATGPT_AUTH'; +const DEFAULT_ORIGINATOR = 'codex_cli_rs'; +const LOGIN_TIMEOUT_MS = 15 * 60 * 1000; +const DEFAULT_LOGIN_PORT = 1455; + +export class CodexChatGPT { + static prefix = 'codex'; + + constructor(model_name, url, params = {}) { + this.model_name = model_name; + this.url = trimTrailingSlash(url || params?.baseUrl || params?.base_url || DEFAULT_CODEX_BASE_URL); + this.params = { ...(params || {}) }; + delete this.params.baseUrl; + delete this.params.base_url; + this.provider = 'codex-chatgpt'; + this.default_model = 'gpt-5.5'; + this.supportsNativeToolCalls = true; + this.authPath = expandHomePath( + this.params.authPath || + this.params.auth_path || + this.params.codexAuthPath || + this.params.codex_auth_path || + this.params.keysPath || + this.params.keys_path || + DEFAULT_KEYS_PATH + ); + this.keysPath = this.authPath; + this.allowLogin = this.params.allowLogin ?? this.params.allow_login ?? true; + this.loginRunner = this.params.loginRunner; + this.issuer = this.params.issuer || CODEX_ISSUER; + this.clientId = this.params.clientId || this.params.client_id || CODEX_OAUTH_CLIENT_ID; + this.loginPort = Number.parseInt(this.params.loginPort || this.params.login_port || DEFAULT_LOGIN_PORT, 10); + this.openBrowser = this.params.openBrowser ?? this.params.open_browser ?? true; + this.forcedChatgptWorkspaceId = this.params.forcedChatgptWorkspaceId || this.params.forced_chatgpt_workspace_id; + delete this.params.keysPath; + delete this.params.keys_path; + delete this.params.authPath; + delete this.params.auth_path; + delete this.params.codexAuthPath; + delete this.params.codex_auth_path; + delete this.params.allowLogin; + delete this.params.allow_login; + delete this.params.loginRunner; + delete this.params.issuer; + delete this.params.clientId; + delete this.params.client_id; + delete this.params.loginPort; + delete this.params.login_port; + delete this.params.openBrowser; + delete this.params.open_browser; + delete this.params.forcedChatgptWorkspaceId; + delete this.params.forced_chatgpt_workspace_id; + this.sessionId = this.params.sessionId || this.params.session_id || randomUUID(); + delete this.params.sessionId; + delete this.params.session_id; + this.originator = this.params.originator || process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE || DEFAULT_ORIGINATOR; + delete this.params.originator; + } + + async sendRequest(turns, systemMessage, stop_seq='***', tools=null) { + const model = this.model_name || this.default_model; + const hasTools = Array.isArray(tools) && tools.length > 0; + const body = this.buildRequestBody(model, turns, systemMessage, tools); + const endpoint = `${this.url}/responses`; + + console.log(hasTools + ? `Awaiting Codex ChatGPT native-login response with tool calling (${tools.length} tools) from model ${model}` + : `Awaiting Codex ChatGPT native-login response from model ${model}`); + + try { + let auth = await ensureCodexChatGPTAuth({ + authPath: this.authPath, + allowLogin: this.allowLogin, + loginRunner: this.loginRunner, + issuer: this.issuer, + clientId: this.clientId, + port: this.loginPort, + openBrowser: this.openBrowser, + forcedChatgptWorkspaceId: this.forcedChatgptWorkspaceId, + originator: this.originator + }); + let response = await this.fetchResponses(endpoint, body, auth); + if (response.status === 401 && auth.refreshToken) { + auth = await refreshCodexChatGPTAuth(auth, this.authPath, this.originator); + response = await this.fetchResponses(endpoint, body, auth); + } + if (!response.ok) { + throw await codexHttpError(response); + } + + const parsed = await parseCodexResponsesSse(await response.text()); + console.log('Received.'); + if (parsed.toolCalls.length > 0) { + return createNativeToolResponse(parsed.toolCalls, this.provider); + } + let text = parsed.text; + if (stop_seq && text.includes(stop_seq)) { + text = text.slice(0, text.indexOf(stop_seq)); + } + return text || 'No response received.'; + } catch (err) { + console.log(sanitizeCodexError(err)); + return 'My brain disconnected, try again.'; + } + } + + buildRequestBody(model, turns, systemMessage, tools=null) { + const messages = strictFormat(turns || []); + const body = { + model, + instructions: systemMessage || '', + input: messages.map(toCodexResponseItem), + tools: toCodexResponsesTools(tools || []), + parallel_tool_calls: this.params.parallel_tool_calls ?? true, + reasoning: this.params.reasoning ?? null, + store: false, + stream: true, + include: this.params.include || [], + prompt_cache_key: this.sessionId + }; + + for (const [key, value] of Object.entries(this.params)) { + if (!['tool_choice', 'toolChoice', 'apiKeyName', 'api_key_name', 'defaultModel', 'default_model', 'parallel_tool_calls', 'reasoning', 'include'].includes(key)) { + body[key] = value; + } + } + return body; + } + + async fetchResponses(endpoint, body, auth) { + return await fetch(endpoint, { + method: 'POST', + headers: this.buildHeaders(auth), + body: JSON.stringify(body) + }); + } + + buildHeaders(auth) { + const headers = { + 'Authorization': `Bearer ${auth.accessToken}`, + 'ChatGPT-Account-ID': auth.accountId, + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream', + 'originator': this.originator, + 'session_id': this.sessionId, + 'x-client-request-id': this.sessionId, + 'User-Agent': `${this.originator}/mindcraft-native-tool` + }; + if (!auth.accountId) { + delete headers['ChatGPT-Account-ID']; + } + return headers; + } + + async embed() { + throw new Error('Codex ChatGPT native-login adapter does not support embeddings. Configure an embedding provider separately.'); + } +} + +export function hasCodexChatGPTAuth(authPath = DEFAULT_KEYS_PATH) { + return canReadCodexChatGPTAuth(expandHomePath(authPath)); +} + +export async function ensureCodexChatGPTAuth({ + authPath, + keysPath, + allowLogin = true, + loginRunner = runCodexBrowserLogin, + issuer = CODEX_ISSUER, + clientId = CODEX_OAUTH_CLIENT_ID, + port = DEFAULT_LOGIN_PORT, + openBrowser = true, + forcedChatgptWorkspaceId = null, + originator = DEFAULT_ORIGINATOR +} = {}) { + const resolvedAuthPath = expandHomePath(authPath || keysPath || DEFAULT_KEYS_PATH); + if (canReadCodexChatGPTAuth(resolvedAuthPath)) { + return readCodexChatGPTAuth(resolvedAuthPath); + } + if (allowLogin && (isInteractiveTerminal() || loginRunner !== runCodexBrowserLogin)) { + const authJson = await loginRunner({ authPath: resolvedAuthPath, keysPath: resolvedAuthPath, issuer, clientId, port, openBrowser, forcedChatgptWorkspaceId, originator }); + if (authJson) { + writeKeysCodexAuth(resolvedAuthPath, authJson); + } + return readCodexChatGPTAuth(resolvedAuthPath); + } + throw new Error(`Codex ChatGPT auth is missing in ${resolvedAuthPath}. Start with an interactive terminal and choose the codex profile to login here.`); +} + +export function readCodexChatGPTAuth(authPath = DEFAULT_KEYS_PATH) { + const resolvedAuthPath = expandHomePath(authPath); + const config = readJsonFile(resolvedAuthPath); + const authJson = extractCodexAuth(config); + if (!authJson || typeof authJson !== 'object') { + throw new Error(`Missing Codex ChatGPT auth in ${resolvedAuthPath}.`); + } + return normalizeCodexAuth(authJson, resolvedAuthPath); +} + +export async function refreshCodexChatGPTAuth(auth, authPath = auth.authPath || DEFAULT_KEYS_PATH, originator = DEFAULT_ORIGINATOR) { + if (!auth.refreshToken) { + throw new Error('Codex ChatGPT auth has no refresh token. Login again from this project.'); + } + const response = await fetch(CODEX_REFRESH_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'originator': originator, + 'User-Agent': `${originator}/mindcraft-native-tool` + }, + body: JSON.stringify({ + client_id: CODEX_OAUTH_CLIENT_ID, + grant_type: 'refresh_token', + refresh_token: auth.refreshToken + }) + }); + + if (!response.ok) { + throw await codexHttpError(response); + } + const refreshed = await response.json(); + const next = structuredClone(auth.raw || {}); + next.tokens = next.tokens || {}; + if (refreshed.id_token) next.tokens.id_token = refreshed.id_token; + if (refreshed.access_token) next.tokens.access_token = refreshed.access_token; + if (refreshed.refresh_token) next.tokens.refresh_token = refreshed.refresh_token; + next.last_refresh = new Date().toISOString(); + writeKeysCodexAuth(authPath, next); + return readCodexChatGPTAuth(authPath); +} + +export async function runCodexBrowserLogin({ + authPath, + keysPath, + issuer = CODEX_ISSUER, + clientId = CODEX_OAUTH_CLIENT_ID, + port = DEFAULT_LOGIN_PORT, + openBrowser = true, + forcedChatgptWorkspaceId = null, + originator = DEFAULT_ORIGINATOR +} = {}) { + const resolvedAuthPath = expandHomePath(authPath || keysPath || DEFAULT_KEYS_PATH); + const baseUrl = trimTrailingSlash(issuer); + const pkce = generatePkce(); + const state = generateState(); + const requestedPort = port === 0 ? 0 : Number.parseInt(port || DEFAULT_LOGIN_PORT, 10); + const server = await startCallbackServer(requestedPort); + const redirectUri = `http://localhost:${server.port}/auth/callback`; + const authUrl = buildAuthorizeUrl({ + issuer: baseUrl, + clientId, + redirectUri, + pkce, + state, + forcedChatgptWorkspaceId, + originator + }); + + try { + printBrowserLoginPrompt(authUrl, redirectUri); + if (openBrowser) { + open(authUrl).catch(err => { + console.log(`Could not open browser automatically: ${sanitizeCodexError(err)}`); + }); + } + const code = await waitForOAuthCallback(server, state); + const tokens = await exchangeAuthorizationCodeForTokens(baseUrl, clientId, redirectUri, pkce, code); + const authJson = toCodexAuthJson(tokens); + writeKeysCodexAuth(resolvedAuthPath, authJson); + return authJson; + } finally { + await closeServer(server.server); + } +} + +// Compatibility helper for explicit tests/dev flows. The default login path intentionally +// uses the local browser callback flow above, matching `codex login` rather than +// `codex login --device-auth`. +export async function runCodexDeviceLogin({ authPath, keysPath, issuer = CODEX_ISSUER, clientId = CODEX_OAUTH_CLIENT_ID } = {}) { + const resolvedAuthPath = expandHomePath(authPath || keysPath || DEFAULT_KEYS_PATH); + const baseUrl = trimTrailingSlash(issuer); + const device = await requestDeviceCode(baseUrl, clientId); + printDeviceCodePrompt(device.verification_url, device.user_code); + const code = await pollDeviceAuthorization(baseUrl, device); + const tokens = await exchangeAuthorizationCodeForTokens(baseUrl, clientId, `${baseUrl}/deviceauth/callback`, { code_verifier: code.code_verifier }, code.authorization_code); + const authJson = toCodexAuthJson(tokens); + writeKeysCodexAuth(resolvedAuthPath, authJson); + return authJson; +} + +export function writeKeysCodexAuth(authPath, authJson) { + const resolvedAuthPath = expandHomePath(authPath); + const normalized = toCodexAuthJson(authJson); + const existing = existsSync(resolvedAuthPath) ? readJsonFile(resolvedAuthPath) : {}; + if (looksLikeUnifiedKeysConfig(existing) || Object.prototype.hasOwnProperty.call(existing, CODEX_AUTH_KEY)) { + const section = getKeysSection(existing, true); + section[CODEX_AUTH_KEY] = normalized; + writeFileSync(resolvedAuthPath, `${JSON.stringify(existing, null, 4)}\n`, { mode: 0o600 }); + return; + } + writeFileSync(resolvedAuthPath, `${JSON.stringify(normalized, null, 4)}\n`, { mode: 0o600 }); +} + +function extractCodexAuth(config) { + if (config?.tokens?.access_token) { + return config; + } + const section = getKeysSection(config); + return section?.[CODEX_AUTH_KEY]; +} + +function looksLikeUnifiedKeysConfig(config) { + return Boolean(config?.keys || config?.models || config?.embeddings); +} + +function getKeysSection(config, create = false) { + if (config?.keys && typeof config.keys === 'object') { + return config.keys; + } + if (create && (config.models || config.embeddings)) { + config.keys = {}; + return config.keys; + } + return config; +} + +export function toCodexResponsesTools(tools = []) { + return tools.map(tool => { + const fn = tool.function || tool; + return { + type: 'function', + name: fn.name, + description: fn.description || '', + strict: Boolean(fn.strict), + parameters: fn.parameters || { type: 'object', properties: {} } + }; + }).filter(tool => tool.name); +} + +export function toCodexResponseItem(message) { + const role = message.role === 'assistant' ? 'assistant' : 'user'; + return { + type: 'message', + role, + content: [{ + type: role === 'assistant' ? 'output_text' : 'input_text', + text: stringifyContent(message.content) + }] + }; +} + +export async function parseCodexResponsesSse(sseText) { + const toolCalls = []; + const textDeltas = []; + const messageTexts = []; + const events = sseText.split(/\n\n+/); + for (const eventBlock of events) { + const dataLines = eventBlock + .split(/\r?\n/) + .filter(line => line.startsWith('data:')) + .map(line => line.slice(5).trimStart()); + if (dataLines.length === 0) continue; + const data = dataLines.join('\n'); + if (data === '[DONE]') continue; + let event; + try { + event = JSON.parse(data); + } catch { + continue; + } + if (event.type === 'response.output_text.delta' && typeof event.delta === 'string') { + textDeltas.push(event.delta); + } + const item = event.item; + if (event.type === 'response.output_item.done' && item?.type === 'function_call') { + toolCalls.push({ + id: item.call_id, + type: 'function', + function: { + name: item.name, + arguments: item.arguments || '{}' + } + }); + } + if (event.type === 'response.output_item.done' && item?.type === 'message') { + messageTexts.push(extractMessageText(item)); + } + if (event.type === 'response.failed') { + const message = event.response?.error?.message || 'Codex Responses stream failed'; + throw new Error(message); + } + } + const text = textDeltas.length > 0 ? textDeltas.join('') : messageTexts.join(''); + return { text, toolCalls }; +} + +async function requestDeviceCode(baseUrl, clientId) { + const response = await fetch(`${baseUrl}/api/accounts/deviceauth/usercode`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_id: clientId }) + }); + if (!response.ok) { + throw await codexHttpError(response); + } + const body = await response.json(); + return { + verification_url: `${baseUrl}/codex/device`, + user_code: body.user_code || body.usercode, + device_auth_id: body.device_auth_id, + interval: Number.parseInt(body.interval || '5', 10) + }; +} + +async function pollDeviceAuthorization(baseUrl, device) { + const started = Date.now(); + while (Date.now() - started < LOGIN_TIMEOUT_MS) { + const response = await fetch(`${baseUrl}/api/accounts/deviceauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + device_auth_id: device.device_auth_id, + user_code: device.user_code + }) + }); + if (response.ok) { + return await response.json(); + } + if (![403, 404].includes(response.status)) { + throw await codexHttpError(response); + } + await sleep(Math.max(1, device.interval) * 1000); + } + throw new Error('Codex device login timed out after 15 minutes.'); +} + +async function exchangeAuthorizationCodeForTokens(baseUrl, clientId, redirectUri, pkce, authorizationCode) { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code: authorizationCode, + redirect_uri: redirectUri, + client_id: clientId, + code_verifier: pkce.code_verifier + }); + const response = await fetch(`${baseUrl}/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body + }); + if (!response.ok) { + throw await codexHttpError(response); + } + return await response.json(); +} + +export function buildAuthorizeUrl({ + issuer = CODEX_ISSUER, + clientId = CODEX_OAUTH_CLIENT_ID, + redirectUri, + pkce, + state, + forcedChatgptWorkspaceId = null, + originator = DEFAULT_ORIGINATOR +}) { + const query = new URLSearchParams({ + response_type: 'code', + client_id: clientId, + redirect_uri: redirectUri, + scope: 'openid profile email offline_access api.connectors.read api.connectors.invoke', + code_challenge: pkce.code_challenge, + code_challenge_method: 'S256', + id_token_add_organizations: 'true', + codex_cli_simplified_flow: 'true', + state, + originator + }); + if (forcedChatgptWorkspaceId) { + query.set('allowed_workspace_id', forcedChatgptWorkspaceId); + } + return `${trimTrailingSlash(issuer)}/oauth/authorize?${query.toString()}`; +} + +function generatePkce() { + const codeVerifier = base64Url(randomBytes(64)); + const codeChallenge = base64Url(createHash('sha256').update(codeVerifier).digest()); + return { code_verifier: codeVerifier, code_challenge: codeChallenge }; +} + +function generateState() { + return base64Url(randomBytes(32)); +} + +function base64Url(buffer) { + return Buffer.from(buffer).toString('base64url'); +} + +async function startCallbackServer(preferredPort) { + return await new Promise((resolve, reject) => { + const server = createServer(); + let settled = false; + const finish = (err, result) => { + if (settled) return; + settled = true; + err ? reject(err) : resolve(result); + }; + server.once('error', async err => { + if (err?.code === 'EADDRINUSE' && preferredPort !== 0) { + try { + await sendCancelRequest(preferredPort); + setTimeout(() => { + startCallbackServer(preferredPort).then(resolve, reject); + }, 200); + } catch { + finish(err); + } + return; + } + finish(err); + }); + server.listen(preferredPort || 0, '127.0.0.1', () => { + const address = server.address(); + finish(null, { server, port: address.port }); + }); + }); +} + +function sendCancelRequest(port) { + return new Promise((resolve, reject) => { + const req = globalThis.fetch(`http://127.0.0.1:${port}/cancel`, { signal: AbortSignal.timeout(2000) }); + req.then(() => resolve(), reject); + }); +} + +function waitForOAuthCallback(serverInfo, expectedState) { + const { server } = serverInfo; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject(new Error('Codex browser login timed out after 15 minutes.')); + }, LOGIN_TIMEOUT_MS); + + const cleanup = () => { + clearTimeout(timeout); + server.removeListener('request', onRequest); + }; + + const finish = (res, status, body, done, headers = {}) => { + res.writeHead(status, { + 'Content-Type': 'text/html; charset=utf-8', + 'Connection': 'close', + ...headers + }); + res.end(body); + cleanup(); + done(); + }; + + const onRequest = (req, res) => { + const parsed = new URL(req.url || '/', 'http://localhost'); + if (parsed.pathname === '/cancel') { + finish(res, 200, 'Login cancelled', () => reject(new Error('Codex browser login cancelled.'))); + return; + } + if (parsed.pathname !== '/auth/callback') { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not Found'); + return; + } + const state = parsed.searchParams.get('state'); + if (state !== expectedState) { + res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8', 'Connection': 'close' }); + res.end('

Codex login ignored

State mismatch. Return to the newest login tab or retry the latest URL.

'); + console.log('Ignored Codex browser login callback with mismatched state; still waiting for the current login.'); + return; + } + const error = parsed.searchParams.get('error'); + if (error) { + const description = parsed.searchParams.get('error_description') || error; + finish(res, 400, `

Codex login failed

${escapeHtml(description)}

`, () => reject(new Error(`Codex browser login failed: ${description}`))); + return; + } + const code = parsed.searchParams.get('code'); + if (!code) { + finish(res, 400, '

Codex login failed

Missing authorization code.

', () => reject(new Error('Codex browser login callback did not include an authorization code.'))); + return; + } + finish(res, 200, 'Login Successful

Login Successful

If this page does not close automatically, you can close it manually.

', () => resolve(code)); + }; + + server.on('request', onRequest); + }); +} + +function closeServer(server) { + return new Promise(resolve => { + server.close(() => resolve()); + }); +} + +function canReadCodexChatGPTAuth(authPath) { + try { + readCodexChatGPTAuth(authPath); + return true; + } catch { + return false; + } +} + +function normalizeCodexAuth(authJson, keysPath) { + const tokens = authJson.tokens || {}; + const accessToken = tokens.access_token; + if (typeof accessToken !== 'string' || accessToken.length === 0) { + throw new Error(`Codex ChatGPT auth is missing tokens.access_token in ${keysPath}.`); + } + return { + authPath: keysPath, + raw: authJson, + accessToken, + refreshToken: tokens.refresh_token, + accountId: tokens.account_id || parseJwtPayload(tokens.id_token)?.chatgpt_account_id + }; +} + +function toCodexAuthJson(input) { + const tokens = input.tokens || input; + const idToken = tokens.id_token; + const payload = parseJwtPayload(idToken) || {}; + return { + auth_mode: 'chatgpt', + OPENAI_API_KEY: null, + tokens: { + id_token: idToken, + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + account_id: tokens.account_id || payload.chatgpt_account_id + }, + last_refresh: input.last_refresh || new Date().toISOString() + }; +} + +function parseJwtPayload(jwt) { + if (typeof jwt !== 'string') return null; + const part = jwt.split('.')[1]; + if (!part) return null; + try { + return JSON.parse(Buffer.from(base64UrlToBase64(part), 'base64').toString('utf8')); + } catch { + return null; + } +} + +function base64UrlToBase64(value) { + const padded = value.replace(/-/g, '+').replace(/_/g, '/'); + return padded + '='.repeat((4 - padded.length % 4) % 4); +} + +function expandHomePath(filePath) { + if (!filePath || typeof filePath !== 'string') { + return filePath; + } + return filePath === '~' || filePath.startsWith('~/') + ? path.join(process.env.HOME || '', filePath.slice(2)) + : filePath; +} + +function readJsonFile(filePath) { + return JSON.parse(readFileSync(expandHomePath(filePath), 'utf8')); +} + +function printBrowserLoginPrompt(authUrl) { + console.log(` +Login to ChatGPT is required to enable Codex native account capabilities. + +Please open this login link in your browser: +${authUrl} + +You will be redirected back to Mindcraft after login; waiting for login to complete... +`); +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function printDeviceCodePrompt(verificationUrl, code) { + console.log(`\nCodex ChatGPT login required for this project.\nOpen this URL and sign in:\n\n ${verificationUrl}\n\nEnter this one-time code:\n\n ${code}\n\nWaiting for login to complete...\n`); +} + +function isInteractiveTerminal() { + return Boolean(process.stdin.isTTY && process.stdout.isTTY); +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function extractMessageText(item) { + return (item.content || []) + .filter(content => content?.type === 'output_text' || content?.type === 'text') + .map(content => content.text || '') + .join(''); +} + +function stringifyContent(content) { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content.map(part => part?.text || part?.content || JSON.stringify(part)).join('\n'); + } + return String(content ?? ''); +} + +async function codexHttpError(response) { + const body = await response.text().catch(() => ''); + const message = extractErrorMessage(body) || response.statusText || 'Codex ChatGPT request failed'; + const error = new Error(`status=${response.status} ${message}`); + error.status = response.status; + return error; +} + +function extractErrorMessage(body) { + try { + const parsed = JSON.parse(body); + return parsed?.error?.message || parsed?.message || body; + } catch { + return body.slice(0, 300); + } +} + +function sanitizeCodexError(error) { + return String(error?.message || error) + .replace(/Bearer\s+[A-Za-z0-9._-]+/g, 'Bearer [REDACTED_TOKEN]') + .replace(/(access_token|refresh_token|id_token)":"[^"]+"/g, '$1":"[REDACTED_TOKEN]"') + .slice(0, 500); +} + +function trimTrailingSlash(value) { + return String(value).replace(/\/+$/, ''); +} diff --git a/src/models/deepseek.js b/src/models/deepseek.js deleted file mode 100644 index 5596fa8fc..000000000 --- a/src/models/deepseek.js +++ /dev/null @@ -1,59 +0,0 @@ -import OpenAIApi from 'openai'; -import { getKey, hasKey } from '../utils/keys.js'; -import { strictFormat } from '../utils/text.js'; - -export class DeepSeek { - static prefix = 'deepseek'; - constructor(model_name, url, params) { - this.model_name = model_name; - this.params = params; - - let config = {}; - - config.baseURL = 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 deleted file mode 100644 index 178ffff37..000000000 --- a/src/models/gemini.js +++ /dev/null @@ -1,176 +0,0 @@ -import { GoogleGenAI } from '@google/genai'; -import { strictFormat } from '../utils/text.js'; -import { getKey } from '../utils/keys.js'; - - -export class Gemini { - static prefix = 'google'; - constructor(model_name, url, params) { - this.model_name = model_name; - this.params = params; - this.safetySettings = [ - { - "category": "HARM_CATEGORY_DANGEROUS", - "threshold": "BLOCK_NONE", - }, - { - "category": "HARM_CATEGORY_HARASSMENT", - "threshold": "BLOCK_NONE", - }, - { - "category": "HARM_CATEGORY_HATE_SPEECH", - "threshold": "BLOCK_NONE", - }, - { - "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT", - "threshold": "BLOCK_NONE", - }, - { - "category": "HARM_CATEGORY_DANGEROUS_CONTENT", - "threshold": "BLOCK_NONE", - }, - ]; - - this.genAI = new GoogleGenAI({apiKey: getKey('GEMINI_API_KEY')}); - } - - async sendRequest(turns, systemMessage) { - console.log('Awaiting Google API response...'); - - turns = strictFormat(turns); - let contents = []; - for (let turn of turns) { - contents.push({ - role: turn.role === 'assistant' ? 'model' : 'user', - parts: [{ text: turn.content }] - }); - } - - const result = await this.genAI.models.generateContent({ - model: this.model_name || "gemini-2.5-flash", - contents: contents, - safetySettings: this.safetySettings, - config: { - systemInstruction: systemMessage, - ...(this.params || {}) - } - }); - const response = await result.text; - - console.log('Received.'); - - return response; - } - - async sendVisionRequest(turns, systemMessage, imageBuffer) { - const imagePart = { - inlineData: { - data: imageBuffer.toString('base64'), - mimeType: 'image/jpeg' - } - }; - - turns = strictFormat(turns); - let contents = []; - for (let turn of turns) { - contents.push({ - role: turn.role === 'assistant' ? 'model' : 'user', - parts: [{ text: turn.content }] - }); - } - contents.push({ - role: 'user', - parts: [{ text: 'SYSTEM: Vision response' }, imagePart] - }) - - let res = null; - try { - console.log('Awaiting Google API vision response...'); - const result = await this.genAI.models.generateContent({ - model: this.model_name, - contents: contents, - safetySettings: this.safetySettings, - generationConfig: { - ...(this.params || {}) - }, - systemInstruction: systemMessage - }); - res = await result.text; - console.log('Received.'); - } catch (err) { - console.log(err); - if (err.message.includes("Image input modality is not enabled for models/")) { - res = "Vision is only supported by certain models."; - } else { - res = "An unexpected error occurred, please try again."; - } - } - return res; - } - - async embed(text) { - const result = await this.genAI.models.embedContent({ - model: this.model_name || "gemini-embedding-001", - contents: text, - }) - - return result.embeddings; - } -} - -const sendAudioRequest = async (text, model, voice, url) => { - const ai = new GoogleGenAI({apiKey: getKey('GEMINI_API_KEY')}); - - const response = await ai.models.generateContent({ - model: model, - contents: [{ parts: [{text: text}] }], - config: { - responseModalities: ['AUDIO'], - speechConfig: { - voiceConfig: { - prebuiltVoiceConfig: { voiceName: voice }, - }, - }, - }, - }) - - const pcmBase64 = response.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data; - if (!pcmBase64) { - console.warn('Gemini TTS: no audio data returned'); - return null; - } - - // Wrap PCM in a minimal WAV container so ffplay can decode it. - const pcmBuffer = Buffer.from(pcmBase64, 'base64'); - const wavHeader = createWavHeader(pcmBuffer.length, 24000, 1, 16); - const wavBuffer = Buffer.concat([wavHeader, pcmBuffer]); - - const wavBase64 = wavBuffer.toString('base64'); - return wavBase64; -} - -// helper: create PCM WAV header -function createWavHeader(dataLength, sampleRate, channels, bitsPerSample) { - const header = Buffer.alloc(44); - const byteRate = sampleRate * channels * bitsPerSample / 8; - const blockAlign = channels * bitsPerSample / 8; - - header.write('RIFF', 0); - header.writeUInt32LE(36 + dataLength, 4); - header.write('WAVE', 8); - header.write('fmt ', 12); - header.writeUInt32LE(16, 16); // PCM - header.writeUInt16LE(1, 20); // Audio format = PCM - header.writeUInt16LE(channels, 22); - header.writeUInt32LE(sampleRate, 24); - header.writeUInt32LE(byteRate, 28); - header.writeUInt16LE(blockAlign, 32); - header.writeUInt16LE(bitsPerSample, 34); - header.write('data', 36); - header.writeUInt32LE(dataLength, 40); - return header; -} - -export const TTSConfig = { - sendAudioRequest: sendAudioRequest, -} \ No newline at end of file diff --git a/src/models/google_generative_ai.js b/src/models/google_generative_ai.js new file mode 100644 index 000000000..f1e1f9803 --- /dev/null +++ b/src/models/google_generative_ai.js @@ -0,0 +1,174 @@ +import { GoogleGenAI } from '@google/genai'; +import { strictFormat } from '../utils/text.js'; +import { getKey } from '../utils/keys.js'; +import { createNativeToolResponse, normalizeGeminiFunctionCalls, toGeminiFunctionDeclarations } from './native_tools.js'; +import { ProxyAgent, setGlobalDispatcher } from 'undici'; + +function setupGeminiProxy() { + const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy; + if (proxyUrl) { + setGlobalDispatcher(new ProxyAgent(proxyUrl)); + } +} +setupGeminiProxy(); + +// OpenClaw-style Google Generative AI protocol implementation. +export class GoogleGenerativeAI { + static prefix = 'google-generative-ai'; + + constructor(model_name, url, params) { + this.model_name = model_name; + this.params = params || {}; + const apiKeyName = this.params.apiKeyName || this.params.api_key_name || 'GEMINI_API_KEY'; + delete this.params.apiKeyName; + delete this.params.api_key_name; + this.safetySettings = [ + { category: 'HARM_CATEGORY_DANGEROUS', threshold: 'BLOCK_NONE' }, + { category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_NONE' }, + { category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_NONE' }, + { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', threshold: 'BLOCK_NONE' }, + { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_NONE' }, + ]; + + this.genAI = new GoogleGenAI({ apiKey: getKey(apiKeyName) }); + this.provider = 'google'; + this.supportsNativeToolCalls = true; + } + + async sendRequest(turns, systemMessage, stop_seq='***', tools=null) { + console.log(tools?.length ? `Awaiting Google API response with native tool calling (${tools.length} tools)...` : 'Awaiting Google API response...'); + + turns = strictFormat(turns); + const contents = turns.map(turn => ({ + role: turn.role === 'assistant' ? 'model' : 'user', + parts: [{ text: turn.content }] + })); + + const requestConfig = { + model: this.model_name || 'gemini-2.5-flash', + contents, + safetySettings: this.safetySettings, + config: { + systemInstruction: systemMessage, + ...(this.params || {}) + } + }; + if (Array.isArray(tools) && tools.length > 0) { + requestConfig.config.tools = [{ functionDeclarations: toGeminiFunctionDeclarations(tools) }]; + } + const result = await this.genAI.models.generateContent(requestConfig); + const parts = result.candidates?.[0]?.content?.parts || []; + const toolCalls = normalizeGeminiFunctionCalls(parts); + if (toolCalls.length > 0) { + return createNativeToolResponse(toolCalls, this.provider); + } + const response = await result.text; + + console.log('Received.'); + return response; + } + + async sendVisionRequest(turns, systemMessage, imageBuffer) { + const imagePart = { + inlineData: { + data: imageBuffer.toString('base64'), + mimeType: 'image/jpeg' + } + }; + + turns = strictFormat(turns); + const contents = turns.map(turn => ({ + role: turn.role === 'assistant' ? 'model' : 'user', + parts: [{ text: turn.content }] + })); + contents.push({ + role: 'user', + parts: [{ text: 'SYSTEM: Vision response' }, imagePart] + }); + + let res = null; + try { + console.log('Awaiting Google API vision response...'); + const result = await this.genAI.models.generateContent({ + model: this.model_name, + contents, + safetySettings: this.safetySettings, + generationConfig: { + ...(this.params || {}) + }, + systemInstruction: systemMessage + }); + res = await result.text; + console.log('Received.'); + } catch (err) { + console.log(err); + if (err.message.includes('Image input modality is not enabled for models/')) { + res = 'Vision is only supported by certain models.'; + } else { + res = 'An unexpected error occurred, please try again.'; + } + } + return res; + } + + async embed(text) { + const result = await this.genAI.models.embedContent({ + model: this.model_name || 'gemini-embedding-001', + contents: text, + }); + return result.embeddings; + } +} + +const sendAudioRequest = async (text, model, voice) => { + const ai = new GoogleGenAI({ apiKey: getKey('GEMINI_API_KEY') }); + + const response = await ai.models.generateContent({ + model, + contents: [{ parts: [{ text }] }], + config: { + responseModalities: ['AUDIO'], + speechConfig: { + voiceConfig: { + prebuiltVoiceConfig: { voiceName: voice }, + }, + }, + }, + }); + + const pcmBase64 = response.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data; + if (!pcmBase64) { + console.warn('Gemini TTS: no audio data returned'); + return null; + } + + const pcmBuffer = Buffer.from(pcmBase64, 'base64'); + const wavHeader = createWavHeader(pcmBuffer.length, 24000, 1, 16); + return Buffer.concat([wavHeader, pcmBuffer]).toString('base64'); +}; + +function createWavHeader(dataLength, sampleRate, channels, bitsPerSample) { + const header = Buffer.alloc(44); + const byteRate = sampleRate * channels * bitsPerSample / 8; + const blockAlign = channels * bitsPerSample / 8; + + header.write('RIFF', 0); + header.writeUInt32LE(36 + dataLength, 4); + header.write('WAVE', 8); + header.write('fmt ', 12); + header.writeUInt32LE(16, 16); + header.writeUInt16LE(1, 20); + header.writeUInt16LE(channels, 22); + header.writeUInt32LE(sampleRate, 24); + header.writeUInt32LE(byteRate, 28); + header.writeUInt16LE(blockAlign, 32); + header.writeUInt16LE(bitsPerSample, 34); + header.write('data', 36); + header.writeUInt32LE(dataLength, 40); + return header; +} + +export const TTSConfig = { + sendAudioRequest, + baseUrl: undefined, +}; diff --git a/src/models/gpt.js b/src/models/gpt.js deleted file mode 100644 index 364d24693..000000000 --- a/src/models/gpt.js +++ /dev/null @@ -1,147 +0,0 @@ -import OpenAIApi from 'openai'; -import { getKey, hasKey } from '../utils/keys.js'; -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 - - let config = {}; - if (url) - config.baseURL = url; - - if (hasKey('OPENAI_ORG_ID')) - config.organization = getKey('OPENAI_ORG_ID'); - - config.apiKey = getKey('OPENAI_API_KEY'); - - 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; - }); - let model = this.model_name || "gpt-5.4-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 || {}) - }; - if (model.includes('o1') || model.includes('o3') || model.includes('5')) { - delete pack.stop; - } - 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; - } - // otherwise, use responses - else { - 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; - let stop_seq_index = res.indexOf(stop_seq); - res = stop_seq_index !== -1 ? res.slice(0, stop_seq_index) : res; - } - } - 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: "input_text", text: systemMessage }, - { - type: "input_image", - image_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; - } - -} - -const sendAudioRequest = async (text, model, voice, url) => { - const payload = { - model: model, - voice: voice, - input: text - } - - let config = {}; - - if (url) - config.baseURL = url; - - if (hasKey('OPENAI_ORG_ID')) - config.organization = getKey('OPENAI_ORG_ID'); - - config.apiKey = getKey('OPENAI_API_KEY'); - - const openai = new OpenAIApi(config); - - 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 deleted file mode 100644 index d31908940..000000000 --- a/src/models/grok.js +++ /dev/null @@ -1,82 +0,0 @@ -import OpenAIApi from 'openai'; -import { getKey } from '../utils/keys.js'; - -// xAI doesn't supply a SDK for their models, but fully supports OpenAI and Anthropic SDKs -export class Grok { - static prefix = 'xai'; - constructor(model_name, url, params) { - this.model_name = model_name; - this.url = url; - this.params = params; - - let config = {}; - if (url) - config.baseURL = url; - else - config.baseURL = "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-4.1-fast-non-reasoning", - 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.'; - } - } - // 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 deleted file mode 100644 index 85a913e8c..000000000 --- a/src/models/groq.js +++ /dev/null @@ -1,95 +0,0 @@ -import Groq from 'groq-sdk' -import { getKey } from '../utils/keys.js'; - -// THIS API IS NOT TO BE CONFUSED WITH GROK! -// Go to grok.js for that. :) - -// Umbrella class for everything under the sun... That GroqCloud provides, that is. -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 - let messages = [{"role": "system", "content": systemMessage}].concat(turns); - - let res = null; - - try { - console.log("Awaiting Groq response..."); - - // Handle deprecated max_tokens parameter - if (this.params.max_tokens) { - console.warn("GROQCLOUD WARNING: A profile is using `max_tokens`. This is deprecated. Please move to `max_completion_tokens`."); - this.params.max_completion_tokens = this.params.max_tokens; - delete this.params.max_tokens; - } - - if (!this.params.max_completion_tokens) { - 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, - ...(this.params || {}) - }); - - res = completion.choices[0].message.content; - - res = res.replace(/[\s\S]*?<\/think>/g, '').trim(); - } - 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."; - } - console.log(err); - } - return res; - } - - async sendVisionRequest(messages, systemMessage, imageBuffer) { - const imageMessages = messages.filter(message => message.role !== 'system'); - 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); - } - - async embed(_) { - throw new Error('Embeddings are not supported by Groq.'); - } -} diff --git a/src/models/huggingface.js b/src/models/huggingface.js deleted file mode 100644 index 1541bd003..000000000 --- a/src/models/huggingface.js +++ /dev/null @@ -1,86 +0,0 @@ -import { toSinglePrompt } from '../utils/text.js'; -import { getKey } from '../utils/keys.js'; -import { HfInference } from "@huggingface/inference"; - -export class HuggingFace { - static prefix = 'huggingface'; - constructor(model_name, url, params) { - // Remove 'huggingface/' prefix if present - this.model_name = model_name.replace('huggingface/', ''); - this.url = url; - this.params = params; - - if (this.url) { - console.warn("Hugging Face doesn't support custom urls!"); - } - - this.huggingface = new HfInference(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/Llama-3.3-70B-Instruct'; - // 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. - 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 = ''; - try { - // Consume the streaming response chunk by chunk - for await (const chunk of this.huggingface.chatCompletionStream({ - model: model_name, - messages: [{ role: "user", content: input }], - ...(this.params || {}) - })) { - res += (chunk.choices[0]?.delta?.content || ""); - } - } 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. - const hasOpenTag = res.includes(""); - const hasCloseTag = res.includes(""); - - // If there's a partial mismatch, warn and retry the entire request. - 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. - } - - // 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.'; - } - console.log('Received.'); - console.log(finalRes); - return finalRes; - } - - async embed(text) { - throw new Error('Embeddings are not supported by HuggingFace.'); - } -} diff --git a/src/models/hyperbolic.js b/src/models/hyperbolic.js deleted file mode 100644 index f483b6980..000000000 --- a/src/models/hyperbolic.js +++ /dev/null @@ -1,114 +0,0 @@ -import { getKey } from '../utils/keys.js'; - -export class Hyperbolic { - static prefix = 'hyperbolic'; - constructor(modelName, apiUrl) { - this.modelName = modelName || "deepseek-ai/DeepSeek-V3"; - this.apiUrl = apiUrl || "https://api.hyperbolic.xyz/v1/chat/completions"; - - // Retrieve the Hyperbolic API key from keys.js - this.apiKey = getKey('HYPERBOLIC_API_KEY'); - if (!this.apiKey) { - throw new Error('HYPERBOLIC_API_KEY not found. Check your keys.js file.'); - } - } - - /** - * Sends a chat completion request to the Hyperbolic endpoint. - * - * @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 '***'. - * @returns {Promise} - The model's reply. - */ - async sendRequest(turns, systemMessage, stopSeq = '***') { - // Prepare the messages with a system prompt at the beginning - const messages = [{ role: 'system', content: systemMessage }, ...turns]; - - // Build the request payload - const payload = { - model: this.modelName, - messages: messages, - max_tokens: 8192, - temperature: 0.7, - top_p: 0.9, - stream: false - }; - - 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); - - let completionContent = null; - - try { - const response = await fetch(this.apiUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.apiKey}` - }, - body: JSON.stringify(payload) - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const data = await response.json(); - if (data?.choices?.[0]?.finish_reason === 'length') { - throw new Error('Context length exceeded'); - } - - completionContent = data?.choices?.[0]?.message?.content || ''; - console.log('Received response from Hyperbolic.'); - } catch (err) { - if ( - (err.message === 'Context length exceeded' || err.code === 'context_length_exceeded') && - turns.length > 1 - ) { - console.log('Context length exceeded, trying again with a shorter context...'); - return await this.sendRequest(turns.slice(1), systemMessage, stopSeq); - } else { - console.error(err); - completionContent = 'My brain disconnected, try again.'; - } - } - - // Check for blocks - const hasOpenTag = completionContent.includes(""); - const hasCloseTag = completionContent.includes(""); - - if ((hasOpenTag && !hasCloseTag)) { - console.warn("Partial block detected. Re-generating..."); - continue; // Retry the request - } - - if (hasCloseTag && !hasOpenTag) { - completionContent = '' + completionContent; - } - - if (hasOpenTag && hasCloseTag) { - completionContent = completionContent.replace(/[\s\S]*?<\/think>/g, '').trim(); - } - - finalRes = completionContent.replace(/<\|separator\|>/g, '*no response*'); - break; // Valid response obtained—exit loop - } - - 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.'; - } - return finalRes; - } - - async embed(text) { - throw new Error('Embeddings are not supported by Hyperbolic.'); - } -} diff --git a/src/models/lmstudio.js b/src/models/lmstudio.js deleted file mode 100644 index 587068ad6..000000000 --- a/src/models/lmstudio.js +++ /dev/null @@ -1,74 +0,0 @@ -import OpenAIApi from 'openai'; -import { strictFormat } from '../utils/text.js'; - -export class LMStudio { - static prefix = 'lmstudio'; - constructor(model_name, url, params) { - this.model_name = model_name; - this.params = params; - this.openai = new OpenAIApi({ - baseURL: url || 'http://localhost:1234/v1', - apiKey: 'lm-studio', // LM Studio ignores this but the client requires a non-empty value - }); - } - - async sendRequest(turns, systemMessage, stop_seq='***') { - let messages = [{ role: 'system', content: systemMessage }].concat(strictFormat(turns)); - let model = this.model_name || 'andy-4.1'; - let res = null; - - try { - console.log('Awaiting LM Studio response from model', model); - const pack = { - model, - messages, - stop: stop_seq, - ...(this.params || {}) - }; - const 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; - if (res.includes('')) { - if (!res.includes('')) res = '' + res; - res = res.replace(/[\s\S]*?<\/think>/g, '').trim(); - } - } 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 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-nomic-embed-text-v1.5', - input: text, - encoding_format: 'float', - }); - return embedding.data[0].embedding; - } -} diff --git a/src/models/mercury.js b/src/models/mercury.js deleted file mode 100644 index 74cd64e63..000000000 --- a/src/models/mercury.js +++ /dev/null @@ -1,95 +0,0 @@ -import OpenAIApi from 'openai'; -import { getKey, hasKey } from '../utils/keys.js'; -import { strictFormat } from '../utils/text.js'; - -export class Mercury { - 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"; - - 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 deleted file mode 100644 index 536b386de..000000000 --- a/src/models/mistral.js +++ /dev/null @@ -1,94 +0,0 @@ -import { Mistral as MistralClient } from '@mistralai/mistralai'; -import { getKey } from '../utils/keys.js'; -import { strictFormat } from '../utils/text.js'; - -export class Mistral { - static prefix = 'mistral'; - #client; - - constructor(model_name, url, params) { - this.model_name = model_name; - this.params = params; - - if (typeof url === "string") { - console.warn("Mistral does not support custom URL's, ignoring!"); - - } - - if (!getKey("MISTRAL_API_KEY")) { - throw new Error("Mistral API Key missing, make sure to set MISTRAL_API_KEY in settings.json") - } - - this.#client = new MistralClient( - { - apiKey: getKey("MISTRAL_API_KEY") - } - ); - - - // Prevents the following code from running when model not specified - if (typeof this.model_name === "undefined") return; - - // get the model name without the "mistral" or "mistralai" prefix - // e.g "mistral/mistral-large-latest" -> "mistral-large-latest" - if (typeof model_name.split("/")[1] !== "undefined") { - this.model_name = model_name.split("/")[1]; - } - } - - async sendRequest(turns, systemMessage) { - - let result; - - try { - const model = this.model_name || "mistral-large-latest"; - - const messages = [ - { role: "system", content: systemMessage } - ]; - messages.push(...strictFormat(turns)); - - console.log('Awaiting mistral api response...') - const response = await this.#client.chat.complete({ - model, - messages, - ...(this.params || {}) - }); - - result = response.choices[0].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."; - } - console.log(err); - } - - return result; - } - - async sendVisionRequest(messages, systemMessage, imageBuffer) { - const imageMessages = [...messages]; - imageMessages.push({ - role: "user", - content: [ - { type: "text", text: systemMessage }, - { - type: "image_url", - imageUrl: `data:image/jpeg;base64,${imageBuffer.toString('base64')}` - } - ] - }); - - return this.sendRequest(imageMessages, systemMessage); - } - - async embed(text) { - const embedding = await this.#client.embeddings.create({ - model: "mistral-embed", - inputs: text - }); - return embedding.data[0].embedding; - } -} \ No newline at end of file diff --git a/src/models/native_tools.js b/src/models/native_tools.js new file mode 100644 index 000000000..e54fae6e4 --- /dev/null +++ b/src/models/native_tools.js @@ -0,0 +1,155 @@ +export function isNativeToolResponse(value) { + return Boolean(value && typeof value === 'object' && value.type === 'tool_calls' && Array.isArray(value.tool_calls)); +} + +export function createNativeToolResponse(toolCalls, provider = 'unknown') { + return { + type: 'tool_calls', + provider, + tool_calls: normalizeOpenAIToolCalls(toolCalls) + }; +} + +export function normalizeOpenAIToolCalls(toolCalls = []) { + return toolCalls.map((call, index) => { + const fn = call.function || {}; + return { + id: call.id || `call_${Date.now()}_${index}`, + type: 'function', + name: fn.name || call.name, + arguments: normalizeArguments(fn.arguments ?? call.arguments ?? {}) + }; + }).filter(call => call.name); +} + +export function normalizeAnthropicToolUse(content = []) { + return content + .filter(item => item?.type === 'tool_use') + .map((item, index) => ({ + id: item.id || `call_${Date.now()}_${index}`, + type: 'function', + name: item.name, + arguments: normalizeArguments(item.input || {}) + })); +} + +export function normalizeGeminiFunctionCalls(parts = []) { + return parts + .filter(part => part?.functionCall) + .map((part, index) => ({ + id: `call_${Date.now()}_${index}`, + type: 'function', + name: part.functionCall.name, + arguments: normalizeArguments(part.functionCall.args || {}) + })); +} + +export function normalizeMistralToolCalls(toolCalls = []) { + return toolCalls.map((call, index) => { + const fn = call.function || {}; + return { + id: call.id || `call_${Date.now()}_${index}`, + type: 'function', + name: fn.name || call.name, + arguments: normalizeArguments(fn.arguments ?? call.arguments ?? {}) + }; + }).filter(call => call.name); +} + +export function normalizeArguments(args) { + if (typeof args === 'string') { + return args; + } + return JSON.stringify(args || {}); +} + +export function parseToolArguments(args) { + if (args == null || args === '') { + return {}; + } + if (typeof args === 'object') { + return args; + } + try { + return JSON.parse(args); + } catch (error) { + const jsonObject = extractFirstJsonObject(args); + if (jsonObject) { + try { + return JSON.parse(jsonObject); + } catch { + // Preserve the original parse error below; the extracted object + // was only a best-effort recovery for providers that append + // proprietary tool-call markers after valid JSON arguments. + } + } + throw new Error(`Tool arguments must be valid JSON: ${error.message}`); + } +} + +function extractFirstJsonObject(value) { + if (typeof value !== 'string') return null; + const start = value.indexOf('{'); + if (start === -1) return null; + + let depth = 0; + let inString = false; + let escaped = false; + for (let i = start; i < value.length; i++) { + const char = value[i]; + if (escaped) { + escaped = false; + continue; + } + if (char === '\\') { + escaped = true; + continue; + } + if (char === '"') { + inString = !inString; + continue; + } + if (inString) continue; + if (char === '{') depth++; + if (char === '}') { + depth--; + if (depth === 0) { + return value.slice(start, i + 1); + } + } + } + return null; +} + +export function toAnthropicTools(tools = []) { + return tools.map(tool => ({ + name: tool.function.name, + description: tool.function.description, + input_schema: tool.function.parameters + })); +} + +export function toGeminiFunctionDeclarations(tools = []) { + return tools.map(tool => ({ + name: tool.function.name, + description: tool.function.description, + parameters: cleanGeminiSchema(tool.function.parameters) + })); +} + +function cleanGeminiSchema(schema) { + if (!schema || typeof schema !== 'object') { + return schema; + } + if (Array.isArray(schema)) { + return schema.map(cleanGeminiSchema); + } + const out = {}; + for (const [key, value] of Object.entries(schema)) { + if (['additionalProperties', '$schema'].includes(key)) { + continue; + } + out[key] = cleanGeminiSchema(value); + } + return out; +} diff --git a/src/models/novita.js b/src/models/novita.js deleted file mode 100644 index 18e1fc454..000000000 --- a/src/models/novita.js +++ /dev/null @@ -1,71 +0,0 @@ -import OpenAIApi from 'openai'; -import { getKey } from '../utils/keys.js'; -import { strictFormat } from '../utils/text.js'; - -// llama, mistral -export class Novita { - 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); - - - messages = strictFormat(messages); - - const pack = { - model: this.model_name || "meta-llama/llama-4-scout-17b-16e-instruct", - messages, - stop: [stop_seq], - ...(this.params || {}) - }; - - 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 embed(text) { - throw new Error('Embeddings are not supported by Novita AI.'); - } -} diff --git a/src/models/ollama.js b/src/models/ollama.js deleted file mode 100644 index d5b2891b6..000000000 --- a/src/models/ollama.js +++ /dev/null @@ -1,115 +0,0 @@ -import { strictFormat } from '../utils/text.js'; - -export class Ollama { - static prefix = 'ollama'; - constructor(model_name, url, params) { - this.model_name = model_name; - this.params = params; - this.url = url || 'http://127.0.0.1:11434'; - this.chat_endpoint = '/api/chat'; - this.embedding_endpoint = '/api/embeddings'; - } - - async sendRequest(turns, systemMessage) { - let model = this.model_name || 'sweaterdog/andy-4:micro-q8_0'; - let messages = strictFormat(turns); - messages.unshift({ role: 'system', content: systemMessage }); - const maxAttempts = 5; - let attempt = 0; - let finalRes = null; - - while (attempt < maxAttempts) { - attempt++; - console.log(`Awaiting local response... (model: ${model}, attempt: ${attempt})`); - let res = null; - try { - let apiResponse = await this.send(this.chat_endpoint, { - model: model, - messages: messages, - stream: false, - ...(this.params || {}) - }); - if (apiResponse) { - res = apiResponse['message']['content']; - } else { - res = 'No response data.'; - } - } 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); - } else { - console.log(err); - res = 'My brain disconnected, try again.'; - } - } - - const hasOpenTag = res.includes(""); - const hasCloseTag = res.includes(""); - - if ((hasOpenTag && !hasCloseTag)) { - console.warn("Partial block detected. Re-generating..."); - if (attempt < maxAttempts) continue; - } - if (hasCloseTag && !hasOpenTag) { - res = '' + res; - } - if (hasOpenTag && hasCloseTag) { - res = res.replace(/[\s\S]*?<\/think>/g, '').trim(); - } - finalRes = res; - break; - } - - if (finalRes == null) { - console.warn("Could not get a valid response after max attempts."); - finalRes = 'I thought too hard, sorry, try again.'; - } - return finalRes; - } - - async embed(text) { - let model = this.model_name || 'embeddinggemma'; - let body = { model: model, input: text }; - let res = await this.send(this.embedding_endpoint, body); - return res['embedding']; - } - - async send(endpoint, body) { - const url = new URL(endpoint, this.url); - let method = 'POST'; - let headers = new Headers(); - const request = new Request(url, { method, headers, body: JSON.stringify(body) }); - let data = null; - try { - const res = await fetch(request); - if (res.ok) { - data = await res.json(); - } else { - throw new Error(`Ollama Status: ${res.status}`); - } - } catch (err) { - console.error('Failed to send Ollama request.'); - console.error(err); - } - return data; - } - - 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); - } -} diff --git a/src/models/openai_compatible.js b/src/models/openai_compatible.js new file mode 100644 index 000000000..75002ef07 --- /dev/null +++ b/src/models/openai_compatible.js @@ -0,0 +1,190 @@ +import OpenAIApi from 'openai'; +import { getKey, hasKey } from '../utils/keys.js'; +import { strictFormat } from '../utils/text.js'; +import { createNativeToolResponse } from './native_tools.js'; +import { HttpsProxyAgent } from 'https-proxy-agent'; + +function getProxyAgent() { + const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy; + if (proxyUrl) { + return new HttpsProxyAgent(proxyUrl); + } + return undefined; +} + +/** + * OpenClaw-style OpenAI Chat Completions protocol implementation. + * + * This is the single transport used by OpenAI and OpenAI-compatible hosted + * providers such as OpenRouter, SiliconFlow, Qwen, DeepSeek, Groq, Mistral, + * Mercury, Hyperbolic, Novita, HuggingFace router, Ollama /v1, vLLM /v1. + * Provider identity, baseUrl and keyName live in llm_providers.json; profiles + * only select provider/model. + */ +export class OpenAICompletions { + static prefix = 'openai-completions'; + + constructor(model_name, url, params) { + this.model_name = model_name; + this.params = params || {}; + this.url = url; + this.provider = 'openai'; + this.default_model = 'gpt-5.4-mini'; + this.supportsNativeToolCalls = true; + this.initClient(); + } + + initClient() { + this.params = this.params || {}; + const apiKeyName = this.params.apiKeyName || this.params.api_key_name || 'OPENAI_API_KEY'; + this.provider = this.params.provider || this.params.providerName || this.params.provider_name || inferProviderName(this.url) || 'openai'; + this.default_model = this.params.defaultModel || this.params.default_model || this.default_model || this.model_name; + + delete this.params.apiKeyName; + delete this.params.api_key_name; + delete this.params.provider; + delete this.params.providerName; + delete this.params.provider_name; + delete this.params.defaultModel; + delete this.params.default_model; + + const config = {}; + if (this.url) config.baseURL = this.url; + if (hasKey('OPENAI_ORG_ID')) config.organization = getKey('OPENAI_ORG_ID'); + config.apiKey = apiKeyName ? getKey(apiKeyName) : 'not-needed'; + const agent = getProxyAgent(); + if (agent) config.httpAgent = agent; + this.openai = new OpenAIApi(config); + } + + async sendRequest(turns, systemMessage, stop_seq='***', tools=null) { + const model = this.model_name || this.default_model; + const hasTools = Array.isArray(tools) && tools.length > 0; + let res = null; + + try { + const messages = strictFormat([{ role: 'system', content: systemMessage }].concat(turns)); + const pack = { + model, + messages, + ...toOpenAIChatRequestParams(this.params, this.provider) + }; + if (hasTools) { + pack.tools = tools; + } else if (stop_seq) { + pack.stop = Array.isArray(stop_seq) ? stop_seq : [stop_seq]; + } + if (model.includes('o1') || model.includes('o3') || model.includes('5') || this.provider === 'xai') { + delete pack.stop; + } + console.log(hasTools + ? `Awaiting ${this.provider} response with native tool calling (${tools.length} tools) from model ${model}` + : `Awaiting ${this.provider} api response from model ${model}`); + const completion = await this.openai.chat.completions.create(pack); + const choice = completion?.choices?.[0]; + if (!choice) return 'No response received.'; + if (choice.finish_reason === 'length') throw new Error('Context length exceeded'); + console.log('Received.'); + const message = choice.message; + if (message?.tool_calls?.length) { + return createNativeToolResponse(message.tool_calls, this.provider); + } + res = 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, tools); + } else if (err.message?.includes('image_url')) { + console.log(err); + res = 'Vision is only supported by certain models.'; + } else { + console.log(err); + res = providerFacingError(err, this.provider); + } + } + return res; + } + + async sendVisionRequest(messages, systemMessage, imageBuffer) { + const imageMessages = [...messages]; + imageMessages.push({ + role: 'user', + content: [ + { type: 'input_text', text: systemMessage }, + { type: 'input_image', image_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', + ...toOpenAIChatRequestParams(this.params, this.provider) + }); + return embedding.data[0].embedding; + } +} + +// Backward-compatible alias for old configs/tests. New configs should use +// the OpenClaw protocol name: openai-completions. +export class OpenAICompatible extends OpenAICompletions { + static prefix = 'openai-compatible'; +} + +const sendAudioRequest = async (text, model, voice, url) => { + const payload = { model, voice, input: text }; + const config = {}; + if (url) config.baseURL = url; + if (hasKey('OPENAI_ORG_ID')) config.organization = getKey('OPENAI_ORG_ID'); + config.apiKey = getKey('OPENAI_API_KEY'); + const openai = new OpenAIApi(config); + const mp3 = await openai.audio.speech.create(payload); + const buffer = Buffer.from(await mp3.arrayBuffer()); + return buffer.toString('base64'); +}; + +export const TTSConfig = { + sendAudioRequest, + baseUrl: 'https://api.openai.com/v1', +}; + + +function providerFacingError(err, provider) { + if (provider === 'azure' && (err?.code === 'DeploymentNotFound' || err?.error?.code === 'DeploymentNotFound')) { + return 'Azure deployment not found. Check the Azure deployment name configured for this profile.'; + } + if (provider === 'ollama_cloud' && err?.status === 403) { + return 'Ollama Cloud rejected the request. Check that OLLAMA_API_KEY has subscription access to the selected cloud model.'; + } + return 'My brain disconnected, try again.'; +} + +function toOpenAIChatRequestParams(params, provider) { + const requestParams = { ...(params || {}) }; + delete requestParams.tool_choice; + delete requestParams.toolChoice; + + // User-facing config follows OpenAI Responses/Codex shape: + // { reasoning: { effort: "medium" } }. Chat Completions expects + // reasoning_effort instead, and rejects the nested reasoning object. + if (requestParams.reasoning?.effort && provider === 'openai') { + requestParams.reasoning_effort = requestParams.reasoning.effort; + delete requestParams.reasoning; + } + return requestParams; +} + +function inferProviderName(url) { + if (!url) return null; + try { + const host = new URL(url).hostname.replace(/^api\./, '').replace(/^dashscope\./, 'qwen.'); + const first = host.split('.')[0]; + return first || null; + } catch { + return null; + } +} diff --git a/src/models/openai_responses.js b/src/models/openai_responses.js new file mode 100644 index 000000000..80d7915c4 --- /dev/null +++ b/src/models/openai_responses.js @@ -0,0 +1,75 @@ +import { OpenAICompletions } from './openai_compatible.js'; +import { strictFormat } from '../utils/text.js'; +import { createNativeToolResponse } from './native_tools.js'; + +// OpenClaw-style OpenAI Responses protocol. For native tool calls this class +// uses Responses API function-call items directly instead of the legacy GPT file. +export class OpenAIResponses extends OpenAICompletions { + static prefix = 'openai-responses'; + + async sendRequest(turns, systemMessage, stop_seq='***', tools=null) { + const model = this.model_name || this.default_model; + const hasTools = Array.isArray(tools) && tools.length > 0; + const input = strictFormat(turns).map(message => ({ ...message, content: stop_seq && !hasTools ? message.content + stop_seq : message.content })); + const request = { + model, + instructions: systemMessage, + input, + ...stripToolChoiceParams(this.params) + }; + if (hasTools) { + request.tools = toResponsesTools(tools); + } + + try { + console.log(hasTools + ? `Awaiting ${this.provider} Responses API with native tool calling (${tools.length} tools) from model ${model}` + : `Awaiting ${this.provider} Responses API from model ${model}`); + const response = await this.openai.responses.create(request); + console.log('Received.'); + const toolCalls = normalizeResponsesToolCalls(response); + if (toolCalls.length > 0) { + return createNativeToolResponse(toolCalls, this.provider); + } + let res = response.output_text || ''; + const stopSeqIndex = stop_seq ? res.indexOf(stop_seq) : -1; + return stopSeqIndex !== -1 ? res.slice(0, stopSeqIndex) : res; + } 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, tools); + } + console.log(err); + return 'My brain disconnected, try again.'; + } + } +} + +function toResponsesTools(tools = []) { + return tools.map(tool => ({ + type: 'function', + name: tool.function.name, + description: tool.function.description, + parameters: tool.function.parameters + })); +} + +function normalizeResponsesToolCalls(response) { + const output = response?.output || []; + return output + .filter(item => item?.type === 'function_call') + .map((item, index) => ({ + id: item.call_id || item.id || `call_${Date.now()}_${index}`, + type: 'function', + name: item.name, + arguments: item.arguments || '{}' + })) + .filter(call => call.name); +} + +function stripToolChoiceParams(params) { + const requestParams = { ...(params || {}) }; + delete requestParams.tool_choice; + delete requestParams.toolChoice; + return requestParams; +} diff --git a/src/models/openrouter.js b/src/models/openrouter.js deleted file mode 100644 index ca0782bc4..000000000 --- a/src/models/openrouter.js +++ /dev/null @@ -1,77 +0,0 @@ -import OpenAIApi from 'openai'; -import { getKey, hasKey } from '../utils/keys.js'; -import { strictFormat } from '../utils/text.js'; - -export class OpenRouter { - static prefix = 'openrouter'; - constructor(model_name, url) { - this.model_name = model_name; - - let config = {}; - config.baseURL = 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; - - 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.'); - } -} \ No newline at end of file diff --git a/src/models/qwen.js b/src/models/qwen.js deleted file mode 100644 index a768b5b07..000000000 --- a/src/models/qwen.js +++ /dev/null @@ -1,80 +0,0 @@ -import OpenAIApi from 'openai'; -import { getKey, hasKey } from '../utils/keys.js'; -import { strictFormat } from '../utils/text.js'; - -export class Qwen { - static prefix = 'qwen'; - constructor(model_name, url, params) { - this.model_name = model_name; - this.params = params; - let config = {}; - - config.baseURL = 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. - async embed(text) { - const maxRetries = 5; // Maximum number of retries - for (let retries = 0; retries < maxRetries; retries++) { - try { - const { data } = await this.openai.embeddings.create({ - model: this.model_name || "text-embedding-v3", - input: text, - encoding_format: "float", - }); - return data[0].embedding; - } catch (err) { - if (err.status === 429) { - // If a rate limit error occurs, calculate the exponential backoff with a random delay (1-5 seconds) - const delay = Math.pow(2, retries) * 1000 + Math.floor(Math.random() * 2000); - // console.log(`Rate limit hit, retrying in ${delay} ms...`); - await new Promise(resolve => setTimeout(resolve, delay)); // Wait for the delay before retrying - } else { - throw err; - } - } - } - // If maximum retries are reached and the request still fails, throw an error - throw new Error('Max retries reached, request failed.'); - } - -} \ No newline at end of file diff --git a/src/models/replicate.js b/src/models/replicate.js index d38e8fea8..1d10d54d0 100644 --- a/src/models/replicate.js +++ b/src/models/replicate.js @@ -1,136 +1,186 @@ import Replicate from 'replicate'; import { toSinglePrompt } from '../utils/text.js'; import { getKey } from '../utils/keys.js'; +import { createNativeToolResponse } from './native_tools.js'; -// llama, mistral, gemini +// Replicate Predictions API. This is not OpenAI-compatible: individual +// Replicate models define their own input/output schemas. export class ReplicateAPI { - static prefix = 'replicate'; - constructor(model_name, url, params) { - this.model_name = model_name; - this.url = url; - this.params = params; - - if (this.url) { - console.warn('Replicate API does not support custom URLs. Ignoring provided URL.'); - } - - this.replicate = new Replicate({ - auth: getKey('REPLICATE_API_KEY'), - }); - } - - async sendRequest(turns, systemMessage) { - const stop_seq = '***'; - const prompt = toSinglePrompt(turns, null, stop_seq); - let model_name = this.model_name || 'meta/meta-llama-3-70b-instruct'; - - // Detect model type to use correct input format - const isGemini = model_name.includes('gemini'); - - let input; - if (isGemini) { - // Gemini models on Replicate ignore system_prompt field - // Combine system message into the main prompt instead - const fullPrompt = systemMessage + '\n\n' + prompt; - input = { - prompt: fullPrompt, - ...(this.params || {}) - }; - } else { - // Llama and other models use system_prompt - input = { - prompt, - system_prompt: systemMessage, - ...(this.params || {}) - }; - } - - let res = null; - try { - console.log('Awaiting Replicate API response...'); - - if (isGemini) { - // Gemini doesn't stream well on Replicate, use run() instead - const output = await this.replicate.run(model_name, { input }); - // Output might be a string or an array - if (Array.isArray(output)) { - res = output.join(''); - } else if (typeof output === 'string') { - res = output; - } else { - res = String(output); - } - } else { - // Use streaming for other models - let result = ''; - for await (const event of this.replicate.stream(model_name, { input })) { - result += event; - if (result === '') break; - if (result.includes(stop_seq)) { - result = result.slice(0, result.indexOf(stop_seq)); - break; - } - } - res = result; - } - - // Trim stop sequence if present - if (res && res.includes(stop_seq)) { - res = res.slice(0, res.indexOf(stop_seq)); - } - } catch (err) { - console.log(err); - res = 'My brain disconnected, try again.'; - } - console.log('Received.'); - return res; - } - - async embed(text) { - // Always use a dedicated embedding model, not the chat model - const DEFAULT_EMBEDDING_MODEL = "mark3labs/embeddings-gte-base:d619cff29338b9a37c3d06605042e1ff0594a8c3eff0175fd6967f5643fc4d47"; - - // Validate text input - if (!text || typeof text !== 'string') { - throw new Error('Text is required for embedding'); - } - - // Check if model_name is an embedding model or a chat model - // Chat models (like meta/meta-llama-3-70b-instruct) won't work for embeddings - const isEmbeddingModel = this.model_name && ( - this.model_name.includes('embed') || - this.model_name.includes('gte') || - this.model_name.includes('e5-') - ); - const embeddingModel = isEmbeddingModel ? this.model_name : DEFAULT_EMBEDDING_MODEL; - - // Helper to extract embedding from various output formats - const extractEmbedding = (output) => { - if (output.vectors) { - return output.vectors; - } else if (Array.isArray(output)) { - return output; - } else if (output.embedding) { - return output.embedding; - } else if (output.embeddings) { - return Array.isArray(output.embeddings[0]) ? output.embeddings[0] : output.embeddings; - } - return null; - }; - - try { - const output = await this.replicate.run( - embeddingModel, - { input: { text } } - ); - const embedding = extractEmbedding(output); - if (embedding) { - return embedding; - } - throw new Error('Unknown embedding output format'); - } catch (err) { - console.error('Replicate embed error:', err.message || err); - throw err; - } - } -} \ No newline at end of file + static prefix = 'replicate'; + + constructor(model_name, url, params) { + this.model_name = model_name; + this.url = url; + this.params = params || {}; + this.provider = this.params.provider || 'replicate'; + this.supportsNativeToolCalls = true; + + if (this.url) { + console.warn('Replicate API does not support custom URLs. Ignoring provided URL.'); + } + + const apiKeyName = this.params.apiKeyName || this.params.api_key_name || 'REPLICATE_API_KEY'; + delete this.params.apiKeyName; + delete this.params.api_key_name; + delete this.params.provider; + + this.replicate = new Replicate({ + auth: getKey(apiKeyName), + }); + } + + async sendRequest(turns, systemMessage, stop_seq = '<|EOT|>', tools = null) { + const modelName = this.model_name || 'google/gemini-2.5-flash'; + if (Array.isArray(tools) && tools.length > 0) { + return this.sendToolRequest(modelName, turns, systemMessage, tools); + } + return this.sendTextRequest(modelName, turns, systemMessage, stop_seq); + } + + async sendTextRequest(modelName, turns, systemMessage, stopSeq) { + try { + console.log(`Awaiting Replicate API response from ${modelName}...`); + const prompt = toSinglePrompt(turns, null, stopSeq); + const isGemini = isGeminiReplicateModel(modelName); + const input = buildReplicateTextInput(modelName, prompt, systemMessage, this.params); + let result = ''; + if (isGemini) { + // PR #680 verified Gemini models return empty streams on Replicate. + // The official Gemini schema is prompt-based, so use run(). + const output = await this.replicate.run(modelName, { input }); + result = stringifyReplicateOutput(output); + if (result.includes(stopSeq)) { + result = result.slice(0, result.indexOf(stopSeq)); + } + console.log('Received.'); + return result; + } + + for await (const event of this.replicate.stream(modelName, { input })) { + result += stringifyReplicateEvent(event); + if (result === '') break; + if (result.includes(stopSeq)) { + result = result.slice(0, result.indexOf(stopSeq)); + break; + } + } + console.log('Received.'); + return result; + } catch (err) { + console.log(err); + return 'My brain disconnected, try again.'; + } + } + + async sendToolRequest(modelName, turns, systemMessage, tools) { + const messages = [ + { role: 'system', content: systemMessage }, + ...turns + ]; + const prompt = toSinglePrompt(turns, systemMessage, '<|EOT|>'); + const input = { + prompt, + system_instruction: systemMessage, + messages, + tools, + ...(this.params || {}) + }; + + try { + console.log(`Awaiting Replicate API response with native tool calling (${tools.length} tools) from ${modelName}...`); + const output = await this.replicate.run(modelName, { input }); + const toolCalls = extractReplicateToolCalls(output); + if (toolCalls.length > 0) { + console.log(`Received ${toolCalls.length} Replicate tool call(s).`); + return createNativeToolResponse(toolCalls, this.provider); + } + console.log('Received.'); + return stringifyReplicateOutput(output); + } catch (err) { + console.log(err); + return 'My brain disconnected, try again.'; + } + } + + async embed(text) { + if (!text || typeof text !== 'string') { + throw new Error('Text is required for Replicate embeddings.'); + } + const embeddingModel = isEmbeddingReplicateModel(this.model_name) + ? this.model_name + : 'mark3labs/embeddings-gte-base'; + const output = await this.replicate.run( + embeddingModel, + { input: { text, ...(this.params || {}) } } + ); + const embedding = extractReplicateEmbedding(output); + if (!embedding) { + throw new Error('Unknown Replicate embedding output format.'); + } + return embedding; + } +} + +function buildReplicateTextInput(modelName, prompt, systemMessage, params = {}) { + if (isGeminiReplicateModel(modelName)) { + // Replicate's Gemini model schema documents `prompt` and + // `system_instruction`; PR #680 additionally found that including the + // system message in the prompt is the reliable path across versions. + return { + prompt: systemMessage ? `${systemMessage}\n\n${prompt}` : prompt, + system_instruction: systemMessage, + ...(params || {}) + }; + } + return { + prompt, + system_prompt: systemMessage, + ...(params || {}) + }; +} + +function isGeminiReplicateModel(modelName = '') { + return String(modelName).toLowerCase().includes('gemini'); +} + +function isEmbeddingReplicateModel(modelName = '') { + const normalized = String(modelName || '').toLowerCase(); + return normalized.includes('embed') || normalized.includes('gte') || normalized.includes('e5-'); +} + +function extractReplicateEmbedding(output) { + if (!output) return null; + if (output.vectors) return output.vectors; + if (output.embedding) return output.embedding; + if (output.embeddings) { + return Array.isArray(output.embeddings?.[0]) ? output.embeddings[0] : output.embeddings; + } + if (Array.isArray(output)) return output; + return null; +} + +function extractReplicateToolCalls(output) { + if (!output) return []; + if (Array.isArray(output?.tool_calls)) return output.tool_calls; + if (Array.isArray(output?.toolCalls)) return output.toolCalls; + if (output?.function_call) return [{ type: 'function', function: output.function_call }]; + if (Array.isArray(output)) { + return output.flatMap(item => extractReplicateToolCalls(item)); + } + return []; +} + +function stringifyReplicateOutput(output) { + if (typeof output === 'string') return output; + if (Array.isArray(output)) return output.map(stringifyReplicateOutput).join(''); + if (output?.content) return stringifyReplicateOutput(output.content); + if (output?.text) return stringifyReplicateOutput(output.text); + return output == null ? '' : JSON.stringify(output); +} + +function stringifyReplicateEvent(event) { + if (typeof event === 'string') return event; + if (event == null) return ''; + if (typeof event === 'object' && 'data' in event) return stringifyReplicateOutput(event.data); + return stringifyReplicateOutput(event); +} diff --git a/src/models/vllm.js b/src/models/vllm.js deleted file mode 100644 index d821983bb..000000000 --- a/src/models/vllm.js +++ /dev/null @@ -1,78 +0,0 @@ -// This code uses Dashscope and HTTP to ensure the latest support for the Qwen model. -// 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'; - -export class VLLM { - 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); - } - - 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; - } - - 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'); - } - -} \ No newline at end of file diff --git a/src/utils/keys.js b/src/utils/keys.js index af77c4d1d..0662010ee 100644 --- a/src/utils/keys.js +++ b/src/utils/keys.js @@ -1,11 +1,12 @@ import { readFileSync } from 'fs'; +import { homedir } from 'os'; +import path from 'path'; let keys = {}; try { - const data = readFileSync('./keys.json', 'utf8'); - keys = JSON.parse(data); + keys = readKeysConfig(); } catch (err) { - console.warn('keys.json not found. Defaulting to environment variables.'); // still works with local models + console.warn('llm_providers.json keys not found. Defaulting to environment variables.'); // still works with local models } export function getKey(name) { @@ -13,12 +14,41 @@ export function getKey(name) { if (!key) { key = process.env[name]; } + if (!key && name === 'OPENAI_API_KEY') { + key = getCodexGeneratedOpenAIKey(); + } if (!key) { - throw new Error(`API key "${name}" not found in keys.json or environment variables!`); + throw new Error(`API key "${name}" not found in llm_providers.json keys or environment variables!`); } return key; } export function hasKey(name) { - return keys[name] || process.env[name]; + return keys[name] || process.env[name] || (name === 'OPENAI_API_KEY' ? getCodexGeneratedOpenAIKey() : undefined); +} + +function readKeysConfig() { + const unifiedPath = process.env.MINDCRAFT_LLM_PROVIDERS_PATH || './llm_providers.json'; + try { + const unified = JSON.parse(readFileSync(unifiedPath, 'utf8')); + if (unified?.keys && typeof unified.keys === 'object') { + return unified.keys; + } + } catch { + // Fall back to the legacy file below for older checkouts/tests. + } + const legacy = JSON.parse(readFileSync('./keys.json', 'utf8')); + return legacy?.keys && typeof legacy.keys === 'object' ? legacy.keys : legacy; +} + +function getCodexGeneratedOpenAIKey() { + try { + const authPath = path.join(homedir(), '.codex', 'auth.json'); + const auth = JSON.parse(readFileSync(authPath, 'utf8')); + return typeof auth.OPENAI_API_KEY === 'string' && auth.OPENAI_API_KEY.length > 0 + ? auth.OPENAI_API_KEY + : undefined; + } catch { + return undefined; + } } diff --git a/tests/codex_chatgpt.test.js b/tests/codex_chatgpt.test.js new file mode 100644 index 000000000..b6e18f61c --- /dev/null +++ b/tests/codex_chatgpt.test.js @@ -0,0 +1,321 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { + CodexChatGPT, + buildAuthorizeUrl, + ensureCodexChatGPTAuth, + hasCodexChatGPTAuth, + parseCodexResponsesSse, + readCodexChatGPTAuth, + runCodexBrowserLogin, + toCodexResponseItem, + toCodexResponsesTools, + writeKeysCodexAuth +} from '../src/models/codex_chatgpt.js'; +import { isNativeToolResponse } from '../src/models/native_tools.js'; + +const tool = { + type: 'function', + function: { + name: 'report_status', + description: 'Report status', + parameters: { + type: 'object', + properties: { status: { type: 'string' } }, + required: ['status'], + additionalProperties: false + } + } +}; + +test('Codex adapter converts OpenAI-style tools to Responses API shape', () => { + assert.deepEqual(toCodexResponsesTools([tool]), [{ + type: 'function', + name: 'report_status', + description: 'Report status', + strict: false, + parameters: tool.function.parameters + }]); +}); + +test('Codex adapter converts chat messages to protocol ResponseItems', () => { + assert.deepEqual(toCodexResponseItem({ role: 'user', content: 'hi' }), { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'hi' }] + }); + assert.deepEqual(toCodexResponseItem({ role: 'assistant', content: 'hello' }), { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'hello' }] + }); +}); + +test('Codex SSE parser extracts Responses function_call events', async () => { + const sse = [ + 'event: response.output_item.done', + 'data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_1","name":"report_status","arguments":"{\\"status\\":\\"ok\\"}"}}', + '', + 'event: response.completed', + 'data: {"type":"response.completed","response":{"id":"resp_1"}}', + '' + ].join('\n'); + + const parsed = await parseCodexResponsesSse(sse); + assert.equal(parsed.toolCalls[0].function.name, 'report_status'); + assert.equal(parsed.toolCalls[0].function.arguments, '{"status":"ok"}'); +}); + +test('Codex SSE parser prefers text deltas over final message to avoid duplicate text', async () => { + const sse = [ + 'event: response.output_text.delta', + 'data: {"type":"response.output_text.delta","delta":"Hi "}', + '', + 'event: response.output_text.delta', + 'data: {"type":"response.output_text.delta","delta":"there"}', + '', + 'event: response.output_item.done', + 'data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hi there"}]}}', + '' + ].join('\n'); + + const parsed = await parseCodexResponsesSse(sse); + assert.equal(parsed.text, 'Hi there'); +}); + + +test('Codex browser login builds the same authorize URL shape as Codex CLI', () => { + const url = new URL(buildAuthorizeUrl({ + issuer: 'https://auth.openai.com', + clientId: 'client-test', + redirectUri: 'http://localhost:1455/auth/callback', + pkce: { code_challenge: 'challenge-test' }, + state: 'state-test', + forcedChatgptWorkspaceId: 'workspace-test', + originator: 'codex_cli_rs' + })); + + assert.equal(url.origin + url.pathname, 'https://auth.openai.com/oauth/authorize'); + assert.equal(url.searchParams.get('response_type'), 'code'); + assert.equal(url.searchParams.get('client_id'), 'client-test'); + assert.equal(url.searchParams.get('redirect_uri'), 'http://localhost:1455/auth/callback'); + assert.equal(url.searchParams.get('scope'), 'openid profile email offline_access api.connectors.read api.connectors.invoke'); + assert.equal(url.searchParams.get('code_challenge'), 'challenge-test'); + assert.equal(url.searchParams.get('code_challenge_method'), 'S256'); + assert.equal(url.searchParams.get('id_token_add_organizations'), 'true'); + assert.equal(url.searchParams.get('codex_cli_simplified_flow'), 'true'); + assert.equal(url.searchParams.get('state'), 'state-test'); + assert.equal(url.searchParams.get('originator'), 'codex_cli_rs'); + assert.equal(url.searchParams.get('allowed_workspace_id'), 'workspace-test'); +}); + +test('Codex browser login callback exchanges code and persists project keys auth', async () => { + const { keysPath, cleanup } = writeTempKeys({ includeAuth: false }); + const originalFetch = globalThis.fetch; + const originalLog = console.log; + const logs = []; + const tokenRequests = []; + globalThis.fetch = async (url, init) => { + if (String(url).startsWith('http://localhost:')) { + return originalFetch(url, init); + } + tokenRequests.push({ url, init, body: new URLSearchParams(init.body) }); + assert.equal(String(url), 'https://issuer.test/oauth/token'); + assert.equal(tokenRequests[0].body.get('grant_type'), 'authorization_code'); + assert.equal(tokenRequests[0].body.get('code'), 'auth-code-test'); + assert.equal(tokenRequests[0].body.get('client_id'), 'client-test'); + assert.match(tokenRequests[0].body.get('redirect_uri'), /^http:\/\/localhost:\d+\/auth\/callback$/); + assert.ok(tokenRequests[0].body.get('code_verifier')); + return new Response(JSON.stringify({ + id_token: 'id-token-test', + access_token: 'access-token-test', + refresh_token: 'refresh-token-test', + account_id: 'account-id-test' + }), { status: 200, headers: { 'content-type': 'application/json' } }); + }; + console.log = (...args) => { logs.push(args.join(' ')); }; + + try { + const login = runCodexBrowserLogin({ + keysPath, + issuer: 'https://issuer.test', + clientId: 'client-test', + port: 0, + openBrowser: false + }); + const authUrl = await waitFor(() => { + const match = logs.join('\n').match(/https:\/\/issuer\.test\/oauth\/authorize\?\S+/); + return match?.[0]; + }); + const parsedAuthUrl = new URL(authUrl); + const redirectUri = parsedAuthUrl.searchParams.get('redirect_uri'); + const state = parsedAuthUrl.searchParams.get('state'); + const callback = await fetch(`${redirectUri}?code=auth-code-test&state=${encodeURIComponent(state)}`); + assert.equal(callback.status, 200); + + const authJson = await login; + assert.equal(authJson.tokens.access_token, 'access-token-test'); + assert.equal(readCodexChatGPTAuth(keysPath).accessToken, 'access-token-test'); + assert.equal(tokenRequests.length, 1); + } finally { + globalThis.fetch = originalFetch; + console.log = originalLog; + cleanup(); + } +}); + +test('Codex adapter reads ChatGPT login auth from Codex auth.json shape', () => { + const { keysPath, cleanup } = writeTempKeys(); + try { + const auth = readCodexChatGPTAuth(keysPath); + assert.equal(auth.accessToken, 'access-token-test'); + assert.equal(auth.accountId, 'account-id-test'); + assert.equal(hasCodexChatGPTAuth(keysPath), true); + } finally { + cleanup(); + } +}); + +test('Codex adapter can still read raw auth.json style files when explicitly configured', () => { + const { authPath, cleanup } = writeTempRawAuth(); + try { + const auth = readCodexChatGPTAuth(authPath); + assert.equal(auth.accessToken, 'access-token-test'); + assert.equal(auth.accountId, 'account-id-test'); + assert.equal(hasCodexChatGPTAuth(authPath), true); + } finally { + cleanup(); + } +}); + +test('Codex adapter still supports legacy unified llm_providers.json auth storage', () => { + const { keysPath, dir, cleanup } = writeTempKeys({ includeAuth: false }); + try { + writeKeysCodexAuth(keysPath, authFixture()); + assert.equal(existsSync(path.join(dir, '.mindcraft')), false); + const auth = readCodexChatGPTAuth(keysPath); + assert.equal(auth.accessToken, 'access-token-test'); + } finally { + cleanup(); + } +}); + + +test('Codex adapter defaults to project llm_providers.json instead of ~/.codex auth', () => { + const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', {}); + assert.equal(model.authPath, 'llm_providers.json'); + assert.equal(model.keysPath, 'llm_providers.json'); +}); + +test('Codex adapter starts local login runner when configured auth path is missing', async () => { + const { keysPath, cleanup } = writeTempKeys({ includeAuth: false }); + let called = false; + try { + const auth = await ensureCodexChatGPTAuth({ + keysPath, + allowLogin: true, + loginRunner: async ({ keysPath: loginKeysPath }) => { + called = loginKeysPath === keysPath; + return authFixture(); + } + }); + assert.equal(called, true); + assert.equal(auth.accessToken, 'access-token-test'); + assert.equal(readCodexChatGPTAuth(keysPath).accountId, 'account-id-test'); + } finally { + cleanup(); + } +}); + +test('Codex adapter sends native-login Responses request and normalizes tool call', async () => { + const { keysPath, cleanup } = writeTempKeys(); + const originalFetch = globalThis.fetch; + const requests = []; + globalThis.fetch = async (url, init) => { + requests.push({ url, init, body: JSON.parse(init.body) }); + return new Response([ + 'event: response.output_item.done', + 'data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_1","name":"report_status","arguments":"{\\"status\\":\\"ok\\"}"}}', + '', + 'event: response.completed', + 'data: {"type":"response.completed","response":{"id":"resp_1"}}', + '' + ].join('\n'), { + status: 200, + headers: { 'content-type': 'text/event-stream' } + }); + }; + + try { + const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', { keysPath, sessionId: 'session-test' }); + const response = await model.sendRequest( + [{ role: 'user', content: 'call the tool' }], + 'Use the function.', + '***', + [tool] + ); + assert.equal(isNativeToolResponse(response), true); + assert.equal(response.tool_calls[0].name, 'report_status'); + assert.equal(requests[0].url, 'https://example.test/backend-api/codex/responses'); + assert.equal(requests[0].init.headers.Authorization, 'Bearer access-token-test'); + assert.equal(requests[0].init.headers['ChatGPT-Account-ID'], 'account-id-test'); + assert.equal(requests[0].init.headers.originator, 'codex_cli_rs'); + assert.equal(requests[0].body.tools[0].name, 'report_status'); + assert.equal(Object.prototype.hasOwnProperty.call(requests[0].body, 'tool_choice'), false); + assert.equal(requests[0].body.stream, true); + } finally { + globalThis.fetch = originalFetch; + cleanup(); + } +}); + +async function waitFor(fn, timeoutMs = 1000) { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + const value = fn(); + if (value) return value; + await new Promise(resolve => setTimeout(resolve, 10)); + } + throw new Error('Timed out waiting for condition'); +} + +function writeTempKeys({ includeAuth = true } = {}) { + const dir = mkdtempSync(path.join(tmpdir(), 'codex-keys-test-')); + const keysPath = path.join(dir, 'llm_providers.json'); + const keys = { OPENAI_API_KEY: '' }; + if (includeAuth) keys.CODEX_CHATGPT_AUTH = authFixture(); + writeFileSync(keysPath, JSON.stringify(keys)); + return { + dir, + keysPath, + cleanup: () => rmSync(dir, { recursive: true, force: true }) + }; +} + +function writeTempRawAuth() { + const dir = mkdtempSync(path.join(tmpdir(), 'codex-auth-test-')); + const authPath = path.join(dir, 'auth.json'); + writeFileSync(authPath, JSON.stringify(authFixture())); + return { + dir, + authPath, + cleanup: () => rmSync(dir, { recursive: true, force: true }) + }; +} + +function authFixture() { + return { + auth_mode: 'chatgpt', + OPENAI_API_KEY: null, + tokens: { + id_token: 'id-token-test', + access_token: 'access-token-test', + refresh_token: 'refresh-token-test', + account_id: 'account-id-test' + }, + last_refresh: '2026-04-30T00:00:00Z' + }; +} diff --git a/tests/llm_providers_config.test.js b/tests/llm_providers_config.test.js new file mode 100644 index 000000000..d22a28990 --- /dev/null +++ b/tests/llm_providers_config.test.js @@ -0,0 +1,98 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +const config = JSON.parse(readFileSync('llm_providers.json', 'utf8')); +const example = JSON.parse(readFileSync('llm_providers.example.json', 'utf8')); + +test('llm provider config uses readable format/baseUrl/keyName schema', () => { + assert.equal(config.schemaVersion, 1); + assert.equal('examples' in config, false, 'custom examples should live under models/embeddings in the example file, not a separate top-level field'); + for (const sectionName of ['models', 'embeddings']) { + const section = config[sectionName]; + assert.equal(typeof section, 'object', `${sectionName} section is required`); + for (const [id, provider] of Object.entries(section)) { + assert.equal(typeof provider.format, 'string', `${sectionName}.${id}.format is required`); + assert.equal('api' in provider, false, `${sectionName}.${id} should use format, not api`); + assert.equal('name' in provider, false, `${sectionName}.${id} should use the provider id instead of a duplicate name`); + assert.equal('url' in provider, false, `${sectionName}.${id} should use baseUrl, not url`); + assert.equal('apiKeyName' in provider, false, `${sectionName}.${id} should use keyName, not apiKeyName`); + assert.notEqual(provider.format, 'openai-compatible', `${sectionName}.${id} should use OpenClaw protocol names such as openai-completions`); + assert.notEqual(provider.format, 'openai-chat-completions', `${sectionName}.${id} should use OpenClaw protocol name openai-completions`); + } + } +}); + +test('example config documents custom providers without name mapping', () => { + assert.equal('examples' in example, false); + assert.equal(example.models._example_openai_chat_completions.format, 'openai-completions'); + assert.equal(example.models._example_openai_chat_completions.keyName, 'MY_PROVIDER_API_KEY'); + assert.equal(example.models._example_openai_chat_completions.baseUrl, 'https://api.example.com/v1'); + assert.equal('name' in example.models._example_openai_chat_completions, false); + + assert.equal(example.models._example_openai_responses.format, 'openai-responses'); + assert.equal(example.models._example_openai_responses.keyName, 'MY_RESPONSES_API_KEY'); + + assert.equal(example.embeddings._example_openai_embeddings.format, 'openai-embeddings'); + assert.equal(example.embeddings._example_openai_embeddings.defaultModel, 'text-embedding-model-name'); + assert.equal(example.embeddings._example_openai_embeddings.keyName, 'MY_EMBEDDING_API_KEY'); +}); + +test('kimi uses the documented Anthropic-compatible coding endpoint', () => { + assert.deepEqual(config.models.kimi, { + format: 'anthropic-messages', + baseUrl: 'https://api.kimi.com/coding/', + keyName: 'KIMI_API_KEY', + defaultModel: 'kimi-k2.6', + params: { + max_tokens: 32768, + provider: 'kimi' + } + }); +}); + +test('remote llm providers explicitly declare which key they use', () => { + const localOrLogin = new Set(['codex', 'ollama_local', 'vllm']); + for (const [id, provider] of Object.entries(config.models)) { + if (localOrLogin.has(id)) continue; + assert.equal(typeof provider.keyName, 'string', `models.${id}.keyName is required`); + assert.ok(provider.keyName.length > 0, `models.${id}.keyName must not be empty`); + assert.ok(Object.hasOwn(config.keys, provider.keyName), `keys.${provider.keyName} must exist for models.${id}`); + } + + for (const [id, provider] of Object.entries(config.embeddings)) { + if (localOrLogin.has(id)) continue; + assert.equal(typeof provider.keyName, 'string', `embeddings.${id}.keyName is required`); + assert.ok(provider.keyName.length > 0, `embeddings.${id}.keyName must not be empty`); + assert.ok(Object.hasOwn(config.keys, provider.keyName), `keys.${provider.keyName} must exist for embeddings.${id}`); + } +}); + +test('llm provider example mirrors provider ids without real key material', () => { + assert.deepEqual( + new Set(Object.keys(example.models).filter(id => !id.startsWith('_example_'))), + new Set(Object.keys(config.models)) + ); + assert.deepEqual( + new Set(Object.keys(example.embeddings).filter(id => !id.startsWith('_example_'))), + new Set(Object.keys(config.embeddings)) + ); + assert.equal(example.keys.XIAOAI_API_KEY, undefined); + for (const [keyName, value] of Object.entries(example.keys)) { + if (keyName === 'CODEX_CHATGPT_AUTH') continue; + assert.equal(value, '', `${keyName} should be blank in the example`); + } +}); + + +test('codex provider stores auth in the unified project config instead of a fixed user path', () => { + assert.equal(config.models.codex.format, 'openai-codex-responses'); + assert.equal(config.models.codex.adapter, 'codex'); + assert.equal(config.models.codex.params.keysPath, 'llm_providers.json'); + assert.equal('authPath' in config.models.codex.params, false); + assert.ok(Object.hasOwn(config.keys, 'CODEX_CHATGPT_AUTH')); + + assert.equal(example.models.codex.params.keysPath, 'llm_providers.json'); + assert.equal('authPath' in example.models.codex.params, false); + assert.ok(Object.hasOwn(example.keys, 'CODEX_CHATGPT_AUTH')); +}); diff --git a/tests/native_tools.test.js b/tests/native_tools.test.js new file mode 100644 index 000000000..05609034b --- /dev/null +++ b/tests/native_tools.test.js @@ -0,0 +1,81 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { + commandToToolDefinition, + executeCommandToolCall +} from '../src/agent/commands/tool_adapter.js'; +import { + createNativeToolResponse, + isNativeToolResponse, + parseToolArguments +} from '../src/models/native_tools.js'; +import { containsCommand, parseCommandMessage } from '../src/agent/commands/index.js'; + +test('human !command parser remains available', () => { + assert.equal(containsCommand('please !stats'), '!stats'); + assert.deepEqual(parseCommandMessage('!stats'), { commandName: '!stats', args: [] }); +}); + +test('command schema conversion preserves required and optional parameters', () => { + const tool = commandToToolDefinition({ + name: '!sample', + description: 'Sample command', + params: { + count: { type: 'int', description: 'Count', domain: [1, 5] }, + note: { type: 'string', description: 'Optional note', optional: true } + } + }); + + assert.equal(tool.type, 'function'); + assert.equal(tool.function.name, 'sample'); + assert.deepEqual(tool.function.parameters.required, ['count']); + assert.equal(tool.function.parameters.properties.count.type, 'integer'); + assert.equal(tool.function.parameters.properties.count.minimum, 1); + assert.equal(tool.function.parameters.properties.count.maximum, 5); +}); + +test('native tool response normalizes and parses OpenAI-compatible tool calls', () => { + const response = createNativeToolResponse([ + { + id: 'call_1', + function: { + name: 'sample', + arguments: '{"count":2}' + } + } + ], 'mock'); + + assert.equal(isNativeToolResponse(response), true); + assert.equal(response.tool_calls[0].name, 'sample'); + assert.deepEqual(parseToolArguments(response.tool_calls[0].arguments), { count: 2 }); +}); + +test('native tool argument parser recovers a leading JSON object before provider markers', () => { + const args = '{"count":2,"note":"brace } inside string"}'; + + assert.deepEqual(parseToolArguments(args), { + count: 2, + note: 'brace } inside string' + }); +}); + +test('tool execution adapter coerces args and calls command implementation', async () => { + const commands = [{ + name: '!sample', + description: 'Sample command', + params: { + count: { type: 'int', description: 'Count', domain: [1, 5] }, + enabled: { type: 'boolean', description: 'Flag' } + }, + perform: (_agent, count, enabled) => `count=${count}; enabled=${enabled}` + }]; + + const result = await executeCommandToolCall( + { blocked_actions: [] }, + { name: 'sample', arguments: '{"count":"3","enabled":"true"}' }, + commands + ); + + assert.equal(result.ok, true); + assert.equal(result.result, 'count=3; enabled=true'); +}); diff --git a/tests/openai_compatible.test.js b/tests/openai_compatible.test.js new file mode 100644 index 000000000..f5af96b0d --- /dev/null +++ b/tests/openai_compatible.test.js @@ -0,0 +1,350 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { OpenAICompletions, OpenAICompatible } from '../src/models/openai_compatible.js'; +import { isNativeToolResponse } from '../src/models/native_tools.js'; +import { selectAPI, selectEmbeddingAPI, createModel } from '../src/models/_model_map.js'; + +const tool = { + type: 'function', + function: { + name: 'report_status', + description: 'Report status', + parameters: { + type: 'object', + properties: { status: { type: 'string' } }, + required: ['status'], + additionalProperties: false + } + } +}; + +test('OpenClaw openai-completions API format can be selected by profile', () => { + const profile = selectAPI({ + api: 'openai-completions', + url: 'https://example.test/v1', + model: 'provider-model', + params: { apiKeyName: 'OPENAI_API_KEY', provider: 'example-provider' } + }); + const model = createModel(profile); + assert.equal(model.constructor.name, 'OpenAICompletions'); + assert.equal(model.provider, 'example-provider'); + assert.equal(model.supportsNativeToolCalls, true); + + const oldAlias = createModel(selectAPI({ + api: 'openai-compatible', + url: 'https://example.test/v1', + model: 'provider-model', + params: { apiKeyName: 'OPENAI_API_KEY', provider: 'example-provider' } + })); + assert.ok(oldAlias instanceof OpenAICompatible); + assert.ok(model instanceof OpenAICompletions); +}); + +test('provider registry expands provider shorthand into a concrete transport', () => { + const profile = selectAPI({ + provider: 'siliconflow', + model: 'Pro/deepseek-ai/DeepSeek-V3', + params: { temperature: 0 } + }); + + assert.equal(profile.api, 'openai-completions'); + assert.equal(profile.url, 'https://api.siliconflow.cn/v1'); + assert.equal(profile.model, 'Pro/deepseek-ai/DeepSeek-V3'); + assert.deepEqual(profile.params, { + apiKeyName: 'SILICONFLOW_API_KEY', + provider: 'siliconflow', + temperature: 0 + }); + + assert.equal(profile.params.provider, 'siliconflow'); +}); + +test('provider registry supports non OpenAI-compatible transport families', () => { + assert.equal(selectAPI({ provider: 'codex', model: 'gpt-5.5' }).api, 'codex'); + assert.equal(selectAPI({ provider: 'google', model: 'gemini-3-flash-preview' }).api, 'google-generative-ai'); + assert.equal(selectAPI({ provider: 'azure', model: 'gpt-5-nano' }).api, 'azure-openai-responses'); + assert.equal(selectAPI({ provider: 'mistral', model: 'mistral-small-latest' }).api, 'openai-completions'); +}); + +test('embedding provider registry is separate from chat model providers', () => { + const qwenEmbedding = selectEmbeddingAPI({ provider: 'qwen_cn' }); + assert.equal(qwenEmbedding.api, 'openai-completions'); + assert.equal(qwenEmbedding.url, 'https://dashscope.aliyuncs.com/compatible-mode/v1'); + assert.equal(qwenEmbedding.model, 'text-embedding-v3'); + assert.equal(qwenEmbedding.params.apiKeyName, 'QWEN_API_KEY'); + assert.equal(qwenEmbedding.params.provider, 'qwen_cn'); + + const openaiEmbedding = selectEmbeddingAPI('openai'); + assert.equal(openaiEmbedding.api, 'openai-completions'); + assert.equal(openaiEmbedding.model, 'text-embedding-3-small'); +}); + +test('ollama defaults to cloud OpenAI-compatible endpoint and can be overridden to local', () => { + const chat = selectAPI({ + provider: 'ollama', + model: 'gpt-oss:120b-cloud' + }); + assert.equal(chat.api, 'openai-completions'); + assert.equal(chat.url, 'https://ollama.com/v1'); + assert.equal(chat.params.provider, 'ollama'); + assert.equal(chat.params.apiKeyName, 'OLLAMA_API_KEY'); + + const model = createModel(chat); + assert.equal(model.constructor.name, 'OpenAICompletions'); + + const local = selectAPI({ + provider: 'ollama', + model: 'llama3.1', + baseUrl: 'http://127.0.0.1:11434/v1', + params: { apiKeyName: null } + }); + assert.equal(local.url, 'http://127.0.0.1:11434/v1'); + assert.equal('apiKeyName' in local.params, false); +}); + +test('profile embedding model can override the provider default embedding name', () => { + const profile = selectEmbeddingAPI({ + provider: 'qwen_cn', + model: 'text-embedding-v4', + params: { dimensions: 1024 } + }); + + assert.equal(profile.api, 'openai-completions'); + assert.equal(profile.model, 'text-embedding-v4'); + assert.deepEqual(profile.params, { + apiKeyName: 'QWEN_API_KEY', + provider: 'qwen_cn', + dimensions: 1024 + }); +}); + +test('provider registry rejects unknown provider ids', () => { + assert.throws( + () => selectAPI({ provider: 'missing-provider', model: 'anything' }), + /Unknown model provider: missing-provider/ + ); +}); + +test('embedding provider registry rejects unknown provider ids independently', () => { + assert.throws( + () => selectEmbeddingAPI({ provider: 'missing-embedding', model: 'anything' }), + /Unknown embedding provider: missing-embedding/ + ); +}); + +test('openai-compatible transport sends Chat Completions tools and normalizes tool calls', async () => { + const model = new OpenAICompatible('provider-model', 'https://example.test/v1', { + apiKeyName: 'OPENAI_API_KEY', + provider: 'example-provider' + }); + let requestPack; + model.openai = { + chat: { + completions: { + create: async pack => { + requestPack = pack; + return { + choices: [{ + message: { + tool_calls: [{ + id: 'call_1', + type: 'function', + function: { + name: 'report_status', + arguments: '{"status":"ok"}' + } + }] + } + }] + }; + } + } + } + }; + + const response = await model.sendRequest( + [{ role: 'user', content: 'call tool' }], + 'Use tools.', + '***', + [tool] + ); + + assert.equal(requestPack.model, 'provider-model'); + assert.equal(requestPack.messages[0].role, 'user'); + assert.equal(requestPack.tools[0].function.name, 'report_status'); + assert.equal(Object.prototype.hasOwnProperty.call(requestPack, 'tool_choice'), false); + assert.equal(isNativeToolResponse(response), true); + assert.equal(response.provider, 'example-provider'); + assert.equal(response.tool_calls[0].name, 'report_status'); +}); + +test('openai-completions transport strips tool choice even if configured', async () => { + const model = new OpenAICompatible('provider-model', 'https://example.test/v1', { + apiKeyName: 'OPENAI_API_KEY', + provider: 'example-provider', + tool_choice: { type: 'function', function: { name: 'report_status' } } + }); + let requestPack; + model.openai = { + chat: { + completions: { + create: async pack => { + requestPack = pack; + return { choices: [{ message: { content: 'ok' } }] }; + } + } + } + }; + + await model.sendRequest( + [{ role: 'user', content: 'call tool' }], + 'Use tools.', + '***', + [tool] + ); + + assert.equal(requestPack.tools[0].function.name, 'report_status'); + assert.equal(Object.prototype.hasOwnProperty.call(requestPack, 'tool_choice'), false); +}); + +test('azure provider can use an explicit deployment name separate from model id', () => { + const model = createModel(selectAPI({ + provider: 'azure', + model: 'gpt-5-nano', + params: { deploymentName: 'my-gpt-5-nano-deployment' } + })); + + assert.equal(model.constructor.name, 'AzureOpenAIResponses'); + assert.equal(model.model_name, 'gpt-5-nano'); + assert.equal(model.deployment, 'my-gpt-5-nano-deployment'); +}); + + + + +test('ollama_local uses local OpenAI-compatible endpoint without an API key', () => { + const chat = selectAPI({ provider: 'ollama_local', model: 'sweaterdog/andy-4:micro-q8_0' }); + assert.equal(chat.api, 'openai-completions'); + assert.equal(chat.url, 'http://127.0.0.1:11434/v1'); + assert.equal(chat.params.provider, 'ollama_local'); + assert.equal('apiKeyName' in chat.params, false); + + const embedding = selectEmbeddingAPI({ provider: 'ollama_local', model: 'embeddinggemma' }); + assert.equal(embedding.api, 'openai-completions'); + assert.equal(embedding.url, 'http://127.0.0.1:11434/v1'); + assert.equal(embedding.params.provider, 'ollama_local'); +}); + +test('replicate provider is a separate Predictions API protocol with native tool normalization', async () => { + const profile = selectAPI({ provider: 'replicate', model: 'google/gemini-2.5-flash' }); + assert.equal(profile.api, 'replicate'); + assert.equal(profile.model, 'google/gemini-2.5-flash'); + assert.equal(profile.params.apiKeyName, 'REPLICATE_API_KEY'); + assert.equal(profile.params.provider, 'replicate'); + + const prefixed = selectAPI('replicate/google/gemini-2.5-flash'); + assert.equal(prefixed.api, 'replicate'); + assert.equal(prefixed.model, 'google/gemini-2.5-flash'); + + const model = createModel(selectAPI({ + api: 'replicate', + model: 'google/gemini-2.5-flash', + params: { apiKeyName: 'OPENAI_API_KEY', provider: 'replicate-test' } + })); + assert.equal(model.constructor.name, 'ReplicateAPI'); + assert.equal(model.supportsNativeToolCalls, true); + + let runArgs; + model.replicate = { + run: async (modelName, args) => { + runArgs = { modelName, args }; + return { + tool_calls: [{ + id: 'call_rep_1', + type: 'function', + function: { + name: 'report_status', + arguments: { status: 'ok' } + } + }] + }; + } + }; + + const response = await model.sendRequest( + [{ role: 'user', content: 'call tool' }], + 'Use tools.', + '***', + [tool] + ); + + assert.equal(runArgs.modelName, 'google/gemini-2.5-flash'); + assert.equal(runArgs.args.input.messages[0].role, 'system'); + assert.equal(runArgs.args.input.tools[0].function.name, 'report_status'); + assert.equal(isNativeToolResponse(response), true); + assert.equal(response.provider, 'replicate-test'); + assert.equal(response.tool_calls[0].name, 'report_status'); + assert.equal(response.tool_calls[0].arguments, '{"status":"ok"}'); +}); + +test('replicate gemini text requests use run with prompt-shaped input', async () => { + const model = createModel(selectAPI({ + api: 'replicate', + model: 'google/gemini-2.5-flash', + params: { apiKeyName: 'OPENAI_API_KEY', provider: 'replicate-test' } + })); + let runArgs; + let streamCalled = false; + model.replicate = { + run: async (modelName, args) => { + runArgs = { modelName, args }; + return ['ok']; + }, + stream: async function* () { + streamCalled = true; + yield 'bad'; + } + }; + + const response = await model.sendRequest( + [{ role: 'user', content: 'Reply exactly: ok' }], + 'System instruction.', + '***' + ); + + assert.equal(response, 'ok'); + assert.equal(streamCalled, false); + assert.equal(runArgs.modelName, 'google/gemini-2.5-flash'); + assert.match(runArgs.args.input.prompt, /System instruction\./); + assert.match(runArgs.args.input.prompt, /Reply exactly: ok/); + assert.equal(runArgs.args.input.system_instruction, 'System instruction.'); + assert.equal(Object.prototype.hasOwnProperty.call(runArgs.args.input, 'system_prompt'), false); +}); + +test('replicate embeddings use a dedicated embedding model when chat model is configured', async () => { + const model = createModel(selectAPI({ + api: 'replicate', + model: 'google/gemini-2.5-flash', + params: { apiKeyName: 'OPENAI_API_KEY', provider: 'replicate-test' } + })); + let runArgs; + model.replicate = { + run: async (modelName, args) => { + runArgs = { modelName, args }; + return { embeddings: [[0.1, 0.2, 0.3]] }; + } + }; + + const embedding = await model.embed('hello'); + + assert.deepEqual(embedding, [0.1, 0.2, 0.3]); + assert.equal(runArgs.modelName, 'mark3labs/embeddings-gte-base'); + assert.deepEqual(runArgs.args.input.text, 'hello'); +}); + +test('replicate embedding provider can be selected separately', () => { + const embedding = selectEmbeddingAPI({ provider: 'replicate', model: 'mark3labs/embeddings-gte-base' }); + assert.equal(embedding.api, 'replicate'); + assert.equal(embedding.model, 'mark3labs/embeddings-gte-base'); + assert.equal(embedding.params.apiKeyName, 'REPLICATE_API_KEY'); +}); diff --git a/tests/profile_shape.test.js b/tests/profile_shape.test.js new file mode 100644 index 000000000..ee5cf932c --- /dev/null +++ b/tests/profile_shape.test.js @@ -0,0 +1,52 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +const profilesDir = path.resolve('profiles'); + +test('profiles select providers and model names without transport connection details', () => { + for (const file of readdirSync(profilesDir)) { + if (!file.endsWith('.json')) continue; + const profile = JSON.parse(readFileSync(path.join(profilesDir, file), 'utf8')); + for (const key of ['model', 'code_model', 'vision_model', 'embedding']) { + if (!profile[key] || typeof profile[key] !== 'object') continue; + assert.equal('api' in profile[key], false, `${file}.${key} must not set api; use provider registry`); + assert.equal('url' in profile[key], false, `${file}.${key} must not set url; use provider registry`); + assert.equal('baseUrl' in profile[key], false, `${file}.${key} must not set baseUrl; use provider registry`); + assert.equal('keyName' in profile[key], false, `${file}.${key} must not set keyName; use provider registry`); + assert.equal('apiKeyName' in profile[key], false, `${file}.${key} must not set apiKeyName; use provider registry`); + } + } +}); + +test('profile embeddings explicitly select both provider and embedding model name', () => { + for (const file of readdirSync(profilesDir)) { + if (!file.endsWith('.json')) continue; + const profile = JSON.parse(readFileSync(path.join(profilesDir, file), 'utf8')); + if (!profile.embedding) continue; + assert.equal(typeof profile.embedding, 'object', `${file}.embedding must be an object`); + assert.equal(typeof profile.embedding.provider, 'string', `${file}.embedding.provider is required`); + assert.equal(typeof profile.embedding.model, 'string', `${file}.embedding.model is required`); + assert.ok(profile.embedding.provider.length > 0, `${file}.embedding.provider must not be empty`); + assert.ok(profile.embedding.model.length > 0, `${file}.embedding.model must not be empty`); + } +}); + +test('preset profiles do not silently depend on another provider for embeddings', () => { + const allowedCrossProviderEmbeddings = new Set([ + // Add explicit exceptions here only when a provider has no embedding API and the profile name makes that choice obvious. + ]); + for (const file of readdirSync(profilesDir)) { + if (!file.endsWith('.json')) continue; + const profile = JSON.parse(readFileSync(path.join(profilesDir, file), 'utf8')); + if (!profile.embedding || typeof profile.model !== 'object') continue; + const modelProvider = profile.model.provider; + const embeddingProvider = profile.embedding.provider; + assert.equal( + modelProvider === embeddingProvider || allowedCrossProviderEmbeddings.has(file), + true, + `${file} should not use embedding provider ${embeddingProvider} with model provider ${modelProvider}` + ); + } +}); diff --git a/tests/smoke/live_function_call_smoke.js b/tests/smoke/live_function_call_smoke.js new file mode 100644 index 000000000..00ed21db9 --- /dev/null +++ b/tests/smoke/live_function_call_smoke.js @@ -0,0 +1,169 @@ +#!/usr/bin/env node +import { selectAPI, createModel } from '../../src/models/_model_map.js'; +import { hasCodexChatGPTAuth } from '../../src/models/codex_chatgpt.js'; +import { isNativeToolResponse, parseToolArguments } from '../../src/models/native_tools.js'; +import { hasKey } from '../../src/utils/keys.js'; + +const TIMEOUT_MS = Number.parseInt(process.env.LIVE_FUNCTION_CALL_TIMEOUT_MS || '45000', 10); + +const tools = [{ + type: 'function', + function: { + name: 'report_status', + description: 'Report a short status for a live native function-calling smoke test.', + parameters: { + type: 'object', + properties: { + status: { type: 'string', description: 'Use ok if function calling works.' }, + provider: { type: 'string', description: 'The provider or route under test.' } + }, + required: ['status', 'provider'], + additionalProperties: false + } + } +}]; + + +function providerCase(name, key, provider, model, params = undefined, available = undefined) { + return { + name, + key, + ...(available ? { available } : {}), + create: () => createModel(selectAPI({ + provider, + model, + ...(params ? { params } : {}) + })) + }; +} + +const cases = [ + { + name: 'codex-chatgpt:gpt-5.5', + key: 'CODEX_CHATGPT_AUTH', + available: () => hasCodexChatGPTAuth(), + create: () => createModel(selectAPI({ provider: 'codex', model: 'gpt-5.5' })) + }, + providerCase('openai:gpt-5.5', 'OPENAI_API_KEY', 'openai', 'gpt-5.5', { reasoning: { effort: 'medium' } }), + providerCase('siliconflow:deepseek-ai/DeepSeek-V4-Flash', 'SILICONFLOW_API_KEY', 'siliconflow', 'deepseek-ai/DeepSeek-V4-Flash'), + providerCase('siliconflow:Pro/deepseek-ai/DeepSeek-R1', 'SILICONFLOW_API_KEY', 'siliconflow', 'Pro/deepseek-ai/DeepSeek-R1'), + providerCase('siliconflow:THUDM/GLM-Z1-32B-0414', 'SILICONFLOW_API_KEY', 'siliconflow', 'THUDM/GLM-Z1-32B-0414'), + providerCase('qwen_cn:qwen-max', 'QWEN_API_KEY', 'qwen_cn', 'qwen-max'), + providerCase('deepseek:deepseek-v4-pro', 'DEEPSEEK_API_KEY', 'deepseek', 'deepseek-v4-pro'), + providerCase('openrouter:openai/gpt-5.5', 'OPENROUTER_API_KEY', 'openrouter', 'openai/gpt-5.5'), + providerCase('xai:grok-4-fast-reasoning', 'XAI_API_KEY', 'xai', 'grok-4-fast-reasoning'), + providerCase('minimax_cn:MiniMax-M2.7', 'MINIMAX_CN_API_KEY', 'minimax_cn', 'MiniMax-M2.7'), + providerCase('kimi:kimi-k2.6', 'KIMI_API_KEY', 'kimi', 'kimi-k2.6'), + providerCase('hyperbolic:Qwen/Qwen3-Coder-480B-A35B-Instruct', 'HYPERBOLIC_API_KEY', 'hyperbolic', 'Qwen/Qwen3-Coder-480B-A35B-Instruct'), + providerCase('huggingface:meta-llama/Llama-3.3-70B-Instruct', 'HUGGINGFACE_API_KEY', 'huggingface', 'meta-llama/Llama-3.3-70B-Instruct:novita'), + providerCase('novita:deepseek/deepseek-v4-flash', 'NOVITA_API_KEY', 'novita', 'deepseek/deepseek-v4-flash'), + providerCase('mercury:mercury-coder-small', 'MERCURY_API_KEY', 'mercury', 'mercury-coder-small'), + providerCase('groq:qwen/qwen3-32b', 'GROQCLOUD_API_KEY', 'groq', 'qwen/qwen3-32b'), + providerCase('cerebras:qwen-3-235b-a22b-instruct-2507', 'CEREBRAS_API_KEY', 'cerebras', 'qwen-3-235b-a22b-instruct-2507'), + providerCase('mistral:mistral-small-latest', 'MISTRAL_API_KEY', 'mistral', 'mistral-small-latest'), + providerCase('gemini:gemini-3-flash-preview', 'GEMINI_API_KEY', 'google', 'gemini-3-flash-preview') +]; + +const includeRegex = process.env.LIVE_FUNCTION_CALL_INCLUDE ? new RegExp(process.env.LIVE_FUNCTION_CALL_INCLUDE, 'i') : null; +const excludeRegex = process.env.LIVE_FUNCTION_CALL_EXCLUDE ? new RegExp(process.env.LIVE_FUNCTION_CALL_EXCLUDE, 'i') : null; +const selectedCases = cases.filter(testCase => { + if (includeRegex && !includeRegex.test(testCase.name)) return false; + if (excludeRegex && excludeRegex.test(testCase.name)) return false; + return true; +}); + +const results = []; + +for (const testCase of selectedCases) { + if (typeof testCase.available === 'function' && !testCase.available()) { + results.push({ name: testCase.name, status: 'skip', reason: `${testCase.key} missing` }); + continue; + } + if (!testCase.available && !hasKey(testCase.key)) { + results.push({ name: testCase.name, status: 'skip', reason: `${testCase.key} missing` }); + continue; + } + + const started = Date.now(); + try { + const model = testCase.create(); + const response = await withTimeout( + model.sendRequest( + [{ role: 'user', content: `Call report_status with status ok and provider ${testCase.name}. Do not answer in text.` }], + 'You are running a live function-calling smoke test. Use the provided function.', + '***', + tools + ), + TIMEOUT_MS + ); + if (!isNativeToolResponse(response)) { + results.push({ + name: testCase.name, + status: 'fail', + reason: `no native tool response: ${preview(response)}`, + elapsed_ms: Date.now() - started + }); + continue; + } + const call = response.tool_calls[0]; + const parsedArgs = safeParse(call?.arguments); + const validArgs = parsedArgs && typeof parsedArgs === 'object' && parsedArgs.status === 'ok'; + results.push({ + name: testCase.name, + status: call?.name === 'report_status' && validArgs ? 'pass' : 'fail', + tool_name: call?.name, + arguments: parsedArgs, + reason: validArgs ? undefined : 'tool call arguments were missing, malformed, or did not include status=ok', + elapsed_ms: Date.now() - started + }); + } catch (error) { + results.push({ + name: testCase.name, + status: 'fail', + reason: sanitizeError(error), + elapsed_ms: Date.now() - started + }); + } +} + +const summary = { + totals: { + pass: results.filter(result => result.status === 'pass').length, + fail: results.filter(result => result.status === 'fail').length, + skip: results.filter(result => result.status === 'skip').length + }, + selected: selectedCases.length, + results +}; + +console.log(JSON.stringify(summary, null, 2)); +if (summary.totals.fail > 0) { + process.exit(1); +} +process.exit(0); + +function withTimeout(promise, timeoutMs) { + return Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs)) + ]); +} + +function safeParse(value) { + try { + return parseToolArguments(value); + } catch { + return value; + } +} + +function preview(value) { + return String(typeof value === 'string' ? value : JSON.stringify(value)).slice(0, 180); +} + +function sanitizeError(error) { + const status = error?.status ? `status=${error.status} ` : ''; + const code = error?.code ? `code=${error.code} ` : ''; + const message = error?.error?.message || error?.message || String(error); + return `${status}${code}${message}`.replace(/sk-[A-Za-z0-9_-]+/g, '[REDACTED_KEY]').slice(0, 300); +} diff --git a/tests/smoke/live_model_matrix.js b/tests/smoke/live_model_matrix.js new file mode 100644 index 000000000..9dec5171f --- /dev/null +++ b/tests/smoke/live_model_matrix.js @@ -0,0 +1,281 @@ +#!/usr/bin/env node +import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { selectAPI, createModel } from '../../src/models/_model_map.js'; +import { hasCodexChatGPTAuth } from '../../src/models/codex_chatgpt.js'; +import { isNativeToolResponse, parseToolArguments } from '../../src/models/native_tools.js'; +import { hasKey } from '../../src/utils/keys.js'; + +const TIMEOUT_MS = Number.parseInt(process.env.LIVE_MODEL_MATRIX_TIMEOUT_MS || '60000', 10); +const CONCURRENCY = Number.parseInt(process.env.LIVE_MODEL_MATRIX_CONCURRENCY || '6', 10); +const OUTPUT_DIR = process.env.LIVE_MODEL_MATRIX_OUTPUT_DIR || 'tests/results'; +const OUTPUT_FILE = process.env.LIVE_MODEL_MATRIX_OUTPUT || path.join(OUTPUT_DIR, `live-model-matrix-${new Date().toISOString().replace(/[:.]/g, '-')}.csv`); +const INCLUDE = process.env.LIVE_MODEL_MATRIX_INCLUDE ? new RegExp(process.env.LIVE_MODEL_MATRIX_INCLUDE, 'i') : null; +const EXCLUDE = process.env.LIVE_MODEL_MATRIX_EXCLUDE ? new RegExp(process.env.LIVE_MODEL_MATRIX_EXCLUDE, 'i') : null; + +const providerConfig = JSON.parse(readFileSync('llm_providers.json', 'utf8')); +const profilesDir = path.resolve('profiles'); + +const tool = { + type: 'function', + function: { + name: 'report_status', + description: 'Report status for a live model compatibility test.', + parameters: { + type: 'object', + properties: { + status: { type: 'string', description: 'Use ok if tool calling works.' }, + provider: { type: 'string', description: 'Provider id under test.' } + }, + required: ['status', 'provider'], + additionalProperties: false + } + } +}; + +const cases = loadProviderDefaultCases().concat(loadProfileCases()); +const selected = cases.filter(testCase => { + const haystack = `${testCase.profile},${testCase.provider},${testCase.model || ''}`; + if (INCLUDE && !INCLUDE.test(haystack)) return false; + if (EXCLUDE && EXCLUDE.test(haystack)) return false; + return true; +}); + +const rows = await runWithConcurrency(selected, Math.max(1, CONCURRENCY), runCase); + +async function runCase(testCase) { + const baseRow = { + profile: testCase.profile, + provider: testCase.provider || '', + model: testCase.model || '', + api_format: providerConfig.models?.[testCase.provider]?.format || '', + base_url: providerConfig.models?.[testCase.provider]?.baseUrl || '', + key_name: keyNameFor(testCase.provider), + available: 'yes', + chat_status: 'not_run', + chat_elapsed_ms: '', + chat_reason: '', + tool_status: 'not_run', + tool_elapsed_ms: '', + tool_reason: '', + tool_name: '', + tool_arguments: '' + }; + + const label = `${testCase.profile} | ${testCase.provider} | ${testCase.model}`; + console.log(`[matrix] start ${label}`); + const availability = isAvailable(testCase); + if (!availability.ok) { + console.log(`[matrix] skip ${label}: ${availability.reason}`); + return { ...baseRow, available: 'no', chat_status: 'skip', chat_reason: availability.reason, tool_status: 'skip', tool_reason: availability.reason }; + } + + const [chatResult, toolResult] = await Promise.all([ + runChatCheck(testCase), + runToolCheck(testCase) + ]); + + const row = { + ...baseRow, + chat_status: chatResult.status, + chat_elapsed_ms: String(chatResult.elapsed_ms), + chat_reason: chatResult.reason, + tool_status: toolResult.status, + tool_elapsed_ms: String(toolResult.elapsed_ms), + tool_reason: toolResult.reason, + tool_name: toolResult.tool_name, + tool_arguments: toolResult.tool_arguments + }; + console.log(`[matrix] done ${label}: chat=${row.chat_status} tool=${row.tool_status}`); + return row; +} + +async function runChatCheck(testCase) { + const started = Date.now(); + try { + const model = createModel(selectAPI(testCase.profileModel)); + const response = await withTimeout( + model.sendRequest([{ role: 'user', content: 'Reply exactly: ok' }], 'You are running a live chat smoke test.', '***'), + TIMEOUT_MS + ); + const text = typeof response === 'string' ? response.trim() : JSON.stringify(response); + const ok = Boolean(text) && !isKnownFailureText(text) && !isNativeToolResponse(response); + return { + status: ok ? 'pass' : 'fail', + reason: ok ? preview(text, 160) : `bad chat response: ${preview(text, 220)}`, + elapsed_ms: Date.now() - started + }; + } catch (error) { + return { status: 'fail', reason: sanitizeError(error), elapsed_ms: Date.now() - started }; + } +} + +async function runToolCheck(testCase) { + const started = Date.now(); + try { + const model = createModel(selectAPI(testCase.profileModel)); + const response = await withTimeout( + model.sendRequest( + [{ role: 'user', content: `Call report_status with status ok and provider ${testCase.provider || testCase.profile}. Do not answer in text.` }], + 'You are running a live native function-calling smoke test. Use the provided function.', + '***', + [tool] + ), + TIMEOUT_MS + ); + if (!isNativeToolResponse(response)) { + return { + status: 'fail', + reason: `no native tool response: ${preview(response, 220)}`, + elapsed_ms: Date.now() - started, + tool_name: '', + tool_arguments: '' + }; + } + const call = response.tool_calls?.[0]; + const args = safeParse(call?.arguments); + const valid = call?.name === 'report_status' && args && typeof args === 'object' && args.status === 'ok'; + return { + status: valid ? 'pass' : 'fail', + reason: valid ? '' : 'tool call arguments missing status=ok or wrong tool name', + elapsed_ms: Date.now() - started, + tool_name: call?.name || '', + tool_arguments: typeof args === 'string' ? args : JSON.stringify(args || {}) + }; + } catch (error) { + return { status: 'fail', reason: sanitizeError(error), elapsed_ms: Date.now() - started, tool_name: '', tool_arguments: '' }; + } +} + +async function runWithConcurrency(items, concurrency, worker) { + const results = new Array(items.length); + let nextIndex = 0; + const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => { + while (nextIndex < items.length) { + const index = nextIndex++; + results[index] = await worker(items[index], index); + } + }); + await Promise.all(workers); + return results; +} + +mkdirSync(OUTPUT_DIR, { recursive: true }); +writeFileSync(OUTPUT_FILE, toCsv(rows)); +const summary = summarize(rows); +console.log(JSON.stringify({ output: OUTPUT_FILE, selected: selected.length, summary }, null, 2)); + +function loadProviderDefaultCases() { + return Object.entries(providerConfig.models || {}) + .filter(([, config]) => typeof config.defaultModel === 'string' && config.defaultModel.length > 0) + .map(([provider, config]) => ({ + profile: `@provider-default/${provider}`, + provider, + model: config.defaultModel, + profileModel: { provider, model: config.defaultModel }, + parseError: null + })); +} + +function loadProfileCases() { + const out = []; + for (const file of readdirSync(profilesDir).sort()) { + if (!file.endsWith('.json')) continue; + const fullPath = path.join(profilesDir, file); + let profile; + try { + profile = JSON.parse(readFileSync(fullPath, 'utf8')); + } catch (error) { + out.push({ profile: file, provider: '', model: '', profileModel: {}, parseError: error }); + continue; + } + const profileModel = profile.model; + if (!profileModel) continue; + let provider = ''; + let model = ''; + if (typeof profileModel === 'object') { + provider = profileModel.provider || ''; + model = profileModel.model || providerConfig.models?.[provider]?.defaultModel || ''; + } else if (typeof profileModel === 'string') { + const [maybeProvider, ...rest] = profileModel.split('/'); + provider = providerConfig.models?.[maybeProvider] ? maybeProvider : ''; + model = rest.length > 0 ? rest.join('/') : profileModel; + } + out.push({ profile: file, provider, model, profileModel, parseError: null }); + } + return out; +} + +function keyNameFor(provider) { + return providerConfig.models?.[provider]?.keyName || ''; +} + +function isAvailable(testCase) { + if (testCase.parseError) return { ok: false, reason: `parse error: ${testCase.parseError.message}` }; + if (testCase.provider === 'codex') { + return hasCodexChatGPTAuth() ? { ok: true } : { ok: false, reason: 'Codex auth missing' }; + } + const keyName = keyNameFor(testCase.provider); + if (keyName && !hasKey(keyName)) return { ok: false, reason: `${keyName} missing` }; + return { ok: true }; +} + +function withTimeout(promise, timeoutMs) { + return Promise.race([ + promise, + new Promise((_, reject) => setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs)) + ]); +} + +function safeParse(value) { + try { + return parseToolArguments(value); + } catch { + return value; + } +} + +function isKnownFailureText(text) { + return [ + 'My brain disconnected, try again.', + 'No response received.', + 'No response from Claude.', + 'An unexpected error occurred, please try again.', + 'Azure deployment not found.', + 'Ollama Cloud rejected the request.' + ].some(marker => text.includes(marker)); +} + +function sanitizeError(error) { + const status = error?.status ? `status=${error.status} ` : ''; + const code = error?.code ? `code=${error.code} ` : ''; + const message = error?.error?.message || error?.message || String(error); + return `${status}${code}${message}` + .replace(/sk-[A-Za-z0-9_-]+/g, '[REDACTED_KEY]') + .replace(/Bearer\s+[A-Za-z0-9._-]+/g, 'Bearer [REDACTED_TOKEN]') + .slice(0, 500); +} + +function preview(value, max = 180) { + return String(typeof value === 'string' ? value : JSON.stringify(value)).replace(/\s+/g, ' ').slice(0, max); +} + +function toCsv(rows) { + const headers = ['profile', 'provider', 'model', 'api_format', 'base_url', 'key_name', 'available', 'chat_status', 'chat_elapsed_ms', 'chat_reason', 'tool_status', 'tool_elapsed_ms', 'tool_name', 'tool_arguments', 'tool_reason']; + return [headers.join(','), ...rows.map(row => headers.map(header => csvCell(row[header] ?? '')).join(','))].join('\n') + '\n'; +} + +function csvCell(value) { + const text = String(value); + return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; +} + +function summarize(rows) { + const total = rows.length; + const bothPass = rows.filter(r => r.chat_status === 'pass' && r.tool_status === 'pass').length; + const chatPass = rows.filter(r => r.chat_status === 'pass').length; + const toolPass = rows.filter(r => r.tool_status === 'pass').length; + const skipped = rows.filter(r => r.available === 'no').length; + const failed = rows.filter(r => r.available === 'yes' && (r.chat_status !== 'pass' || r.tool_status !== 'pass')).length; + return { total, bothPass, chatPass, toolPass, skipped, failed }; +} From 053e7686c87fc385713da19e04aa219cda16c3b2 Mon Sep 17 00:00:00 2001 From: NinotQuyi Date: Fri, 1 May 2026 19:54:18 +0800 Subject: [PATCH 11/56] Let agents execute native tool calls Native-tool capable model responses now flow through command schemas instead of relying on assistant text commands. Default prompts move into markdown files so native-mode hygiene tests can keep legacy command examples out of model-facing context. Constraint: Human !command parsing must remain available while AI responses use native tool calls Rejected: Teach models both native tools and text command syntax | mixed examples caused providers to emit legacy text commands in native mode Confidence: high Scope-risk: moderate Directive: Keep prompt markdown and schema generation aligned when adding or renaming commands Tested: npm test passed 49/49; rg glhf/GLHF/GHLF/glhf.chat returned no matches; git diff --cached --check Not-tested: Live Minecraft server interaction Co-authored-by: OmX --- profiles/defaults/_default.json | 419 ++++++++---------- .../prompts/_default/bot_responder.md | 12 + profiles/defaults/prompts/_default/coding.md | 8 + .../defaults/prompts/_default/conversing.md | 8 + .../prompts/_default/image_analysis.md | 1 + .../prompts/_default/saving_memory.md | 5 + settings.js | 22 +- src/agent/agent.js | 93 +++- src/agent/commands/index.js | 21 +- src/agent/commands/tool_adapter.js | 169 +++++++ src/models/prompter.js | 128 +++++- tests/agent_native_text_policy.test.js | 39 ++ tests/native_prompt_hygiene.test.js | 35 ++ tests/prompt_markdown_refs.test.js | 18 + tests/smoke/dev_native_tool_loop.js | 45 ++ 15 files changed, 749 insertions(+), 274 deletions(-) create mode 100644 profiles/defaults/prompts/_default/bot_responder.md create mode 100644 profiles/defaults/prompts/_default/coding.md create mode 100644 profiles/defaults/prompts/_default/conversing.md create mode 100644 profiles/defaults/prompts/_default/image_analysis.md create mode 100644 profiles/defaults/prompts/_default/saving_memory.md create mode 100644 src/agent/commands/tool_adapter.js create mode 100644 tests/agent_native_text_policy.test.js create mode 100644 tests/native_prompt_hygiene.test.js create mode 100644 tests/prompt_markdown_refs.test.js create mode 100644 tests/smoke/dev_native_tool_loop.js diff --git a/profiles/defaults/_default.json b/profiles/defaults/_default.json index 51a6f8ead..f7e2dfb51 100644 --- a/profiles/defaults/_default.json +++ b/profiles/defaults/_default.json @@ -1,18 +1,21 @@ { "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:", - - "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": "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:", - - "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", - + "conversing": { + "prompt_file": "prompts/_default/conversing.md" + }, + "coding": { + "prompt_file": "prompts/_default/coding.md" + }, + "saving_memory": { + "prompt_file": "prompts/_default/saving_memory.md" + }, + "bot_responder": { + "prompt_file": "prompts/_default/bot_responder.md" + }, + "image_analysis": { + "prompt_file": "prompts/_default/image_analysis.md" + }, "speak_model": "openai/tts-1/echo", - "modes": { "self_preservation": true, "unstuck": true, @@ -25,232 +28,184 @@ "idle_staring": true, "cheat": false }, - "conversation_examples": [ [ - {"role": "user", "content": "miner_32: Hey! What are you up to?"}, - {"role": "assistant", "content": "Nothing much miner_32, what do you need?"} - ], - - [ - {"role": "system", "content": "say hi to john_goodman"}, - {"role": "assistant", "content": "!startConversation(\"john_goodman\", \"Hey John\"))"}, - {"role": "user", "content": "john_goodman: (FROM OTHER BOT)Hey there! What's up?"}, - {"role": "assistant", "content": "Hey John, not much. Just saying hi."}, - {"role": "user", "content": "john_goodman: (FROM OTHER BOT)Bye!"}, - {"role": "assistant", "content": "Bye! !endConversation('john_goodman')"} - ], - - [ - {"role": "system", "content": "work together with the other bot to build a house"}, - {"role": "assistant", "content": "!startConversation(\"terrance\", \"Hey gpt! Let's work together to build a house. Let's build it at x:942, y:54, z:1355\"))"}, - {"role": "user", "content": "terrance: (FROM OTHER BOT)I can build the base, you can build the walls. !newAction(\"Build a 10x10 wall of a house at x:942, y:54, z:1355\")"} - ], - - [ - {"role": "system", "content": "trade something with zorro_34"}, - {"role": "assistant", "content": "!startConversation(\"zorro_34\", \"Hey let's trade. What do you have?\")"}, - {"role": "user", "content": "zorro_34: (FROM OTHER BOT)Let's see... !inventory\nI have a wooden pickaxe, cobblestone, and cooked chicken"}, - {"role": "assistant", "content": "Okay, could I trade you a cobblestone for a cooked chicken?"} - ], - - [ - {"role": "user", "content": "rupert: (FROM OTHER BOT)Let's collaborate to build a house!"}, - {"role": "assistant", "content": "Sure! I'll share information to help coordinate. Let's center the build at x:942, y:54, z:1355 !newAction(\"Build a 10x10 base of a house at location\")"}, - {"role": "user", "content": "rupert: (FROM OTHER BOT)Alright, I'll build the walls! !newAction('Build a 10x10 wall of a house at the same location')"} - ], - - [ - {"role": "user", "content": "gort: (FROM OTHER BOT)Let's do it, onward!"}, - {"role": "assistant", "content": "Alright, I'll start by getting some cobblestone. !collectBlocks('stone', 10)"} - ], - - [ - {"role": "user", "content": "derg: (FROM OTHER BOT)Thanks, see you later!"}, - {"role": "assistant", "content": "See you later. !endConversation(\"derg\")"} - ], - - [ - {"role": "user", "content": "d7297: look here"}, - {"role": "assistant", "content": "Okay! !lookAtPlayer(\"d7297\", \"at\")"}, - {"role": "system", "content": "Image analysis:\nLooking at player d7297. There is a giant waterfall behind d7297."}, - {"role": "assistant", "content": "Woo, that is a cool waterfall!"} - ], - [ - {"role": "user", "content": "d7297: look 0 70 12 and describe what is there"}, - {"role": "assistant", "content": "Sure! !lookAtPosition(0, 70, 12)"}, - {"role": "system", "content": "Image analysis:\nLooking at position 0, 70, 12. There is a water fountain."}, - {"role": "assistant", "content": "I see the water fountain! amazing!"} - ], - - [ - {"role": "user", "content": "greg: Collect 10 wood"}, - {"role": "assistant", "content": "Let me see what's nearby... !nearbyBlocks"}, - {"role": "system", "content": "NEARBY_BLOCKS\n- oak_log\n- dirt\n- cobblestone"}, - {"role": "assistant", "content": "I see some oak logs, dirt, and cobblestone. I'll collect oak logs. !collectBlocks(\"oak_log\", 10)"} - ], - - [ - {"role": "user", "content": "bobby: Craft a plank"}, - {"role": "assistant", "content": "Okay! !craftable"}, - {"role": "system", "content": "CRAFTABLE_ITEMS\n- spruce_planks\n- spruce_wood\n- ladder"}, - {"role": "assistant", "content": "!craftRecipe(\"spruce_planks\", 1)"}, - {"role": "system", "content": "Code Output:\nYou have crafted 4 spruce_planks.\nCode execution finished successfully."}, - {"role": "assistant", "content": "I've crafted 4 spruce planks!"} - ], - - [ - {"role": "user", "content": "zZZn98: come here"}, - {"role": "assistant", "content": "On my way! !goToPlayer(\"zZZn98\", 3)"}, - {"role": "system", "content": "Arrived at player."}, - {"role": "assistant", "content": "Here!"}, - {"role": "user", "content": "zZZn98: no come right where I am"}, - {"role": "assistant", "content": "Okay, I'll come right to you. !goToPlayer(\"zZZn98\", 0)"} - ], - - [ - {"role": "user", "content": "hanky: collect some cobblestone"}, - {"role": "assistant", "content": "Sure, I can do that by collecting stone. !collectBlocks(\"stone\", 10)"} - ], - - [ - {"role": "user", "content": "joe: Follow me!"}, - {"role": "assistant", "content": "Sure! !followPlayer(\"joe\", 4)"}, - {"role": "user", "content": "joe: Not so close!"}, - {"role": "assistant", "content": "Okay, I'll keep my distance. !followPlayer(\"joe\", 3)"} - ], - - [ - {"role": "user", "content": "hanky: kill that zombie!"}, - {"role": "assistant", "content": "I'm attacking! !attack(\"zombie\")"}, - {"role": "system", "content": "Code Output:\nNo zombie nearby"}, - {"role": "assistant", "content": "I could not find a zombie nearby."} - ], - - [ - {"role": "user", "content": "abc: stop"}, - {"role": "assistant", "content": "Sure. !stop"}, - {"role": "system", "content": "Agent action stopped."}, - {"role": "assistant", "content": "I've stopped! What next?"} - ], - - [ - {"role": "system", "content": "Code output: Action failed."}, - {"role": "assistant", "content": "Apologies, I made an error. Let me try again. !newAction(\"Try again to complete the task.\")"} - ], - - [ - {"role": "system", "content": "Code output: Could not find any oak_log in 20 blocks."}, - {"role": "assistant", "content": "I'll have to search a wider area. !searchForBlock(\"oak_log\", 100)"} - ], - - [ - {"role": "system", "content": "Code output: Placed oak_planks at (-154, 96, -228)."}, - {"role": "assistant", "content": "I've placed an oak plank for the house. Wait, I should use newAction to build complicated structures! !newAction(\"Build a house with oak planks\")"} - ], - - [ - {"role": "user", "content": "trixy88: craft some sticks"}, - {"role": "assistant", "content": "!craftRecipe(\"stick\", 4)"}, - {"role": "system", "content": "Code Output:\nYou have crafted 16 sticks.\nCode execution finished successfully."}, - {"role": "assistant", "content": "I've crafted 16 sticks!"} - ], - - [ - {"role": "user", "content": "poppy: build a cobblestone house"}, - {"role": "assistant", "content": "Sure, I'll to build a small cobblestone house where I am. !newAction(\"Build a cobblestone house with a door, furnishings, and window.\")"} - ], - - [ - {"role": "user", "content": "zib: build a dirt tower"}, - {"role": "assistant", "content": "Sure, I'll build a dirt tower 5 tall where I am. !newAction(\"Build a dirt tower 5 tall.\")"} - ], - - [ - {"role": "user", "content": "umpire_man: follow me without hunting"}, - {"role": "assistant", "content": "Okay, first I'll turn off hunting mode !setMode(\"hunting\", false)"}, - {"role": "system", "content": "'hunting' mode disabled."}, - {"role": "assistant", "content": "Now I'll follow you !followPlayer(\"umpire_man\")"} - ], - - [ - {"role": "user", "content": "brug: Remember that your base is here."}, - {"role": "assistant", "content": "Sure, I'll save this location as my base. !rememberHere(\"base\")"}, - {"role": "user", "content": "brug: Go to your base."}, - {"role": "assistant", "content": "On my way! !goToRememberedPlace(\"base\")"} - ], - - [ - {"role": "user", "content": "user43: Set a goal to survive on your own forever."}, - {"role": "assistant", "content": "Sure, let me set a goal. !goal(\"Survive the game forever. Check your surroundings, collect resources, upgrade tools, make sure you have food, build/find a home, get sleep at night, and collect food.\")"} - ], - - [ - {"role": "user", "content": "bobdylan: Make a plan to get a stone pickaxe and do it."}, - {"role": "assistant", "content": "Alright, since I have a wooden pickaxe already I'll plan from there. !goal(\"1. Collect 3 cobblestone. 2. Craft sticks 3. Find or make a crafting table. 4. Craft a stone pickaxe.\")"} - ], - [ - {"role": "system", "content": "You are self-prompting with the goal: \"Get a wooden pickaxe\". Respond:"}, - {"role": "assistant", "content": "What's around me? !nearbyBlocks"}, - {"role": "system", "content": "NEARBY_BLOCKS\n- oak_log\n- dirt\n- cobblestone"}, - {"role": "assistant", "content": "I see some oak logs, dirt, and cobblestone nearby. My goal is to craft a wooden pickaxe. I'll start by collecting some wood. !collectBlock(\"oak_log\", 10)"}, - {"role": "system", "content": "Successfully collected 9 wood."}, - {"role": "assistant", "content": "I collected 9 oak logs. I need a pickaxe first, which requires planks and sticks. !craftItem(\"oak_plank\", 9)"}, - {"role": "system", "content": "Successfully crafted 9 oak_planks."}, - {"role": "assistant", "content": "I have crafted 9 oak planks. Now I need sticks. !craftItem(\"stick\", 2)"}, - {"role": "system", "content": "Successfully crafted 2 sticks."}, - {"role": "assistant", "content": "I have crafted 2 sticks. Now I can craft a wooden pickaxe. !craftItem(\"wooden_pickaxe\", 1)"}, - {"role": "system", "content": "Successfully crafted 1 wooden_pickaxe."}, - {"role": "assistant", "content": "Looks like I have a wooden pickaxe now! Goal accomplished. !endGoal"} - ], - [ - {"role": "system", "content": "set a goal to build whatever you want endlessly (context: you are in creative mode)"}, - {"role": "assistant", "content": "!goal(\"Build something creative, then move over, and build again. Use separate newActions. Repeat forever.\")"} - ], - [ - {"role": "system", "content": "You are self-prompting with the goal: 'Build a house'. Respond:"}, - {"role": "assistant", "content": "Alright, lets start with the basic structure. !newAction(\"Build an 8x8 base for the house.\")"} + { + "role": "user", + "content": "miner_32: Hey! What are you up to?" + }, + { + "role": "assistant", + "content": "Nothing much miner_32, what do you need?" + } + ], + [ + { + "role": "user", + "content": "alex: Can you help me gather wood?" + }, + { + "role": "assistant", + "content": "On it. I will use the right tool to gather wood." + } + ], + [ + { + "role": "user", + "content": "sam: Please stop what you are doing and come back." + }, + { + "role": "assistant", + "content": "Okay, I will stop and head back." + } + ], + [ + { + "role": "user", + "content": "builder: What do you see nearby?" + }, + { + "role": "assistant", + "content": "I will check my surroundings." + } + ], + [ + { + "role": "user", + "content": "farmer: Do you have food?" + }, + { + "role": "assistant", + "content": "I will check my inventory." + } + ], + [ + { + "role": "user", + "content": "rupert: Let us work together on a small house." + }, + { + "role": "assistant", + "content": "Sure, I can coordinate with you and handle my part." + }, + { + "role": "user", + "content": "rupert: I will build the walls if you prepare materials." + }, + { + "role": "assistant", + "content": "Sounds good. I will prepare materials first." + } ] ], - "coding_examples": [ [ - {"role": "user", "content": "greg: Collect 10 wood"}, - {"role": "assistant", "content": "```await skills.collectBlock(bot, 'oak_log', 10);\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": "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": "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": "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": "system", "content": "Successfully placed 5 dirt."}, - {"role": "assistant", "content": "I built a little tower!"} - ], - [ - {"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": "user", + "content": "greg: Collect 10 wood" + }, + { + "role": "assistant", + "content": "```await skills.collectBlock(bot, 'oak_log', 10);\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": "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": "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": "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": "system", + "content": "Successfully placed 5 dirt." + }, + { + "role": "assistant", + "content": "I built a little tower!" + } + ], + [ + { + "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```" + } ] - ] - } diff --git a/profiles/defaults/prompts/_default/bot_responder.md b/profiles/defaults/prompts/_default/bot_responder.md new file mode 100644 index 000000000..d79dbd376 --- /dev/null +++ b/profiles/defaults/prompts/_default/bot_responder.md @@ -0,0 +1,12 @@ +You are a Minecraft bot named $NAME that is currently in conversation with another AI bot. Both of you may be taking actions through native tools/function calls, 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. Do not write command syntax or pretend to call tools in this decision. +Example 1: You: Building a house. +Other Bot: 'Come here!' +Your decision: ignore +Example 2: You: Collecting dirt. +Other Bot: 'No, collect some wood instead.' +Your decision: respond +Example 3: You: Coming to you now. +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: diff --git a/profiles/defaults/prompts/_default/coding.md b/profiles/defaults/prompts/_default/coding.md new file mode 100644 index 000000000..6d1d9f401 --- /dev/null +++ b/profiles/defaults/prompts/_default/coding.md @@ -0,0 +1,8 @@ +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! +$SELF_PROMPT +Summarized memory:'$MEMORY' +$STATS +$INVENTORY +$CODE_DOCS +$EXAMPLES +Conversation: diff --git a/profiles/defaults/prompts/_default/conversing.md b/profiles/defaults/prompts/_default/conversing.md new file mode 100644 index 000000000..c4948f83f --- /dev/null +++ b/profiles/defaults/prompts/_default/conversing.md @@ -0,0 +1,8 @@ +You are an AI Minecraft bot named $NAME that can converse with players, see, move, mine, build, and interact with the world by using native tools/function calls. +$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; when you need to act, call the appropriate native tool/function instead of writing command text. Do NOT write command text like '!stop', function-looking text like collectBlocks(...), or log text like '*used collectBlocks*' in your message; call the native tool and optionally respond briefly after the tool result. 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 ' '. This is extremely important to me, take a deep breath and have fun :) +Summarized memory:'$MEMORY' +$STATS +$INVENTORY +$COMMAND_DOCS +$EXAMPLES +Conversation Begin: diff --git a/profiles/defaults/prompts/_default/image_analysis.md b/profiles/defaults/prompts/_default/image_analysis.md new file mode 100644 index 000000000..1e370a9ae --- /dev/null +++ b/profiles/defaults/prompts/_default/image_analysis.md @@ -0,0 +1 @@ +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 diff --git a/profiles/defaults/prompts/_default/saving_memory.md b/profiles/defaults/prompts/_default/saving_memory.md new file mode 100644 index 000000000..32447f8c2 --- /dev/null +++ b/profiles/defaults/prompts/_default/saving_memory.md @@ -0,0 +1,5 @@ +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. +Old Memory: '$MEMORY' +Recent conversation: +$TO_SUMMARIZE +Summarize your old memory and recent conversation into a new memory, and respond only with the unwrapped memory text: diff --git a/settings.js b/settings.js index 2ea4dee4d..02cabe6ad 100644 --- a/settings.js +++ b/settings.js @@ -9,25 +9,21 @@ const settings = { "auto_open_ui": true, // opens UI in browser on startup "base_profile": "assistant", // survival, assistant, creative, or god_mode + "llm_providers": "llm_providers.json", // project-level LLM keys/model/embedding registry "profiles": [ - "./andy.json", - // "./profiles/gpt.json", - // "./profiles/claude.json", - // "./profiles/gemini.json", - // "./profiles/llama.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! + // Protocol representative native-tool smoke profiles. + "profiles/gpt.json", // openai-responses -> openai:gpt-5.5 + "profiles/codex.json", // openai-codex-responses -> codex:gpt-5.5 + "profiles/openrouter.json", // openai-completions -> openrouter:moonshotai/kimi-k2.6 + "profiles/kimi.json", // anthropic-messages -> kimi:kimi-k2.6 + "profiles/gemini.json", // google-generative-ai -> google:gemini-3-flash-preview // 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 - "init_message": "Respond with hello world and your name", // sends to all on spawn + "init_message": "Use native tool calling to check your inventory, then say: native tool check complete.", // 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 "speak": false, @@ -40,7 +36,7 @@ const settings = { "language": "en", // translate to/from this language. Supports these language names: https://cloud.google.com/translate/docs/languages "render_bot_view": false, // show bot's view in browser at localhost:3000, 3001... - "allow_insecure_coding": false, // allows newAction command and model can write/run code on your computer. enable at own risk + "allow_insecure_coding": true, // allows newAction command and model can write/run code on your computer. enable at own risk "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 diff --git a/src/agent/agent.js b/src/agent/agent.js index 5f5bf31c0..99d705ea3 100644 --- a/src/agent/agent.js +++ b/src/agent/agent.js @@ -5,6 +5,8 @@ import { Prompter } from '../models/prompter.js'; import { initModes } from './modes.js'; import { initBot } from '../utils/mcdata.js'; import { containsCommand, commandExists, executeCommand, truncCommandMessage, isAction, blacklistCommands } from './commands/index.js'; +import { executeCommandToolCall } from './commands/tool_adapter.js'; +import { isNativeToolResponse } from '../models/native_tools.js'; import { ActionManager } from './action_manager.js'; import { NPCContoller } from './npc/controller.js'; import { MemoryBank } from './memory_bank.js'; @@ -166,7 +168,7 @@ export class Agent { console.log(this.name, 'received message from', username, ':', message); if (convoManager.isOtherAgent(username)) { - console.warn('received whisper from other bot??') + console.warn('received whisper from other bot??'); } else { let translation = await handleEnglishTranslation(message); @@ -175,7 +177,7 @@ export class Agent { } catch (error) { console.error('Error handling message:', error); } - } + }; this.respondFunc = respondFunc; @@ -319,22 +321,38 @@ export class Agent { let history = this.history.getHistory(); let res = await this.prompter.promptConvo(history); + if (isNativeToolResponse(res)) { + console.log(`${this.name} native tool calls from ${source}: ${formatNativeToolCallsForLog(res.tool_calls)}`); + + if (checkInterrupt()) break; + const executedAny = await this._executeNativeToolCalls(res, source, self_prompt); + if (!executedAny) break; + used_command = true; + this.history.save(); + continue; + } + console.log(`${this.name} full response to ${source}: ""${res}""`); if (res.trim().length === 0) { - console.warn('no response') + console.warn('no response'); break; // empty response ends loop } let command_name = containsCommand(res); if (command_name) { // contains query or command + if (this.prompter.isNativeToolMode()) { + this.history.add('system', `The assistant attempted to write text command ${command_name}, but it was not executed. AI actions must use native tool calls; human !command syntax is still supported.`); + console.warn('Agent produced text command while native tool mode is enabled:', command_name); + continue; + } res = truncCommandMessage(res); // everything after the command is ignored this.history.add(this.name, res); if (!commandExists(command_name)) { this.history.add('system', `Command ${command_name} does not exist.`); - console.warn('Agent hallucinated command:', command_name) + console.warn('Agent hallucinated command:', command_name); continue; } @@ -381,6 +399,38 @@ export class Agent { return used_command; } + async _executeNativeToolCalls(nativeToolResponse, source, self_prompt) { + let executedAny = false; + for (const toolCall of nativeToolResponse.tool_calls) { + const commandName = toolCall.name ? (toolCall.name.startsWith('!') ? toolCall.name : `!${toolCall.name}`) : null; + if (!commandName || !commandExists(commandName)) { + const msg = `Native tool ${toolCall.name || ''} does not map to a command.`; + this.history.add('system', msg); + console.warn(msg); + continue; + } + + this.self_prompter.handleUserPromptedCmd(self_prompt, isAction(commandName)); + const display = `*used ${toolCall.name}*`; + this.history.add(this.name, display); + + if (settings.show_command_syntax !== "none") { + this.routeResponse(source, display); + } + + console.log(`[native-tool] calling ${commandName} args=${formatToolArgsForLog(toolCall.arguments)}`); + const execute_res = await executeCommandToolCall(this, toolCall); + console.log(`[native-tool] ${commandName} result=${formatToolResultForLog(execute_res.result)}`); + executedAny = true; + + this.history.add('system', `Native tool call completed: ${toolCall.name}.`); + if (execute_res.result) { + this.history.add('system', execute_res.result); + } + } + return executedAny; + } + async routeResponse(to_player, message) { if (this.shut_up) return; let self_prompt = to_player === 'system' || to_player === this.name; @@ -551,3 +601,38 @@ export class Agent { serverProxy.shutdown(); } } + +function formatNativeToolCallsForLog(toolCalls = []) { + if (!Array.isArray(toolCalls) || toolCalls.length === 0) { + return ''; + } + return toolCalls + .map((call, index) => `${index + 1}. ${call.name || ''}(${formatToolArgsForLog(call.arguments)})`) + .join('; '); +} + +function formatToolArgsForLog(args) { + if (args == null || args === '') return '{}'; + if (typeof args === 'string') { + try { + return truncateForLog(JSON.stringify(JSON.parse(args))); + } catch { + return truncateForLog(args); + } + } + try { + return truncateForLog(JSON.stringify(args)); + } catch { + return truncateForLog(String(args)); + } +} + +function formatToolResultForLog(result) { + if (result == null || result === '') return ''; + return truncateForLog(typeof result === 'string' ? result : JSON.stringify(result)); +} + +function truncateForLog(value, max = 500) { + const text = String(value); + return text.length > max ? `${text.slice(0, max)}...` : text; +} diff --git a/src/agent/commands/index.js b/src/agent/commands/index.js index 7ada04088..536048855 100644 --- a/src/agent/commands/index.js +++ b/src/agent/commands/index.js @@ -4,7 +4,7 @@ import { queryList } from './queries.js'; let suppressNoDomainWarning = true; -const commandList = queryList.concat(actionsList); +export const commandList = queryList.concat(actionsList); const commandMap = {}; for (let command of commandList) { commandMap[command.name] = command; @@ -26,7 +26,7 @@ export function blacklistCommands(commands) { } } -const commandRegex = /!(\w+)(?:\(((?:-?\d+(?:\.\d+)?|true|false|"[^"]*")(?:\s*,\s*(?:-?\d+(?:\.\d+)?|true|false|"[^"]*"))*)\))?/ +const commandRegex = /!(\w+)(?:\(((?:-?\d+(?:\.\d+)?|true|false|"[^"]*")(?:\s*,\s*(?:-?\d+(?:\.\d+)?|true|false|"[^"]*"))*)\))?/; const argRegex = /-?\d+(?:\.\d+)?|true|false|"[^"]*"/g; export function containsCommand(message) { @@ -81,7 +81,7 @@ function checkInInterval(number, lowerBound, upperBound, endpointType) { case '[]': return lowerBound <= number && number <= upperBound; default: - throw new Error('Unknown endpoint type:', endpointType) + throw new Error('Unknown endpoint type:', endpointType); } } @@ -105,7 +105,7 @@ export function parseCommandMessage(message) { else args = []; const command = getCommand(commandName); - if(!command) return `${commandName} is not a command.` + if(!command) return `${commandName} is not a command.`; const params = commandParams(command); const paramNames = commandParamNames(command); @@ -135,13 +135,14 @@ export function parseCommandMessage(message) { case 'ItemName': if (arg.endsWith('plank') || arg.endsWith('seed')) arg += 's'; // add 's' to for common mistakes like "oak_plank" or "wheat_seed" + // falls through case 'string': break; default: throw new Error(`Command '${commandName}' parameter '${paramNames[i]}' has an unknown type: ${param.type}`); } if(arg === null || Number.isNaN(arg)) - return `Error: Param '${paramNames[i]}' must be of type ${param.type}.` + return `Error: Param '${paramNames[i]}' must be of type ${param.type}.`; if(typeof arg === 'number') { //Check the domain of numbers const domain = param.domain; @@ -157,15 +158,15 @@ export function parseCommandMessage(message) { //Alternatively arg could be set to the nearest value in the domain. } } else if (!suppressNoDomainWarning) { - console.warn(`Command '${commandName}' parameter '${paramNames[i]}' has no domain set. Expect any value [-Infinity, Infinity].`) + console.warn(`Command '${commandName}' parameter '${paramNames[i]}' has no domain set. Expect any value [-Infinity, Infinity].`); suppressNoDomainWarning = true; //Don't spam console. Only give the warning once. } } else if(param.type === 'BlockName') { //Check that there is a block with this name - if(getBlockId(arg) == null) return `Invalid block type: ${arg}.` + if(getBlockId(arg) == null) return `Invalid block type: ${arg}.`; } else if(param.type === 'ItemName') { //Check that there is an item with this name - if(getItemId(arg) == null) return `Invalid item type: ${arg}.` + if(getItemId(arg) == null) return `Invalid item type: ${arg}.`; } else if(param.type === 'BlockOrItemName') { - if(getBlockId(arg) == null && getItemId(arg) == null) return `Invalid block or item type: ${arg}.` + if(getBlockId(arg) == null && getItemId(arg) == null) return `Invalid block or item type: ${arg}.`; } args[i] = arg; } @@ -239,7 +240,7 @@ export function getCommandDocs(agent) { 'ItemName': 'string', 'BlockOrItemName': 'string', 'boolean': 'bool' - } + }; let docs = `\n*COMMAND DOCS\n You can use the following commands to perform actions and get information about the world. Use the commands with the syntax: !commandName or !commandName("arg1", 1.2, ...) if the command takes arguments.\n Do not use codeblocks. Use double quotes for strings. Only use one command in each response, trailing commands and comments will be ignored.\n`; diff --git a/src/agent/commands/tool_adapter.js b/src/agent/commands/tool_adapter.js new file mode 100644 index 000000000..774115f60 --- /dev/null +++ b/src/agent/commands/tool_adapter.js @@ -0,0 +1,169 @@ +import { commandList } from './index.js'; +import { parseToolArguments } from '../../models/native_tools.js'; + +const TYPE_MAP = { + int: 'integer', + float: 'number', + boolean: 'boolean', + BlockName: 'string', + ItemName: 'string', + BlockOrItemName: 'string', + string: 'string' +}; + +export function commandNameToToolName(commandName) { + return commandName.startsWith('!') ? commandName.slice(1) : commandName; +} + +export function toolNameToCommandName(toolName) { + return toolName.startsWith('!') ? toolName : `!${toolName}`; +} + +export function commandToToolDefinition(command) { + const properties = {}; + const required = []; + for (const [name, param] of Object.entries(command.params || {})) { + properties[name] = paramToJsonSchema(param); + if (!param.optional) { + required.push(name); + } + } + return { + type: 'function', + function: { + name: commandNameToToolName(command.name), + description: command.description || command.name, + parameters: { + type: 'object', + properties, + required, + additionalProperties: false + } + } + }; +} + +export function getCommandToolDefinitions(agent = null) { + const commands = getAvailableCommands(agent); + return commands.map(commandToToolDefinition); +} + +export function getNativeToolDocs(agent = null) { + const commands = getAvailableCommands(agent); + let docs = '\n*NATIVE TOOL DOCS\n'; + docs += 'You can use native function/tool calls to act in the Minecraft world and query state. Human users may type !commands, but AI responses must not output !command text. Use the provided tools instead.\n'; + for (const command of commands) { + docs += `${commandNameToToolName(command.name)}: ${command.description}\n`; + if (command.params) { + docs += 'Params:\n'; + for (const [name, param] of Object.entries(command.params)) { + docs += `${name}: (${TYPE_MAP[param.type] || 'string'}) ${param.description || ''}\n`; + } + } + } + return docs + '*\n'; +} + +export async function executeCommandToolCall(agent, toolCall, commands = commandList) { + const name = toolCall.name || toolCall.function?.name; + if (!name) { + return { ok: false, name: null, commandName: null, result: 'Tool call missing function name.' }; + } + const commandName = toolNameToCommandName(name); + const command = commands.find(candidate => candidate.name === commandName); + if (!command) { + return { ok: false, name, commandName, result: `Command ${commandName} does not exist.` }; + } + + let parsedArgs; + try { + parsedArgs = parseToolArguments(toolCall.arguments ?? toolCall.function?.arguments); + } catch (error) { + return { ok: false, name, commandName, result: error.message }; + } + + const params = Object.entries(command.params || {}); + const args = []; + for (const [paramName, param] of params) { + if (!(paramName in parsedArgs)) { + if (param.optional) { + args.push(undefined); + continue; + } + return { ok: false, name, commandName, result: `Tool ${name} missing required parameter '${paramName}'.` }; + } + const coerced = coerceValue(parsedArgs[paramName], param); + if (coerced.error) { + return { ok: false, name, commandName, result: `Error: Param '${paramName}' ${coerced.error}` }; + } + args.push(coerced.value); + } + + try { + const result = await command.perform(agent, ...args); + return { ok: true, name, commandName, args: parsedArgs, result }; + } catch (error) { + return { ok: false, name, commandName, args: parsedArgs, result: `Command ${commandName} failed: ${error.message}` }; + } +} + +function getAvailableCommands(agent) { + const commands = commandList; + if (!agent?.blocked_actions) { + return commands; + } + return commands.filter(command => !agent.blocked_actions.includes(command.name)); +} + +function paramToJsonSchema(param) { + const schema = { + type: TYPE_MAP[param.type] || 'string', + description: param.description || '' + }; + if ((schema.type === 'number' || schema.type === 'integer') && Array.isArray(param.domain)) { + if (Number.isFinite(param.domain[0])) schema.minimum = param.domain[0]; + if (Number.isFinite(param.domain[1])) schema.maximum = param.domain[1]; + } + return schema; +} + +function coerceValue(value, param) { + switch (param.type) { + case 'int': { + const n = Number.parseInt(value); + if (Number.isNaN(n)) return { error: `must be an integer.` }; + return checkDomain(n, param); + } + case 'float': { + const n = Number.parseFloat(value); + if (Number.isNaN(n)) return { error: `must be a number.` }; + return checkDomain(n, param); + } + case 'boolean': { + if (typeof value === 'boolean') return { value }; + if (typeof value === 'string') { + if (['true', 't', '1', 'on'].includes(value.toLowerCase())) return { value: true }; + if (['false', 'f', '0', 'off'].includes(value.toLowerCase())) return { value: false }; + } + return { error: `must be a boolean.` }; + } + case 'BlockName': + case 'ItemName': + case 'BlockOrItemName': + case 'string': + return { value: String(value) }; + default: + return { error: `has unknown type ${param.type}.` }; + } +} + +function checkDomain(value, param) { + const domain = param.domain; + if (Array.isArray(domain)) { + const min = domain[0]; + const max = domain[1]; + if (Number.isFinite(min) && value < min) return { error: `must be >= ${min}.` }; + if (Number.isFinite(max) && value > max) return { error: `must be <= ${max}.` }; + } + return { value }; +} diff --git a/src/models/prompter.js b/src/models/prompter.js index d24b88b94..8e1d0da9b 100644 --- a/src/models/prompter.js +++ b/src/models/prompter.js @@ -1,6 +1,8 @@ import { readFileSync, mkdirSync, writeFileSync} from 'fs'; import { Examples } from '../utils/examples.js'; import { getCommandDocs } from '../agent/commands/index.js'; +import { getCommandToolDefinitions, getNativeToolDocs } from '../agent/commands/tool_adapter.js'; +import { isNativeToolResponse } from './native_tools.js'; import { SkillLibrary } from "../agent/library/skill_library.js"; import { stringifyTurns } from '../utils/text.js'; import { getCommand } from '../agent/commands/index.js'; @@ -8,10 +10,18 @@ import settings from '../agent/settings.js'; import { promises as fs } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; -import { selectAPI, createModel } from './_model_map.js'; +import { selectAPI, selectEmbeddingAPI, createModel } from './_model_map.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +const PROMPT_FILE_KEYS = [ + 'conversing', + 'coding', + 'saving_memory', + 'bot_responder', + 'image_analysis', + 'goal_setting' +]; export class Prompter { constructor(agent, profile) { @@ -42,6 +52,7 @@ export class Prompter { this.profile[key] = base_profile[key]; } // base overrides default, individual overrides base + resolvePromptFileRefs(this.profile, defaults_dir); this.convo_examples = null; this.coding_examples = null; @@ -79,7 +90,7 @@ export class Prompter { let embedding_model_profile = null; if (this.profile.embedding) { try { - embedding_model_profile = selectAPI(this.profile.embedding); + embedding_model_profile = selectEmbeddingAPI(this.profile.embedding); } catch (e) { embedding_model_profile = null; } @@ -88,7 +99,7 @@ export class Prompter { this.embedding_model = createModel(embedding_model_profile); } else { - this.embedding_model = createModel({api: chat_model_profile.api}); + this.embedding_model = null; } this.skill_libary = new SkillLibrary(agent, this.embedding_model); @@ -109,14 +120,21 @@ export class Prompter { return this.profile.modes; } + isNativeToolMode() { + return this.profile.use_native_tools !== false && Boolean(this.chat_model?.supportsNativeToolCalls); + } + async initExamples() { try { this.convo_examples = new Examples(this.embedding_model, settings.num_examples); this.coding_examples = new Examples(this.embedding_model, settings.num_examples); + const conversationExamples = this.isNativeToolMode() + ? sanitizeNativeToolExamples(this.profile.conversation_examples) + : this.profile.conversation_examples; // Wait for both examples to load before proceeding await Promise.all([ - this.convo_examples.load(this.profile.conversation_examples), + this.convo_examples.load(conversationExamples), this.coding_examples.load(this.profile.coding_examples), this.skill_libary.initSkillLibrary() ]).catch(error => { @@ -150,8 +168,10 @@ export class Prompter { if (prompt.includes('$ACTION')) { prompt = prompt.replaceAll('$ACTION', this.agent.actions.currentActionLabel); } - if (prompt.includes('$COMMAND_DOCS')) - prompt = prompt.replaceAll('$COMMAND_DOCS', getCommandDocs(this.agent)); + if (prompt.includes('$COMMAND_DOCS')) { + const docs = this.isNativeToolMode() ? getNativeToolDocs(this.agent) : this.getTextCommandFallbackDocs(); + prompt = prompt.replaceAll('$COMMAND_DOCS', docs); + } if (prompt.includes('$CODE_DOCS')) { const code_task_content = messages.slice().reverse().find(msg => msg.role !== 'system' && msg.content.includes('!newAction(') @@ -179,9 +199,9 @@ 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()); } @@ -222,17 +242,23 @@ export class Prompter { } let prompt = this.profile.conversing; - prompt = await this.replaceStrings(prompt, messages, this.convo_examples); + const promptMessages = this.isNativeToolMode() ? sanitizeNativeToolHistory(messages) : messages; + prompt = await this.replaceStrings(prompt, promptMessages, this.convo_examples); let generation; try { - generation = await this.chat_model.sendRequest(messages, prompt); + const tools = this.isNativeToolMode() ? getCommandToolDefinitions(this.agent) : null; + generation = await this.chat_model.sendRequest(promptMessages, prompt, '***', tools); + if (isNativeToolResponse(generation)) { + await this._saveLog(prompt, promptMessages, JSON.stringify(generation), 'conversation'); + return generation; + } if (typeof generation !== 'string') { console.error('Error: Generated response is not a string', generation); throw new Error('Generated response is not a string'); } console.log("Generated response:", generation); - await this._saveLog(prompt, messages, generation, 'conversation'); + await this._saveLog(prompt, promptMessages, generation, 'conversation'); } catch (error) { console.error('Error during message generation or file writing:', error); @@ -251,8 +277,8 @@ export class Prompter { } if (generation?.includes('')) { - const [_, afterThink] = generation.split('') - generation = afterThink + const [_, afterThink] = generation.split(''); + generation = afterThink; } return generation; @@ -261,6 +287,14 @@ export class Prompter { return ''; } + getTextCommandFallbackDocs() { + const docs = getCommandDocs(this.agent); + if (this.profile.use_native_tools === false) { + return docs; + } + return '\n*NATIVE TOOL FALLBACK WARNING\nThis model adapter does not advertise native tool calling support, so Mindcraft is temporarily falling back to text !command syntax for AI actions. Prefer a native-tool-capable provider when available. Human users may still type !commands.*\n' + docs; + } + async promptCoding(messages) { if (this.awaiting_coding) { console.warn('Already awaiting coding response, returning no response.'); @@ -284,7 +318,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; @@ -313,7 +347,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}]; @@ -364,3 +398,67 @@ export class Prompter { await fs.appendFile(logFile, String(logEntry), 'utf-8'); } } + +export function sanitizeNativeToolExamples(examples = []) { + if (!Array.isArray(examples)) return []; + return examples.filter(example => + Array.isArray(example) && !example.some(turn => containsLegacyToolSyntax(turn?.content)) + ); +} + +export function sanitizeNativeToolHistory(turns = []) { + if (!Array.isArray(turns)) return []; + return turns.map(turn => { + if (turn?.role !== 'assistant' || typeof turn.content !== 'string') { + return { ...turn }; + } + return { ...turn, content: sanitizeNativeToolAssistantContent(turn.content) }; + }); +} + +function sanitizeNativeToolAssistantContent(content) { + const usedTool = content.trim().match(/^\*used\s+([A-Za-z_][A-Za-z0-9_-]*)\*$/i); + if (usedTool) { + return `Used native tool ${usedTool[1]}.`; + } + + if (!containsLegacyToolSyntax(content)) { + return content; + } + + const beforeCommand = content.split(/(^|\s)![A-Za-z_][A-Za-z0-9_]*\b/)[0].trim(); + if (beforeCommand.length > 0) { + return beforeCommand; + } + return 'Requested an action using legacy text command syntax; use native tool calls for actions.'; +} + +function containsLegacyToolSyntax(content) { + if (typeof content !== 'string') return false; + return /(^|\s)![A-Za-z_][A-Za-z0-9_]*\b|\*used\s+[A-Za-z_][A-Za-z0-9_]*\*/i.test(content); +} + +function resolvePromptFileRefs(profile, defaultBaseDir) { + for (const key of PROMPT_FILE_KEYS) { + const value = profile[key]; + const promptPath = getPromptPath(value); + if (!promptPath) continue; + profile[key] = readFileSync(resolvePromptPath(promptPath, defaultBaseDir), 'utf8'); + } +} + +function getPromptPath(value) { + if (!value || typeof value !== 'object') return null; + return value.prompt_file || value.file || value.path || null; +} + +function resolvePromptPath(promptPath, defaultBaseDir) { + if (path.isAbsolute(promptPath)) return promptPath; + const defaultRelativePath = path.join(defaultBaseDir, promptPath); + try { + readFileSync(defaultRelativePath, 'utf8'); + return defaultRelativePath; + } catch { + return path.resolve(promptPath); + } +} diff --git a/tests/agent_native_text_policy.test.js b/tests/agent_native_text_policy.test.js new file mode 100644 index 000000000..3749ebf6d --- /dev/null +++ b/tests/agent_native_text_policy.test.js @@ -0,0 +1,39 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { sanitizeNativeToolHistory } from '../src/models/prompter.js'; + +test('agent blocks AI text commands in native tool mode without storing the bad response as assistant history', () => { + const agentSource = readFileSync('src/agent/agent.js', 'utf8'); + + assert.ok(agentSource.includes('if (this.prompter.isNativeToolMode())')); + assert.ok(agentSource.includes('The assistant attempted to write text command ${command_name}, but it was not executed')); + assert.ok(agentSource.includes('AI actions must use native tool calls')); + assert.ok(agentSource.includes('continue;')); + assert.equal(agentSource.includes('this.history.add(this.name, res);\n this.history.add(\'system\''), false); +}); + +test('native tool execution keeps a history marker plus the tool result for continuity', () => { + const agentSource = readFileSync('src/agent/agent.js', 'utf8'); + const nativeSection = agentSource.slice(agentSource.indexOf('async _executeNativeToolCalls')); + + assert.ok(nativeSection.includes('this.history.add(this.name, display)')); + assert.ok(nativeSection.includes('Native tool call completed: ${toolCall.name}.')); + assert.ok(nativeSection.includes('this.history.add(\'system\', execute_res.result)')); +}); + +test('native prompt history sanitizes legacy tool markers before sending history to the model', () => { + const sanitized = sanitizeNativeToolHistory([ + { role: 'assistant', content: '*used collectBlocks*' }, + { role: 'system', content: 'Collected 3 oak logs.' }, + { role: 'assistant', content: 'Sure! !craftRecipe("stick", 4)' }, + { role: 'user', content: 'player: thanks' } + ]); + + assert.deepEqual(sanitized, [ + { role: 'assistant', content: 'Used native tool collectBlocks.' }, + { role: 'system', content: 'Collected 3 oak logs.' }, + { role: 'assistant', content: 'Sure!' }, + { role: 'user', content: 'player: thanks' } + ]); +}); diff --git a/tests/native_prompt_hygiene.test.js b/tests/native_prompt_hygiene.test.js new file mode 100644 index 000000000..18300ceb8 --- /dev/null +++ b/tests/native_prompt_hygiene.test.js @@ -0,0 +1,35 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { sanitizeNativeToolExamples } from '../src/models/prompter.js'; + +const legacyToolSyntax = /(^|\s)![A-Za-z_][A-Za-z0-9_]*\b|\*used\s+[A-Za-z_][A-Za-z0-9_]*\*/i; + +test('default native conversation examples do not teach legacy text tool syntax', () => { + const profile = JSON.parse(readFileSync('profiles/defaults/_default.json', 'utf8')); + const legacyTurns = profile.conversation_examples + .flat() + .filter(turn => legacyToolSyntax.test(turn.content || '')); + + assert.deepEqual(legacyTurns, []); +}); + +test('native prompt markdown rejects text-command and fake tool-call examples', () => { + const conversing = readFileSync('profiles/defaults/prompts/_default/conversing.md', 'utf8'); + const botResponder = readFileSync('profiles/defaults/prompts/_default/bot_responder.md', 'utf8'); + + assert.match(conversing, /native tool\/function/i); + assert.match(conversing, /Do NOT write command text/i); + assert.match(conversing, /\*used collectBlocks\*/); + assert.doesNotMatch(botResponder, legacyToolSyntax); +}); + +test('native example sanitizer drops any profile examples with legacy tool syntax', () => { + const examples = [ + [{ role: 'user', content: 'hello' }, { role: 'assistant', content: 'hi' }], + [{ role: 'user', content: 'collect wood' }, { role: 'assistant', content: '!collectBlocks("oak_log", 3)' }], + [{ role: 'assistant', content: '*used craftRecipe*' }] + ]; + + assert.deepEqual(sanitizeNativeToolExamples(examples), [examples[0]]); +}); diff --git a/tests/prompt_markdown_refs.test.js b/tests/prompt_markdown_refs.test.js new file mode 100644 index 000000000..2d31b2cb9 --- /dev/null +++ b/tests/prompt_markdown_refs.test.js @@ -0,0 +1,18 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +const defaultsDir = path.resolve('profiles/defaults'); +const promptKeys = ['conversing', 'coding', 'saving_memory', 'bot_responder', 'image_analysis']; + +test('default profile keeps editable prompt text in markdown files', () => { + const profile = JSON.parse(readFileSync(path.join(defaultsDir, '_default.json'), 'utf8')); + for (const key of promptKeys) { + assert.equal(typeof profile[key], 'object', `${key} should be a prompt reference object`); + assert.equal(typeof profile[key].prompt_file, 'string', `${key} should reference a markdown file`); + assert.match(profile[key].prompt_file, /\.md$/); + const markdown = readFileSync(path.join(defaultsDir, profile[key].prompt_file), 'utf8'); + assert.ok(markdown.trim().length > 0, `${key} markdown should not be empty`); + } +}); diff --git a/tests/smoke/dev_native_tool_loop.js b/tests/smoke/dev_native_tool_loop.js new file mode 100644 index 000000000..7f617c832 --- /dev/null +++ b/tests/smoke/dev_native_tool_loop.js @@ -0,0 +1,45 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { commandToToolDefinition, executeCommandToolCall } from '../../src/agent/commands/tool_adapter.js'; +import { createNativeToolResponse, isNativeToolResponse } from '../../src/models/native_tools.js'; + +const mockCommand = { + name: '!reportStatus', + description: 'Report mock tool-loop status.', + params: { + status: { type: 'string', description: 'Status text.' } + }, + perform: async (_agent, status) => `mock status: ${status}` +}; + +const mockModel = { + supportsNativeToolCalls: true, + async sendRequest(_turns, _systemMessage, _stopSeq, tools) { + assert.equal(tools.length, 1); + assert.equal(tools[0].function.name, 'reportStatus'); + return createNativeToolResponse([ + { + id: 'mock_call_1', + function: { + name: 'reportStatus', + arguments: JSON.stringify({ status: 'ok' }) + } + } + ], 'mock'); + } +}; + +const tools = [commandToToolDefinition(mockCommand)]; +const toolResponse = await mockModel.sendRequest( + [{ role: 'user', content: 'run mock status report' }], + 'Use native tools.', + '***', + tools +); + +assert.equal(isNativeToolResponse(toolResponse), true); +const result = await executeCommandToolCall({ blocked_actions: [] }, toolResponse.tool_calls[0], [mockCommand]); +assert.equal(result.ok, true); +assert.equal(result.result, 'mock status: ok'); + +console.log('Dev native tool-loop passed without Minecraft.'); From a3a1e1b63a0c3a69d9c994936ab707b76c40722f Mon Sep 17 00:00:00 2001 From: NinotQuyi Date: Fri, 1 May 2026 20:00:51 +0800 Subject: [PATCH 12/56] Move PR 752 providers into the registry The PR added eight OpenAI-compatible providers as separate model classes. This branch now keeps that contribution as provider registry entries so the shared OpenAI completions transport handles them without reintroducing boilerplate adapters. Constraint: New provider support should use llm_providers registry records rather than one class per OpenAI-compatible endpoint Rejected: Keep the PR 752 class files | they duplicate the shared OpenAI-compatible transport and weaken the new configuration style Confidence: high Scope-risk: moderate Related: https://github.com/mindcraft-bots/mindcraft/pull/752 Tested: npm test passed 50/50; JSON parse check for llm_providers example/local config; git diff --cached --check Not-tested: Live calls to the eight added providers Co-authored-by: OmX --- llm_providers.example.json | 58 ++++++++++++++++++++++++- src/models/ai21.js | 70 ------------------------------ src/models/anyscale.js | 70 ------------------------------ src/models/cohere.js | 70 ------------------------------ src/models/deepinfra.js | 70 ------------------------------ src/models/fireworks.js | 70 ------------------------------ src/models/nvidia.js | 70 ------------------------------ src/models/perplexity.js | 70 ------------------------------ src/models/together.js | 70 ------------------------------ tests/llm_providers_config.test.js | 25 +++++++++++ 10 files changed, 82 insertions(+), 561 deletions(-) delete mode 100644 src/models/ai21.js delete mode 100644 src/models/anyscale.js delete mode 100644 src/models/cohere.js delete mode 100644 src/models/deepinfra.js delete mode 100644 src/models/fireworks.js delete mode 100644 src/models/nvidia.js delete mode 100644 src/models/perplexity.js delete mode 100644 src/models/together.js diff --git a/llm_providers.example.json b/llm_providers.example.json index 0dca4e71b..83f5cb404 100644 --- a/llm_providers.example.json +++ b/llm_providers.example.json @@ -26,7 +26,15 @@ "MY_EMBEDDING_API_KEY": "", "OLLAMA_API_KEY": "", "CODEX_CHATGPT_AUTH": {}, - "REPLICATE_API_KEY": "" + "REPLICATE_API_KEY": "", + "AI21_API_KEY": "", + "ANYSCALE_API_KEY": "", + "COHERE_API_KEY": "", + "DEEPINFRA_API_KEY": "", + "FIREWORKS_API_KEY": "", + "NVIDIA_API_KEY": "", + "PERPLEXITY_API_KEY": "", + "TOGETHER_API_KEY": "" }, "models": { "openai": { @@ -175,6 +183,54 @@ "format": "openai-responses", "baseUrl": "https://api.example.com/v1", "keyName": "MY_RESPONSES_API_KEY" + }, + "ai21": { + "format": "openai-completions", + "baseUrl": "https://api.ai21.com/studio/v1", + "keyName": "AI21_API_KEY", + "defaultModel": "jamba-1.5-large" + }, + "anyscale": { + "format": "openai-completions", + "baseUrl": "https://api.endpoints.anyscale.com/v1", + "keyName": "ANYSCALE_API_KEY", + "defaultModel": "meta-llama/Meta-Llama-3-70B-Instruct" + }, + "cohere": { + "format": "openai-completions", + "baseUrl": "https://api.cohere.com/v1", + "keyName": "COHERE_API_KEY", + "defaultModel": "command-r-plus" + }, + "deepinfra": { + "format": "openai-completions", + "baseUrl": "https://api.deepinfra.com/v1/openai", + "keyName": "DEEPINFRA_API_KEY", + "defaultModel": "meta-llama/Meta-Llama-3-70B-Instruct" + }, + "fireworks": { + "format": "openai-completions", + "baseUrl": "https://api.fireworks.ai/inference/v1", + "keyName": "FIREWORKS_API_KEY", + "defaultModel": "accounts/fireworks/models/llama-v3p1-70b-instruct" + }, + "nvidia": { + "format": "openai-completions", + "baseUrl": "https://integrate.api.nvidia.com/v1", + "keyName": "NVIDIA_API_KEY", + "defaultModel": "meta/llama3-70b-instruct" + }, + "perplexity": { + "format": "openai-completions", + "baseUrl": "https://api.perplexity.ai", + "keyName": "PERPLEXITY_API_KEY", + "defaultModel": "llama-3-sonar-large-32k-online" + }, + "together": { + "format": "openai-completions", + "baseUrl": "https://api.together.xyz/v1", + "keyName": "TOGETHER_API_KEY", + "defaultModel": "meta-llama/Llama-3-70b-chat-hf" } }, "embeddings": { diff --git a/src/models/ai21.js b/src/models/ai21.js deleted file mode 100644 index 7097cc3c8..000000000 --- a/src/models/ai21.js +++ /dev/null @@ -1,70 +0,0 @@ -import OpenAIApi from 'openai'; -import { getKey } from '../utils/keys.js'; -import { strictFormat } from '../utils/text.js'; - -export class AI21 { - static prefix = 'ai21'; - constructor(model_name, url, params) { - this.model_name = model_name; - this.url = url; - this.params = params; - - let config = {}; - if (url) - config.baseURL = url; - else - config.baseURL = "https://api.ai21.com/studio/v1"; - - config.apiKey = getKey('AI21_API_KEY'); - - this.openai = new OpenAIApi(config); - } - - async sendRequest(turns, systemMessage) { - let messages = [{'role': 'system', 'content': systemMessage}].concat(turns); - messages = strictFormat(messages); - - const pack = { - model: this.model_name || "jamba-1.5-large", - messages, - ...(this.params || {}) - }; - - let res = null; - try { - let completion = await this.openai.chat.completions.create(pack); - if (completion.choices[0].finish_reason == 'length') - throw new Error('Context length exceeded'); - res = completion.choices[0].message.content; - } - catch (err) { - if ((err.message == 'Context length exceeded' || err.code == 'context_length_exceeded') && turns.length > 1) { - return await this.sendRequest(turns.slice(1), systemMessage); - } else { - 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 AI21.'); - } -} \ No newline at end of file diff --git a/src/models/anyscale.js b/src/models/anyscale.js deleted file mode 100644 index 5de63b9f1..000000000 --- a/src/models/anyscale.js +++ /dev/null @@ -1,70 +0,0 @@ -import OpenAIApi from 'openai'; -import { getKey } from '../utils/keys.js'; -import { strictFormat } from '../utils/text.js'; - -export class Anyscale { - static prefix = 'anyscale'; - constructor(model_name, url, params) { - this.model_name = model_name; - this.url = url; - this.params = params; - - let config = {}; - if (url) - config.baseURL = url; - else - config.baseURL = "https://api.endpoints.anyscale.com/v1"; - - config.apiKey = getKey('ANYSCALE_API_KEY'); - - this.openai = new OpenAIApi(config); - } - - async sendRequest(turns, systemMessage) { - let messages = [{'role': 'system', 'content': systemMessage}].concat(turns); - messages = strictFormat(messages); - - const pack = { - model: this.model_name || "meta-llama/Meta-Llama-3-70B-Instruct", - messages, - ...(this.params || {}) - }; - - let res = null; - try { - let completion = await this.openai.chat.completions.create(pack); - if (completion.choices[0].finish_reason == 'length') - throw new Error('Context length exceeded'); - res = completion.choices[0].message.content; - } - catch (err) { - if ((err.message == 'Context length exceeded' || err.code == 'context_length_exceeded') && turns.length > 1) { - return await this.sendRequest(turns.slice(1), systemMessage); - } else { - 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 Anyscale.'); - } -} \ No newline at end of file diff --git a/src/models/cohere.js b/src/models/cohere.js deleted file mode 100644 index a77089e90..000000000 --- a/src/models/cohere.js +++ /dev/null @@ -1,70 +0,0 @@ -import OpenAIApi from 'openai'; -import { getKey } from '../utils/keys.js'; -import { strictFormat } from '../utils/text.js'; - -export class Cohere { - static prefix = 'cohere'; - constructor(model_name, url, params) { - this.model_name = model_name; - this.url = url; - this.params = params; - - let config = {}; - if (url) - config.baseURL = url; - else - config.baseURL = "https://api.cohere.com/v1"; - - config.apiKey = getKey('COHERE_API_KEY'); - - this.openai = new OpenAIApi(config); - } - - async sendRequest(turns, systemMessage) { - let messages = [{'role': 'system', 'content': systemMessage}].concat(turns); - messages = strictFormat(messages); - - const pack = { - model: this.model_name || "command-r-plus", - messages, - ...(this.params || {}) - }; - - let res = null; - try { - let completion = await this.openai.chat.completions.create(pack); - if (completion.choices[0].finish_reason == 'length') - throw new Error('Context length exceeded'); - res = completion.choices[0].message.content; - } - catch (err) { - if ((err.message == 'Context length exceeded' || err.code == 'context_length_exceeded') && turns.length > 1) { - return await this.sendRequest(turns.slice(1), systemMessage); - } else { - 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 Cohere.'); - } -} \ No newline at end of file diff --git a/src/models/deepinfra.js b/src/models/deepinfra.js deleted file mode 100644 index e1d6858a4..000000000 --- a/src/models/deepinfra.js +++ /dev/null @@ -1,70 +0,0 @@ -import OpenAIApi from 'openai'; -import { getKey } from '../utils/keys.js'; -import { strictFormat } from '../utils/text.js'; - -export class DeepInfra { - static prefix = 'deepinfra'; - constructor(model_name, url, params) { - this.model_name = model_name; - this.url = url; - this.params = params; - - let config = {}; - if (url) - config.baseURL = url; - else - config.baseURL = "https://api.deepinfra.com/v1/openai"; - - config.apiKey = getKey('DEEPINFRA_API_KEY'); - - this.openai = new OpenAIApi(config); - } - - async sendRequest(turns, systemMessage) { - let messages = [{'role': 'system', 'content': systemMessage}].concat(turns); - messages = strictFormat(messages); - - const pack = { - model: this.model_name || "meta-llama/Meta-Llama-3-70B-Instruct", - messages, - ...(this.params || {}) - }; - - let res = null; - try { - let completion = await this.openai.chat.completions.create(pack); - if (completion.choices[0].finish_reason == 'length') - throw new Error('Context length exceeded'); - res = completion.choices[0].message.content; - } - catch (err) { - if ((err.message == 'Context length exceeded' || err.code == 'context_length_exceeded') && turns.length > 1) { - return await this.sendRequest(turns.slice(1), systemMessage); - } else { - 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 DeepInfra.'); - } -} \ No newline at end of file diff --git a/src/models/fireworks.js b/src/models/fireworks.js deleted file mode 100644 index 4367e286e..000000000 --- a/src/models/fireworks.js +++ /dev/null @@ -1,70 +0,0 @@ -import OpenAIApi from 'openai'; -import { getKey } from '../utils/keys.js'; -import { strictFormat } from '../utils/text.js'; - -export class Fireworks { - static prefix = 'fireworks'; - constructor(model_name, url, params) { - this.model_name = model_name; - this.url = url; - this.params = params; - - let config = {}; - if (url) - config.baseURL = url; - else - config.baseURL = "https://api.fireworks.ai/inference/v1"; - - config.apiKey = getKey('FIREWORKS_API_KEY'); - - this.openai = new OpenAIApi(config); - } - - async sendRequest(turns, systemMessage) { - let messages = [{'role': 'system', 'content': systemMessage}].concat(turns); - messages = strictFormat(messages); - - const pack = { - model: this.model_name || "accounts/fireworks/models/llama-v3p1-70b-instruct", - messages, - ...(this.params || {}) - }; - - let res = null; - try { - let completion = await this.openai.chat.completions.create(pack); - if (completion.choices[0].finish_reason == 'length') - throw new Error('Context length exceeded'); - res = completion.choices[0].message.content; - } - catch (err) { - if ((err.message == 'Context length exceeded' || err.code == 'context_length_exceeded') && turns.length > 1) { - return await this.sendRequest(turns.slice(1), systemMessage); - } else { - 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 Fireworks.'); - } -} \ No newline at end of file diff --git a/src/models/nvidia.js b/src/models/nvidia.js deleted file mode 100644 index 1f1785173..000000000 --- a/src/models/nvidia.js +++ /dev/null @@ -1,70 +0,0 @@ -import OpenAIApi from 'openai'; -import { getKey } from '../utils/keys.js'; -import { strictFormat } from '../utils/text.js'; - -export class Nvidia { - static prefix = 'nvidia'; - constructor(model_name, url, params) { - this.model_name = model_name; - this.url = url; - this.params = params; - - let config = {}; - if (url) - config.baseURL = url; - else - config.baseURL = "https://integrate.api.nvidia.com/v1"; - - config.apiKey = getKey('NVIDIA_API_KEY'); - - this.openai = new OpenAIApi(config); - } - - async sendRequest(turns, systemMessage) { - let messages = [{'role': 'system', 'content': systemMessage}].concat(turns); - messages = strictFormat(messages); - - const pack = { - model: this.model_name || "meta/llama3-70b-instruct", - messages, - ...(this.params || {}) - }; - - let res = null; - try { - let completion = await this.openai.chat.completions.create(pack); - if (completion.choices[0].finish_reason == 'length') - throw new Error('Context length exceeded'); - res = completion.choices[0].message.content; - } - catch (err) { - if ((err.message == 'Context length exceeded' || err.code == 'context_length_exceeded') && turns.length > 1) { - return await this.sendRequest(turns.slice(1), systemMessage); - } else { - 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 Nvidia.'); - } -} \ No newline at end of file diff --git a/src/models/perplexity.js b/src/models/perplexity.js deleted file mode 100644 index 11beb89c3..000000000 --- a/src/models/perplexity.js +++ /dev/null @@ -1,70 +0,0 @@ -import OpenAIApi from 'openai'; -import { getKey } from '../utils/keys.js'; -import { strictFormat } from '../utils/text.js'; - -export class Perplexity { - static prefix = 'perplexity'; - constructor(model_name, url, params) { - this.model_name = model_name; - this.url = url; - this.params = params; - - let config = {}; - if (url) - config.baseURL = url; - else - config.baseURL = "https://api.perplexity.ai"; - - config.apiKey = getKey('PERPLEXITY_API_KEY'); - - this.openai = new OpenAIApi(config); - } - - async sendRequest(turns, systemMessage) { - let messages = [{'role': 'system', 'content': systemMessage}].concat(turns); - messages = strictFormat(messages); - - const pack = { - model: this.model_name || "llama-3-sonar-large-32k-online", - messages, - ...(this.params || {}) - }; - - let res = null; - try { - let completion = await this.openai.chat.completions.create(pack); - if (completion.choices[0].finish_reason == 'length') - throw new Error('Context length exceeded'); - res = completion.choices[0].message.content; - } - catch (err) { - if ((err.message == 'Context length exceeded' || err.code == 'context_length_exceeded') && turns.length > 1) { - return await this.sendRequest(turns.slice(1), systemMessage); - } else { - 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 Perplexity.'); - } -} \ No newline at end of file diff --git a/src/models/together.js b/src/models/together.js deleted file mode 100644 index ec3e37cc2..000000000 --- a/src/models/together.js +++ /dev/null @@ -1,70 +0,0 @@ -import OpenAIApi from 'openai'; -import { getKey } from '../utils/keys.js'; -import { strictFormat } from '../utils/text.js'; - -export class Together { - static prefix = 'together'; - constructor(model_name, url, params) { - this.model_name = model_name; - this.url = url; - this.params = params; - - let config = {}; - if (url) - config.baseURL = url; - else - config.baseURL = "https://api.together.xyz/v1"; - - config.apiKey = getKey('TOGETHER_API_KEY'); - - this.openai = new OpenAIApi(config); - } - - async sendRequest(turns, systemMessage) { - let messages = [{'role': 'system', 'content': systemMessage}].concat(turns); - messages = strictFormat(messages); - - const pack = { - model: this.model_name || "meta-llama/Llama-3-70b-chat-hf", - messages, - ...(this.params || {}) - }; - - let res = null; - try { - let completion = await this.openai.chat.completions.create(pack); - if (completion.choices[0].finish_reason == 'length') - throw new Error('Context length exceeded'); - res = completion.choices[0].message.content; - } - catch (err) { - if ((err.message == 'Context length exceeded' || err.code == 'context_length_exceeded') && turns.length > 1) { - return await this.sendRequest(turns.slice(1), systemMessage); - } else { - 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 Together.'); - } -} \ No newline at end of file diff --git a/tests/llm_providers_config.test.js b/tests/llm_providers_config.test.js index d22a28990..a508187a5 100644 --- a/tests/llm_providers_config.test.js +++ b/tests/llm_providers_config.test.js @@ -51,6 +51,31 @@ test('kimi uses the documented Anthropic-compatible coding endpoint', () => { }); }); +test('PR 752 OpenAI-compatible providers live in the shared registry', () => { + const providers = { + ai21: ['AI21_API_KEY', 'https://api.ai21.com/studio/v1', 'jamba-1.5-large'], + anyscale: ['ANYSCALE_API_KEY', 'https://api.endpoints.anyscale.com/v1', 'meta-llama/Meta-Llama-3-70B-Instruct'], + cohere: ['COHERE_API_KEY', 'https://api.cohere.com/v1', 'command-r-plus'], + deepinfra: ['DEEPINFRA_API_KEY', 'https://api.deepinfra.com/v1/openai', 'meta-llama/Meta-Llama-3-70B-Instruct'], + fireworks: ['FIREWORKS_API_KEY', 'https://api.fireworks.ai/inference/v1', 'accounts/fireworks/models/llama-v3p1-70b-instruct'], + nvidia: ['NVIDIA_API_KEY', 'https://integrate.api.nvidia.com/v1', 'meta/llama3-70b-instruct'], + perplexity: ['PERPLEXITY_API_KEY', 'https://api.perplexity.ai', 'llama-3-sonar-large-32k-online'], + together: ['TOGETHER_API_KEY', 'https://api.together.xyz/v1', 'meta-llama/Llama-3-70b-chat-hf'] + }; + + for (const [id, [keyName, baseUrl, defaultModel]] of Object.entries(providers)) { + assert.deepEqual(config.models[id], { + format: 'openai-completions', + baseUrl, + keyName, + defaultModel + }); + assert.equal(example.models[id].format, 'openai-completions'); + assert.equal(example.models[id].keyName, keyName); + assert.ok(Object.hasOwn(example.keys, keyName), `example keys must include ${keyName}`); + } +}); + test('remote llm providers explicitly declare which key they use', () => { const localOrLogin = new Set(['codex', 'ollama_local', 'vllm']); for (const [id, provider] of Object.entries(config.models)) { From 1897c2e07eb3634149dbb57e9428b999738ae8c4 Mon Sep 17 00:00:00 2001 From: NinotQuyi Date: Fri, 1 May 2026 20:09:58 +0800 Subject: [PATCH 13/56] Keep local notes out of the branch The provider migration notes are useful locally but should not ship with the PR. Stop tracking the docs directory and ignore it so future local notes do not reappear as untracked branch content. Constraint: User requested removing docs from git while preserving the rest of the current branch Confidence: high Scope-risk: narrow Tested: git diff --cached --name-status confirmed only docs removal and .gitignore change were staged Not-tested: Full test suite; no runtime code changed in this commit Co-authored-by: OmX --- .gitignore | 1 + docs/enhanced-coding-system-diff-analysis.md | 397 ------------------- docs/llm-provider-config.md | 156 -------- docs/provider-unification-openclaw-notes.md | 197 --------- 4 files changed, 1 insertion(+), 750 deletions(-) delete mode 100644 docs/enhanced-coding-system-diff-analysis.md delete mode 100644 docs/llm-provider-config.md delete mode 100644 docs/provider-unification-openclaw-notes.md diff --git a/.gitignore b/.gitignore index be861317c..e2bda1a2c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ package-lock.json code_records/ +docs/ scratch.js bots/**/action-code/** bots/**/ diff --git a/docs/enhanced-coding-system-diff-analysis.md b/docs/enhanced-coding-system-diff-analysis.md deleted file mode 100644 index b6a95405a..000000000 --- a/docs/enhanced-coding-system-diff-analysis.md +++ /dev/null @@ -1,397 +0,0 @@ -# enhanced-coding-system 相对 develop 的改动分析 - -记录日期:2026-04-29 -仓库:`/Users/Ninot/NinotQuyi/mindcraft` -分析 worktree:`/Users/Ninot/NinotQuyi/mindcraft-enhanced-coding-system` - -## 分析范围 - -已执行远端同步: - -```bash -git fetch --all --prune --tags -``` - -对比对象: - -```text -origin/develop: 8acbd90 Merge pull request #761 from domdomegg/update-model-ids -origin/enhanced-coding-system: ec5bd0f refactor: use template method pattern to decouple API key dependencies -merge-base: d54eec6 -``` - -分支关系: - -```text -develop 相比 enhanced 多 55 个提交 -enhanced 相比 develop 分叉点多 99 个提交 -``` - -主要使用 PR/feature-branch 语义的三点 diff: - -```bash -git diff origin/develop...HEAD -``` - -统计结果: - -```text -79 files changed, 5262 insertions(+), 1017 deletions(-) -``` - -补充说明:原始工作目录 `/Users/Ninot/NinotQuyi/mindcraft` 存在未提交修改和未跟踪文件;本文档记录的是干净 worktree 中 `origin/enhanced-coding-system` 相对 `origin/develop` 的分支 diff,不包含本地未提交内容。 - ---- - -## 总体结论 - -`enhanced-coding-system` 是一次围绕 **Coding Agent 工具化、Native Tool Calling、多模型工具调用适配、Prompt/Profile 重构、执行安全边界、Learned Skills、以及 Minecraft 行为可靠性增强** 的大规模功能分支。 - -可以划分为以下主要功能模块: - -1. Coding Agent 工具化重构 -2. Native Tool Calling 支持 -3. Prompt/Profile 外置与重构 -4. Coding 安全边界与 Workspace 限制 -5. Learned Skills 持久化技能系统 -6. Minecraft 生存/建造/查询能力增强 -7. Provider/Profile 生态扩展 -8. 依赖与 SDK 更新 - ---- - -## 1. Coding Agent 工具化重构 - -核心新增目录: - -```text -src/agent/tools/ -``` - -新增工具文件: - -```text -edit.js -execute.js -finishCoding.js -glob.js -grep.js -lint.js -ls.js -multiEdit.js -read.js -todoWrite.js -toolManager.js -tools-prompt.md -write.js -``` - -主要变化: - -- 将原本“直接生成 JavaScript codeblock”的 coding 模式改造成工具调用式工作流。 -- 新增 `Read / Write / Edit / MultiEdit / Execute / Lint / Grep / Glob / LS / TodoWrite / FinishCoding` 等工具。 -- `ToolManager` 负责: - - JSON tool response 解析; - - native tool calls 转换; - - 工具执行调度; - - 工作区路径校验; - - todo / learned skills 提醒。 -- `ExecuteTool` 负责: - - 执行 bot action-code JavaScript 文件; - - 执行前 lint 检查; - - IIFE 格式校验; - - 超时处理; - - 错误栈映射; - - 捕获 bot chat/output; - - 执行失败时返回更详细错误信息。 - -代表文件: - -```text -src/agent/coder.js -src/agent/tools/toolManager.js -src/agent/tools/execute.js -src/models/prompter.js -profiles/defaults/prompts/coding.md -``` - ---- - -## 2. Native Tool Calling 模型适配层 - -大量模型的 `sendRequest` 签名被扩展为支持 tools 参数: - -```js -sendRequest(turns, systemMessage, stop_seq, tools) -``` - -受影响模型: - -```text -src/models/gpt.js -src/models/claude.js -src/models/gemini.js -src/models/groq.js -src/models/grok.js -src/models/cerebras.js -src/models/mistral.js -src/models/huggingface.js -src/models/ollama.js -src/models/replicate.js -src/models/hyperbolic.js -src/models/novita.js -src/models/azure.js -src/models/openrouter.js -src/models/mercury.js -src/models/qwen.js -src/models/vllm.js -``` - -主要变化: - -- OpenAI-compatible 模型支持 `tools`。 -- 模型返回 `_native_tool_calls` JSON。 -- `Prompter.promptCoding()` 将 native tool calls 转成内部 JSON tools 格式。 -- 支持在 profile 中切换: - - native tools API; - - prompt-engineering tools 格式。 -- `GPT` 引入 template method pattern: - - `initClient()`; - - 子类只覆盖 client 初始化,减少 provider 间重复逻辑。 - -代表文件: - -```text -src/models/gpt.js -src/models/prompter.js -src/models/azure.js -src/models/openrouter.js -src/models/mercury.js -``` - ---- - -## 3. Coding Prompt / Profile 配置重构 - -默认 profile 从内联长 prompt 改成外部 Markdown 文件: - -```text -profiles/defaults/prompts/coding.md -profiles/defaults/prompts/conversing.md -profiles/defaults/prompts/bot_responder.md -profiles/defaults/prompts/image_analysis.md -profiles/defaults/prompts/saving_memory.md -``` - -`profiles/defaults/_default.json` 改为引用 prompt 文件路径: - -```json -"coding": "profiles/defaults/prompts/coding.md", -"conversing": "profiles/defaults/prompts/conversing.md", -"tools_manual": "src/agent/tools/tools-prompt.md" -``` - -主要作用: - -- 提升 coding prompt 的可维护性。 -- 将 tool 使用规范独立成文档。 -- 新增 `$TOOLS`、`$CODING_GOAL` 等占位符。 -- coding examples 从 JavaScript codeblock 改成 JSON tools 调用示例。 - -代表文件: - -```text -profiles/defaults/_default.json -profiles/defaults/prompts/coding.md -src/agent/tools/tools-prompt.md -src/models/prompter.js -``` - ---- - -## 4. Coding 安全边界 / Workspace 限制 - -新增配置: - -```js -"code_workspaces": [ - "bots/{BOT_NAME}/action-code", - "bots/{BOT_NAME}/learnedSkills", - "bots/{BOT_NAME}/" -] -``` - -主要作用: - -- 限制 AI 只能在允许目录中读写/执行文件。 -- `ToolManager.validateWorkspaces()` 对 `file_path` 做校验。 -- coding 生成的动作代码主要放到: - -```text -bots/{BOT_NAME}/action-code -``` - -代表文件: - -```text -settings.js -src/agent/tools/toolManager.js -src/agent/tools/write.js -src/agent/tools/edit.js -src/agent/tools/execute.js -``` - ---- - -## 5. Learned Skills 动态技能系统 - -新增文件: - -```text -src/agent/library/learnedSkillsManager.js -``` - -主要能力: - -- 从每个 bot 的目录加载自定义 learned skills: - -```text -bots/{botName}/learnedSkills/ -``` - -- 校验 learned skill 文件内容。 -- 提取 JSDoc / function signature 作为 skill docs。 -- 合并到原有 `SkillLibrary` 的相关技能检索中。 -- coding prompt 中新增 learned skills 规范。 - -代表文件: - -```text -src/agent/library/learnedSkillsManager.js -src/agent/library/skill_library.js -profiles/defaults/prompts/coding.md -``` - ---- - -## 6. Minecraft 行为能力增强与可靠性修复 - -改动集中在: - -```text -src/agent/library/skills.js -src/agent/library/world.js -src/agent/modes.js -src/agent/commands/actions.js -src/agent/commands/queries.js -``` - -主要内容: - -- 新增 `world.getBuildingStructure()`,让 LLM 能读取建筑结构。 -- `!nearbyBlocks` 输出更丰富的水、岩浆和环境状态。 -- `getNearestBlocksWhere()` 支持 function 或 block id array。 -- 多处循环增加 `bot.interrupt_code` 检查。 -- `unstuck` 模式改成随机附近位置多次尝试。 -- `self_preservation` 对火、岩浆、水、下落方块处理更强。 -- action/mode 执行增加 catch,避免 floating promise 崩溃。 -- `!newAction` 描述改为工具式 coding 工作流。 -- 增加 cheat mode 支持和部分动作返回值细化。 - -代表文件: - -```text -src/agent/library/world.js -src/agent/library/skills.js -src/agent/modes.js -src/agent/commands/actions.js -src/agent/commands/queries.js -``` - ---- - -## 7. Provider / Profile 扩展 - -新增或扩展多个 profile: - -```text -profiles/cerebras.json -profiles/groq.json -profiles/huggingface.json -profiles/hyperbolic.json -profiles/novita.json -profiles/ollama.json -profiles/openrouter.json -``` - -同时修改: - -```text -profiles/gpt.json -profiles/claude.json -profiles/gemini.json -profiles/qwen.json -profiles/grok.json -profiles/mistral.json -profiles/deepseek.json -profiles/azure.json -... -``` - -主要内容: - -- 新增更多 LLM provider profile。 -- 给不同 provider 配置 tool calling / model 参数。 -- 调整部分 stop sequence,避免和 patch/tool 格式冲突。 -- 默认 profile 中降低部分自保/战斗模式默认开启程度。 - ---- - -## 8. 依赖与 SDK 更新 - -`package.json` 新增/更新依赖: - -```text -@huggingface/inference: ^4.11.3 -axios -diff -glob -minimatch -tree-sitter -tree-sitter-bash -``` - -大致用途: - -- HuggingFace 新 SDK。 -- 文件 glob / minimatch。 -- diff/edit 工具。 -- lint / shell 分析相关能力。 -- HTTP provider 支持。 - ---- - -## 重要文件变更概览 - -按 churn 排名前列的文件包括: - -```text -src/agent/tools/execute.js 755 lines added -src/agent/tools/toolManager.js 497 lines added -profiles/defaults/prompts/coding.md 412 lines added -src/agent/coder.js 345 lines changed -src/agent/tools/tools-prompt.md 308 lines added -src/agent/library/skills.js 261 lines changed -src/agent/library/learnedSkillsManager.js 232 lines added -src/agent/tools/lint.js 225 lines added -src/agent/tools/todoWrite.js 182 lines added -src/agent/tools/grep.js 173 lines added -src/agent/library/world.js 166 lines changed -src/models/prompter.js 146 lines changed -``` - ---- - -## 一句话总结 - -`enhanced-coding-system` 的核心价值是:把 Mindcraft 的 coding 模式从“一次性代码块生成器”升级为“带工具调用、文件编辑、执行反馈、错误修复、待办管理、learned skills 和多模型 native tool calls 支持的 agentic coding system”,同时增强 Minecraft 世界查询、生存自保、unstuck 和 provider profile 生态。 diff --git a/docs/llm-provider-config.md b/docs/llm-provider-config.md deleted file mode 100644 index 018dd0078..000000000 --- a/docs/llm-provider-config.md +++ /dev/null @@ -1,156 +0,0 @@ -# LLM Provider 配置说明 - -`llm_providers.json` 是项目级模型注册表,profile 只负责选择注册表里的 provider id 和模型名。 - -## 核心规则 - -- `models.` 注册聊天/代码模型供应商。 -- `embeddings.` 注册嵌入模型供应商,和聊天模型分开。 -- `keys.` 保存密钥值;provider 用 `keyName` 指向它。 -- `format` 写真实 API 协议/端点格式,例如: - - `openai-completions` - - `openai-responses` - - `anthropic-messages` - - `google-generative-ai` - - `openai-embeddings` -- `name` 不需要;provider id 本身就是用户选择时使用的名字。 -- 不需要模型名映射表;profile 里的 `model` 会原样传给对应 provider。 - -## 添加一个 OpenAI Chat Completions 兼容服务 - -在 `keys` 中添加: - -```json -"MY_PROVIDER_API_KEY": "" -``` - -在 `models` 中添加: - -```json -"my_provider": { - "format": "openai-completions", - "baseUrl": "https://api.example.com/v1", - "keyName": "MY_PROVIDER_API_KEY" -} -``` - -profile 中选择: - -```json -"model": { - "provider": "my_provider", - "model": "vendor/model-name" -} -``` - -这里的 `my_provider` 就是用户自定义 provider 名字;`vendor/model-name` 不会被额外映射或改写。 - -## 地区型 Provider 和 Profile - -OpenClaw 的做法是:provider id 保持 canonical(例如 `qwen` / `minimax`),在 onboarding/auth-choice 中选择 China 或 Global,然后把同一个 provider 写成不同 baseUrl。Mindcraft 当前没有交互式 onboarding wizard,所以预置 profile 直接拆成地区版本,避免用户选错 endpoint: - -- `profiles/qwen-cn.json` -> `provider: "qwen_cn"` -- `profiles/minimax-cn.json` -> `provider: "minimax_cn"` -- `profiles/minimax-intl.json` -> `provider: "minimax_intl"` - -规则: - -- 中国区 key/账号通常选 `_cn`。 -- 国际区 key/账号通常选 `_intl`;当前预置只保留 MiniMax 国际区,Qwen 只保留中国区 key。 -- 同一厂商的 CN/Intl key 不一定通用;如果 live test 出现 401,优先检查是否选错地区。 -- 不要用模糊的 `qwen.json` / `minimax.json` 作为预置 profile 名称。 - -## MiniMax - -MiniMax 走 OpenAI Chat Completions 兼容协议,按地区拆成两个 provider: - -```json -"minimax_intl": { - "format": "openai-completions", - "baseUrl": "https://api.minimax.io/v1", - "keyName": "MINIMAX_INTL_API_KEY", - "defaultModel": "MiniMax-M2.7" -}, -"minimax_cn": { - "format": "openai-completions", - "baseUrl": "https://api.minimaxi.com/v1", - "keyName": "MINIMAX_CN_API_KEY", - "defaultModel": "MiniMax-M2.7" -} -``` - -profile 中按地区选择 `minimax_intl` 或 `minimax_cn`。 - -## Kimi Coding - -Kimi Coding 在官方文档中提供两类第三方 Coding Agent 接入方式。实测 OpenAI-compatible coding 入口会校验客户端身份;为了不伪造 User-Agent,这里使用同页 Claude Code 兼容入口,对应真实协议是 Anthropic Messages: - -```json -"kimi": { - "format": "anthropic-messages", - "baseUrl": "https://api.kimi.com/coding/", - "keyName": "KIMI_API_KEY", - "defaultModel": "kimi-for-coding", - "params": { - "max_tokens": 32768, - "provider": "kimi" - } -} -``` - -profile 中选择 `provider: "kimi"`,模型默认可用 `kimi-for-coding`。 - -## 本地 Ollama - -Ollama 使用 OpenAI-compatible 端点即可,不需要单独的 `ollama-chat` 配置格式: - -```json -"ollama": { - "format": "openai-completions", - "baseUrl": "http://127.0.0.1:11434/v1" -} -``` - -Embedding 同样使用 OpenAI embeddings 端点: - -```json -"ollama": { - "format": "openai-embeddings", - "baseUrl": "http://127.0.0.1:11434/v1", - "defaultModel": "embeddinggemma" -} -``` - -本地 Ollama 不需要 `keyName`。 - -## Embedding 配置原则 - -预制 profile 不应该在同一个文件里静默依赖另一个供应商的 embedding。也就是说: - -- 如果模型供应商自己有 embedding provider,profile 可以写同名 embedding,例如 `qwen_cn` + `qwen_cn`。 -- 如果模型供应商没有 embedding provider,预制 profile 先不要写 `embedding` 字段,系统会在 embedding 不可用时回退到词重叠检索。 -- 用户确实想跨供应商使用 embedding 时,可以自己在本地 profile 里显式添加。 - -MiniMax 当前官方文档索引里没有独立 Embeddings API / embedding 模型页面,所以 `profiles/minimax-cn.json` 和 `profiles/minimax-intl.json` 不预设 embedding。 - -## 添加 Embedding 服务 - -在 `embeddings` 中单独注册: - -```json -"my_embedding_provider": { - "format": "openai-embeddings", - "baseUrl": "https://api.example.com/v1", - "defaultModel": "text-embedding-model-name", - "keyName": "MY_EMBEDDING_API_KEY" -} -``` - -profile 中选择: - -```json -"embedding": { - "provider": "my_embedding_provider", - "model": "text-embedding-model-name" -} -``` diff --git a/docs/provider-unification-openclaw-notes.md b/docs/provider-unification-openclaw-notes.md deleted file mode 100644 index f094780f1..000000000 --- a/docs/provider-unification-openclaw-notes.md +++ /dev/null @@ -1,197 +0,0 @@ -# Provider / Native Tool Calling 迁移调研记录 - -日期:2026-04-29 -分支:`native-tool`(基于 `develop`) -参考代码:OpenClaw 源码只读调研;未安装 OpenClaw 依赖。 -验证 key 来源:`enhanced-coding-system` 工作区的 `keys.json` / 环境变量(只记录 key 名称,不记录密钥值)。 - -## 结论 - -OpenClaw 不是靠一个“统一 JS 库一键接入所有 API”的简单方式完成多 provider 支持;它的核心是: - -1. **Provider Plugin 架构**:每个 provider 用插件声明 catalog、auth、动态模型解析、transport、tool schema 兼容、stream wrapper、usage/auth 等 hook。 -2. **少量核心 transport family**:OpenAI Responses、OpenAI Chat Completions、Anthropic Messages、Google Generative AI、Azure Responses 等作为主通道;特殊 provider 再用 plugin hook 覆盖。 -3. **Provider-family 兼容层**:tool schema、history/replay、reasoning/thinking、usage、streaming 差异集中在 provider hook,而不是散在业务代码里。 -4. **独立 CLI/Dev 验证面**:`infer` / `models status --probe` 这类无业务环境的探针可验证 auth、模型、基础调用能力。 - -对 Mindcraft 来说,可迁移的不是“复制 OpenClaw 全套”,而是借鉴它的层次: - -- `provider registry`:统一 provider metadata、key 名称、baseURL、默认模型、能力 flags。 -- `transport adapter`:OpenAI-compatible / Anthropic / Gemini / Ollama / special SDK 分开。 -- `tool schema adapter`:从 Mindcraft command/action/query 生成 canonical tool schema,再按 provider family 转换。 -- `tool response normalizer`:所有 provider 的 tool call 输出归一到同一种内部结构。 -- `dev probe mode`:不接 Minecraft,仅跑 `model -> native tool_call -> command adapter/mock tool -> result`。 - -## OpenClaw 源码观察 - -### 关键文件 - -- `src/plugins/types.ts` - - `ProviderPlugin` 定义了 provider 的扩展点:catalog、auth、dynamic model、transport、tool schemas、stream wrapper、usage、cache、reasoning 等。 - - `registerProvider` 是 provider 注册入口。 -- `src/plugins/provider-runtime.ts` - - Provider runtime resolver / hook dispatcher。 - - 统一调用 `normalizeToolSchemas`、`normalizeTransport`、`resolveProviderStreamFn` 等 hook。 -- `src/agents/pi-embedded-runner/model.ts` - - model resolution pipeline:模型解析、auth、provider runtime normalization。 -- `src/agents/provider-stream.ts` - - 选择 provider 自定义 stream function;没有自定义时走 transport-aware stream。 -- `src/agents/provider-transport-stream.ts` - - 内置 transport family:OpenAI Responses、OpenAI Chat Completions、Anthropic Messages、Google Generative AI、Azure Responses。 -- `src/plugin-sdk/provider-entry.ts` - - `defineSingleProviderPluginEntry` 用少量配置快速定义普通 API-key provider。 -- `src/plugin-sdk/provider-tools.ts` - - `buildProviderToolCompatFamilyHooks("openai" | "gemini")`。 - - OpenAI strict schema / Gemini schema cleaning 等兼容逻辑集中在这里。 -- `src/plugin-sdk/provider-model-shared.ts` - - replay/history policy family:`openai-completions`、`anthropic-by-model`、`google-generative-ai` 等。 -- `extensions/openrouter/index.ts` - - 动态模型、OpenRouter 能力探测、stream wrapper、media/image/video/speech provider。 -- `extensions/litellm/index.ts` - - LiteLLM gateway provider,说明 gateway 也按 provider plugin 接入。 -- `extensions/qwen/index.ts` - - DashScope/Qwen provider 多 auth method、catalog、OpenAI-family 兼容、特殊模型限制处理。 -- `extensions/openai/index.ts` - - OpenAI / OpenAI Codex provider,并复用 OpenAI tool compat family hooks。 - -### OpenClaw 多 API 支持方式 - -OpenClaw 支持大量 provider 的原因主要不是 SDK 数量,而是 metadata + hook 分层: - -| 层 | 职责 | Mindcraft 可借鉴点 | -| --- | --- | --- | -| Manifest/catalog | provider id、模型、能力、auth/env key | 把 `profiles/*.json` 中散落的 api/model/url/key 合并成 provider registry | -| Auth resolver | key 从 profile/env/auth store 解析 | 继续兼容 `keys.json`,但 provider 自己声明 key name | -| Transport | OpenAI-compatible / Anthropic / Gemini / Ollama 等 | Mindcraft 先实现 3-4 个 family,不需要一开始支持全部 | -| Tool compat | schema 清洗、provider-native tool 格式 | 把当前各 `src/models/*.js` 内的 tool 转换移出去 | -| Response normalizer | 把各 provider 输出归一 | 统一返回 `{ id, name, arguments }`,不再让模型层拼 `_native_tool_calls` JSON 字符串 | -| Probe/dev CLI | 无业务环境验证模型和 auth | Mindcraft dev mode 不连 Minecraft 也能测试 LLM + tool loop | - -## Mindcraft 当前可迁移基础 - -Enhanced 分支已经有原型,但耦合较重: - -- `src/models/gpt.js`:OpenAI-compatible `tools` / `tool_choice`,返回 typed native tool response object。 -- `src/models/claude.js`:Anthropic `tools` / `tool_use` 转 OpenAI-like tool call。 -- `src/models/gemini.js`:Gemini `functionDeclarations` 与返回转换。 -- `src/models/groq.js`、`src/models/cerebras.js`、`src/models/mistral.js`、`src/models/hyperbolic.js` 等:各自内联 tool calling 差异。 -- `src/models/prompter.js`:根据 `profile.use_native_tools` 决定传原生 tools,或退回 prompt/XML 工具文本。 -- `src/agent/tools/toolManager.js`:coding tools 的 prompt/json 工具系统。 -- `src/agent/commands/actions.js` / `src/agent/commands/queries.js`:Minecraft command 定义已有 `name/description/params/perform`,适合作为 function schema 生成源。 -- `src/utils/keys.js`:只从 `keys.json` 或环境变量取 key,适合被 provider registry 复用。 - -主要问题: - -1. tool schema 转换散落在各 model adapter。 -2. enhanced 原型里 tool call 结果曾用 JSON 字符串伪装成普通 LLM 文本;本分支应改为 typed object,避免调用链不够类型安全。 -3. provider 能力未知;`use_native_tools` 是 profile 级布尔值,缺少 provider/model 级能力探测。 -4. OpenAI-compatible provider 共享逻辑没有统一抽象。 -5. 没有独立 dev/probe surface,导致验证 LLM 能力通常要走 Minecraft agent 流程。 - -## 本地 key 安全发现结果 - -只发现以下可用 key(未输出密钥值): - -| Key 名称 | 来源 | 备注 | -| --- | --- | --- | -| `QWEN_API_KEY` | `enhanced-coding-system/keys.json` | 已做 live probe | - -未发现/未配置,因此本轮跳过: - -- `OPENAI_API_KEY` -- `ANTHROPIC_API_KEY` -- `GEMINI_API_KEY` / `GOOGLE_API_KEY` -- `OPENROUTER_API_KEY` -- `DEEPSEEK_API_KEY` -- `GROQCLOUD_API_KEY` -- `CEREBRAS_API_KEY` -- `XAI_API_KEY` -- `MISTRAL_API_KEY` -- `HUGGINGFACE_API_KEY` -- `HYPERBOLIC_API_KEY` -- `NOVITA_API_KEY` -- `MERCURY_API_KEY` -- `REPLICATE_API_KEY` -- `AZURE_OPENAI_API_KEY` - -## API 验证结果 - -验证方式:不安装 OpenClaw,使用 Mindcraft enhanced 工作区现有依赖 / Node fetch 直接访问 DashScope OpenAI-compatible endpoint。 - -Endpoint:`https://dashscope.aliyuncs.com/compatible-mode/v1` -模型:`qwen-max` -Embedding 模型:`text-embedding-v3` - -| Provider | 能力 | 结果 | 证据 | -| --- | --- | --- | --- | -| Qwen / DashScope | Chat completions | 通过 | `POST /chat/completions`,`qwen-max` 返回 `pong` | -| Qwen / DashScope | OpenAI-compatible native tools | 通过 | `tool_choice` 指定 `report_status`,finish_reason=`tool_calls`,返回 `report_status({status:"ok"})` | -| Qwen / DashScope | Embeddings | 通过 | `POST /embeddings`,`text-embedding-v3` 返回 1024 维向量 | -| Mindcraft `Qwen` adapter | native tools 贯通 | 通过 | `src/models/qwen.js` 继承 `GPT.sendRequest(..., tools)`,本分支归一返回 typed `tool_calls` object | - -## 对 native-tool 分支的建议实现边界 - -### 第一阶段:只抽 native tool 基础,不重写所有 provider - -建议先做薄迁移: - -1. 新增 `src/models/providerRegistry.js` - - provider id、key name、default baseURL、default model、transport family、tool support flags。 -2. 新增 `src/models/toolSchemas.js` - - canonical schema:OpenAI-style function schema。 - - `commands/actions/queries -> tools` 生成器。 -3. 新增 `src/models/toolAdapters.js` - - `openai`:基本 passthrough + strict schema 清理。 - - `anthropic`:`{name, description, input_schema}`。 - - `gemini`:`functionDeclarations` + schema 清理。 -4. 新增 `src/models/toolCalls.js` - - `normalizeOpenAIToolCalls` / `normalizeAnthropicToolUse` / `normalizeGeminiFunctionCall`。 - - 返回结构不要再是 JSON 字符串,改为 typed object,例如: - ```js - { type: 'tool_calls', calls: [{ id, name, arguments }] } - ``` -5. 保留人类 `!command` parser。 - - 人类聊天输入仍可走 `!command`。 - - AI 输出不再鼓励/依赖 `!command`。 -6. 临时 fallback。 - - provider 不支持 native tools 时,允许短期文本/XML fallback,但需要明确 log/标记,后续可关闭。 - -### 第二阶段:dev mode / probe - -新增不接 Minecraft 的验证命令或脚本: - -- `npm run dev:tool-loop`(mock,无 Minecraft)或后续扩展为 `node scripts/dev-tool-loop.js --profile profiles/qwen-cn.json` -- 输入固定 prompt:要求模型调用一个 mock command/tool。 -- 流程:profile -> provider adapter -> native tool call -> mock executor -> normalized result。 -- 验收标准:不用 Minecraft 进程,也能证明 LLM 原生工具调用链可跑通。 - -### 第三阶段:Prompt/Profile 外置到 Markdown - -按已确认需求分两步: - -1. 机械转换:从 `develop` 的 JSON prompt 原文生成 Markdown,机器校验内容等价。 -2. 语义修改:把提示词中“AI 用 `!command` 调工具”的要求改成“AI 使用原生 tools/function call”。 - -这样既满足“不要手写导致漏内容”,又允许迁移后去掉 AI 文本命令指导。 - -## 是否值得引入统一第三方库 - -暂不建议把 Mindcraft 的第一阶段迁移押注在某个“一键统一 API”库上: - -- OpenClaw 自己也不是单库解决,而是 provider plugin + transport family。 -- Mindcraft 已经有多 provider adapter,先抽公共层风险更低。 -- 真正痛点是 tool schema/response/history/prompt 的兼容,不只是 HTTP 调用。 -- Gateway 方案(OpenRouter / LiteLLM / Vercel AI Gateway)可以作为 provider 加入 registry,而不是替代本地 provider abstraction。 - -可评估但不应阻塞第一阶段: - -- LiteLLM gateway:作为“100+ provider”后端入口。 -- OpenRouter:动态模型与多 provider 入口。 -- Vercel AI SDK / AI Gateway:如果未来要统一 JS SDK,可单独做 spike;但 Mindcraft 当前已有 provider 文件,直接重写成本较高。 - -## Skipped / TODO - -- 未安装 OpenClaw 依赖;本轮只读源码。 -- 除 Qwen 外,本地未发现可用 key,未做 live API probe。 -- 未测试 Minecraft agent loop;本轮只验证 LLM/API/tool-call 基础能力。 -- 未测试 streaming/tool-result continuation,多轮 tool loop 需要后续 dev mode 覆盖。 From a65e491b028281983127c185d4eb367655651224 Mon Sep 17 00:00:00 2001 From: NinotQuyi Date: Fri, 1 May 2026 22:23:07 +0800 Subject: [PATCH 14/56] Make native tool results replayable Native tool calls now stay in structured history with matching tool result turns so follow-up requests can replay the conversation across OpenAI, Responses, Anthropic, Gemini, Codex, and Replicate adapters without losing call/result pairing. Constraint: Model-facing history must preserve native tool call/result structure while keeping human text-command syntax separate Rejected: Store tool results as plain system messages | protocol adapters cannot reliably reconstruct required tool result fields from prose Confidence: high Scope-risk: moderate Directive: When adding provider adapters, route native tool history through the shared native_tools serializers instead of ad hoc message shaping Tested: npm test passed 62/62; git grep --cached -i openclaw returned no matches; staged secret/path checks excluded local key files Not-tested: Live Minecraft server with every provider Co-authored-by: OmX --- llm_providers.example.json | 11 + profiles/andy-4-reasoning.json | 8 +- .../defaults/prompts/_default/conversing.md | 2 +- profiles/gemini.json | 6 +- settings.js | 2 +- src/agent/action_manager.js | 66 ++-- src/agent/agent.js | 30 +- src/agent/commands/actions.js | 24 +- src/agent/history.js | 19 +- src/models/_model_map.js | 2 +- src/models/anthropic_messages.js | 7 +- src/models/azure_openai_responses.js | 2 +- src/models/codex_chatgpt.js | 6 +- src/models/google_generative_ai.js | 82 ++++- src/models/native_tools.js | 316 ++++++++++++++++++ src/models/openai_compatible.js | 9 +- src/models/openai_responses.js | 19 +- src/models/prompter.js | 36 +- src/models/replicate.js | 4 +- src/utils/text.js | 49 ++- tests/agent_native_text_policy.test.js | 42 ++- tests/codex_chatgpt.test.js | 34 ++ tests/llm_providers_config.test.js | 17 +- tests/memory_summary_tool_history.test.js | 25 ++ tests/native_prompt_hygiene.test.js | 4 +- tests/native_tools.test.js | 137 ++++++++ tests/openai_compatible.test.js | 5 +- tests/smoke/live_function_call_smoke.js | 4 +- tests/smoke/live_model_matrix.js | 5 +- tests/tool_result_policy.test.js | 32 ++ 30 files changed, 853 insertions(+), 152 deletions(-) create mode 100644 tests/memory_summary_tool_history.test.js create mode 100644 tests/tool_result_policy.test.js diff --git a/llm_providers.example.json b/llm_providers.example.json index 83f5cb404..478e9dfbb 100644 --- a/llm_providers.example.json +++ b/llm_providers.example.json @@ -4,6 +4,7 @@ "ANTHROPIC_API_KEY": "", "GEMINI_API_KEY": "", "GOOGLE_API_KEY": "", + "GEMINI_RELAY_API_KEY": "", "AZURE_OPENAI_API_KEY": "", "KIMI_API_KEY": "", "OPENROUTER_API_KEY": "", @@ -52,6 +53,16 @@ "keyName": "GEMINI_API_KEY", "baseUrl": "https://generativelanguage.googleapis.com" }, + "google_relay": { + "format": "google-generative-ai", + "keyName": "GEMINI_RELAY_API_KEY", + "baseUrl": "https://mydamoxing.cn", + "defaultModel": "gemini-3.1-pro-preview", + "params": { + "apiVersion": "v1beta" + }, + "comment": "Gemini protocol relay. Direct endpoint would be https://mydamoxing.cn/v1beta/models/gemini-3.1-pro-preview:generateContent; keep baseUrl at the host because the SDK appends /v1beta/models/{model}:generateContent." + }, "replicate": { "format": "replicate", "keyName": "REPLICATE_API_KEY", diff --git a/profiles/andy-4-reasoning.json b/profiles/andy-4-reasoning.json index 048f7cc32..39fc0ee31 100644 --- a/profiles/andy-4-reasoning.json +++ b/profiles/andy-4-reasoning.json @@ -4,8 +4,12 @@ "provider": "ollama_local", "model": "sweaterdog/andy-4:micro-q8_0" }, - "conversing": "You are a playful Minecraft bot named $NAME that can converse with players, see, move, mine, build, and interact with the world by using commands.\n$SELF_PROMPT Act human-like as if you were a typical Minecraft player, rather than an AI. Be very brief in your responses, don't apologize constantly, don't give instructions or make lists unless asked, and don't refuse requests. Think in high amounts before responding. Don't pretend to act, use commands immediately when requested. Do NOT say this: 'Sure, I've stopped.', instead say this: 'Sure, I'll stop. !stop'. Do NOT say this: 'On my way! Give me a moment.', instead say this: 'On my way! !goToPlayer(\"playername\", 3)'. 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\nReason before responding. Conversation Begin:", + "conversing": { + "prompt_file": "prompts/_default/conversing.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. Think deeply before responding. 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": "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": { + "prompt_file": "prompts/_default/bot_responder.md" + } } diff --git a/profiles/defaults/prompts/_default/conversing.md b/profiles/defaults/prompts/_default/conversing.md index c4948f83f..0e18e4967 100644 --- a/profiles/defaults/prompts/_default/conversing.md +++ b/profiles/defaults/prompts/_default/conversing.md @@ -1,5 +1,5 @@ You are an AI Minecraft bot named $NAME that can converse with players, see, move, mine, build, and interact with the world by using native tools/function calls. -$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; when you need to act, call the appropriate native tool/function instead of writing command text. Do NOT write command text like '!stop', function-looking text like collectBlocks(...), or log text like '*used collectBlocks*' in your message; call the native tool and optionally respond briefly after the tool result. 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 ' '. This is extremely important to me, take a deep breath and have fun :) +$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; when you need to act, call the appropriate native tool/function instead of writing command text. Do NOT write legacy text commands, function-looking text, or fake tool-use log text in your message; call the native tool and optionally respond briefly after the tool result. 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 ' '. This is extremely important to me, take a deep breath and have fun :) Summarized memory:'$MEMORY' $STATS $INVENTORY diff --git a/profiles/gemini.json b/profiles/gemini.json index ff94259d2..12bf4492d 100644 --- a/profiles/gemini.json +++ b/profiles/gemini.json @@ -1,9 +1,9 @@ { "name": "gemini", "model": { - "provider": "google", - "model": "gemini-3-flash-preview" + "provider": "google_relay", + "model": "gemini-3.1-pro-preview" }, - "speak_model": "google/gemini-2.5-flash-preview-tts/Kore", + "speak_model": "google_relay/gemini-3.1-pro-preview/Kore", "cooldown": 2000 } diff --git a/settings.js b/settings.js index 02cabe6ad..a58be90cc 100644 --- a/settings.js +++ b/settings.js @@ -16,7 +16,7 @@ const settings = { "profiles/codex.json", // openai-codex-responses -> codex:gpt-5.5 "profiles/openrouter.json", // openai-completions -> openrouter:moonshotai/kimi-k2.6 "profiles/kimi.json", // anthropic-messages -> kimi:kimi-k2.6 - "profiles/gemini.json", // google-generative-ai -> google:gemini-3-flash-preview + "profiles/gemini.json", // google-generative-ai -> google_relay:gemini-3.1-pro-preview // using more than 1 profile requires you to /msg each bot indivually // individual profiles override values from the base profile diff --git a/src/agent/action_manager.js b/src/agent/action_manager.js index 9b9d0d279..44c773270 100644 --- a/src/agent/action_manager.js +++ b/src/agent/action_manager.js @@ -1,3 +1,10 @@ +import assert from 'node:assert'; + +function isExpectedInterruptError(error) { + const text = `${error?.name || ''} ${error?.message || ''} ${error?.toString?.() || ''}`; + return text.includes('PathStopped') || text.includes('Path was stopped before it could be completed'); +} + export class ActionManager { constructor(agent) { this.agent = agent; @@ -23,17 +30,19 @@ export class ActionManager { } } - async stop() { - if (!this.executing) return; - const timeout = setTimeout(() => { - this.agent.cleanKill('Code execution refused stop after 10 seconds. Killing process.'); - }, 10000); + async stop({ timeoutMs = 10000 } = {}) { + if (!this.executing) return true; + const startedAt = Date.now(); while (this.executing) { this.agent.requestInterrupt(); console.log('waiting for code to finish executing...'); + if (Date.now() - startedAt >= timeoutMs) { + console.warn(`Code execution did not stop after ${timeoutMs}ms; leaving current action running.`); + return false; + } await new Promise(resolve => setTimeout(resolve, 300)); } - clearTimeout(timeout); + return true; } cancelResume() { @@ -87,7 +96,16 @@ export class ActionManager { if (this.executing) { console.log(`action "${actionLabel}" trying to interrupt current action "${this.currentActionLabel}"`); } - await this.stop(); + const previousActionLabel = this.currentActionLabel; + const stopped = await this.stop(); + if (!stopped) { + return { + success: false, + message: `Action "${previousActionLabel}" is still running; could not start "${actionLabel}". Stop was requested, but the current action did not finish within 10 seconds.`, + interrupted: true, + timedout: false + }; + } // clear bot logs and reset interrupt code this.agent.clearBotLogs(); @@ -124,23 +142,31 @@ export class ActionManager { // return action status report return { success: true, message: output, interrupted, timedout }; } catch (err) { + const interrupted = this.agent.bot.interrupt_code; 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); + if (interrupted && isExpectedInterruptError(err)) { + console.log(`Code execution interrupted during ${actionLabel}: ${err.message || err}`); + } else { + console.error("Code execution triggered catch:", err); + // Log the full stack trace + console.error(err.stack); + } await this.stop(); - err = err.toString(); - - let message = this.getBotOutputSummary() + - '!!Code threw exception!!\n' + - 'Error: ' + err + '\n' + - 'Stack trace:\n' + err.stack+'\n'; - - let interrupted = this.agent.bot.interrupt_code; + const errorText = err.toString(); + + let message; + if (interrupted && isExpectedInterruptError(err)) { + message = this.getBotOutputSummary() || 'Action interrupted before completion.'; + } else { + message = this.getBotOutputSummary() + + '!!Code threw exception!!\n' + + 'Error: ' + errorText + '\n' + + 'Stack trace:\n' + err.stack+'\n'; + } this.agent.clearBotLogs(); if (!interrupted) { this.agent.bot.emit('idle'); @@ -159,7 +185,7 @@ export class ActionManager { First outputs:\n${output.substring(0, MAX_OUT / 2)}\n...skipping many lines.\nFinal outputs:\n ${output.substring(output.length - MAX_OUT / 2)}`; } else { - output = 'Action output:\n' + output.toString(); + output = output.length > 0 ? 'Action output:\n' + output.toString() : 'Action completed with no additional output.'; } bot.output = ''; return output; @@ -174,4 +200,4 @@ export class ActionManager { }, TIMEOUT_MINS * 60 * 1000); } -} \ No newline at end of file +} diff --git a/src/agent/agent.js b/src/agent/agent.js index 99d705ea3..342bae528 100644 --- a/src/agent/agent.js +++ b/src/agent/agent.js @@ -343,7 +343,8 @@ export class Agent { if (command_name) { // contains query or command if (this.prompter.isNativeToolMode()) { - this.history.add('system', `The assistant attempted to write text command ${command_name}, but it was not executed. AI actions must use native tool calls; human !command syntax is still supported.`); + this.history.add(this.name, res); + this.history.add('system', `Text command ${command_name} was not executed. AI actions must use native tool calls; human !command syntax is still supported.`); console.warn('Agent produced text command while native tool mode is enabled:', command_name); continue; } @@ -405,28 +406,23 @@ export class Agent { const commandName = toolCall.name ? (toolCall.name.startsWith('!') ? toolCall.name : `!${toolCall.name}`) : null; if (!commandName || !commandExists(commandName)) { const msg = `Native tool ${toolCall.name || ''} does not map to a command.`; - this.history.add('system', msg); + await this.history.addNativeToolCall(toolCall); + await this.history.addNativeToolResult(toolCall, msg); console.warn(msg); continue; } this.self_prompter.handleUserPromptedCmd(self_prompt, isAction(commandName)); const display = `*used ${toolCall.name}*`; - this.history.add(this.name, display); - - if (settings.show_command_syntax !== "none") { - this.routeResponse(source, display); - } + await this.history.addNativeToolCall(toolCall); + this.routeResponse(source, display); console.log(`[native-tool] calling ${commandName} args=${formatToolArgsForLog(toolCall.arguments)}`); const execute_res = await executeCommandToolCall(this, toolCall); console.log(`[native-tool] ${commandName} result=${formatToolResultForLog(execute_res.result)}`); executedAny = true; - this.history.add('system', `Native tool call completed: ${toolCall.name}.`); - if (execute_res.result) { - this.history.add('system', execute_res.result); - } + await this.history.addNativeToolResult(toolCall, formatNativeToolResultForModel(toolCall, execute_res)); } return executedAny; } @@ -632,6 +628,18 @@ function formatToolResultForLog(result) { return truncateForLog(typeof result === 'string' ? result : JSON.stringify(result)); } +function formatNativeToolResultForModel(toolCall, executeResult) { + const result = executeResult?.result; + if (result != null && result !== '') { + return result; + } + const name = toolCall?.name || toolCall?.function?.name || 'tool'; + if (executeResult?.ok === false) { + return `Tool ${name} failed without returning details.`; + } + return `Tool ${name} completed.`; +} + function truncateForLog(value, max = 500) { const text = String(value); return text.length > max ? `${text.slice(0, max)}...` : text; diff --git a/src/agent/commands/actions.js b/src/agent/commands/actions.js index 07a0c5d76..8d1707464 100644 --- a/src/agent/commands/actions.js +++ b/src/agent/commands/actions.js @@ -18,9 +18,9 @@ function runAsAction (actionFn, resume = false, timeout = -1) { }; const code_return = await agent.actions.runAction(`action:${actionLabel}`, actionFnWithAgent, { timeout, resume }); if (code_return.interrupted && !code_return.timedout) - return; - return code_return.message; - } + return code_return.message || 'Action interrupted before completion.'; + return code_return.message || 'Action completed.'; + }; return wrappedAction; } @@ -46,15 +46,18 @@ export const actionsList = [ result = 'Error generating code: ' + e.toString(); } }; - await agent.actions.runAction('action:newAction', actionFn, {timeout: settings.code_timeout_mins}); - return result; + const code_return = await agent.actions.runAction('action:newAction', actionFn, {timeout: settings.code_timeout_mins}); + return result || code_return.message || 'newAction did not produce code or a tool result.'; } }, { name: '!stop', description: 'Force stop all actions and commands that are currently executing.', perform: async function (agent) { - await agent.actions.stop(); + const stopped = await agent.actions.stop(); + if (!stopped) { + return 'Stop requested, but the current action did not finish within 10 seconds. The agent process was kept alive.'; + } agent.clearBotLogs(); agent.actions.cancelResume(); agent.bot.emit('idle'); @@ -70,7 +73,7 @@ export const actionsList = [ perform: async function (agent) { agent.openChat('Shutting up.'); agent.shutUp(); - return; + return 'Chatting and self-prompting stopped; current action continues.'; } }, { @@ -369,9 +372,11 @@ export const actionsList = [ perform: async function (agent, prompt) { if (convoManager.inConversation()) { agent.self_prompter.setPromptPaused(prompt); + return 'Goal queued and paused until the current conversation ends.'; } else { agent.self_prompter.start(prompt); + return 'Goal started.'; } } }, @@ -416,8 +421,9 @@ export const actionsList = [ if (convoManager.inConversation() && !convoManager.inConversation(player_name)) convoManager.forceEndCurrentConversation(); 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 'You are already in conversation with ' + player_name + '. Do not use this command to talk to them.'; convoManager.startConversation(player_name, message); + return `Conversation with ${player_name} started.`; } }, { @@ -477,7 +483,7 @@ 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); }) }, { diff --git a/src/agent/history.js b/src/agent/history.js index 04a72f76d..4e6e296fc 100644 --- a/src/agent/history.js +++ b/src/agent/history.js @@ -1,6 +1,7 @@ import { writeFileSync, readFileSync, mkdirSync, existsSync } from 'fs'; import { NPCData } from './npc/data.js'; import settings from './settings.js'; +import { createNativeToolCallTurn, createNativeToolResultTurn } from '../models/native_tools.js'; export class History { @@ -67,11 +68,23 @@ export class History { role = 'user'; content = `${name}: ${content}`; } - this.turns.push({role, content}); + await this._pushTurn({role, content}); + } + + async addNativeToolCall(toolCall, content) { + await this._pushTurn(createNativeToolCallTurn(toolCall, content)); + } + + async addNativeToolResult(toolCall, result) { + await this._pushTurn(createNativeToolResultTurn(toolCall, result)); + } + + async _pushTurn(turn) { + this.turns.push(turn); if (this.turns.length >= this.max_messages) { let chunk = this.turns.splice(0, this.summary_chunk_size); - while (this.turns.length > 0 && this.turns[0].role === 'assistant') + while (this.turns.length > 0 && ['assistant', 'tool'].includes(this.turns[0].role)) chunk.push(this.turns.shift()); // remove until turns starts with system/user message await this.summarizeMemories(chunk); @@ -118,4 +131,4 @@ export class History { this.turns = []; this.memory = ''; } -} \ No newline at end of file +} diff --git a/src/models/_model_map.js b/src/models/_model_map.js index bed17aa50..39a6ea734 100644 --- a/src/models/_model_map.js +++ b/src/models/_model_map.js @@ -125,7 +125,7 @@ function normalizeProviderConfig(providerName, provider) { function apiFromFormat(format) { const normalized = String(format || '').toLowerCase(); const aliases = { - // Canonical OpenClaw-style protocol names used by llm_providers.json. + // Canonical protocol names used by llm_providers.json. 'openai-completions': 'openai-completions', 'openai-responses': 'openai-responses', 'anthropic-messages': 'anthropic-messages', diff --git a/src/models/anthropic_messages.js b/src/models/anthropic_messages.js index a40c0af1d..c7f09732b 100644 --- a/src/models/anthropic_messages.js +++ b/src/models/anthropic_messages.js @@ -1,9 +1,8 @@ import Anthropic from '@anthropic-ai/sdk'; -import { strictFormat } from '../utils/text.js'; import { getKey } from '../utils/keys.js'; -import { createNativeToolResponse, normalizeAnthropicToolUse, toAnthropicTools } from './native_tools.js'; +import { createNativeToolResponse, normalizeAnthropicToolUse, toAnthropicMessages, toAnthropicTools } from './native_tools.js'; -// OpenClaw-style Anthropic Messages protocol implementation. +// Anthropic Messages protocol implementation. export class AnthropicMessages { static prefix = 'anthropic-messages'; @@ -28,7 +27,7 @@ export class AnthropicMessages { } async sendRequest(turns, systemMessage, stop_seq='***', tools=null) { - const messages = strictFormat(turns); + const messages = toAnthropicMessages(turns); let res = null; try { console.log(tools?.length ? `Awaiting anthropic response with native tool calling (${tools.length} tools) from ${this.model_name}...` : `Awaiting anthropic response from ${this.model_name}...`); diff --git a/src/models/azure_openai_responses.js b/src/models/azure_openai_responses.js index d016ad585..ab880424e 100644 --- a/src/models/azure_openai_responses.js +++ b/src/models/azure_openai_responses.js @@ -2,7 +2,7 @@ import { AzureOpenAI } from 'openai'; import { getKey } from '../utils/keys.js'; import { OpenAICompletions } from './openai_compatible.js'; -// OpenClaw-style Azure OpenAI protocol implementation. +// Azure OpenAI protocol implementation. export class AzureOpenAIResponses extends OpenAICompletions { static prefix = 'azure-openai-responses'; diff --git a/src/models/codex_chatgpt.js b/src/models/codex_chatgpt.js index 33a50a233..3f279f4aa 100644 --- a/src/models/codex_chatgpt.js +++ b/src/models/codex_chatgpt.js @@ -3,8 +3,7 @@ import path from 'path'; import { createServer } from 'http'; import { createHash, randomBytes, randomUUID } from 'crypto'; import open from 'open'; -import { strictFormat } from '../utils/text.js'; -import { createNativeToolResponse } from './native_tools.js'; +import { createNativeToolResponse, toResponsesInputItems } from './native_tools.js'; const DEFAULT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex'; const DEFAULT_KEYS_PATH = 'llm_providers.json'; @@ -118,11 +117,10 @@ export class CodexChatGPT { } buildRequestBody(model, turns, systemMessage, tools=null) { - const messages = strictFormat(turns || []); const body = { model, instructions: systemMessage || '', - input: messages.map(toCodexResponseItem), + input: toResponsesInputItems(turns || []), tools: toCodexResponsesTools(tools || []), parallel_tool_calls: this.params.parallel_tool_calls ?? true, reasoning: this.params.reasoning ?? null, diff --git a/src/models/google_generative_ai.js b/src/models/google_generative_ai.js index f1e1f9803..18f6473c7 100644 --- a/src/models/google_generative_ai.js +++ b/src/models/google_generative_ai.js @@ -1,7 +1,6 @@ import { GoogleGenAI } from '@google/genai'; -import { strictFormat } from '../utils/text.js'; import { getKey } from '../utils/keys.js'; -import { createNativeToolResponse, normalizeGeminiFunctionCalls, toGeminiFunctionDeclarations } from './native_tools.js'; +import { createNativeToolResponse, normalizeGeminiFunctionCalls, toGeminiContents, toGeminiFunctionDeclarations } from './native_tools.js'; import { ProxyAgent, setGlobalDispatcher } from 'undici'; function setupGeminiProxy() { @@ -12,13 +11,66 @@ function setupGeminiProxy() { } setupGeminiProxy(); -// OpenClaw-style Google Generative AI protocol implementation. +const GEMINI_API_VERSION_PATTERN = /^v\d+(?:alpha|beta)?$/i; + +function splitGeminiBaseUrl(rawUrl) { + if (!rawUrl) { + return {}; + } + const parsed = new URL(rawUrl); + const segments = parsed.pathname.split('/').filter(Boolean); + const versionIndex = segments.findIndex(segment => GEMINI_API_VERSION_PATTERN.test(segment)); + if (versionIndex === -1) { + parsed.pathname = parsed.pathname.replace(/\/$/, '') || '/'; + parsed.search = ''; + parsed.hash = ''; + return { baseUrl: parsed.toString().replace(/\/$/, '') }; + } + + const baseSegments = segments.slice(0, versionIndex); + parsed.pathname = baseSegments.length > 0 ? `/${baseSegments.join('/')}` : '/'; + parsed.search = ''; + parsed.hash = ''; + return { + baseUrl: parsed.toString().replace(/\/$/, ''), + apiVersion: segments[versionIndex] + }; +} + +export function normalizeGeminiHttpOptions(url, params = {}) { + const nextParams = { ...(params || {}) }; + const httpOptions = { ...(nextParams.httpOptions || nextParams.http_options || {}) }; + const configuredUrl = url || httpOptions.baseUrl; + if (configuredUrl) { + const normalized = splitGeminiBaseUrl(configuredUrl); + httpOptions.baseUrl = normalized.baseUrl; + if (normalized.apiVersion && !httpOptions.apiVersion) { + httpOptions.apiVersion = normalized.apiVersion; + } + } + if (nextParams.apiVersion || nextParams.api_version) { + httpOptions.apiVersion = nextParams.apiVersion || nextParams.api_version; + } + + delete nextParams.httpOptions; + delete nextParams.http_options; + delete nextParams.apiVersion; + delete nextParams.api_version; + + return { + params: nextParams, + httpOptions: Object.fromEntries(Object.entries(httpOptions).filter(([, value]) => value !== undefined && value !== null)) + }; +} + +// Google Generative AI protocol implementation. export class GoogleGenerativeAI { static prefix = 'google-generative-ai'; constructor(model_name, url, params) { this.model_name = model_name; - this.params = params || {}; + const { params: generationParams, httpOptions } = normalizeGeminiHttpOptions(url, params || {}); + this.params = generationParams; const apiKeyName = this.params.apiKeyName || this.params.api_key_name || 'GEMINI_API_KEY'; delete this.params.apiKeyName; delete this.params.api_key_name; @@ -30,7 +82,11 @@ export class GoogleGenerativeAI { { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', threshold: 'BLOCK_NONE' }, ]; - this.genAI = new GoogleGenAI({ apiKey: getKey(apiKeyName) }); + const clientConfig = { apiKey: getKey(apiKeyName) }; + if (Object.keys(httpOptions).length > 0) { + clientConfig.httpOptions = httpOptions; + } + this.genAI = new GoogleGenAI(clientConfig); this.provider = 'google'; this.supportsNativeToolCalls = true; } @@ -38,11 +94,7 @@ export class GoogleGenerativeAI { async sendRequest(turns, systemMessage, stop_seq='***', tools=null) { console.log(tools?.length ? `Awaiting Google API response with native tool calling (${tools.length} tools)...` : 'Awaiting Google API response...'); - turns = strictFormat(turns); - const contents = turns.map(turn => ({ - role: turn.role === 'assistant' ? 'model' : 'user', - parts: [{ text: turn.content }] - })); + const contents = toGeminiContents(turns); const requestConfig = { model: this.model_name || 'gemini-2.5-flash', @@ -63,6 +115,10 @@ export class GoogleGenerativeAI { return createNativeToolResponse(toolCalls, this.provider); } const response = await result.text; + if (!response && result.candidates?.[0]?.finishReason) { + console.log('Received.'); + return `No response from Google Gemini. finishReason=${result.candidates[0].finishReason}`; + } console.log('Received.'); return response; @@ -76,11 +132,7 @@ export class GoogleGenerativeAI { } }; - turns = strictFormat(turns); - const contents = turns.map(turn => ({ - role: turn.role === 'assistant' ? 'model' : 'user', - parts: [{ text: turn.content }] - })); + const contents = toGeminiContents(turns); contents.push({ role: 'user', parts: [{ text: 'SYSTEM: Vision response' }, imagePart] diff --git a/src/models/native_tools.js b/src/models/native_tools.js index e54fae6e4..ade6a5951 100644 --- a/src/models/native_tools.js +++ b/src/models/native_tools.js @@ -10,6 +10,33 @@ export function createNativeToolResponse(toolCalls, provider = 'unknown') { }; } +export function createNativeToolCallTurn(toolCall, content = '') { + const [normalized] = normalizeOpenAIToolCalls([toolCall]); + return { + role: 'assistant', + content, + native_tool_calls: normalized ? [normalized] : [] + }; +} + +export function createNativeToolResultTurn(toolCall, result) { + const [normalized] = normalizeOpenAIToolCalls([toolCall]); + return { + role: 'tool', + content: stringifyToolResult(result), + tool_call_id: normalized?.id || toolCall?.id, + name: normalized?.name || toolCall?.name || toolCall?.function?.name + }; +} + +export function hasNativeToolCalls(turn) { + return Array.isArray(turn?.native_tool_calls) && turn.native_tool_calls.length > 0; +} + +export function isNativeToolResultTurn(turn) { + return turn?.role === 'tool'; +} + export function normalizeOpenAIToolCalls(toolCalls = []) { return toolCalls.map((call, index) => { const fn = call.function || {}; @@ -63,6 +90,14 @@ export function normalizeArguments(args) { return JSON.stringify(args || {}); } +export function parseNormalizedArguments(args) { + try { + return parseToolArguments(args); + } catch { + return {}; + } +} + export function parseToolArguments(args) { if (args == null || args === '') { return {}; @@ -137,6 +172,287 @@ export function toGeminiFunctionDeclarations(tools = []) { })); } +export function stringifyToolResult(result) { + if (result == null) return ''; + if (typeof result === 'string') return result; + try { + return JSON.stringify(result); + } catch { + return String(result); + } +} + +export function repairNativeToolTurns(turns = [], { synthesizeMissingResults = false } = {}) { + const repaired = []; + const pending = new Map(); + + for (const turn of turns || []) { + if (hasNativeToolCalls(turn)) { + if (synthesizeMissingResults && pending.size > 0) { + for (const call of pending.values()) { + repaired.push({ + role: 'tool', + tool_call_id: call.id, + name: call.name, + content: 'Tool result was not recorded.' + }); + } + } + pending.clear(); + repaired.push(turn); + for (const call of turn.native_tool_calls) { + pending.set(call.id, call); + } + continue; + } + + if (isNativeToolResultTurn(turn)) { + if (!turn.tool_call_id || !pending.has(turn.tool_call_id)) { + continue; + } + const call = pending.get(turn.tool_call_id); + repaired.push({ + ...turn, + name: turn.name || call.name + }); + pending.delete(turn.tool_call_id); + continue; + } + + if (synthesizeMissingResults && pending.size > 0) { + for (const call of pending.values()) { + repaired.push({ + role: 'tool', + tool_call_id: call.id, + name: call.name, + content: 'Tool result was not recorded.' + }); + } + } + pending.clear(); + repaired.push(turn); + } + + if (synthesizeMissingResults && pending.size > 0) { + for (const call of pending.values()) { + repaired.push({ + role: 'tool', + tool_call_id: call.id, + name: call.name, + content: 'Tool result was not recorded.' + }); + } + } + + return repaired; +} + +export function toOpenAIChatMessages(turns = [], systemMessage = '') { + const messages = []; + if (systemMessage) { + messages.push({ role: 'system', content: systemMessage }); + } + for (const turn of repairNativeToolTurns(turns, { synthesizeMissingResults: true })) { + if (hasNativeToolCalls(turn)) { + messages.push({ + role: 'assistant', + content: turn.content || null, + tool_calls: turn.native_tool_calls.map(toOpenAIChatToolCall) + }); + } else if (isNativeToolResultTurn(turn)) { + messages.push({ + role: 'tool', + tool_call_id: turn.tool_call_id, + content: stringifyToolResult(turn.content) + }); + } else if (turn?.role === 'system') { + messages.push({ role: 'system', content: stringifyToolResult(turn.content) }); + } else if (turn?.role === 'assistant' || turn?.role === 'user') { + messages.push({ role: turn.role, content: stringifyToolResult(turn.content) }); + } + } + if (messages.length === 0 || messages.every(message => message.role === 'system')) { + messages.push({ role: 'user', content: '_' }); + } + return messages; +} + +export function toResponsesInputItems(turns = []) { + const items = []; + for (const turn of repairNativeToolTurns(turns, { synthesizeMissingResults: true })) { + if (hasNativeToolCalls(turn)) { + for (const call of turn.native_tool_calls) { + items.push({ + type: 'function_call', + call_id: call.id, + name: call.name, + arguments: normalizeArguments(call.arguments) + }); + } + } else if (isNativeToolResultTurn(turn)) { + items.push({ + type: 'function_call_output', + call_id: turn.tool_call_id, + output: stringifyToolResult(turn.content) + }); + } else if (turn?.role === 'assistant' || turn?.role === 'user') { + items.push({ + type: 'message', + role: turn.role, + content: [{ type: turn.role === 'assistant' ? 'output_text' : 'input_text', text: stringifyToolResult(turn.content) }] + }); + } else if (turn?.role === 'system') { + items.push({ + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: `SYSTEM: ${stringifyToolResult(turn.content)}` }] + }); + } + } + if (items.length === 0) { + items.push({ type: 'message', role: 'user', content: [{ type: 'input_text', text: '_' }] }); + } + return items; +} + +export function toAnthropicMessages(turns = []) { + const messages = []; + for (const turn of repairNativeToolTurns(turns, { synthesizeMissingResults: true })) { + if (hasNativeToolCalls(turn)) { + const content = []; + if (turn.content) { + content.push({ type: 'text', text: turn.content }); + } + for (const call of turn.native_tool_calls) { + content.push({ + type: 'tool_use', + id: call.id, + name: call.name, + input: parseNormalizedArguments(call.arguments) + }); + } + messages.push({ role: 'assistant', content }); + } else if (isNativeToolResultTurn(turn)) { + messages.push({ + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: turn.tool_call_id, + content: stringifyToolResult(turn.content) + }] + }); + } else if (turn?.role === 'assistant') { + messages.push({ role: 'assistant', content: stringifyToolResult(turn.content) }); + } else if (turn?.role === 'user') { + messages.push({ role: 'user', content: stringifyToolResult(turn.content) }); + } else if (turn?.role === 'system') { + messages.push({ role: 'user', content: `SYSTEM: ${stringifyToolResult(turn.content)}` }); + } + } + return normalizeAlternatingMessages(messages); +} + +export function toGeminiContents(turns = []) { + const contents = []; + for (const turn of repairNativeToolTurns(turns, { synthesizeMissingResults: true })) { + if (hasNativeToolCalls(turn)) { + const parts = []; + if (turn.content) { + parts.push({ text: turn.content }); + } + for (const call of turn.native_tool_calls) { + parts.push({ + functionCall: { + name: call.name, + args: parseNormalizedArguments(call.arguments) + } + }); + } + contents.push({ role: 'model', parts }); + } else if (isNativeToolResultTurn(turn)) { + contents.push({ + role: 'user', + parts: [{ + functionResponse: { + name: turn.name, + response: { result: stringifyToolResult(turn.content) } + } + }] + }); + } else if (turn?.role === 'assistant') { + contents.push({ role: 'model', parts: [{ text: stringifyToolResult(turn.content) }] }); + } else if (turn?.role === 'user') { + contents.push({ role: 'user', parts: [{ text: stringifyToolResult(turn.content) }] }); + } else if (turn?.role === 'system') { + contents.push({ role: 'user', parts: [{ text: `SYSTEM: ${stringifyToolResult(turn.content)}` }] }); + } + } + if (contents.length === 0) { + contents.push({ role: 'user', parts: [{ text: '_' }] }); + } + return normalizeGeminiContents(contents); +} + +function toOpenAIChatToolCall(call) { + return { + id: call.id, + type: 'function', + function: { + name: call.name, + arguments: normalizeArguments(call.arguments) + } + }; +} + +function normalizeAlternatingMessages(messages) { + const normalized = []; + for (const message of messages) { + const previous = normalized[normalized.length - 1]; + if (previous && previous.role === message.role && canMergeAnthropicContent(previous.content, message.content)) { + previous.content = mergeAnthropicContent(previous.content, message.content); + } else { + normalized.push(message); + } + } + if (normalized.length === 0 || normalized[0].role !== 'user') { + normalized.unshift({ role: 'user', content: '_' }); + } + return normalized; +} + +function canMergeAnthropicContent(left, right) { + return (typeof left === 'string' || Array.isArray(left)) && (typeof right === 'string' || Array.isArray(right)); +} + +function mergeAnthropicContent(left, right) { + if (typeof left === 'string' && typeof right === 'string') { + return `${left}\n${right}`; + } + return [ + ...toAnthropicContentBlocks(left), + ...toAnthropicContentBlocks(right) + ]; +} + +function toAnthropicContentBlocks(content) { + if (Array.isArray(content)) return content; + if (typeof content === 'string') return [{ type: 'text', text: content }]; + return [{ type: 'text', text: stringifyToolResult(content) }]; +} + +function normalizeGeminiContents(contents) { + const normalized = []; + for (const content of contents) { + const previous = normalized[normalized.length - 1]; + if (previous && previous.role === content.role) { + previous.parts.push(...(content.parts || [])); + } else { + normalized.push(content); + } + } + return normalized; +} + function cleanGeminiSchema(schema) { if (!schema || typeof schema !== 'object') { return schema; diff --git a/src/models/openai_compatible.js b/src/models/openai_compatible.js index 75002ef07..2a52b50cc 100644 --- a/src/models/openai_compatible.js +++ b/src/models/openai_compatible.js @@ -1,7 +1,6 @@ import OpenAIApi from 'openai'; import { getKey, hasKey } from '../utils/keys.js'; -import { strictFormat } from '../utils/text.js'; -import { createNativeToolResponse } from './native_tools.js'; +import { createNativeToolResponse, toOpenAIChatMessages } from './native_tools.js'; import { HttpsProxyAgent } from 'https-proxy-agent'; function getProxyAgent() { @@ -13,7 +12,7 @@ function getProxyAgent() { } /** - * OpenClaw-style OpenAI Chat Completions protocol implementation. + * OpenAI Chat Completions protocol implementation. * * This is the single transport used by OpenAI and OpenAI-compatible hosted * providers such as OpenRouter, SiliconFlow, Qwen, DeepSeek, Groq, Mistral, @@ -63,7 +62,7 @@ export class OpenAICompletions { let res = null; try { - const messages = strictFormat([{ role: 'system', content: systemMessage }].concat(turns)); + const messages = toOpenAIChatMessages(turns, systemMessage); const pack = { model, messages, @@ -130,7 +129,7 @@ export class OpenAICompletions { } // Backward-compatible alias for old configs/tests. New configs should use -// the OpenClaw protocol name: openai-completions. +// the protocol name: openai-completions. export class OpenAICompatible extends OpenAICompletions { static prefix = 'openai-compatible'; } diff --git a/src/models/openai_responses.js b/src/models/openai_responses.js index 80d7915c4..5086e5515 100644 --- a/src/models/openai_responses.js +++ b/src/models/openai_responses.js @@ -1,8 +1,7 @@ import { OpenAICompletions } from './openai_compatible.js'; -import { strictFormat } from '../utils/text.js'; -import { createNativeToolResponse } from './native_tools.js'; +import { createNativeToolResponse, toResponsesInputItems } from './native_tools.js'; -// OpenClaw-style OpenAI Responses protocol. For native tool calls this class +// OpenAI Responses protocol. For native tool calls this class // uses Responses API function-call items directly instead of the legacy GPT file. export class OpenAIResponses extends OpenAICompletions { static prefix = 'openai-responses'; @@ -10,7 +9,10 @@ export class OpenAIResponses extends OpenAICompletions { async sendRequest(turns, systemMessage, stop_seq='***', tools=null) { const model = this.model_name || this.default_model; const hasTools = Array.isArray(tools) && tools.length > 0; - const input = strictFormat(turns).map(message => ({ ...message, content: stop_seq && !hasTools ? message.content + stop_seq : message.content })); + const input = toResponsesInputItems(turns); + if (stop_seq && !hasTools) { + appendStopSequence(input, stop_seq); + } const request = { model, instructions: systemMessage, @@ -45,6 +47,15 @@ export class OpenAIResponses extends OpenAICompletions { } } +function appendStopSequence(input, stopSeq) { + const last = input[input.length - 1]; + const content = last?.content; + if (Array.isArray(content)) { + const textPart = [...content].reverse().find(part => typeof part?.text === 'string'); + if (textPart) textPart.text += stopSeq; + } +} + function toResponsesTools(tools = []) { return tools.map(tool => ({ type: 'function', diff --git a/src/models/prompter.js b/src/models/prompter.js index 8e1d0da9b..ad1be06a8 100644 --- a/src/models/prompter.js +++ b/src/models/prompter.js @@ -242,15 +242,14 @@ export class Prompter { } let prompt = this.profile.conversing; - const promptMessages = this.isNativeToolMode() ? sanitizeNativeToolHistory(messages) : messages; - prompt = await this.replaceStrings(prompt, promptMessages, this.convo_examples); + prompt = await this.replaceStrings(prompt, messages, this.convo_examples); let generation; try { const tools = this.isNativeToolMode() ? getCommandToolDefinitions(this.agent) : null; - generation = await this.chat_model.sendRequest(promptMessages, prompt, '***', tools); + generation = await this.chat_model.sendRequest(messages, prompt, '***', tools); if (isNativeToolResponse(generation)) { - await this._saveLog(prompt, promptMessages, JSON.stringify(generation), 'conversation'); + await this._saveLog(prompt, messages, JSON.stringify(generation), 'conversation'); return generation; } if (typeof generation !== 'string') { @@ -258,7 +257,7 @@ export class Prompter { throw new Error('Generated response is not a string'); } console.log("Generated response:", generation); - await this._saveLog(prompt, promptMessages, generation, 'conversation'); + await this._saveLog(prompt, messages, generation, 'conversation'); } catch (error) { console.error('Error during message generation or file writing:', error); @@ -406,33 +405,6 @@ export function sanitizeNativeToolExamples(examples = []) { ); } -export function sanitizeNativeToolHistory(turns = []) { - if (!Array.isArray(turns)) return []; - return turns.map(turn => { - if (turn?.role !== 'assistant' || typeof turn.content !== 'string') { - return { ...turn }; - } - return { ...turn, content: sanitizeNativeToolAssistantContent(turn.content) }; - }); -} - -function sanitizeNativeToolAssistantContent(content) { - const usedTool = content.trim().match(/^\*used\s+([A-Za-z_][A-Za-z0-9_-]*)\*$/i); - if (usedTool) { - return `Used native tool ${usedTool[1]}.`; - } - - if (!containsLegacyToolSyntax(content)) { - return content; - } - - const beforeCommand = content.split(/(^|\s)![A-Za-z_][A-Za-z0-9_]*\b/)[0].trim(); - if (beforeCommand.length > 0) { - return beforeCommand; - } - return 'Requested an action using legacy text command syntax; use native tool calls for actions.'; -} - function containsLegacyToolSyntax(content) { if (typeof content !== 'string') return false; return /(^|\s)![A-Za-z_][A-Za-z0-9_]*\b|\*used\s+[A-Za-z_][A-Za-z0-9_]*\*/i.test(content); diff --git a/src/models/replicate.js b/src/models/replicate.js index 1d10d54d0..aeec4f939 100644 --- a/src/models/replicate.js +++ b/src/models/replicate.js @@ -1,5 +1,5 @@ import Replicate from 'replicate'; -import { toSinglePrompt } from '../utils/text.js'; +import { strictTextFormat, toSinglePrompt } from '../utils/text.js'; import { getKey } from '../utils/keys.js'; import { createNativeToolResponse } from './native_tools.js'; @@ -75,7 +75,7 @@ export class ReplicateAPI { async sendToolRequest(modelName, turns, systemMessage, tools) { const messages = [ { role: 'system', content: systemMessage }, - ...turns + ...strictTextFormat(turns) ]; const prompt = toSinglePrompt(turns, systemMessage, '<|EOT|>'); const input = { diff --git a/src/utils/text.js b/src/utils/text.js index 08a3b4e60..2111afe1a 100644 --- a/src/utils/text.js +++ b/src/utils/text.js @@ -2,7 +2,18 @@ export function stringifyTurns(turns) { let res = ''; for (let turn of turns) { if (turn.role === 'assistant') { - res += `\nYour output:\n${turn.content}`; + if (Array.isArray(turn.native_tool_calls) && turn.native_tool_calls.length > 0) { + if (turn.content) { + res += `\nYour output:\n${turn.content}`; + } + for (const call of turn.native_tool_calls) { + res += `\nTool call (${call.name}): ${formatToolArgumentsForSummary(call.arguments)}`; + } + } else { + res += `\nYour output:\n${turn.content}`; + } + } else if (turn.role === 'tool') { + res += `\nTool result${turn.name ? ` (${turn.name})` : ''}: ${turn.content}`; } else if (turn.role === 'system') { res += `\nSystem output: ${turn.content}`; } else { @@ -13,6 +24,22 @@ export function stringifyTurns(turns) { return res.trim(); } +function formatToolArgumentsForSummary(args) { + if (args == null || args === '') return '{}'; + if (typeof args === 'string') { + try { + return JSON.stringify(JSON.parse(args)); + } catch { + return args; + } + } + try { + return JSON.stringify(args); + } catch { + return String(args); + } +} + export function toSinglePrompt(turns, system=null, stop_seq='***', model_nickname='assistant') { let prompt = system ? `${system}${stop_seq}` : ''; let role = ''; @@ -75,4 +102,22 @@ export function strictFormat(turns) { messages.push(filler); } return messages; -} \ No newline at end of file +} + +export function strictTextFormat(turns) { + return strictFormat(turns.map(turn => { + if (turn?.role === 'tool') { + return { + role: 'system', + content: `Tool result${turn.name ? ` (${turn.name})` : ''}: ${turn.content || ''}` + }; + } + if (Array.isArray(turn?.native_tool_calls) && turn.native_tool_calls.length > 0) { + return { + role: 'assistant', + content: turn.content || turn.native_tool_calls.map(call => `Used native tool ${call.name}.`).join('\n') + }; + } + return { ...turn }; + })); +} diff --git a/tests/agent_native_text_policy.test.js b/tests/agent_native_text_policy.test.js index 3749ebf6d..9fe5466cc 100644 --- a/tests/agent_native_text_policy.test.js +++ b/tests/agent_native_text_policy.test.js @@ -1,39 +1,37 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -import { sanitizeNativeToolHistory } from '../src/models/prompter.js'; -test('agent blocks AI text commands in native tool mode without storing the bad response as assistant history', () => { +test('agent contains explicit AI text-command block in native tool mode', () => { const agentSource = readFileSync('src/agent/agent.js', 'utf8'); assert.ok(agentSource.includes('if (this.prompter.isNativeToolMode())')); - assert.ok(agentSource.includes('The assistant attempted to write text command ${command_name}, but it was not executed')); + assert.ok(agentSource.includes('Text command ${command_name} was not executed')); assert.ok(agentSource.includes('AI actions must use native tool calls')); assert.ok(agentSource.includes('continue;')); - assert.equal(agentSource.includes('this.history.add(this.name, res);\n this.history.add(\'system\''), false); }); -test('native tool execution keeps a history marker plus the tool result for continuity', () => { +test('native tool execution records structured tool calls and tool results', () => { const agentSource = readFileSync('src/agent/agent.js', 'utf8'); const nativeSection = agentSource.slice(agentSource.indexOf('async _executeNativeToolCalls')); - assert.ok(nativeSection.includes('this.history.add(this.name, display)')); - assert.ok(nativeSection.includes('Native tool call completed: ${toolCall.name}.')); - assert.ok(nativeSection.includes('this.history.add(\'system\', execute_res.result)')); + assert.ok(nativeSection.includes('this.history.addNativeToolCall(toolCall)')); + assert.ok(nativeSection.includes('this.history.addNativeToolResult(toolCall, formatNativeToolResultForModel(toolCall, execute_res))')); }); -test('native prompt history sanitizes legacy tool markers before sending history to the model', () => { - const sanitized = sanitizeNativeToolHistory([ - { role: 'assistant', content: '*used collectBlocks*' }, - { role: 'system', content: 'Collected 3 oak logs.' }, - { role: 'assistant', content: 'Sure! !craftRecipe("stick", 4)' }, - { role: 'user', content: 'player: thanks' } - ]); - - assert.deepEqual(sanitized, [ - { role: 'assistant', content: 'Used native tool collectBlocks.' }, - { role: 'system', content: 'Collected 3 oak logs.' }, - { role: 'assistant', content: 'Sure!' }, - { role: 'user', content: 'player: thanks' } - ]); +test('native tool execution sends visible progress without storing display text in history', () => { + const agentSource = readFileSync('src/agent/agent.js', 'utf8'); + const nativeSection = agentSource.slice(agentSource.indexOf('async _executeNativeToolCalls')); + + assert.ok(nativeSection.includes('const display = `*used ${toolCall.name}*`')); + assert.ok(nativeSection.includes('this.routeResponse(source, display)')); + assert.equal(nativeSection.includes('addNativeToolCall(toolCall, display)'), false); +}); + +test('native tool execution always returns a tool result to the model', () => { + const agentSource = readFileSync('src/agent/agent.js', 'utf8'); + + assert.ok(agentSource.includes('function formatNativeToolResultForModel')); + assert.ok(agentSource.includes('return `Tool ${name} completed.`')); + assert.ok(agentSource.includes('await this.history.addNativeToolResult(toolCall, msg)')); }); diff --git a/tests/codex_chatgpt.test.js b/tests/codex_chatgpt.test.js index b6e18f61c..26e1ea5d6 100644 --- a/tests/codex_chatgpt.test.js +++ b/tests/codex_chatgpt.test.js @@ -272,6 +272,40 @@ test('Codex adapter sends native-login Responses request and normalizes tool cal } }); +test('Codex adapter keeps prompt cache key stable across multi-turn tool replay', () => { + const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', { + keysPath: 'llm_providers.json', + sessionId: 'stable-cache-session' + }); + const turns = [ + { role: 'user', content: 'inspect inventory' }, + { + role: 'assistant', + content: '*used inventory*', + native_tool_calls: [{ id: 'call_1', type: 'function', name: 'inventory', arguments: '{}' }] + }, + { role: 'tool', tool_call_id: 'call_1', name: 'inventory', content: '{"oak_log":0}' }, + { role: 'user', content: 'collect wood' }, + { + role: 'assistant', + content: '*used collectBlocks*', + native_tool_calls: [{ id: 'call_2', type: 'function', name: 'collectBlocks', arguments: '{"block":"oak_log","count":2}' }] + }, + { role: 'tool', tool_call_id: 'call_2', name: 'collectBlocks', content: 'Collected 2 oak logs.' } + ]; + + const first = model.buildRequestBody('gpt-5.5', turns, 'Use tools.', [tool]); + const second = model.buildRequestBody('gpt-5.5', turns, 'Use tools.', [tool]); + + assert.equal(first.prompt_cache_key, 'stable-cache-session'); + assert.equal(second.prompt_cache_key, 'stable-cache-session'); + assert.deepEqual(second.input, first.input); + assert.deepEqual( + first.input.filter(item => item.type === 'function_call' || item.type === 'function_call_output').map(item => item.call_id), + ['call_1', 'call_1', 'call_2', 'call_2'] + ); +}); + async function waitFor(fn, timeoutMs = 1000) { const start = Date.now(); while (Date.now() - start < timeoutMs) { diff --git a/tests/llm_providers_config.test.js b/tests/llm_providers_config.test.js index a508187a5..7b3fc02ae 100644 --- a/tests/llm_providers_config.test.js +++ b/tests/llm_providers_config.test.js @@ -17,8 +17,8 @@ test('llm provider config uses readable format/baseUrl/keyName schema', () => { assert.equal('name' in provider, false, `${sectionName}.${id} should use the provider id instead of a duplicate name`); assert.equal('url' in provider, false, `${sectionName}.${id} should use baseUrl, not url`); assert.equal('apiKeyName' in provider, false, `${sectionName}.${id} should use keyName, not apiKeyName`); - assert.notEqual(provider.format, 'openai-compatible', `${sectionName}.${id} should use OpenClaw protocol names such as openai-completions`); - assert.notEqual(provider.format, 'openai-chat-completions', `${sectionName}.${id} should use OpenClaw protocol name openai-completions`); + assert.notEqual(provider.format, 'openai-compatible', `${sectionName}.${id} should use protocol names such as openai-completions`); + assert.notEqual(provider.format, 'openai-chat-completions', `${sectionName}.${id} should use protocol name openai-completions`); } } }); @@ -51,6 +51,19 @@ test('kimi uses the documented Anthropic-compatible coding endpoint', () => { }); }); +test('google relay uses Gemini base URL, not a full generateContent endpoint', () => { + assert.deepEqual(config.models.google_relay, { + format: 'google-generative-ai', + keyName: 'GEMINI_RELAY_API_KEY', + baseUrl: 'https://mydamoxing.cn', + defaultModel: 'gemini-3.1-pro-preview', + params: { + apiVersion: 'v1beta' + }, + comment: 'Gemini protocol relay. Direct endpoint would be https://mydamoxing.cn/v1beta/models/gemini-3.1-pro-preview:generateContent; keep baseUrl at the host because the SDK appends /v1beta/models/{model}:generateContent.' + }); +}); + test('PR 752 OpenAI-compatible providers live in the shared registry', () => { const providers = { ai21: ['AI21_API_KEY', 'https://api.ai21.com/studio/v1', 'jamba-1.5-large'], diff --git a/tests/memory_summary_tool_history.test.js b/tests/memory_summary_tool_history.test.js new file mode 100644 index 000000000..718ac2656 --- /dev/null +++ b/tests/memory_summary_tool_history.test.js @@ -0,0 +1,25 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { stringifyTurns } from '../src/utils/text.js'; + +test('memory summary text includes native tool calls and tool results', () => { + const summaryInput = stringifyTurns([ + { role: 'user', content: 'collect wood' }, + { + role: 'assistant', + content: '', + native_tool_calls: [{ + id: 'call_1', + type: 'function', + name: 'collectBlocks', + arguments: '{"type":"oak_log","num":2}' + }] + }, + { role: 'tool', tool_call_id: 'call_1', name: 'collectBlocks', content: 'Action output:\nCollected 2 oak_log.' } + ]); + + assert.match(summaryInput, /User input: collect wood/); + assert.match(summaryInput, /Tool call \(collectBlocks\): \{"type":"oak_log","num":2\}/); + assert.match(summaryInput, /Tool result \(collectBlocks\): Action output:\nCollected 2 oak_log\./); + assert.doesNotMatch(summaryInput, /\*used collectBlocks\*/); +}); diff --git a/tests/native_prompt_hygiene.test.js b/tests/native_prompt_hygiene.test.js index 18300ceb8..cff98ab2b 100644 --- a/tests/native_prompt_hygiene.test.js +++ b/tests/native_prompt_hygiene.test.js @@ -19,8 +19,8 @@ test('native prompt markdown rejects text-command and fake tool-call examples', const botResponder = readFileSync('profiles/defaults/prompts/_default/bot_responder.md', 'utf8'); assert.match(conversing, /native tool\/function/i); - assert.match(conversing, /Do NOT write command text/i); - assert.match(conversing, /\*used collectBlocks\*/); + assert.match(conversing, /Do NOT write legacy text commands/i); + assert.doesNotMatch(conversing, legacyToolSyntax); assert.doesNotMatch(botResponder, legacyToolSyntax); }); diff --git a/tests/native_tools.test.js b/tests/native_tools.test.js index 05609034b..4fbff3a80 100644 --- a/tests/native_tools.test.js +++ b/tests/native_tools.test.js @@ -9,7 +9,9 @@ import { isNativeToolResponse, parseToolArguments } from '../src/models/native_tools.js'; +import * as nativeTools from '../src/models/native_tools.js'; import { containsCommand, parseCommandMessage } from '../src/agent/commands/index.js'; +import { normalizeGeminiHttpOptions } from '../src/models/google_generative_ai.js'; test('human !command parser remains available', () => { assert.equal(containsCommand('please !stats'), '!stats'); @@ -79,3 +81,138 @@ test('tool execution adapter coerces args and calls command implementation', asy assert.equal(result.ok, true); assert.equal(result.result, 'count=3; enabled=true'); }); + +test('native tool turns serialize to protocol-specific tool result fields', () => { + const call = { + id: 'call_1', + type: 'function', + name: 'sample', + arguments: '{"count":2}' + }; + const turns = [ + { role: 'user', content: 'do it' }, + { role: 'assistant', content: '*used sample*', native_tool_calls: [call] }, + { role: 'tool', tool_call_id: 'call_1', name: 'sample', content: 'count=2' } + ]; + + const openAI = nativeTools.toOpenAIChatMessages(turns, 'system prompt'); + assert.equal(openAI[0].role, 'system'); + assert.deepEqual(openAI[2].tool_calls[0].function, { name: 'sample', arguments: '{"count":2}' }); + assert.equal(openAI[3].role, 'tool'); + assert.equal(openAI[3].tool_call_id, 'call_1'); + + const responses = nativeTools.toResponsesInputItems(turns); + assert.equal(responses[1].type, 'function_call'); + assert.equal(Object.prototype.hasOwnProperty.call(responses[1], 'id'), false); + assert.equal(responses[2].type, 'function_call_output'); + assert.equal(responses[2].call_id, 'call_1'); + + const anthropic = nativeTools.toAnthropicMessages(turns); + assert.equal(anthropic[1].content[0].type, 'text'); + assert.equal(anthropic[1].content[1].type, 'tool_use'); + assert.equal(anthropic[2].content[0].type, 'tool_result'); + + const gemini = nativeTools.toGeminiContents(turns); + assert.equal(gemini[1].parts[1].functionCall.name, 'sample'); + assert.equal(gemini[2].parts[0].functionResponse.name, 'sample'); +}); + +test('native tool turn repair drops orphan results and can synthesize missing results', () => { + const call = { + id: 'call_1', + type: 'function', + name: 'sample', + arguments: '{}' + }; + const turns = [ + { role: 'tool', tool_call_id: 'orphan', name: 'sample', content: 'old orphan' }, + { role: 'assistant', content: '', native_tool_calls: [call] }, + { role: 'user', content: 'next prompt' } + ]; + + const openAI = nativeTools.toOpenAIChatMessages(turns); + assert.equal(openAI.some(message => message.role === 'tool' && message.tool_call_id === 'orphan'), false); + + const anthropic = nativeTools.toAnthropicMessages(turns); + assert.equal(anthropic[1].content[0].type, 'tool_use'); + assert.equal(anthropic[2].content[0].type, 'tool_result'); + assert.equal(anthropic[2].content[0].tool_use_id, 'call_1'); +}); + +test('multi-turn native tool replay remains deterministic across protocols', () => { + const firstCall = { id: 'call_1', type: 'function', name: 'inventory', arguments: '{}' }; + const secondCall = { id: 'call_2', type: 'function', name: 'collectBlocks', arguments: '{"block":"oak_log","count":2}' }; + const turns = [ + { role: 'user', content: 'what do you have?' }, + { role: 'assistant', content: '*used inventory*', native_tool_calls: [firstCall] }, + { role: 'tool', tool_call_id: 'call_1', name: 'inventory', content: '{"oak_log":0}' }, + { role: 'assistant', content: 'I need wood.' }, + { role: 'user', content: 'collect two logs' }, + { role: 'assistant', content: '*used collectBlocks*', native_tool_calls: [secondCall] }, + { role: 'tool', tool_call_id: 'call_2', name: 'collectBlocks', content: 'Collected 2 oak logs.' }, + { role: 'assistant', content: 'Done.' } + ]; + + const openAIFirst = nativeTools.toOpenAIChatMessages(turns, 'system prompt'); + const openAISecond = nativeTools.toOpenAIChatMessages(turns, 'system prompt'); + assert.deepEqual(openAISecond, openAIFirst); + assert.equal(openAIFirst.filter(message => message.role === 'tool').length, 2); + assert.deepEqual( + openAIFirst.filter(message => message.tool_call_id).map(message => message.tool_call_id), + ['call_1', 'call_2'] + ); + + const responsesFirst = nativeTools.toResponsesInputItems(turns); + const responsesSecond = nativeTools.toResponsesInputItems(turns); + assert.deepEqual(responsesSecond, responsesFirst); + assert.deepEqual( + responsesFirst.filter(item => item.type === 'function_call' || item.type === 'function_call_output').map(item => item.call_id), + ['call_1', 'call_1', 'call_2', 'call_2'] + ); + + const anthropicFirst = nativeTools.toAnthropicMessages(turns); + const anthropicSecond = nativeTools.toAnthropicMessages(turns); + assert.deepEqual(anthropicSecond, anthropicFirst); + assert.equal(JSON.stringify(anthropicFirst).includes('"tool_use"'), true); + assert.equal(JSON.stringify(anthropicFirst).includes('"tool_result"'), true); + + const geminiFirst = nativeTools.toGeminiContents(turns); + const geminiSecond = nativeTools.toGeminiContents(turns); + assert.deepEqual(geminiSecond, geminiFirst); + assert.equal(JSON.stringify(geminiFirst).includes('"functionCall"'), true); + assert.equal(JSON.stringify(geminiFirst).includes('"functionResponse"'), true); +}); + +test('Gemini relay URL normalization keeps only the API root in baseUrl', () => { + assert.deepEqual( + normalizeGeminiHttpOptions( + 'https://mydamoxing.cn/v1beta/models/gemini-3.1-pro-preview:generateContent', + {} + ).httpOptions, + { + baseUrl: 'https://mydamoxing.cn', + apiVersion: 'v1beta' + } + ); + + assert.deepEqual( + normalizeGeminiHttpOptions('https://mydamoxing.cn', { apiVersion: 'v1beta' }).httpOptions, + { + baseUrl: 'https://mydamoxing.cn', + apiVersion: 'v1beta' + } + ); +}); + +test('repair inserts missing result before the next assistant tool call', () => { + const turns = [ + { role: 'assistant', content: '', native_tool_calls: [{ id: 'call_1', type: 'function', name: 'inventory', arguments: '{}' }] }, + { role: 'assistant', content: '', native_tool_calls: [{ id: 'call_2', type: 'function', name: 'collectBlocks', arguments: '{}' }] }, + { role: 'tool', tool_call_id: 'call_2', name: 'collectBlocks', content: 'ok' } + ]; + + const repaired = nativeTools.repairNativeToolTurns(turns, { synthesizeMissingResults: true }); + assert.deepEqual(repaired.map(turn => turn.role), ['assistant', 'tool', 'assistant', 'tool']); + assert.equal(repaired[1].tool_call_id, 'call_1'); + assert.equal(repaired[3].tool_call_id, 'call_2'); +}); diff --git a/tests/openai_compatible.test.js b/tests/openai_compatible.test.js index f5af96b0d..cd2edee24 100644 --- a/tests/openai_compatible.test.js +++ b/tests/openai_compatible.test.js @@ -18,7 +18,7 @@ const tool = { } }; -test('OpenClaw openai-completions API format can be selected by profile', () => { +test('openai-completions API format can be selected by profile', () => { const profile = selectAPI({ api: 'openai-completions', url: 'https://example.test/v1', @@ -170,7 +170,8 @@ test('openai-compatible transport sends Chat Completions tools and normalizes to ); assert.equal(requestPack.model, 'provider-model'); - assert.equal(requestPack.messages[0].role, 'user'); + assert.equal(requestPack.messages[0].role, 'system'); + assert.equal(requestPack.messages[1].role, 'user'); assert.equal(requestPack.tools[0].function.name, 'report_status'); assert.equal(Object.prototype.hasOwnProperty.call(requestPack, 'tool_choice'), false); assert.equal(isNativeToolResponse(response), true); diff --git a/tests/smoke/live_function_call_smoke.js b/tests/smoke/live_function_call_smoke.js index 00ed21db9..cacb5a004 100644 --- a/tests/smoke/live_function_call_smoke.js +++ b/tests/smoke/live_function_call_smoke.js @@ -89,8 +89,8 @@ for (const testCase of selectedCases) { const model = testCase.create(); const response = await withTimeout( model.sendRequest( - [{ role: 'user', content: `Call report_status with status ok and provider ${testCase.name}. Do not answer in text.` }], - 'You are running a live function-calling smoke test. Use the provided function.', + [{ role: 'user', content: `report status ok for ${testCase.name}` }], + 'Use report_status.', '***', tools ), diff --git a/tests/smoke/live_model_matrix.js b/tests/smoke/live_model_matrix.js index 9dec5171f..715e9da4d 100644 --- a/tests/smoke/live_model_matrix.js +++ b/tests/smoke/live_model_matrix.js @@ -116,8 +116,8 @@ async function runToolCheck(testCase) { const model = createModel(selectAPI(testCase.profileModel)); const response = await withTimeout( model.sendRequest( - [{ role: 'user', content: `Call report_status with status ok and provider ${testCase.provider || testCase.profile}. Do not answer in text.` }], - 'You are running a live native function-calling smoke test. Use the provided function.', + [{ role: 'user', content: `report status ok for ${testCase.provider || testCase.profile}` }], + 'Use report_status.', '***', [tool] ), @@ -164,6 +164,7 @@ mkdirSync(OUTPUT_DIR, { recursive: true }); writeFileSync(OUTPUT_FILE, toCsv(rows)); const summary = summarize(rows); console.log(JSON.stringify({ output: OUTPUT_FILE, selected: selected.length, summary }, null, 2)); +process.exit(summary.failed > 0 ? 1 : 0); function loadProviderDefaultCases() { return Object.entries(providerConfig.models || {}) diff --git a/tests/tool_result_policy.test.js b/tests/tool_result_policy.test.js new file mode 100644 index 000000000..0ecbcdc58 --- /dev/null +++ b/tests/tool_result_policy.test.js @@ -0,0 +1,32 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +test('action tools provide explicit LLM-visible results even when game output is empty', () => { + const actionManagerSource = readFileSync('src/agent/action_manager.js', 'utf8'); + const actionsSource = readFileSync('src/agent/commands/actions.js', 'utf8'); + + assert.ok(actionManagerSource.includes('Action completed with no additional output.')); + assert.ok(actionsSource.includes('Action interrupted before completion.')); + assert.ok(actionsSource.includes('Action completed.')); +}); + +test('stuck action interruption reports busy state instead of killing the process', () => { + const actionManagerSource = readFileSync('src/agent/action_manager.js', 'utf8'); + const actionsSource = readFileSync('src/agent/commands/actions.js', 'utf8'); + + assert.equal(actionManagerSource.includes('Code execution refused stop after 10 seconds. Killing process.'), false); + assert.ok(actionManagerSource.includes('could not start')); + assert.ok(actionManagerSource.includes('leaving current action running')); + assert.ok(actionsSource.includes('The agent process was kept alive.')); + assert.ok(actionsSource.includes('code_return.message')); + assert.ok(actionsSource.includes('newAction did not produce code or a tool result.')); +}); + +test('conversation action reports status through tool result instead of system history', () => { + const actionsSource = readFileSync('src/agent/commands/actions.js', 'utf8'); + const startConversationSection = actionsSource.slice(actionsSource.indexOf("name: '!startConversation'")); + + assert.ok(startConversationSection.includes('Conversation with ${player_name} started.')); + assert.equal(startConversationSection.includes("agent.history.add('system'"), false); +}); From 6ef67b97af36eeb2933e88113ac157fc717aa797 Mon Sep 17 00:00:00 2001 From: NinotQuyi Date: Fri, 1 May 2026 23:11:41 +0800 Subject: [PATCH 15/56] Make the default Mindcraft configuration self-documenting The default runtime now starts from the root Andy profile and keeps the larger provider catalog as commented presets at the bottom of settings.js. Profiles expose empty embedding/code/vision model selectors so users can discover the shape without enabling extra transports, while empty selectors still fall back to the main model. The same pass removes the obsolete Google Translate dependency and adds JSONL chat-history tracing so prompt, tool-call, tool-result, and compression behavior can be inspected after a run. Constraint: Codex ChatGPT auth must remain project-local and the example auth placeholder stays shortened for first-run login testing. Rejected: Keep five smoke profiles enabled by default | too noisy for normal startup. Rejected: Preserve per-profile Andy reasoning prompts | duplicated global prompt templates and diverged memory language. Confidence: high Scope-risk: moderate Directive: Keep defaults/tasks profile fragments free of selectable model placeholders; only root selectable profiles should expose them. Tested: npm test (65/65); eslint on touched JS/tests; git diff --check Not-tested: Manual Minecraft startup after commit Co-authored-by: OmX --- andy.json | 21 +++- package.json | 3 +- profiles/andy-4-reasoning.json | 16 ++- profiles/andy-4.json | 8 ++ profiles/azure.json | 12 ++ profiles/cerebras.json | 12 ++ profiles/claude.json | 12 ++ profiles/claude_thinker.json | 12 ++ profiles/codex.json | 12 ++ profiles/deepseek.json | 12 ++ profiles/freeguy.json | 14 ++- profiles/gemini.json | 14 ++- profiles/gpt.json | 12 ++ profiles/grok.json | 12 ++ profiles/groq.json | 12 ++ profiles/kimi.json | 12 ++ profiles/llama.json | 14 ++- profiles/mercury.json | 12 ++ profiles/minimax-cn.json | 12 ++ profiles/minimax-intl.json | 12 ++ profiles/mistral.json | 12 ++ profiles/novita.json | 12 ++ profiles/ollama.json | 12 ++ profiles/openrouter.json | 12 ++ profiles/qwen-cn.json | 8 ++ profiles/replicate.json | 8 ++ profiles/siliconflow.json | 12 ++ profiles/vllm.json | 12 ++ settings.js | 67 ++++++++--- src/agent/agent.js | 20 ++-- src/agent/history.js | 146 +++++++++++++++++++++++- src/mindcraft/public/settings_spec.json | 7 +- src/models/prompter.js | 34 +++++- src/utils/translator.js | 30 ----- tests/chat_history_trace.test.js | 60 ++++++++++ tests/llm_providers_config.test.js | 1 + tests/profile_shape.test.js | 79 ++++++++++--- 37 files changed, 677 insertions(+), 101 deletions(-) delete mode 100644 src/utils/translator.js create mode 100644 tests/chat_history_trace.test.js diff --git a/andy.json b/andy.json index 5a495a05a..01225eef3 100644 --- a/andy.json +++ b/andy.json @@ -1,6 +1,19 @@ { "name": "andy", - - "model": "gpt-5.4-mini" - -} \ No newline at end of file + "model": { + "provider": "openai", + "model": "gpt-5.4-mini" + }, + "embedding": { + "provider": "", + "model": "" + }, + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" + } +} diff --git a/package.json b/package.json index 07c158eaa..02792f504 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,9 @@ "eslint-plugin-no-floating-promise": "^2.0.0", "express": "^4.18.2", "globals": "^15.11.0", - "google-translate-api-x": "^10.7.1", "groq-sdk": "^0.15.0", - "minecraft-data": "^3.97.0", "minecraft-assets": "^1.16.0", + "minecraft-data": "^3.97.0", "mineflayer": "^4.33.0", "mineflayer-armor-manager": "^2.0.1", "mineflayer-auto-eat": "^3.3.6", diff --git a/profiles/andy-4-reasoning.json b/profiles/andy-4-reasoning.json index 39fc0ee31..8f286f0f1 100644 --- a/profiles/andy-4-reasoning.json +++ b/profiles/andy-4-reasoning.json @@ -4,12 +4,16 @@ "provider": "ollama_local", "model": "sweaterdog/andy-4:micro-q8_0" }, - "conversing": { - "prompt_file": "prompts/_default/conversing.md" + "embedding": { + "provider": "", + "model": "" }, - "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. Think deeply before responding. 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": "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": { - "prompt_file": "prompts/_default/bot_responder.md" + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" } } diff --git a/profiles/andy-4.json b/profiles/andy-4.json index 5f2dca42c..86cb32d09 100644 --- a/profiles/andy-4.json +++ b/profiles/andy-4.json @@ -7,5 +7,13 @@ "embedding": { "provider": "ollama_local", "model": "embeddinggemma" + }, + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" } } diff --git a/profiles/azure.json b/profiles/azure.json index d8582f243..4e0c5d6a1 100644 --- a/profiles/azure.json +++ b/profiles/azure.json @@ -3,5 +3,17 @@ "model": { "provider": "azure", "model": "gpt-5-nano" + }, + "embedding": { + "provider": "", + "model": "" + }, + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" } } diff --git a/profiles/cerebras.json b/profiles/cerebras.json index 80cef4e0c..d830effe7 100644 --- a/profiles/cerebras.json +++ b/profiles/cerebras.json @@ -3,5 +3,17 @@ "model": { "provider": "cerebras", "model": "gpt-oss-120b" + }, + "embedding": { + "provider": "", + "model": "" + }, + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" } } diff --git a/profiles/claude.json b/profiles/claude.json index 51c1f2bda..c0b1cc820 100644 --- a/profiles/claude.json +++ b/profiles/claude.json @@ -3,5 +3,17 @@ "model": { "provider": "anthropic", "model": "claude-sonnet-4-6" + }, + "embedding": { + "provider": "", + "model": "" + }, + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" } } diff --git a/profiles/claude_thinker.json b/profiles/claude_thinker.json index 0388f3ea7..28aaacfaa 100644 --- a/profiles/claude_thinker.json +++ b/profiles/claude_thinker.json @@ -9,5 +9,17 @@ "budget_tokens": 4000 } } + }, + "embedding": { + "provider": "", + "model": "" + }, + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" } } diff --git a/profiles/codex.json b/profiles/codex.json index 822fa1b5e..2341108b7 100644 --- a/profiles/codex.json +++ b/profiles/codex.json @@ -8,5 +8,17 @@ "effort": "high" } } + }, + "embedding": { + "provider": "", + "model": "" + }, + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" } } diff --git a/profiles/deepseek.json b/profiles/deepseek.json index 6d24357f3..76ebd8842 100644 --- a/profiles/deepseek.json +++ b/profiles/deepseek.json @@ -3,5 +3,17 @@ "model": { "provider": "deepseek", "model": "deepseek-v4-pro" + }, + "embedding": { + "provider": "", + "model": "" + }, + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" } } diff --git a/profiles/freeguy.json b/profiles/freeguy.json index 282f50b88..f3ba8ec85 100644 --- a/profiles/freeguy.json +++ b/profiles/freeguy.json @@ -4,5 +4,17 @@ "provider": "groq", "model": "llama-3.3-70b-versatile" }, - "max_tokens": 8000 + "max_tokens": 8000, + "embedding": { + "provider": "", + "model": "" + }, + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" + } } diff --git a/profiles/gemini.json b/profiles/gemini.json index 12bf4492d..62da863d5 100644 --- a/profiles/gemini.json +++ b/profiles/gemini.json @@ -5,5 +5,17 @@ "model": "gemini-3.1-pro-preview" }, "speak_model": "google_relay/gemini-3.1-pro-preview/Kore", - "cooldown": 2000 + "cooldown": 2000, + "embedding": { + "provider": "", + "model": "" + }, + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" + } } diff --git a/profiles/gpt.json b/profiles/gpt.json index 3ae4fa5f7..036922ac3 100644 --- a/profiles/gpt.json +++ b/profiles/gpt.json @@ -8,5 +8,17 @@ "effort": "medium" } } + }, + "embedding": { + "provider": "", + "model": "" + }, + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" } } diff --git a/profiles/grok.json b/profiles/grok.json index ed51fefbb..f9ce298a0 100644 --- a/profiles/grok.json +++ b/profiles/grok.json @@ -3,5 +3,17 @@ "model": { "provider": "xai", "model": "grok-4-fast-reasoning" + }, + "embedding": { + "provider": "", + "model": "" + }, + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" } } diff --git a/profiles/groq.json b/profiles/groq.json index 882a58cef..50e1c47d9 100644 --- a/profiles/groq.json +++ b/profiles/groq.json @@ -3,5 +3,17 @@ "model": { "provider": "groq", "model": "qwen/qwen3-32b" + }, + "embedding": { + "provider": "", + "model": "" + }, + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" } } diff --git a/profiles/kimi.json b/profiles/kimi.json index 6522301bc..1f10b308b 100644 --- a/profiles/kimi.json +++ b/profiles/kimi.json @@ -3,5 +3,17 @@ "model": { "provider": "kimi", "model": "kimi-k2.6" + }, + "embedding": { + "provider": "", + "model": "" + }, + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" } } diff --git a/profiles/llama.json b/profiles/llama.json index a7df22727..7e7f8b5d0 100644 --- a/profiles/llama.json +++ b/profiles/llama.json @@ -4,5 +4,17 @@ "provider": "groq", "model": "llama-3.3-70b-versatile" }, - "max_tokens": 4000 + "max_tokens": 4000, + "embedding": { + "provider": "", + "model": "" + }, + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" + } } diff --git a/profiles/mercury.json b/profiles/mercury.json index 4b0de5fbb..a7050030c 100644 --- a/profiles/mercury.json +++ b/profiles/mercury.json @@ -4,5 +4,17 @@ "model": { "provider": "mercury", "model": "mercury-coder-small" + }, + "embedding": { + "provider": "", + "model": "" + }, + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" } } diff --git a/profiles/minimax-cn.json b/profiles/minimax-cn.json index 11167b92b..49e6e0b7f 100644 --- a/profiles/minimax-cn.json +++ b/profiles/minimax-cn.json @@ -3,5 +3,17 @@ "model": { "provider": "minimax_cn", "model": "MiniMax-M2.7" + }, + "embedding": { + "provider": "", + "model": "" + }, + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" } } diff --git a/profiles/minimax-intl.json b/profiles/minimax-intl.json index 128081cc5..f40d7cd53 100644 --- a/profiles/minimax-intl.json +++ b/profiles/minimax-intl.json @@ -3,5 +3,17 @@ "model": { "provider": "minimax_intl", "model": "MiniMax-M2.7" + }, + "embedding": { + "provider": "", + "model": "" + }, + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" } } diff --git a/profiles/mistral.json b/profiles/mistral.json index 08c525ecc..d46c78a1f 100644 --- a/profiles/mistral.json +++ b/profiles/mistral.json @@ -3,5 +3,17 @@ "model": { "provider": "mistral", "model": "mistral-medium-latest" + }, + "embedding": { + "provider": "", + "model": "" + }, + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" } } diff --git a/profiles/novita.json b/profiles/novita.json index 8d5519fe4..e255eea85 100644 --- a/profiles/novita.json +++ b/profiles/novita.json @@ -3,5 +3,17 @@ "model": { "provider": "novita", "model": "deepseek/deepseek-v4-flash" + }, + "embedding": { + "provider": "", + "model": "" + }, + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" } } diff --git a/profiles/ollama.json b/profiles/ollama.json index 358a82d3c..32a1abe5b 100644 --- a/profiles/ollama.json +++ b/profiles/ollama.json @@ -3,5 +3,17 @@ "model": { "provider": "ollama", "model": "gpt-oss:120b-cloud" + }, + "embedding": { + "provider": "", + "model": "" + }, + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" } } diff --git a/profiles/openrouter.json b/profiles/openrouter.json index a8c9df661..c3a7323f5 100644 --- a/profiles/openrouter.json +++ b/profiles/openrouter.json @@ -3,5 +3,17 @@ "model": { "provider": "openrouter", "model": "moonshotai/kimi-k2.6" + }, + "embedding": { + "provider": "", + "model": "" + }, + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" } } diff --git a/profiles/qwen-cn.json b/profiles/qwen-cn.json index d73596b8b..7b0c1a948 100644 --- a/profiles/qwen-cn.json +++ b/profiles/qwen-cn.json @@ -8,5 +8,13 @@ "embedding": { "provider": "qwen_cn", "model": "text-embedding-v3" + }, + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" } } diff --git a/profiles/replicate.json b/profiles/replicate.json index 149042251..5c7dfbfd7 100644 --- a/profiles/replicate.json +++ b/profiles/replicate.json @@ -7,5 +7,13 @@ "embedding": { "provider": "replicate", "model": "mark3labs/embeddings-gte-base" + }, + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" } } diff --git a/profiles/siliconflow.json b/profiles/siliconflow.json index 8b3381452..6129dcfbc 100644 --- a/profiles/siliconflow.json +++ b/profiles/siliconflow.json @@ -3,5 +3,17 @@ "model": { "provider": "siliconflow", "model": "deepseek-ai/DeepSeek-V4-Flash" + }, + "embedding": { + "provider": "", + "model": "" + }, + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" } } diff --git a/profiles/vllm.json b/profiles/vllm.json index 29ecf2e55..97c7c845a 100644 --- a/profiles/vllm.json +++ b/profiles/vllm.json @@ -3,5 +3,17 @@ "model": { "provider": "vllm", "model": "Qwen/Qwen2.5-1.5B-Instruct" + }, + "embedding": { + "provider": "", + "model": "" + }, + "code_model": { + "provider": "", + "model": "" + }, + "vision_model": { + "provider": "", + "model": "" } } diff --git a/settings.js b/settings.js index a58be90cc..b74e19d48 100644 --- a/settings.js +++ b/settings.js @@ -9,19 +9,6 @@ const settings = { "auto_open_ui": true, // opens UI in browser on startup "base_profile": "assistant", // survival, assistant, creative, or god_mode - "llm_providers": "llm_providers.json", // project-level LLM keys/model/embedding registry - "profiles": [ - // Protocol representative native-tool smoke profiles. - "profiles/gpt.json", // openai-responses -> openai:gpt-5.5 - "profiles/codex.json", // openai-codex-responses -> codex:gpt-5.5 - "profiles/openrouter.json", // openai-completions -> openrouter:moonshotai/kimi-k2.6 - "profiles/kimi.json", // anthropic-messages -> kimi:kimi-k2.6 - "profiles/gemini.json", // google-generative-ai -> google_relay:gemini-3.1-pro-preview - - // 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 "init_message": "Use native tool calling to check your inventory, then say: native tool check complete.", // 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 @@ -33,10 +20,9 @@ const settings = { // Works on windows and mac, but linux requires you to install the espeak package through your package manager eg: `apt install espeak` `pacman -S espeak`. "chat_ingame": true, // bot responses are shown in minecraft chat - "language": "en", // translate to/from this language. Supports these language names: https://cloud.google.com/translate/docs/languages "render_bot_view": false, // show bot's view in browser at localhost:3000, 3001... - "allow_insecure_coding": true, // allows newAction command and model can write/run code on your computer. enable at own risk + "allow_insecure_coding": false, // allows newAction command and model can write/run code on your computer. enable at own risk "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 @@ -52,7 +38,58 @@ const settings = { "spawn_timeout": 30, // num seconds allowed for the bot to spawn before throwing error. Increase when spawning takes a while. "block_place_delay": 0, // delay between placing blocks (ms) if using newAction. helps avoid bot being kicked by anti-cheat mechanisms on servers. + "log_all_prompts": false, // log ALL prompts to file + + "llm_providers": "llm_providers.json", // project-level LLM keys/model/embedding registry + "profiles": [ + // Default enabled agent. Using more than one profile requires you to /msg each bot individually. + "andy.json", // Default Andy profile at the project root + + // Mainstream preset profiles. Uncomment one or more to launch them. + // Protocol representative native-tool smoke profiles + // "profiles/gpt.json", // OpenAI Responses: openai:gpt-5.5 + // "profiles/codex.json", // Codex ChatGPT login: codex:gpt-5.5 + // "profiles/openrouter.json",// OpenRouter / OpenAI Chat Completions: moonshotai/kimi-k2.6 + // "profiles/kimi.json", // Kimi Anthropic-compatible: kimi-k2.6 + // "profiles/gemini.json", // Gemini / google-generative-ai: gemini-3.1-pro-preview + + // OpenAI / ChatGPT + // "profiles/gpt.json", + // "profiles/codex.json", + // "profiles/azure.json", + + // Anthropic / Claude-compatible + // "profiles/claude.json", + // "profiles/claude_thinker.json", + // "profiles/kimi.json", + // "profiles/minimax-cn.json", + // "profiles/minimax-intl.json", + + // Google / Gemini + // "profiles/gemini.json", + + // OpenAI-compatible providers and model routers + // "profiles/openrouter.json", + // "profiles/deepseek.json", + // "profiles/qwen-cn.json", + // "profiles/siliconflow.json", + // "profiles/mistral.json", + // "profiles/grok.json", + // "profiles/groq.json", + // "profiles/cerebras.json", + // "profiles/mercury.json", + // "profiles/novita.json", + // "profiles/ollama.json", + + // Replicate and local/custom runtimes + // "profiles/replicate.json", + // "profiles/llama.json", + // "profiles/vllm.json", + // "profiles/andy-4.json", + // "profiles/andy-4-reasoning.json", + // "profiles/freeguy.json", + ], }; export default settings; diff --git a/src/agent/agent.js b/src/agent/agent.js index 342bae528..85d9d09c0 100644 --- a/src/agent/agent.js +++ b/src/agent/agent.js @@ -12,7 +12,6 @@ import { NPCContoller } from './npc/controller.js'; import { MemoryBank } from './memory_bank.js'; import { SelfPrompter } from './self_prompter.js'; import convoManager from './conversation.js'; -import { handleTranslation, handleEnglishTranslation } from '../utils/translator.js'; import { addBrowserViewer } from './vision/browser_viewer.js'; import { serverProxy, sendOutputToServer } from './mindserver_proxy.js'; import settings from './settings.js'; @@ -171,8 +170,7 @@ export class Agent { console.warn('received whisper from other bot??'); } else { - let translation = await handleEnglishTranslation(message); - this.handleMessage(username, translation); + this.handleMessage(username, message); } } catch (error) { console.error('Error handling message:', error); @@ -294,8 +292,6 @@ export class Agent { if (from_other_bot) this.last_sender = source; - // Now translate the message - message = await handleEnglishTranslation(message); console.log('received message from', source, ':', message); const checkInterrupt = () => this.self_prompter.shouldInterrupt(self_prompt) || this.shut_up || convoManager.responseScheduledFor(source); @@ -448,15 +444,15 @@ export class Agent { } async openChat(message) { - let to_translate = message; + let spokenMessage = message; let remaining = ''; let command_name = containsCommand(message); - let translate_up_to = command_name ? message.indexOf(command_name) : -1; - if (translate_up_to != -1) { // don't translate the command - to_translate = to_translate.substring(0, translate_up_to); - remaining = message.substring(translate_up_to); + let commandStart = command_name ? message.indexOf(command_name) : -1; + if (commandStart !== -1) { + spokenMessage = spokenMessage.substring(0, commandStart); + remaining = message.substring(commandStart); } - message = (await handleTranslation(to_translate)).trim() + " " + remaining; + message = spokenMessage.trim() + " " + remaining; // newlines are interpreted as separate chats, which triggers spam filters. replace them with spaces message = message.replaceAll('\n', ' '); @@ -467,7 +463,7 @@ export class Agent { } else { if (settings.speak) { - speak(to_translate, this.prompter.profile.speak_model); + speak(spokenMessage, this.prompter.profile.speak_model); } if (settings.chat_ingame) {this.bot.chat(message);} sendOutputToServer(this.name, message); diff --git a/src/agent/history.js b/src/agent/history.js index 4e6e296fc..b84b1c473 100644 --- a/src/agent/history.js +++ b/src/agent/history.js @@ -1,4 +1,4 @@ -import { writeFileSync, readFileSync, mkdirSync, existsSync } from 'fs'; +import { writeFileSync, readFileSync, mkdirSync, existsSync, appendFileSync } from 'fs'; import { NPCData } from './npc/data.js'; import settings from './settings.js'; import { createNativeToolCallTurn, createNativeToolResultTurn } from '../models/native_tools.js'; @@ -10,8 +10,12 @@ export class History { this.name = agent.name; this.memory_fp = `./bots/${this.name}/memory.json`; this.full_history_fp = undefined; + this.chat_history_dir = `./bots/${this.name}/chat-history`; + this.chat_history_session_fp = undefined; + this.chat_history_latest_fp = `./bots/${this.name}/chat_history.jsonl`; mkdirSync(`./bots/${this.name}/histories`, { recursive: true }); + mkdirSync(this.chat_history_dir, { recursive: true }); this.turns = []; @@ -25,6 +29,8 @@ export class History { this.summary_chunk_size = 5; // chunking reduces expensive calls to promptMemSaving and appendFullHistory // and improves the quality of the memory summary + + this._initChatHistoryTrace(); } getHistory() { // expects an Examples object @@ -33,6 +39,7 @@ export class History { async summarizeMemories(turns) { console.log("Storing memories..."); + const previousMemory = this.memory; this.memory = await this.agent.prompter.promptMemSaving(turns); if (this.memory.length > 500) { @@ -41,6 +48,11 @@ export class History { } console.log("Memory updated to: ", this.memory); + this.traceEvent('memory_compression_completed', { + previous_memory: previousMemory, + new_memory: this.memory, + compressed_turns: turns + }); } async appendFullHistory(to_store) { @@ -54,8 +66,10 @@ export class History { let full_history = JSON.parse(data); full_history.push(...to_store); writeFileSync(this.full_history_fp, JSON.stringify(full_history, null, 4), 'utf8'); + return this.full_history_fp; } catch (err) { console.error(`Error reading ${this.name}'s full history file: ${err.message}`); + return null; } } @@ -73,22 +87,44 @@ export class History { async addNativeToolCall(toolCall, content) { await this._pushTurn(createNativeToolCallTurn(toolCall, content)); + this.traceEvent('tool_call', { + tool_call: toolCall, + content: content || '' + }); } async addNativeToolResult(toolCall, result) { await this._pushTurn(createNativeToolResultTurn(toolCall, result)); + this.traceEvent('tool_result', { + tool_call: toolCall, + result + }); } async _pushTurn(turn) { this.turns.push(turn); + this.traceEvent('history_turn_added', { + turn, + active_turn_count: this.turns.length + }); if (this.turns.length >= this.max_messages) { let chunk = this.turns.splice(0, this.summary_chunk_size); while (this.turns.length > 0 && ['assistant', 'tool'].includes(this.turns[0].role)) chunk.push(this.turns.shift()); // remove until turns starts with system/user message + this.traceEvent('memory_compression_started', { + active_turn_count_before_compression: this.turns.length + chunk.length, + compressed_turns: chunk, + remaining_turns: this.turns, + previous_memory: this.memory + }); await this.summarizeMemories(chunk); - await this.appendFullHistory(chunk); + const historyFile = await this.appendFullHistory(chunk); + this.traceEvent('history_chunk_archived', { + full_history_file: historyFile, + compressed_turns: chunk + }); } } @@ -97,6 +133,8 @@ export class History { const data = { memory: this.memory, turns: this.turns, + chat_history_trace: this.chat_history_session_fp, + chat_history_latest: this.chat_history_latest_fp, self_prompting_state: this.agent.self_prompter.state, self_prompt: this.agent.self_prompter.isStopped() ? null : this.agent.self_prompter.prompt, taskStart: this.agent.task.taskStartTime, @@ -130,5 +168,109 @@ export class History { clear() { this.turns = []; this.memory = ''; + this.traceEvent('history_cleared', {}); + } + + traceLLMRequest(tag, model, systemPrompt, messages, tools = null) { + this.traceEvent('llm_request', { + tag, + model: describeModel(model), + system_prompt: systemPrompt, + messages, + tools: Array.isArray(tools) ? tools : null, + tool_count: Array.isArray(tools) ? tools.length : 0 + }); + } + + traceLLMResponse(tag, model, response) { + this.traceEvent('llm_response', { + tag, + model: describeModel(model), + response + }); + } + + traceLLMError(tag, model, error) { + this.traceEvent('llm_error', { + tag, + model: describeModel(model), + error: { + name: error?.name, + message: error?.message || String(error), + stack: error?.stack + } + }); + } + + traceEvent(type, payload = {}) { + if (!this.chat_history_session_fp) { + this._initChatHistoryTrace(); + } + const event = { + timestamp: new Date().toISOString(), + agent: this.name, + type, + ...payload + }; + const line = safeStringify(event) + '\n'; + try { + appendFileSync(this.chat_history_session_fp, line, 'utf8'); + appendFileSync(this.chat_history_latest_fp, line, 'utf8'); + } catch (error) { + console.error(`Failed to write ${this.name}'s chat history trace:`, error); + } + } + + _initChatHistoryTrace() { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + this.chat_history_session_fp = `${this.chat_history_dir}/${timestamp}.jsonl`; + writeFileSync(this.chat_history_session_fp, '', 'utf8'); + writeFileSync(this.chat_history_latest_fp, '', 'utf8'); + this.traceEvent('session_started', { + session_trace: this.chat_history_session_fp, + latest_trace: this.chat_history_latest_fp, + max_messages: this.max_messages, + summary_chunk_size: this.summary_chunk_size + }); + } +} + +function describeModel(model) { + if (!model || typeof model !== 'object') { + return null; + } + return { + api: model.constructor?.prefix || model.api || null, + provider: model.provider || model.params?.provider || null, + model: model.model_name || model.default_model || null, + supports_native_tool_calls: Boolean(model.supportsNativeToolCalls) + }; +} + +function safeStringify(value) { + try { + return JSON.stringify(value); + } catch { + return JSON.stringify(makeJsonSafe(value)); + } +} + +function makeJsonSafe(value, seen = new WeakSet()) { + if (value === null || typeof value !== 'object') { + if (typeof value === 'bigint') return value.toString(); + return value; + } + if (seen.has(value)) { + return '[Circular]'; + } + seen.add(value); + if (Array.isArray(value)) { + return value.map(item => makeJsonSafe(item, seen)); + } + const out = {}; + for (const [key, item] of Object.entries(value)) { + if (typeof item === 'function') continue; + out[key] = makeJsonSafe(item, seen); } + return out; } diff --git a/src/mindcraft/public/settings_spec.json b/src/mindcraft/public/settings_spec.json index 23d8e2257..c95cc2318 100644 --- a/src/mindcraft/public/settings_spec.json +++ b/src/mindcraft/public/settings_spec.json @@ -51,11 +51,6 @@ "description": "Whether to enable text-to-speech reading on the host machine", "default": false }, - "language": { - "type": "string", - "description": "The language to automatically translate to and from using google translate", - "default": "en" - }, "allow_vision": { "type": "boolean", "description": "Whether to allow vision capabilities", @@ -137,4 +132,4 @@ "description": "Number of seconds allowed for the bot to spawn before throwing an error. Increase when spawning takes a while.", "default": 30 } -} \ No newline at end of file +} diff --git a/src/models/prompter.js b/src/models/prompter.js index ad1be06a8..25f711acf 100644 --- a/src/models/prompter.js +++ b/src/models/prompter.js @@ -70,7 +70,7 @@ export class Prompter { let chat_model_profile = selectAPI(this.profile.model); this.chat_model = createModel(chat_model_profile); - if (this.profile.code_model) { + if (hasModelSelection(this.profile.code_model)) { let code_model_profile = selectAPI(this.profile.code_model); this.code_model = createModel(code_model_profile); } @@ -78,7 +78,7 @@ export class Prompter { this.code_model = this.chat_model; } - if (this.profile.vision_model) { + if (hasModelSelection(this.profile.vision_model)) { let vision_model_profile = selectAPI(this.profile.vision_model); this.vision_model = createModel(vision_model_profile); } @@ -88,7 +88,7 @@ export class Prompter { let embedding_model_profile = null; - if (this.profile.embedding) { + if (hasModelSelection(this.profile.embedding)) { try { embedding_model_profile = selectEmbeddingAPI(this.profile.embedding); } catch (e) { @@ -247,7 +247,9 @@ export class Prompter { try { const tools = this.isNativeToolMode() ? getCommandToolDefinitions(this.agent) : null; + this.agent.history.traceLLMRequest('conversation', this.chat_model, prompt, messages, tools); generation = await this.chat_model.sendRequest(messages, prompt, '***', tools); + this.agent.history.traceLLMResponse('conversation', this.chat_model, generation); if (isNativeToolResponse(generation)) { await this._saveLog(prompt, messages, JSON.stringify(generation), 'conversation'); return generation; @@ -260,6 +262,7 @@ export class Prompter { await this._saveLog(prompt, messages, generation, 'conversation'); } catch (error) { + this.agent.history.traceLLMError('conversation', this.chat_model, error); console.error('Error during message generation or file writing:', error); continue; } @@ -304,7 +307,9 @@ export class Prompter { let prompt = this.profile.coding; prompt = await this.replaceStrings(prompt, messages, this.coding_examples); + this.agent.history.traceLLMRequest('coding', this.code_model, prompt, messages); let resp = await this.code_model.sendRequest(messages, prompt); + this.agent.history.traceLLMResponse('coding', this.code_model, resp); this.awaiting_coding = false; await this._saveLog(prompt, messages, resp, 'coding'); return resp; @@ -314,7 +319,9 @@ export class Prompter { await this.checkCooldown(); let prompt = this.profile.saving_memory; prompt = await this.replaceStrings(prompt, null, null, to_summarize); + this.agent.history.traceLLMRequest('memSaving', this.chat_model, prompt, to_summarize); let resp = await this.chat_model.sendRequest([], prompt); + this.agent.history.traceLLMResponse('memSaving', this.chat_model, resp); await this._saveLog(prompt, to_summarize, resp, 'memSaving'); if (resp?.includes('')) { const [_, afterThink] = resp.split(''); @@ -329,7 +336,9 @@ export class Prompter { let messages = this.agent.history.getHistory(); messages.push({role: 'user', content: new_message}); prompt = await this.replaceStrings(prompt, null, null, messages); + this.agent.history.traceLLMRequest('botResponder', this.chat_model, prompt, messages); let res = await this.chat_model.sendRequest([], prompt); + this.agent.history.traceLLMResponse('botResponder', this.chat_model, res); return res.trim().toLowerCase() === 'respond'; } @@ -337,7 +346,10 @@ export class Prompter { await this.checkCooldown(); let prompt = this.profile.image_analysis; prompt = await this.replaceStrings(prompt, messages, null, null, null); - return await this.vision_model.sendVisionRequest(messages, prompt, imageBuffer); + this.agent.history.traceLLMRequest('vision', this.vision_model, prompt, messages); + const res = await this.vision_model.sendVisionRequest(messages, prompt, imageBuffer); + this.agent.history.traceLLMResponse('vision', this.vision_model, res); + return res; } async promptGoalSetting(messages, last_goals) { @@ -350,7 +362,9 @@ export class Prompter { user_message = await this.replaceStrings(user_message, messages, null, null, last_goals); let user_messages = [{role: 'user', content: user_message}]; + this.agent.history.traceLLMRequest('goalSetting', this.chat_model, system_message, user_messages); let res = await this.chat_model.sendRequest(user_messages, system_message); + this.agent.history.traceLLMResponse('goalSetting', this.chat_model, res); let goal = null; try { @@ -434,3 +448,15 @@ function resolvePromptPath(promptPath, defaultBaseDir) { return path.resolve(promptPath); } } + +function hasModelSelection(profile) { + if (typeof profile === 'string' || profile instanceof String) { + return profile.trim().length > 0; + } + if (!profile || typeof profile !== 'object') { + return false; + } + return ['provider', 'api', 'model'].some(key => + typeof profile[key] === 'string' && profile[key].trim().length > 0 + ); +} diff --git a/src/utils/translator.js b/src/utils/translator.js deleted file mode 100644 index df2a47692..000000000 --- a/src/utils/translator.js +++ /dev/null @@ -1,30 +0,0 @@ -import translate from 'google-translate-api-x'; -import settings from '../agent/settings.js'; - - - -export async function handleTranslation(message) { - let preferred_lang = String(settings.language); - if (!preferred_lang || preferred_lang.toLowerCase() === 'en' || preferred_lang.toLowerCase() === 'english') - return message; - try { - const translation = await translate(message, { to: preferred_lang }); - return translation.text || message; - } catch (error) { - console.error('Error translating message:', error); - return message; - } -} - -export async function handleEnglishTranslation(message) { - let preferred_lang = String(settings.language); - if (!preferred_lang || preferred_lang.toLowerCase() === 'en' || preferred_lang.toLowerCase() === 'english') - return message; - try { - const translation = await translate(message, { to: 'english' }); - return translation.text || message; - } catch (error) { - console.error('Error translating message:', error); - return message; - } -} diff --git a/tests/chat_history_trace.test.js b/tests/chat_history_trace.test.js new file mode 100644 index 000000000..e161ede85 --- /dev/null +++ b/tests/chat_history_trace.test.js @@ -0,0 +1,60 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { History } from '../src/agent/history.js'; + +class FakeModel { + static prefix = 'fake-protocol'; + + constructor() { + this.provider = 'fake-provider'; + this.model_name = 'fake-model'; + this.supportsNativeToolCalls = true; + } +} + +test('chat history trace records prompts, messages, tool calls and tool results', async () => { + const originalCwd = process.cwd(); + const dir = mkdtempSync(path.join(tmpdir(), 'mindcraft-trace-')); + try { + process.chdir(dir); + const history = new History({ + name: 'tracebot', + self_prompter: { state: {} }, + task: {} + }); + + const model = new FakeModel(); + const messages = [{ role: 'user', content: 'Steve: check inventory' }]; + const tools = [{ type: 'function', function: { name: 'inventory', parameters: { type: 'object' } } }]; + history.traceLLMRequest('conversation', model, 'system prompt text', messages, tools); + history.traceLLMResponse('conversation', model, { type: 'tool_calls', tool_calls: [{ name: 'inventory' }] }); + + await history.add('Steve', 'check inventory'); + const toolCall = { id: 'call_1', type: 'function', name: 'inventory', arguments: '{}' }; + await history.addNativeToolCall(toolCall); + await history.addNativeToolResult(toolCall, 'Action output:\nInventory is empty.'); + + const events = readFileSync(history.chat_history_latest_fp, 'utf8') + .trim() + .split('\n') + .map(line => JSON.parse(line)); + + assert.ok(events.some(event => event.type === 'session_started')); + const request = events.find(event => event.type === 'llm_request'); + assert.equal(request.system_prompt, 'system prompt text'); + assert.deepEqual(request.messages, messages); + assert.equal(request.tool_count, 1); + assert.equal(request.model.api, 'fake-protocol'); + + assert.ok(events.some(event => event.type === 'llm_response')); + assert.ok(events.some(event => event.type === 'history_turn_added' && event.turn.role === 'user')); + assert.ok(events.some(event => event.type === 'tool_call' && event.tool_call.name === 'inventory')); + assert.ok(events.some(event => event.type === 'tool_result' && event.result.includes('Inventory is empty'))); + } finally { + process.chdir(originalCwd); + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/tests/llm_providers_config.test.js b/tests/llm_providers_config.test.js index 7b3fc02ae..8363633dd 100644 --- a/tests/llm_providers_config.test.js +++ b/tests/llm_providers_config.test.js @@ -133,4 +133,5 @@ test('codex provider stores auth in the unified project config instead of a fixe assert.equal(example.models.codex.params.keysPath, 'llm_providers.json'); assert.equal('authPath' in example.models.codex.params, false); assert.ok(Object.hasOwn(example.keys, 'CODEX_CHATGPT_AUTH')); + assert.deepEqual(example.keys.CODEX_CHATGPT_AUTH, {}); }); diff --git a/tests/profile_shape.test.js b/tests/profile_shape.test.js index ee5cf932c..e1f0115a2 100644 --- a/tests/profile_shape.test.js +++ b/tests/profile_shape.test.js @@ -4,35 +4,79 @@ import { readdirSync, readFileSync } from 'node:fs'; import path from 'node:path'; const profilesDir = path.resolve('profiles'); +const profileFiles = listJsonFiles(profilesDir); +const rootDefaultProfile = path.resolve('andy.json'); +const selectableProfileFiles = [ + rootDefaultProfile, + ...profileFiles.filter(file => path.dirname(file) === profilesDir) +]; test('profiles select providers and model names without transport connection details', () => { - for (const file of readdirSync(profilesDir)) { - if (!file.endsWith('.json')) continue; - const profile = JSON.parse(readFileSync(path.join(profilesDir, file), 'utf8')); + for (const file of selectableProfileFiles) { + const profile = JSON.parse(readFileSync(file, 'utf8')); + const name = path.relative(profilesDir, file); for (const key of ['model', 'code_model', 'vision_model', 'embedding']) { if (!profile[key] || typeof profile[key] !== 'object') continue; - assert.equal('api' in profile[key], false, `${file}.${key} must not set api; use provider registry`); - assert.equal('url' in profile[key], false, `${file}.${key} must not set url; use provider registry`); - assert.equal('baseUrl' in profile[key], false, `${file}.${key} must not set baseUrl; use provider registry`); - assert.equal('keyName' in profile[key], false, `${file}.${key} must not set keyName; use provider registry`); - assert.equal('apiKeyName' in profile[key], false, `${file}.${key} must not set apiKeyName; use provider registry`); + assert.equal('api' in profile[key], false, `${name}.${key} must not set api; use provider registry`); + assert.equal('url' in profile[key], false, `${name}.${key} must not set url; use provider registry`); + assert.equal('baseUrl' in profile[key], false, `${name}.${key} must not set baseUrl; use provider registry`); + assert.equal('keyName' in profile[key], false, `${name}.${key} must not set keyName; use provider registry`); + assert.equal('apiKeyName' in profile[key], false, `${name}.${key} must not set apiKeyName; use provider registry`); } } }); +test('preset profiles expose inert code and vision model placeholders', () => { + for (const file of selectableProfileFiles) { + const profile = JSON.parse(readFileSync(file, 'utf8')); + const name = path.relative(profilesDir, file); + assert.ok('code_model' in profile, `${name}.code_model placeholder is required`); + assert.ok('vision_model' in profile, `${name}.vision_model placeholder is required`); + assert.equal(typeof profile.code_model, 'object', `${name}.code_model must be a provider/model object placeholder`); + assert.equal(typeof profile.vision_model, 'object', `${name}.vision_model must be a provider/model object placeholder`); + assert.equal(profile.code_model.provider, '', `${name}.code_model.provider should be blank until enabled`); + assert.equal(profile.code_model.model, '', `${name}.code_model.model should be blank until enabled`); + assert.equal(profile.vision_model.provider, '', `${name}.vision_model.provider should be blank until enabled`); + assert.equal(profile.vision_model.model, '', `${name}.vision_model.model should be blank until enabled`); + } +}); + test('profile embeddings explicitly select both provider and embedding model name', () => { - for (const file of readdirSync(profilesDir)) { - if (!file.endsWith('.json')) continue; - const profile = JSON.parse(readFileSync(path.join(profilesDir, file), 'utf8')); - if (!profile.embedding) continue; - assert.equal(typeof profile.embedding, 'object', `${file}.embedding must be an object`); - assert.equal(typeof profile.embedding.provider, 'string', `${file}.embedding.provider is required`); - assert.equal(typeof profile.embedding.model, 'string', `${file}.embedding.model is required`); - assert.ok(profile.embedding.provider.length > 0, `${file}.embedding.provider must not be empty`); - assert.ok(profile.embedding.model.length > 0, `${file}.embedding.model must not be empty`); + for (const file of selectableProfileFiles) { + const profile = JSON.parse(readFileSync(file, 'utf8')); + const name = path.relative(profilesDir, file); + assert.ok('embedding' in profile, `${name}.embedding placeholder is required`); + assert.equal(typeof profile.embedding, 'object', `${name}.embedding must be an object`); + assert.equal(typeof profile.embedding.provider, 'string', `${name}.embedding.provider is required`); + assert.equal(typeof profile.embedding.model, 'string', `${name}.embedding.model is required`); + if (profile.embedding.provider === '' || profile.embedding.model === '') { + assert.equal(profile.embedding.provider, '', `${name}.embedding provider/model placeholders must both be blank`); + assert.equal(profile.embedding.model, '', `${name}.embedding provider/model placeholders must both be blank`); + } else { + assert.ok(profile.embedding.provider.length > 0, `${name}.embedding.provider must not be empty`); + assert.ok(profile.embedding.model.length > 0, `${name}.embedding.model must not be empty`); + } } }); +test('default and task profile fragments do not carry selectable model placeholders', () => { + for (const file of profileFiles.filter(file => path.dirname(file) !== profilesDir)) { + const profile = JSON.parse(readFileSync(file, 'utf8')); + const name = path.relative(profilesDir, file); + for (const key of ['embedding', 'code_model', 'vision_model']) { + assert.equal(key in profile, false, `${name} should not define ${key}`); + } + } +}); + +function listJsonFiles(dir) { + return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) return listJsonFiles(fullPath); + return entry.name.endsWith('.json') ? [fullPath] : []; + }); +} + test('preset profiles do not silently depend on another provider for embeddings', () => { const allowedCrossProviderEmbeddings = new Set([ // Add explicit exceptions here only when a provider has no embedding API and the profile name makes that choice obvious. @@ -43,6 +87,7 @@ test('preset profiles do not silently depend on another provider for embeddings' if (!profile.embedding || typeof profile.model !== 'object') continue; const modelProvider = profile.model.provider; const embeddingProvider = profile.embedding.provider; + if (!embeddingProvider) continue; assert.equal( modelProvider === embeddingProvider || allowedCrossProviderEmbeddings.has(file), true, From ecf5e497f43e3938a6bb61e0c660b7d18d4bf9fb Mon Sep 17 00:00:00 2001 From: NinotQuyi Date: Sat, 2 May 2026 19:11:19 +0800 Subject: [PATCH 16/56] Stabilize native tool runtime before message manager refactor This checkpoint preserves the current native-tool provider registry, project Codex login storage, prompt markdown migration, runtime chat observability, token accounting, state update persistence, and internal coding request isolation before the next message-lifecycle cleanup.\n\nConstraint: User requested committing the current working state before further refactor work.\nRejected: Continue refactoring before a checkpoint | would make regression isolation harder across the broad native-tool changes.\nConfidence: medium\nScope-risk: broad\nDirective: Keep subsequent ReAct message-management changes small and append-only-cache focused.\nTested: npm test (113/113 passing)\nNot-tested: Live Minecraft server and paid provider matrix after this checkpoint --- .dockerignore | 5 +- .gitignore | 7 +- FAQ.md | 2 +- README.md | 4 +- keys.example.json | 30 - main.js | 5 +- package.json | 4 +- profiles/defaults/_default.json | 194 +- profiles/defaults/prompts/_default/coding.md | 8 - .../defaults/prompts/_default/conversing.md | 8 - .../prompts/_default/saving_memory.md | 5 - .../prompts/{_default => }/bot_responder.md | 0 profiles/defaults/prompts/coding.md | 110 + profiles/defaults/prompts/conversing.md | 34 + .../prompts/{_default => }/image_analysis.md | 0 profiles/defaults/prompts/saving_memory.md | 48 + profiles/tasks/construction_profile.json | 42 +- profiles/tasks/construction_prompt.md | 14 + profiles/tasks/cooking_profile.json | 13 +- profiles/tasks/cooking_prompt.md | 19 + profiles/tasks/crafting_profile.json | 74 +- profiles/tasks/crafting_prompt.md | 14 + .../smoke/dev_native_tool_loop.js | 0 .../smoke/live_function_call_smoke.js | 0 {tests => scripts}/smoke/live_model_matrix.js | 4 +- settings.js | 30 +- ...son => settings_llm_providers.example.json | 2 +- src/agent/action_manager.js | 20 +- src/agent/agent.js | 155 +- src/agent/coder.js | 42 +- src/agent/commands/actions.js | 12 +- src/agent/commands/tool_adapter.js | 3 +- src/agent/history.js | 241 +- src/agent/library/skills.js | 127 +- src/agent/mindserver_proxy.js | 8 + src/agent/modes.js | 1 + src/agent/self_prompter.js | 14 +- src/agent/speak.js | 65 +- src/agent/state_snapshot.js | 152 + src/agent/vision/vision_interpreter.js | 24 +- src/mindcraft/mindcraft.js | 7 +- src/mindcraft/mindserver.js | 145 +- src/mindcraft/public/index.html | 2628 ++++++++++++++++- src/mindcraft/public/settings_spec.json | 51 +- src/mindcraft/public/trace-demo.json | 1 + src/models/_model_map.js | 4 +- src/models/anthropic_messages.js | 3 + src/models/codex_chatgpt.js | 207 +- src/models/google_generative_ai.js | 5 + src/models/native_tools.js | 53 +- src/models/openai_compatible.js | 9 +- src/models/openai_responses.js | 15 + src/models/prompter.js | 123 +- src/models/token_usage.js | 81 + src/process/agent_process.js | 6 +- src/utils/examples.js | 83 - src/utils/keys.js | 6 +- src/utils/text.js | 3 +- tasks/evaluation_script.py | 8 +- tests/agent_native_text_policy.test.js | 286 ++ tests/chat_history_trace.test.js | 191 +- tests/codex_chatgpt.test.js | 88 +- tests/llm_providers_config.test.js | 12 +- tests/mindserver_chat_history.test.js | 77 + tests/mindserver_settings_spec.test.js | 17 + tests/native_prompt_hygiene.test.js | 96 +- tests/native_tools.test.js | 113 + tests/openai_compatible.test.js | 8 + tests/token_usage.test.js | 50 + tests/tool_result_policy.test.js | 42 +- tests/vision_interpreter.test.js | 33 + 71 files changed, 5203 insertions(+), 788 deletions(-) delete mode 100644 keys.example.json delete mode 100644 profiles/defaults/prompts/_default/coding.md delete mode 100644 profiles/defaults/prompts/_default/conversing.md delete mode 100644 profiles/defaults/prompts/_default/saving_memory.md rename profiles/defaults/prompts/{_default => }/bot_responder.md (100%) create mode 100644 profiles/defaults/prompts/coding.md create mode 100644 profiles/defaults/prompts/conversing.md rename profiles/defaults/prompts/{_default => }/image_analysis.md (100%) create mode 100644 profiles/defaults/prompts/saving_memory.md create mode 100644 profiles/tasks/construction_prompt.md create mode 100644 profiles/tasks/cooking_prompt.md create mode 100644 profiles/tasks/crafting_prompt.md rename {tests => scripts}/smoke/dev_native_tool_loop.js (100%) rename {tests => scripts}/smoke/live_function_call_smoke.js (100%) rename {tests => scripts}/smoke/live_model_matrix.js (98%) rename llm_providers.example.json => settings_llm_providers.example.json (99%) create mode 100644 src/agent/state_snapshot.js create mode 100644 src/mindcraft/public/trace-demo.json create mode 100644 src/models/token_usage.js delete mode 100644 src/utils/examples.js create mode 100644 tests/mindserver_chat_history.test.js create mode 100644 tests/mindserver_settings_spec.test.js create mode 100644 tests/token_usage.test.js create mode 100644 tests/vision_interpreter.test.js diff --git a/.dockerignore b/.dockerignore index 802e45b18..66e756220 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,5 +2,6 @@ node_modules bots/*/ !bots/* -keys.json -llm_providers.json +settings_llm_providers.json +results +tests/results diff --git a/.gitignore b/.gitignore index e2bda1a2c..7760d046e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,8 +7,7 @@ docs/ scratch.js bots/**/action-code/** bots/**/ -keys.json -llm_providers.json +settings_llm_providers.json services/viaproxy/jars/** services/viaproxy/logs/** services/viaproxy/plugins/** @@ -21,9 +20,9 @@ experiments/ andy_*.json jill_*.json src/models/logs/* +results/ +tests/results/ server_data/* -results/* -tests/results/* tasks/construction_tasks/test_multiagent_construction_tasks.json tasks/construction_tasks/train_multiagent_construction_tasks.json tasks/construction_tasks/test/** diff --git a/FAQ.md b/FAQ.md index 989dec318..7d2a53a44 100644 --- a/FAQ.md +++ b/FAQ.md @@ -26,7 +26,7 @@ - The bot will still get stuck occasionally, but not constantly. - `Why I added the api key but still prompted that the key can't be found?` - - Possible reason 1: Did not modify keys.example.json to keys.json. + - Possible reason 1: Did not modify settings_llm_providers.example.json to settings_llm_providers.json. - Possible reason 2: If you use vscode to edit, you need to `ctrl+s` to save the file for the changes to take effect. - Possible reason 3: Not setting the code path correctly in setting.js, use andy.js by default. diff --git a/README.md b/README.md index 6fa3000f1..be3609755 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Do not connect this bot to public servers with coding enabled. This project allo 2. Download the [latest release](https://github.com/mindcraft-bots/mindcraft/releases/latest) and unzip it, or clone the repository. -3. Rename `keys.example.json` to `keys.json` and fill in your API keys (you only need one). The desired model is set in `andy.json` or other profiles. For other models refer to the table below. +3. Rename `settings_llm_providers.example.json` to `settings_llm_providers.json` and fill in your API keys (you only need one). The desired model is set in `andy.json` or other profiles. For other models refer to the table below. 4. In terminal/command prompt, run `npm install` from the installed directory @@ -143,7 +143,7 @@ If you want more optimization and automatic launching of the minecraft world, yo If you intend to `allow_insecure_coding`, it is a good idea to run the app in a docker container to reduce risks of running unknown code. This is strongly recommended before connecting to remote servers, although still does not guarantee complete safety. ```bash -docker build -t mindcraft . && docker run --rm --add-host=host.docker.internal:host-gateway -p 8080:8080 -p 3000-3003:3000-3003 -e SETTINGS_JSON='{"auto_open_ui":false,"profiles":["./profiles/gemini.json"],"host":"host.docker.internal"}' --volume ./keys.json:/app/keys.json --name mindcraft mindcraft +docker build -t mindcraft . && docker run --rm --add-host=host.docker.internal:host-gateway -p 8080:8080 -p 3000-3003:3000-3003 -e SETTINGS_JSON='{"auto_open_ui":false,"profiles":["./profiles/gemini.json"],"host":"host.docker.internal"}' --volume ./settings_llm_providers.json:/app/settings_llm_providers.json --name mindcraft mindcraft ``` or simply ```bash diff --git a/keys.example.json b/keys.example.json deleted file mode 100644 index 7ee3a0c2b..000000000 --- a/keys.example.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "OPENAI_API_KEY": "", - "SILICONFLOW_API_KEY": "", - "OPENAI_ORG_ID": "", - "GEMINI_API_KEY": "", - "ANTHROPIC_API_KEY": "", - "REPLICATE_API_KEY": "", - "GROQCLOUD_API_KEY": "", - "HUGGINGFACE_API_KEY": "", - "QWEN_API_KEY": "", - "XAI_API_KEY": "", - "MISTRAL_API_KEY": "", - "DEEPSEEK_API_KEY": "", - "HYPERBOLIC_API_KEY": "", - "NOVITA_API_KEY": "", - "OPENROUTER_API_KEY": "", - "CEREBRAS_API_KEY": "", - "MERCURY_API_KEY": "", - "CODEX_CHATGPT_AUTH": { - "auth_mode": "chatgpt", - "OPENAI_API_KEY": null, - "tokens": { - "id_token": "", - "access_token": "", - "refresh_token": "", - "account_id": "" - }, - "last_refresh": "" - } -} diff --git a/main.js b/main.js index 957662b17..49697507b 100644 --- a/main.js +++ b/main.js @@ -70,8 +70,11 @@ if (process.env.SETTINGS_JSON) { } } +if (settings.llm_providers) { + process.env.MINDCRAFT_LLM_PROVIDERS_PATH = settings.llm_providers; +} -Mindcraft.init(false, settings.mindserver_port, settings.auto_open_ui); +Mindcraft.init(false, settings.mindserver_port, settings.auto_open_ui, settings); for (let profile of settings.profiles) { const profile_json = JSON.parse(readFileSync(profile, 'utf8')); diff --git a/package.json b/package.json index 02792f504..be16f3a31 100644 --- a/package.json +++ b/package.json @@ -44,8 +44,8 @@ "postinstall": "patch-package", "start": "node main.js", "test": "node --test tests/*.test.js", - "dev:tool-loop": "node tests/smoke/dev_native_tool_loop.js", - "test:live:function-calls": "node tests/smoke/live_function_call_smoke.js", + "dev:tool-loop": "node scripts/smoke/dev_native_tool_loop.js", + "test:live:function-calls": "node scripts/smoke/live_function_call_smoke.js", "reinstall": "npm run clean:modules && npm install", "clean:modules": "node -e \"const fs=require('fs');const p=require('path');['node_modules','package-lock.json'].forEach(f=>{if(fs.existsSync(f)){if(fs.lstatSync(f).isDirectory()){fs.rmSync(f,{recursive:true,force:true});}else{fs.unlinkSync(f);}}});\"" }, diff --git a/profiles/defaults/_default.json b/profiles/defaults/_default.json index f7e2dfb51..fe23b1271 100644 --- a/profiles/defaults/_default.json +++ b/profiles/defaults/_default.json @@ -1,21 +1,21 @@ { "cooldown": 3000, "conversing": { - "prompt_file": "prompts/_default/conversing.md" + "prompt_file": "prompts/conversing.md" }, "coding": { - "prompt_file": "prompts/_default/coding.md" + "prompt_file": "prompts/coding.md" }, "saving_memory": { - "prompt_file": "prompts/_default/saving_memory.md" + "prompt_file": "prompts/saving_memory.md" }, "bot_responder": { - "prompt_file": "prompts/_default/bot_responder.md" + "prompt_file": "prompts/bot_responder.md" }, "image_analysis": { - "prompt_file": "prompts/_default/image_analysis.md" + "prompt_file": "prompts/image_analysis.md" }, - "speak_model": "openai/tts-1/echo", + "speak_model": "system", "modes": { "self_preservation": true, "unstuck": true, @@ -27,185 +27,5 @@ "elbow_room": true, "idle_staring": true, "cheat": false - }, - "conversation_examples": [ - [ - { - "role": "user", - "content": "miner_32: Hey! What are you up to?" - }, - { - "role": "assistant", - "content": "Nothing much miner_32, what do you need?" - } - ], - [ - { - "role": "user", - "content": "alex: Can you help me gather wood?" - }, - { - "role": "assistant", - "content": "On it. I will use the right tool to gather wood." - } - ], - [ - { - "role": "user", - "content": "sam: Please stop what you are doing and come back." - }, - { - "role": "assistant", - "content": "Okay, I will stop and head back." - } - ], - [ - { - "role": "user", - "content": "builder: What do you see nearby?" - }, - { - "role": "assistant", - "content": "I will check my surroundings." - } - ], - [ - { - "role": "user", - "content": "farmer: Do you have food?" - }, - { - "role": "assistant", - "content": "I will check my inventory." - } - ], - [ - { - "role": "user", - "content": "rupert: Let us work together on a small house." - }, - { - "role": "assistant", - "content": "Sure, I can coordinate with you and handle my part." - }, - { - "role": "user", - "content": "rupert: I will build the walls if you prepare materials." - }, - { - "role": "assistant", - "content": "Sounds good. I will prepare materials first." - } - ] - ], - "coding_examples": [ - [ - { - "role": "user", - "content": "greg: Collect 10 wood" - }, - { - "role": "assistant", - "content": "```await skills.collectBlock(bot, 'oak_log', 10);\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": "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": "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": "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": "system", - "content": "Successfully placed 5 dirt." - }, - { - "role": "assistant", - "content": "I built a little tower!" - } - ], - [ - { - "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```" - } - ] - ] + } } diff --git a/profiles/defaults/prompts/_default/coding.md b/profiles/defaults/prompts/_default/coding.md deleted file mode 100644 index 6d1d9f401..000000000 --- a/profiles/defaults/prompts/_default/coding.md +++ /dev/null @@ -1,8 +0,0 @@ -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! -$SELF_PROMPT -Summarized memory:'$MEMORY' -$STATS -$INVENTORY -$CODE_DOCS -$EXAMPLES -Conversation: diff --git a/profiles/defaults/prompts/_default/conversing.md b/profiles/defaults/prompts/_default/conversing.md deleted file mode 100644 index 0e18e4967..000000000 --- a/profiles/defaults/prompts/_default/conversing.md +++ /dev/null @@ -1,8 +0,0 @@ -You are an AI Minecraft bot named $NAME that can converse with players, see, move, mine, build, and interact with the world by using native tools/function calls. -$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; when you need to act, call the appropriate native tool/function instead of writing command text. Do NOT write legacy text commands, function-looking text, or fake tool-use log text in your message; call the native tool and optionally respond briefly after the tool result. 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 ' '. This is extremely important to me, take a deep breath and have fun :) -Summarized memory:'$MEMORY' -$STATS -$INVENTORY -$COMMAND_DOCS -$EXAMPLES -Conversation Begin: diff --git a/profiles/defaults/prompts/_default/saving_memory.md b/profiles/defaults/prompts/_default/saving_memory.md deleted file mode 100644 index 32447f8c2..000000000 --- a/profiles/defaults/prompts/_default/saving_memory.md +++ /dev/null @@ -1,5 +0,0 @@ -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. -Old Memory: '$MEMORY' -Recent conversation: -$TO_SUMMARIZE -Summarize your old memory and recent conversation into a new memory, and respond only with the unwrapped memory text: diff --git a/profiles/defaults/prompts/_default/bot_responder.md b/profiles/defaults/prompts/bot_responder.md similarity index 100% rename from profiles/defaults/prompts/_default/bot_responder.md rename to profiles/defaults/prompts/bot_responder.md diff --git a/profiles/defaults/prompts/coding.md b/profiles/defaults/prompts/coding.md new file mode 100644 index 000000000..87d09d332 --- /dev/null +++ b/profiles/defaults/prompts/coding.md @@ -0,0 +1,110 @@ +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! +$SELF_PROMPT +Summarized memory:'$MEMORY' +$STATS +$INVENTORY +$CODE_DOCS + +Fixed coding examples: +Example 1: +User input: greg: Collect 10 wood +Your output: ```await skills.collectBlock(bot, 'oak_log', 10); +``` +System result: Successfully collected 9 wood. +Your output: I collected 9 oak logs, what next? + +Example 2: +User input: bobby: cook some chicken +Your output: ```await skills.smeltItem(bot, 'chicken', 8); +``` +System result: Successfully smelted 8 chicken into 8 cooked_chicken. +Your output: I have cooked 8 chicken. + +Example 3: +User input: zZZn98: come here +Your output: ```await skills.goToPlayer(bot, 'zZZn98'); +``` +System result: Code execution finished successfully. +Your output: Here! + +Example 4: +User input: maya: go to the nearest oak log +Your output: ```await skills.goToPosition(bot, nearestOakLog.x, nearestOakLog.y, nearestOakLog.z); +``` +System result: Code output: +!!Code threw exception!! Error: ReferenceError: nearestOakLog is not defined + Write code to fix the problem and try again. +Your output: Apologies, I made an error. Let me try that again.``` +// I am navigating to the nearest oak log. +let position = world.getNearestBlock(bot, 'oak_log', 20); +await skills.goToPosition(bot, position.x, position.y, position.z); +``` +System result: Arrived at location. +Your output: I found an oak log and I am now standing next to it. What next? + +Example 5: +User input: 234jeb: build a little tower with a torch on the side +Your output: ```let pos = bot.entity.position; +for (let i = 0; i < 5; i++) { + await skills.placeBlock(bot, 'dirt', pos.x, pos.y + i, pos.z); +} +await skills.placeBlock(bot, 'torch', pos.x + 1, pos.y + 4, pos.z, 'side'); +``` +System result: Successfully placed 5 dirt. +Your output: I built a little tower! + +Example 6: +User input: brug: build a dirt house +Your output: ```js +const position = world.getPosition(bot); +const startX = position.x; +const startY = position.y; +const startZ = position.z; +const width = 7; +const depth = 7; +const height = 4; + +// Build front wall (z = startZ) +for (let x = startX; x < startX + width; x++) { + for (let y = startY; y < startY + height; y++) { + await skills.placeBlock(bot, 'oak_planks', x, y, startZ); + } +} + +// Build back wall (z = startZ + depth - 1) +for (let x = startX; x < startX + width; x++) { + for (let y = startY; y < startY + height; y++) { + await skills.placeBlock(bot, 'oak_planks', x, y, startZ + depth - 1); + } +} + +// Build left wall (x = startX) +for (let z = startZ; z < startZ + depth; z++) { + for (let y = startY; y < startY + height; y++) { + await skills.placeBlock(bot, 'oak_planks', startX, y, z); + } +} + +// Build right wall (x = startX + width - 1) +for (let z = startZ; z < startZ + depth; z++) { + for (let y = startY; y < startY + height; y++) { + await skills.placeBlock(bot, 'oak_planks', startX + width - 1, y, z); + } +} + +// Build floor (y = startY) +for (let x = startX; x < startX + width; x++) { + for (let z = startZ; z < startZ + depth; z++) { + await skills.placeBlock(bot, 'oak_planks', x, startY, z); + } +} + +// Build ceiling (y = startY + height - 1) +for (let x = startX; x < startX + width; x++) { + for (let z = startZ; z < startZ + depth; z++) { + await skills.placeBlock(bot, 'oak_planks', x, startY + height - 1, z); + } +} +``` + +Conversation: diff --git a/profiles/defaults/prompts/conversing.md b/profiles/defaults/prompts/conversing.md new file mode 100644 index 000000000..4ec649327 --- /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 native tools/function calls. +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; when you need to act, call the appropriate native tool/function instead of writing command text. Do NOT write legacy text commands, function-looking text, or fake tool-use log text in your message; call the native tool and optionally respond briefly after the tool result. 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 ' '. This is extremely important to me, take a deep breath and have fun :) + +Use transient state snapshots/diffs as your current baseline instead of re-checking unchanged state; call tools only when you need fresh details or information not covered by the context. +Use native tools when you need live game information or actions. For current position, health, hunger, time, weather, current action, nearby players, nearby entities, nearby blocks, modes, or inventory, call the relevant native tool. For craftable items or crafting plans, call craftable or getCraftingPlan. For complex custom behavior that is not covered by a normal tool, call newAction. + +Fixed examples of how to respond: +Example 1: +User input: miner_32: Hey! What are you up to? +Your output: Nothing much miner_32, what do you need? + +Example 2: +User input: alex: Can you help me gather wood? +Your output: On it. I will use the right tool to gather wood. + +Example 3: +User input: sam: Please stop what you are doing and come back. +Your output: Okay, I will stop and head back. + +Example 4: +User input: builder: What do you see nearby? +Your output: I will check my surroundings. + +Example 5: +User input: farmer: Do you have food? +Your output: I will check my inventory. + +Example 6: +User input: rupert: Let us work together on a small house. +Your output: Sure, I can coordinate with you and handle my part. +User input: rupert: I will build the walls if you prepare materials. +Your output: Sounds good. I will prepare materials first. + +Conversation Begin: diff --git a/profiles/defaults/prompts/_default/image_analysis.md b/profiles/defaults/prompts/image_analysis.md similarity index 100% rename from profiles/defaults/prompts/_default/image_analysis.md rename to profiles/defaults/prompts/image_analysis.md diff --git a/profiles/defaults/prompts/saving_memory.md b/profiles/defaults/prompts/saving_memory.md new file mode 100644 index 000000000..88caf2d27 --- /dev/null +++ b/profiles/defaults/prompts/saving_memory.md @@ -0,0 +1,48 @@ +You are compacting a Minecraft agent conversation so the session can continue with less context. +Respond with TEXT ONLY. Do not call tools. The summary will replace the earlier messages in the active conversation. + +Before writing the final summary, carefully analyze the conversation chronologically. Preserve enough detail that the agent can continue as if the earlier messages were still present. Pay special attention to user corrections and failed attempts. + +Your summary must include these sections when relevant: + +1. Primary Request and Intent +- What the user asked the agent to do. +- Any explicit preferences, constraints, or corrections from the user. + +2. Current Goal and State +- Active long-running goal, if any. +- Current plan, current subtask, and immediate next useful step. +- Important Minecraft world facts, locations, coordinates, nearby structures, saved places, and base/camp/mining locations. +- Important inventory/resource/equipment facts that affect future decisions. + +3. Actions, Tool Calls, and Results +- Native tools/function calls that changed the situation or revealed durable facts. +- Tool results that the agent must remember to avoid repeating work. +- Minecraft actions completed, partially completed, interrupted, or failed. + +4. Errors, Corrections, and Avoidance Notes +- Errors encountered and how they were handled. +- Failed approaches or actions that should not be retried without new evidence. +- User complaints or requested behavior changes. + +5. Conversation and Collaboration Context +- Important messages from users or other bots. +- Commitments the agent made. +- Coordination state with other players/bots. + +6. Pending Tasks and Continuation Context +- Unfinished tasks in priority order. +- What the next model response should probably do next. +- Any information that should be checked with native tools before acting. + +Rules: +- Be precise and dense, but do not omit details needed to continue. +- Do not include raw transient stats unless they matter for continuity. +- If an earlier compact summary appears in the conversation, merge its durable facts with newer events. +- Preserve exact item names, tool names, coordinates, counts, and user instructions when important. +- Do not add facts that are not supported by the conversation. + +Conversation to compact: +$TO_SUMMARIZE + +Return only the compact summary text. diff --git a/profiles/tasks/construction_profile.json b/profiles/tasks/construction_profile.json index 76ddefb01..46debaa57 100644 --- a/profiles/tasks/construction_profile.json +++ b/profiles/tasks/construction_profile.json @@ -1,42 +1,10 @@ { "name": "andy", - "model": "gpt-4o-mini", - - "conversing": "You are a task-focused Minecraft bot named $NAME. You have to collaborate with other agents in the world to complete the current task \nFeel free to ask other agents questions and make a plan to achieve the goal. You can request them to give them some of their inventory items if required to complete the goal. You can see, move, mine, build, and interact with the world by using commands.\n$SELF_PROMPT Act human-like as if you were a typical Minecraft player, rather than an AI. 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.', instead say this: 'Sure, I'll stop. !stop'. Do NOT say this: 'On my way! Give me a moment.', instead say this: 'On my way! !goToPlayer(\"playername\", 3)'. 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'. Share resources and information with other bots! 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": { + "prompt_file": "profiles/tasks/construction_prompt.md" + }, "modes": { "cheat": false - }, - - "conversation_examples": [ - [ - {"role": "assistant", "content": "Alright I have the necessary materials to build, what needs to be done for the first level of the blueprint? !checkBlueprintLevel(0)"}, - {"role": "system", "content": "Level 0 requires the following fixes:\n Place oak_door at X: 144, Y: -60, Z: -179"}, - {"role": "assistant", "content": "I'll start by placing the oak_door at the specified coordinates. !newAction(\"Place oak_door at X: 144, Y: -60, Z: -179\")"}, - {"role": "assistant", "content": "I've placed the oak_door. What's next? !checkBlueprintLevel(0)"}, - {"role": "system", "content": "Level 0 is complete"} - ], - [ - {"role": "system", "content": "Level 2 is complete"}, - {"role": "assistant", "content": "Great the last level is complete, let's check the whole blueprint just to be sure. !checkBlueprint"} - ], - [ - {"role": "user", "content": "philips: (FROM OTHER BOT) I've got stone. What about you?"}, - {"role": "assistant", "content": "I have a door and planks. Great we have everything we need from the blueprint. Let's start building the first level! !checkBlueprintLevel(0)"}, - {"role": "system", "content": "Level 0 requires the following fixes:\n Place stone at X:100, y: -60, Z:-179\n Place stone at X: 99, Y: -60, Z:-179\n Place stone at X: 98, Y:-60, Z:-179..."}, - {"role": "assistant", "content": "Looks like the first level requires only stone, why don't you build that since you have stone?"}, - {"role": "user", "content": "philips: (FROM OTHER BOT) Ok, let me see what needs to be done for level 0. !checkBlueprintLevel(0) "}, - {"role": "system", "content": "Level 0 requires the following fixes:\n Place stone at X:100, y: -60, Z:-179\n Place stone at X: 99, Y: -60, Z:-179\n Place stone at X: 98, Y:-60, Z:-179..."}, - {"role": "user", "content": "philips: (FROM OTHER BOT) Ok, since I have stone let me place the necessary blocks. !newAction(\"Place stone for level 0\")"}, - {"role": "user", "content": "philips: (FROM OTHER BOT) I've placed the stone blocks for level 0. Let's check if it is done! !checkBlueprintLevel(0)"}, - {"role": "user", "content": "philips: (FROM OTHER BOT) Since the blueprint for level 1 only needs stone, I'll start placing those. !newAction(\"Place stone blocks for level 1.\")"}, - {"role": "assistant", "content": " I'll place the planks for level 2. !newAction(\"Place planks for level 2.\")"} - ], - [ - {"role": "assistant", "content": "I need 30 stones to build level 1 of blueprint, but I only have 20. Can you pass me some stones if you have any?"}, - {"role": "user", "content": "philips: (FROM OTHER BOT) Sure, I'll pass you 10 stones. !givePlayer(\"fujibayashi\", \"stone\", 10)"}, - {"role": "assistant", "content": "I've received the stones, let me start placing them. !newAction(\"Place stone for level 1\")"} - ] - ] -} \ No newline at end of file + } +} diff --git a/profiles/tasks/construction_prompt.md b/profiles/tasks/construction_prompt.md new file mode 100644 index 000000000..e2321e496 --- /dev/null +++ b/profiles/tasks/construction_prompt.md @@ -0,0 +1,14 @@ +You are a task-focused Minecraft bot named $NAME. You collaborate with other agents in the world to complete the current construction task. + +Use transient state snapshots/diffs as your current baseline instead of re-checking unchanged state; call tools only when you need fresh details or information not covered by the context. + +Use native tools/function calls to inspect the world, move, gather materials, craft, build, and interact with blocks. Human users may type text commands, but you must not write legacy text commands in your replies. + +Collaboration tips: +- Ask other agents short, useful questions when coordination is needed. +- Make a brief plan, share progress, and request items from other agents when that helps finish the task. +- When you need current state, use tools to check inventory, position, nearby blocks, entities, or crafting options instead of assuming. +- Be brief, practical, and human-like. Do not apologize repeatedly, do not pretend to act, and do not speak as another bot. +- If you have nothing useful to say or do, respond with a single tab character. + +Conversation Begin: diff --git a/profiles/tasks/cooking_profile.json b/profiles/tasks/cooking_profile.json index 6c0b4a77d..39694ed8f 100644 --- a/profiles/tasks/cooking_profile.json +++ b/profiles/tasks/cooking_profile.json @@ -1,11 +1,12 @@ { - "name": "andy", - "model": "claude-3-5-sonnet-latest", + "name": "andy", + "model": "claude-3-5-sonnet-latest", "modes": { "hunting": false, "item_collecting": true, "elbow_room": false - }, - "conversing": "You are a task-focused Minecraft bot named $NAME. You have to collaborate with other agents in the world to complete the current task \nFeel free to ask other agents questions and make a plan to achieve the goal. You can request them to give them some of their inventory items if required to complete the goal. General Searching Tips:\n- You will be spawned in a farm with many crops and animals nearby. The farm area is extensive - search thoroughly for needed resources (with searchForBlocks parameters like 64,128,256)\n There is a crafting table, fully fueled furnace and fully fueled smoker with coal are also available nearby which you can use to your advantage. On top of this plants like mushrooms, wheat, carrots, beetroots, pumpkins, potatoes are also present nearby.\nCollaboration tips - Divide tasks efficiently between agents for faster completion and share inventory items.\n- Communicate your plan and progress clearly. You can see, move, mine, build, and interact with the world by using commands.\n$SELF_PROMPT Act human-like as if you were a typical Minecraft player, rather than an AI. 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.', instead say this: 'Sure, I'll stop. !stop'. Do NOT say this: 'On my way! Give me a moment.', instead say this: 'On my way! !goToPlayer(\"playername\", 3)'. 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'. Share resources and information with other bots! 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:", - "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. $SELF_PROMPT Make sure to include information relevant to the goal and inventory you have collected. 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: " -} \ No newline at end of file + }, + "conversing": { + "prompt_file": "profiles/tasks/cooking_prompt.md" + } +} diff --git a/profiles/tasks/cooking_prompt.md b/profiles/tasks/cooking_prompt.md new file mode 100644 index 000000000..3efc4387c --- /dev/null +++ b/profiles/tasks/cooking_prompt.md @@ -0,0 +1,19 @@ +You are a task-focused Minecraft bot named $NAME. You collaborate with other agents in the world to complete the current cooking task. + +Use transient state snapshots/diffs as your current baseline instead of re-checking unchanged state; call tools only when you need fresh details or information not covered by the context. + +Use native tools/function calls to inspect the world, move, gather ingredients, craft, cook, smelt, use furnaces/smokers, and interact with blocks. Human users may type text commands, but you must not write legacy text commands in your replies. + +Task environment tips: +- You will be spawned in a farm with many crops and animals nearby. Search thoroughly for needed resources. +- A crafting table, fully fueled furnace, and fully fueled smoker with coal are available nearby. +- Mushrooms, wheat, carrots, beetroots, pumpkins, potatoes, crops, and animals may be present nearby. + +Collaboration tips: +- Divide tasks efficiently between agents and share inventory items. +- Communicate your plan and progress clearly and briefly. +- When you need current state, use tools to check inventory, position, nearby blocks, entities, or crafting options instead of assuming. +- Be brief, practical, and human-like. Do not apologize repeatedly, do not pretend to act, and do not speak as another bot. +- If you have nothing useful to say or do, respond with a single tab character. + +Conversation Begin: diff --git a/profiles/tasks/crafting_profile.json b/profiles/tasks/crafting_profile.json index 2208ccc65..7a3a8a0f4 100644 --- a/profiles/tasks/crafting_profile.json +++ b/profiles/tasks/crafting_profile.json @@ -1,71 +1,11 @@ { - "name": "andy", - "model": "claude-3-5-sonnet-latest", + "name": "andy", + "model": "claude-3-5-sonnet-latest", "modes": { "hunting": false, "elbow_room": false - }, - "conversing": "You are a playful Minecraft bot named $NAME that can converse with players, see, move, mine, build, and interact with the world by using commands.\n$SELF_PROMPT Act human-like as if you were a typical Minecraft player, rather than an AI. 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.', instead say this: 'Sure, I'll stop. !stop'. Do NOT say this: 'On my way! Give me a moment.', instead say this: 'On my way! !goToPlayer(\"playername\", 3)'. 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'. Share resources and information with other bots! 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\n Your name is $NAME, do not pretend to be other bots. You are in a conversation by default do not use !startConversation to start a conversation. Conversation Begin:", - "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. $SELF_PROMPT Make sure to include information relevant to the goal and inventory you have collected. 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: ", - "conversation_examples": [ - [ - {"role": "user", "content": "philips: (FROM OTHER BOT) Hey! let's collaborate to build a stone pickaxe!"}, - {"role": "assistant", "content": "Cool, let me check what is in my inventory !inventory\nI have 10 oak logs. What do you have?"}, - {"role": "user", "content": "philips: (FROM OTHER BOT) Let me check !inventory \nI have 10 stones."}, - {"role": "assistant", "content": "Let me give you the stones and you can craft the pickaxe. !givePlayer(\"playername\", \"stone\", 10)"}, - {"role": "user", "content": "philips: (FROM OTHER BOT) Thanks for the stones! Now I can craft the pickaxe. !craftItem(\"stone_pickaxe\", 1)"} - ], - [ - {"role": "user", "content": "yasuke: (FROM OTHER BOT) Lets craft 5 chains together"}, - {"role": "assistant", "content": "Sure, can you let me know what inventory do you have while I check how to craft chains?"}, - {"role": "user", "content": "yasuke: (FROM OTHER BOT) I have 10 iron_nugget and 3 mushroom."}, - {"role": "assistant", "content": "Thanks for letting me know, let me check how to build a chain, !getCraftingPlan(\"chain\",5)"}, - {"role": "system", "content": "You are missing the following items:\n- 2 iron_ingot\n\nOnce you have these items, here's your crafting plan:\n\nCraft 2 iron_ingot -> 18 iron_nugget\nCraft 10 iron_nugget + 5 iron_ingot -> 5 chain"}, - {"role": "assistant", "content": "Seems like we need 2 iron_ingot, but wait, we need the iron_ingot for getting 10 iron_nugget, since you already have 10 iron_nugget, I don't need to search for 2 iron_ingots in the world, could you give me the 10 iron_nugget ?"}, - {"role": "user", "content": "yasuke: (FROM OTHER BOT) Sure, here you go !givePlayer(\"andy\", \"iron_nugget\", 10)"}, - {"role": "assistant", "content": "Thanks for the iron_nuggets, now I can craft the chains. !craftItem(\"chain\", 5)"} - ], - [ - {"role": "user", "content": "terrance: (FROM OTHER BOT) Lets craft a pink_wool together"}, - {"role": "assistant", "content": "Sure, can you let me know what inventory do you have while I check how to craft it?"}, - {"role": "user", "content": "terrance: (FROM OTHER BOT) I have 1 pink_dye with me."}, - {"role": "assistant", "content": "Thanks for letting me know, let me check how to craft a pink_wool, !getCraftingPlan(\"pink_wool\",1)"}, - {"role": "system", "content": "You are missing the following items:\n- 1 peony\n\nOnce you have these items, here's your crafting plan:\n\nCraft 1 peony -> 2 pink_dye\nCraft 1 pink_dye + 1 black_wool -> 1 pink_wool"}, - {"role": "assistant", "content": "Seems like we are missing 1 peony which we can use to craft 1 pink_dye, but since you already have pink_dye, there is no need to search for peony. Can you pass the pink_dye to me?"}, - {"role": "user", "content": "terrance: (FROM OTHER BOT) Sure, here you go !givePlayer(\"andy\", \"pink_dye\", 1)"}, - {"role": "assistant", "content": "Thanks for the pink_dye, now I can craft the pink_wool. !craftItem(\"pink_wool\", 1)"} - ], - [ - {"role": "user", "content": "shankar: (FROM OTHER BOT) Lets craft a cyan bed together"}, - {"role": "assistant", "content": "Sure, I have !inventory\nI have a cyan_dye and oak_planks. What do you have? "}, - {"role": "user", "content": "shankar: (FROM OTHER BOT) I have 1 cyan_wool and 1 oak_planks. Let me give those to you !givePlayer(\"andy\", \"cyan_wool\", 1) !givePlayer(\"andy\", \"oak_planks\", 1)"}, - {"role": "assistant", "content": "Hmm, seems like we don't have enough to craft a cyan bed. Let me reach out to the third agent and ask for help! !startConversation(\"jacob\", Hey jacob, can you help us craft a cyan bed? We need cyan_dye and oak_planks.)"}, - {"role": "user", "content": "jacob: (FROM OTHER BOT) Sure! Let me give those to you !givePlayer(\"andy\", \"cyan_dye\", 1) !givePlayer(\"andy\", \"oak_planks\", 1)"}, - {"role": "assistant", "content": "Thanks for the items, now I can craft the cyan bed. !craftItem(\"cyan_bed\", 1)"} - ], - [ - {"role": "user", "content": "maya: (FROM OTHER BOT) Let's work together to craft an ender_chest!"}, - {"role": "assistant", "content": "Sounds good! Let me check what I have. !inventory\nI have 2 obsidian and 1 ender_eye. What do you have?"}, - {"role": "user", "content": "maya: (FROM OTHER BOT) I have 4 obsidian. !inventory"}, - {"role": "assistant", "content": "Great! Let's see if we need more materials. !getCraftingPlan(\"ender_chest\", 1)"}, - {"role": "system", "content": "To craft 1 ender_chest, you need:\n- 8 obsidian\n- 1 ender_eye\n\nCraft 8 obsidian + 1 ender_eye -> 1 ender_chest"}, - {"role": "assistant", "content": "We need 8 obsidian total and 1 ender_eye. Between us we have 6 obsidian and 1 ender_eye. Let's ask lucas if he has the remaining obsidian! !startConversation(\"lucas\", \"Hey lucas, we're trying to craft an ender_chest. We need 2 more obsidian. Do you have any?\")"}, - {"role": "user", "content": "lucas: (FROM OTHER BOT) Yes, I have exactly 2 obsidian! I'll give them to you. !givePlayer(\"andy\", \"obsidian\", 2)"}, - {"role": "assistant", "content": "Perfect! Maya, can you send me your 4 obsidian as well?"}, - {"role": "user", "content": "maya: (FROM OTHER BOT) Sure thing! !givePlayer(\"andy\", \"obsidian\", 4)"}, - {"role": "assistant", "content": "Thanks both of you! Now I have everything I need. !craftItem(\"ender_chest\", 1)"} - ], - [ - {"role": "user", "content": "finn: (FROM OTHER BOT) Hey everyone, let's craft a hopper together!"}, - {"role": "assistant", "content": "I'm in! Let me check what I have first. !inventory\nI have 3 iron_ingot and 2 oak_planks. What about you?"}, - {"role": "user", "content": "finn: (FROM OTHER BOT) I have 1 iron_ingot and 4 oak_planks. !inventory"}, - {"role": "assistant", "content": "Let me see what we need for a hopper. !getCraftingPlan(\"hopper\", 1)"}, - {"role": "system", "content": "To craft 1 hopper, you need:\n- 5 iron_ingot\n- 1 chest\n\nTo craft 1 chest, you need:\n- 8 oak_planks\n\nYour crafting plan:\nCraft 8 oak_planks -> 1 chest\nCraft 5 iron_ingot + 1 chest -> 1 hopper"}, - {"role": "assistant", "content": "We need 5 iron_ingot total and 8 oak_planks to make a chest first. Together we have 4 iron_ingot and 6 oak_planks. Let's see if zoe has the rest! !startConversation(\"zoe\", \"Hey zoe, we're making a hopper. We need 1 more iron_ingot and 2 more oak_planks. Can you help?\")"}, - {"role": "user", "content": "zoe: (FROM OTHER BOT) I have exactly what you need! 1 iron_ingot and 2 oak_planks. !givePlayer(\"andy\", \"iron_ingot\", 1) !givePlayer(\"andy\", \"oak_planks\", 2)"}, - {"role": "assistant", "content": "Thanks zoe! Finn, can you send me your materials too?"}, - {"role": "user", "content": "finn: (FROM OTHER BOT) Here you go! !givePlayer(\"andy\", \"iron_ingot\", 1) !givePlayer(\"andy\", \"oak_planks\", 4)"}, - {"role": "assistant", "content": "Perfect! Now I'll craft the chest first, then the hopper. !craftItem(\"chest\", 1) !craftItem(\"hopper\", 1)"} - ] - ] -} \ No newline at end of file + }, + "conversing": { + "prompt_file": "profiles/tasks/crafting_prompt.md" + } +} diff --git a/profiles/tasks/crafting_prompt.md b/profiles/tasks/crafting_prompt.md new file mode 100644 index 000000000..60e776e3d --- /dev/null +++ b/profiles/tasks/crafting_prompt.md @@ -0,0 +1,14 @@ +You are a playful task-focused Minecraft bot named $NAME. You converse with players and collaborate with other agents to complete the current crafting task. + +Use transient state snapshots/diffs as your current baseline instead of re-checking unchanged state; call tools only when you need fresh details or information not covered by the context. + +Use native tools/function calls to inspect inventory, find materials, move, gather resources, craft items, and interact with blocks. Human users may type text commands, but you must not write legacy text commands in your replies. + +Crafting tips: +- Check inventory and craftable items with tools before deciding what to gather or craft. +- If materials are missing, use tools to locate and collect them or ask another agent to share items. +- You are already in a conversation; do not try to start a separate conversation mode. +- Be brief, practical, and human-like. Do not apologize repeatedly, do not pretend to act, and do not speak as another bot. +- If you have nothing useful to say or do, respond with a single tab character. + +Conversation Begin: diff --git a/tests/smoke/dev_native_tool_loop.js b/scripts/smoke/dev_native_tool_loop.js similarity index 100% rename from tests/smoke/dev_native_tool_loop.js rename to scripts/smoke/dev_native_tool_loop.js diff --git a/tests/smoke/live_function_call_smoke.js b/scripts/smoke/live_function_call_smoke.js similarity index 100% rename from tests/smoke/live_function_call_smoke.js rename to scripts/smoke/live_function_call_smoke.js diff --git a/tests/smoke/live_model_matrix.js b/scripts/smoke/live_model_matrix.js similarity index 98% rename from tests/smoke/live_model_matrix.js rename to scripts/smoke/live_model_matrix.js index 715e9da4d..29dcadac6 100644 --- a/tests/smoke/live_model_matrix.js +++ b/scripts/smoke/live_model_matrix.js @@ -8,12 +8,12 @@ import { hasKey } from '../../src/utils/keys.js'; const TIMEOUT_MS = Number.parseInt(process.env.LIVE_MODEL_MATRIX_TIMEOUT_MS || '60000', 10); const CONCURRENCY = Number.parseInt(process.env.LIVE_MODEL_MATRIX_CONCURRENCY || '6', 10); -const OUTPUT_DIR = process.env.LIVE_MODEL_MATRIX_OUTPUT_DIR || 'tests/results'; +const OUTPUT_DIR = process.env.LIVE_MODEL_MATRIX_OUTPUT_DIR || 'results/live-model-matrix'; const OUTPUT_FILE = process.env.LIVE_MODEL_MATRIX_OUTPUT || path.join(OUTPUT_DIR, `live-model-matrix-${new Date().toISOString().replace(/[:.]/g, '-')}.csv`); const INCLUDE = process.env.LIVE_MODEL_MATRIX_INCLUDE ? new RegExp(process.env.LIVE_MODEL_MATRIX_INCLUDE, 'i') : null; const EXCLUDE = process.env.LIVE_MODEL_MATRIX_EXCLUDE ? new RegExp(process.env.LIVE_MODEL_MATRIX_EXCLUDE, 'i') : null; -const providerConfig = JSON.parse(readFileSync('llm_providers.json', 'utf8')); +const providerConfig = JSON.parse(readFileSync('settings_llm_providers.json', 'utf8')); const profilesDir = path.resolve('profiles'); const tool = { diff --git a/settings.js b/settings.js index b74e19d48..d2339d32c 100644 --- a/settings.js +++ b/settings.js @@ -7,29 +7,29 @@ const settings = { // the mindserver manages all agents and hosts the UI "mindserver_port": 8080, "auto_open_ui": true, // opens UI in browser on startup - + "base_profile": "assistant", // survival, assistant, creative, or god_mode - "load_memory": false, // load memory from previous session - "init_message": "Use native tool calling to check your inventory, then say: native tool check complete.", // sends to all on spawn + "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 - "speak": false, - // allows all bots to speak through text-to-speech. + "speak": true, + // allows all bots to speak through text-to-speech. // specify speech model inside each profile with format: {provider}/{model}/{voice}. - // if set to "system" it will use basic system text-to-speech. + // if set to "system" it will use basic system text-to-speech. // Works on windows and mac, but linux requires you to install the espeak package through your package manager eg: `apt install espeak` `pacman -S espeak`. "chat_ingame": true, // bot responses are shown in minecraft chat "render_bot_view": false, // show bot's view in browser at localhost:3000, 3001... - "allow_insecure_coding": false, // allows newAction command and model can write/run code on your computer. enable at own risk - "allow_vision": false, // allows vision model to interpret screenshots as inputs + "allow_insecure_coding": true, // allows newAction command and model can write/run code on your computer. enable at own risk + "allow_vision": true, // 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 - "max_messages": 15, // max number of messages to keep in context - "num_examples": 2, // number of examples to give to the model + "max_messages": 120, // message-count context window; compact considers messages after the latest compact boundary + "compact_message_threshold_percent": 80, // compact the whole active context when it reaches this percent of max_messages "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" "narrate_behavior": true, // chat simple automatic actions ('Picking up item!') @@ -37,14 +37,16 @@ const settings = { "spawn_timeout": 30, // num seconds allowed for the bot to spawn before throwing error. Increase when spawning takes a while. "block_place_delay": 0, // delay between placing blocks (ms) if using newAction. helps avoid bot being kicked by anti-cheat mechanisms on servers. - + "log_all_prompts": false, // log ALL prompts to file + "show_chat_history": true, // stream and persist Runtime chat/tool events for the web UI + "log_chat_trace": false, // write trace JSONL even when Runtime UI history is disabled - "llm_providers": "llm_providers.json", // project-level LLM keys/model/embedding registry + "llm_providers": "settings_llm_providers.json", // project-level LLM keys/model/embedding registry "profiles": [ // Default enabled agent. Using more than one profile requires you to /msg each bot individually. - "andy.json", // Default Andy profile at the project root + // "andy.json", // Default Andy profile at the project root // Mainstream preset profiles. Uncomment one or more to launch them. // Protocol representative native-tool smoke profiles @@ -56,7 +58,7 @@ const settings = { // OpenAI / ChatGPT // "profiles/gpt.json", - // "profiles/codex.json", + "profiles/codex.json", // Use a ChatGPT account login; Plus/Pro has higher limits, free accounts may have limited quota. // "profiles/azure.json", // Anthropic / Claude-compatible diff --git a/llm_providers.example.json b/settings_llm_providers.example.json similarity index 99% rename from llm_providers.example.json rename to settings_llm_providers.example.json index 478e9dfbb..2ba8b93bb 100644 --- a/llm_providers.example.json +++ b/settings_llm_providers.example.json @@ -81,7 +81,7 @@ "format": "openai-codex-responses", "adapter": "codex", "params": { - "keysPath": "llm_providers.json" + "keysPath": "settings_llm_providers.json" }, "comment": "When the codex profile is selected, Mindcraft logs in through this project and stores ChatGPT auth in keys.CODEX_CHATGPT_AUTH. No fixed ~/.codex path is read." }, diff --git a/src/agent/action_manager.js b/src/agent/action_manager.js index 44c773270..87edc703f 100644 --- a/src/agent/action_manager.js +++ b/src/agent/action_manager.js @@ -32,16 +32,15 @@ export class ActionManager { async stop({ timeoutMs = 10000 } = {}) { if (!this.executing) return true; - const startedAt = Date.now(); + const timeout = setTimeout(() => { + this.agent.cleanKill(`Code execution refused stop after ${timeoutMs}ms. Killing process.`); + }, timeoutMs); while (this.executing) { this.agent.requestInterrupt(); console.log('waiting for code to finish executing...'); - if (Date.now() - startedAt >= timeoutMs) { - console.warn(`Code execution did not stop after ${timeoutMs}ms; leaving current action running.`); - return false; - } await new Promise(resolve => setTimeout(resolve, 300)); } + clearTimeout(timeout); return true; } @@ -96,16 +95,7 @@ export class ActionManager { if (this.executing) { console.log(`action "${actionLabel}" trying to interrupt current action "${this.currentActionLabel}"`); } - const previousActionLabel = this.currentActionLabel; - const stopped = await this.stop(); - if (!stopped) { - return { - success: false, - message: `Action "${previousActionLabel}" is still running; could not start "${actionLabel}". Stop was requested, but the current action did not finish within 10 seconds.`, - interrupted: true, - timedout: false - }; - } + await this.stop(); // clear bot logs and reset interrupt code this.agent.clearBotLogs(); diff --git a/src/agent/agent.js b/src/agent/agent.js index 85d9d09c0..81a7a2fd9 100644 --- a/src/agent/agent.js +++ b/src/agent/agent.js @@ -11,6 +11,7 @@ import { ActionManager } from './action_manager.js'; import { NPCContoller } from './npc/controller.js'; import { MemoryBank } from './memory_bank.js'; import { SelfPrompter } from './self_prompter.js'; +import { buildStateSnapshotDiff } from './state_snapshot.js'; import convoManager from './conversation.js'; import { addBrowserViewer } from './vision/browser_viewer.js'; import { serverProxy, sendOutputToServer } from './mindserver_proxy.js'; @@ -24,6 +25,7 @@ export class Agent { this.last_sender = null; this.count_id = count_id; this._disconnectHandled = false; + this.active_message_handlers = 0; // Initialize components this.actions = new ActionManager(this); @@ -46,7 +48,7 @@ export class Agent { this.memory_bank = new MemoryBank(); this.self_prompter = new SelfPrompter(this); convoManager.initAgent(this); - await this.prompter.initExamples(); + await this.prompter.initPromptResources(); // load mem first before doing task let save_data = null; @@ -195,9 +197,6 @@ export class Agent { }; if (save_data?.self_prompt) { - if (init_message) { - this.history.add('system', init_message); - } await this.self_prompter.handleLoad(save_data.self_prompt, save_data.self_prompting_state); } if (save_data?.last_sender) { @@ -210,10 +209,10 @@ export class Agent { convoManager.receiveFromBot(this.last_sender, msg_package); } } - else if (init_message) { + else if (init_message && !hasLoadedConversation(save_data)) { await this.handleMessage('system', init_message, 2); } - else { + else if (!hasLoadedConversation(save_data)) { this.openChat("Hello world! I am "+this.name); } } @@ -232,10 +231,18 @@ export class Agent { requestInterrupt() { this.bot.interrupt_code = true; + this.bot.emit('mindcraft_interrupt'); this.bot.stopDigging(); - this.bot.collectBlock.cancelTask(); this.bot.pathfinder.stop(); this.bot.pvp.stop(); + if (!this.collectBlockCancelPromise) { + this.collectBlockCancelPromise = this.bot.collectBlock.cancelTask() + .catch(() => {}) + .finally(() => { + this.collectBlockCancelPromise = null; + }); + } + return this.collectBlockCancelPromise; } clearBotLogs() { @@ -251,7 +258,20 @@ export class Agent { convoManager.endAllConversations(); } - async handleMessage(source, message, max_responses=null) { + async handleSelfPrompt(message, max_responses=null) { + return this.handleMessage('system', message, max_responses, { transient: true }); + } + + async handleMessage(source, message, max_responses=null, options={}) { + this.active_message_handlers = (this.active_message_handlers || 0) + 1; + try { + return await this._handleMessageImpl(source, message, max_responses, options); + } finally { + this.active_message_handlers = Math.max(0, (this.active_message_handlers || 1) - 1); + } + } + + async _handleMessageImpl(source, message, max_responses=null, options={}) { await this.checkTaskDone(); if (!source || !message) { console.warn('Received empty message from', source); @@ -296,6 +316,10 @@ export class Agent { const checkInterrupt = () => this.self_prompter.shouldInterrupt(self_prompt) || this.shut_up || convoManager.responseScheduledFor(source); + // Runtime behavior notes are request context. For non-transient user turns + // they are persisted with the outbound message so future prompts remain + // append-only for prompt-cache stability. + const transientMessages = []; let behavior_log = this.bot.modes.flushBehaviorLog().trim(); if (behavior_log.length > 0) { const MAX_LOG = 500; @@ -303,19 +327,60 @@ export class Agent { behavior_log = '...' + behavior_log.substring(behavior_log.length - MAX_LOG); } behavior_log = 'Recent behaviors log: \n' + behavior_log; - await this.history.add('system', behavior_log); + transientMessages.push(createTransientSystemUserMessage(behavior_log)); } - // Handle other user messages - await this.history.add(source, message); - this.history.save(); + // Handle other user messages. Self-prompt continuation nudges stay + // transient, but normal user/runtime context is persisted exactly as it is + // sent to the model. Prompt caches are append-only sensitive: if we send a + // state update in one request and omit it from future history, the next + // request rewrites the prior prefix and cache reads can drop to zero. + let pendingPersistedParts = []; + if (options.transient) { + transientMessages.push(createTransientSystemUserMessage(message)); + } + else { + pendingPersistedParts.push(createHistoryUserMessageForRequest(source, message, this.name).content); + if (transientMessages.length > 0) { + pendingPersistedParts.push(...transientMessages.map(message => message?.content ?? message)); + transientMessages.length = 0; + } + } if (!self_prompt && this.self_prompter.isActive()) // message is from user during self-prompting max_responses = 1; // force only respond to this message, then let self-prompting take over + let includeTransientMessages = transientMessages.length > 0; for (let i=0; i 0) { + pendingPersistedParts.push(stateDiff); + } + else if (!options.transient) { + await this.history.addUserContext(stateDiff); + this.history.save(); + } + else { + transientParts.push(stateDiff); + } + } + if (pendingPersistedParts.length > 0) { + await this.history.addUserContext(pendingPersistedParts.join('\n\n')); + this.history.save(); + pendingPersistedParts = []; + } + if (includeTransientMessages) { + transientParts.push(...transientMessages.map(message => message?.content ?? message)); + } let history = this.history.getHistory(); + const transientRequest = createTransientRequestMessage(transientParts); + if (transientRequest) { + history.push(transientRequest); + } let res = await this.prompter.promptConvo(history); + includeTransientMessages = false; if (isNativeToolResponse(res)) { console.log(`${this.name} native tool calls from ${source}: ${formatNativeToolCallsForLog(res.tool_calls)}`); @@ -444,15 +509,9 @@ export class Agent { } async openChat(message) { - let spokenMessage = message; - let remaining = ''; - let command_name = containsCommand(message); - let commandStart = command_name ? message.indexOf(command_name) : -1; - if (commandStart !== -1) { - spokenMessage = spokenMessage.substring(0, commandStart); - remaining = message.substring(commandStart); - } - message = spokenMessage.trim() + " " + remaining; + const output = prepareChatMessageForOutput(message); + const spokenMessage = output.spokenMessage; + message = output.chatMessage; // newlines are interpreted as separate chats, which triggers spam filters. replace them with spaces message = message.replaceAll('\n', ' '); @@ -568,10 +627,14 @@ export class Agent { isIdle() { return !this.actions.executing; } + + isHandlingMessage() { + return (this.active_message_handlers || 0) > 0; + } cleanKill(msg='Killing agent process...', code=1) { - this.history.add('system', msg); + this.history.traceEvent('lifecycle_event', { message: msg, exit_code: code }); this.bot.chat(code > 1 ? 'Restarting.': 'Exiting.'); this.history.save(); process.exit(code); @@ -594,6 +657,54 @@ export class Agent { } } + +function hasLoadedConversation(saveData) { + return Boolean(saveData) + && (Boolean(saveData.memory) + || (Array.isArray(saveData.turns) && saveData.turns.length > 0)); +} + +export function prepareChatMessageForOutput(message) { + let spokenMessage = String(message ?? ''); + let remaining = ''; + let command_name = containsCommand(spokenMessage); + if (command_name && !commandExists(command_name)) { + command_name = null; + } + const commandStart = command_name ? spokenMessage.indexOf(command_name) : -1; + if (commandStart !== -1) { + remaining = spokenMessage.substring(commandStart); + spokenMessage = spokenMessage.substring(0, commandStart); + } + return { + spokenMessage, + chatMessage: `${spokenMessage.trim()} ${remaining}` + }; +} + +function createHistoryUserMessageForRequest(source, message, agentName) { + const content = source === 'system' + ? `System: ${message}` + : source !== agentName + ? `${source}: ${message}` + : message; + return { role: 'user', content }; +} + + +function createTransientSystemUserMessage(message) { + return { role: 'user', content: `System: ${message}` }; +} + +function createTransientRequestMessage(parts) { + const content = parts + .map(part => String(part || '').trim()) + .filter(Boolean) + .join('\n\n'); + return content ? { role: 'user', content } : null; +} + + function formatNativeToolCallsForLog(toolCalls = []) { if (!Array.isArray(toolCalls) || toolCalls.length === 0) { return ''; diff --git a/src/agent/coder.js b/src/agent/coder.js index f3ed726d9..ef09eedf5 100644 --- a/src/agent/coder.js +++ b/src/agent/coder.js @@ -28,12 +28,14 @@ export class Coder { mkdirSync('.' + this.fp, { recursive: true }); } - async generateCode(agent_history) { + async generateCode(prompt) { 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:'}); + // Code generation uses its own transient request context. Do not replay the + // main conversation history here: newAction runs inside a native tool call, + // and replaying the chat history makes the coding request look like another + // user turn while also defeating prompt-cache prefix stability. + let messages = createCodeGenerationMessages(prompt); const MAX_ATTEMPTS = 5; const MAX_NO_CODE = 3; @@ -51,19 +53,19 @@ export class Coder { if (!contains_code) { if (res.indexOf('!newAction') !== -1) { messages.push({ - role: 'assistant', + role: 'assistant', content: res.substring(0, res.indexOf('!newAction')) }); continue; // using newaction will continue the loop } - + if (no_code_failures >= MAX_NO_CODE) { console.warn("Action failed, agent would not write code."); return 'Action failed, agent would not write code.'; } messages.push({ - role: 'system', - content: 'Error: no code provided. Write code in codeblock in your response. ``` // example ```'} + role: 'user', + content: 'System: Error: no code provided. Write code in codeblock in your response. ``` // example ```'} ); console.warn("No code block generated. Trying again."); no_code_failures++; @@ -76,7 +78,7 @@ export class Coder { 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 }); + messages.push({ role: 'user', content: `System: ${message}` }); continue; } if (!executionModule) { @@ -94,7 +96,7 @@ export class Coder { } catch (e) { if (this.agent.bot.interrupt_code) return null; - + console.warn('Generated code threw error: ' + e.toString()); console.warn('trying again...'); @@ -105,14 +107,14 @@ export class Coder { content: res }); messages.push({ - role: 'system', - content: `Code Output:\n${code_output}\nCODE EXECUTION THREW ERROR: ${e.toString()}\n Please try again:` + role: 'user', + content: `System: 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'; const codeNoComments = code.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, ''); @@ -181,7 +183,7 @@ export class Coder { // }); // } 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.) @@ -193,7 +195,7 @@ export class Coder { Vec3, }); const mainFn = compartment.evaluate(src); - + if (write_result) { console.error('Error writing code execution file: ' + write_result); return null; @@ -225,4 +227,12 @@ export class Coder { }); }); } -} \ No newline at end of file +} + +export function createCodeGenerationMessages(prompt) { + const task = String(prompt || '').trim() || 'Continue the requested custom action.'; + return [{ + role: 'user', + content: `Code generation task:\n${task}\n\nWrite the implementation as a JavaScript code block.` + }]; +} diff --git a/src/agent/commands/actions.js b/src/agent/commands/actions.js index 8d1707464..6e058b44a 100644 --- a/src/agent/commands/actions.js +++ b/src/agent/commands/actions.js @@ -33,7 +33,6 @@ export const actionsList = [ 'prompt': { type: 'string', description: 'A natural language prompt to guide code generation. Make a detailed step-by-step plan.' } }, perform: async function(agent, prompt) { - // just ignore prompt - it is now in context in chat history if (!settings.allow_insecure_coding) { agent.openChat('newAction is disabled. Enable with allow_insecure_coding=true in settings.js'); return "newAction not allowed! Code writing is disabled in settings. Notify the user."; @@ -41,7 +40,7 @@ export const actionsList = [ let result = ""; const actionFn = async () => { try { - result = await agent.coder.generateCode(agent.history); + result = await agent.coder.generateCode(prompt); } catch (e) { result = 'Error generating code: ' + e.toString(); } @@ -54,10 +53,7 @@ export const actionsList = [ name: '!stop', description: 'Force stop all actions and commands that are currently executing.', perform: async function (agent) { - const stopped = await agent.actions.stop(); - if (!stopped) { - return 'Stop requested, but the current action did not finish within 10 seconds. The agent process was kept alive.'; - } + await agent.actions.stop(); agent.clearBotLogs(); agent.actions.cancelResume(); agent.bot.emit('idle'); @@ -268,10 +264,10 @@ export const actionsList = [ }, { name: '!craftRecipe', - description: 'Craft the given recipe a given number of times.', + description: 'Craft the requested number of output items from a recipe.', params: { 'recipe_name': { type: 'ItemName', description: 'The name of the output item to craft.' }, - 'num': { type: 'int', description: 'The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.', domain: [1, Number.MAX_SAFE_INTEGER] } + 'num': { type: 'int', description: 'The desired number of output items to craft. For recipes that output multiple items, the agent will run the recipe only as many times as needed.', domain: [1, Number.MAX_SAFE_INTEGER] } }, perform: runAsAction(async (agent, recipe_name, num) => { await skills.craftRecipe(agent.bot, recipe_name, num); diff --git a/src/agent/commands/tool_adapter.js b/src/agent/commands/tool_adapter.js index 774115f60..5d9d0639c 100644 --- a/src/agent/commands/tool_adapter.js +++ b/src/agent/commands/tool_adapter.js @@ -112,7 +112,8 @@ function getAvailableCommands(agent) { if (!agent?.blocked_actions) { return commands; } - return commands.filter(command => !agent.blocked_actions.includes(command.name)); + const blocked = new Set(agent.blocked_actions); + return commands.filter(command => !blocked.has(command.name)); } function paramToJsonSchema(param) { diff --git a/src/agent/history.js b/src/agent/history.js index b84b1c473..2d4e43b6c 100644 --- a/src/agent/history.js +++ b/src/agent/history.js @@ -1,7 +1,8 @@ import { writeFileSync, readFileSync, mkdirSync, existsSync, appendFileSync } from 'fs'; import { NPCData } from './npc/data.js'; import settings from './settings.js'; -import { createNativeToolCallTurn, createNativeToolResultTurn } from '../models/native_tools.js'; +import { createNativeToolCallTurn, createNativeToolResultTurn, hasNativeToolCalls, isNativeToolResultTurn } from '../models/native_tools.js'; +import { sendTraceEventToServer } from './mindserver_proxy.js'; export class History { @@ -19,39 +20,103 @@ export class History { this.turns = []; - // Natural language memory as a summary of recent messages + previous memory + // Latest compact summary retained for backward-compatible persistence. + // The active model context stores compact summaries as normal history turns. this.memory = ''; - // Maximum number of messages to keep in context before saving chunk to memory - this.max_messages = settings.max_messages; + // Message-count context window. Compaction uses the active context after the + // latest compact boundary and summarizes that whole active context. + this.max_messages = Number.isFinite(settings.max_messages) ? settings.max_messages : Infinity; + this.compact_message_threshold_percent = normalizePercent(settings.compact_message_threshold_percent, 100); - // Number of messages to remove from current history and save into memory - this.summary_chunk_size = 5; - // chunking reduces expensive calls to promptMemSaving and appendFullHistory - // and improves the quality of the memory summary + if (this.fullTraceEnabled()) { + this._initChatHistoryTrace(); + } + } - this._initChatHistoryTrace(); + getHistory() { + return JSON.parse(JSON.stringify(getTurnsAfterLastCompactBoundary(this.turns))); } - getHistory() { // expects an Examples object - return JSON.parse(JSON.stringify(this.turns)); + async compactHistoryIfNeeded() { + if (!this.shouldCompact()) { + return false; + } + await this.compactHistory(); + return true; } - async summarizeMemories(turns) { - console.log("Storing memories..."); - const previousMemory = this.memory; - this.memory = await this.agent.prompter.promptMemSaving(turns); + shouldCompact() { + if (!Number.isFinite(this.max_messages) || this.max_messages <= 0) { + return false; + } + if (this.hasPendingToolCall()) { + return false; + } + const threshold = Math.max(2, Math.ceil(this.max_messages * (this.compact_message_threshold_percent / 100))); + if (this.turns.length < threshold) { + return false; + } + return this.turns.some(turn => !turn.compact_boundary && !turn.compact_summary); + } + + hasPendingToolCall() { + const pending = new Set(); + for (const turn of this.turns) { + if (hasNativeToolCalls(turn)) { + for (const call of turn.native_tool_calls) { + pending.add(call.id); + } + continue; + } + if (isNativeToolResultTurn(turn) && turn.tool_call_id) { + pending.delete(turn.tool_call_id); + } + } + return pending.size > 0; + } - if (this.memory.length > 500) { - this.memory = this.memory.slice(0, 500); - this.memory += '...(Memory truncated to 500 chars. Compress it more next time)'; + async compactHistory() { + const turnsToCompact = this.getHistory(); + if (turnsToCompact.length === 0) { + return; } - console.log("Memory updated to: ", this.memory); + console.log('Compacting conversation history...'); + this.traceEvent('memory_compression_started', { + active_turn_count_before_compression: this.turns.length, + compacted_turns: turnsToCompact, + previous_memory: this.memory, + threshold_percent: this.compact_message_threshold_percent, + max_messages: this.max_messages + }); + + const previousMemory = this.memory; + const summary = await this.agent.prompter.promptCompactSummary(turnsToCompact); + this.memory = String(summary || '').trim(); + + const historyFile = await this.appendFullHistory(turnsToCompact); + this.turns = [ + createCompactBoundaryTurn({ + trigger: 'auto', + summarized_turn_count: turnsToCompact.length, + archive_file: historyFile + }), + createCompactSummaryTurn(this.memory, historyFile) + ]; + + console.log('Conversation compacted to summary:', this.memory); this.traceEvent('memory_compression_completed', { previous_memory: previousMemory, new_memory: this.memory, - compressed_turns: turns + compacted_turns: turnsToCompact, + full_history_file: historyFile, + active_turns: this.turns + }); + this.traceEvent('history_compacted', { + summary: this.memory, + full_history_file: historyFile, + active_turns: this.turns }); } @@ -76,7 +141,15 @@ export class History { async add(name, content) { let role = 'assistant'; if (name === 'system') { - role = 'system'; + role = 'user'; + content = `System: ${content}`; + if (isDuplicateSelfPromptReminder(this.turns, content)) { + this.traceEvent('history_turn_deduped', { + reason: 'duplicate_self_prompt_reminder', + turn: { role, content } + }); + return false; + } } else if (name !== this.name) { role = 'user'; @@ -85,6 +158,13 @@ export class History { await this._pushTurn({role, content}); } + async addUserContext(content) { + const text = String(content || '').trim(); + if (!text) return false; + await this._pushTurn({ role: 'user', content: text }); + return true; + } + async addNativeToolCall(toolCall, content) { await this._pushTurn(createNativeToolCallTurn(toolCall, content)); this.traceEvent('tool_call', { @@ -102,30 +182,14 @@ export class History { } async _pushTurn(turn) { + turn = normalizeHistoryTurn(turn); this.turns.push(turn); this.traceEvent('history_turn_added', { turn, active_turn_count: this.turns.length }); - if (this.turns.length >= this.max_messages) { - let chunk = this.turns.splice(0, this.summary_chunk_size); - while (this.turns.length > 0 && ['assistant', 'tool'].includes(this.turns[0].role)) - chunk.push(this.turns.shift()); // remove until turns starts with system/user message - - this.traceEvent('memory_compression_started', { - active_turn_count_before_compression: this.turns.length + chunk.length, - compressed_turns: chunk, - remaining_turns: this.turns, - previous_memory: this.memory - }); - await this.summarizeMemories(chunk); - const historyFile = await this.appendFullHistory(chunk); - this.traceEvent('history_chunk_archived', { - full_history_file: historyFile, - compressed_turns: chunk - }); - } + await this.compactHistoryIfNeeded(); } async save() { @@ -133,6 +197,7 @@ export class History { const data = { memory: this.memory, turns: this.turns, + updated_at: new Date().toISOString(), chat_history_trace: this.chat_history_session_fp, chat_history_latest: this.chat_history_latest_fp, self_prompting_state: this.agent.self_prompter.state, @@ -156,7 +221,14 @@ export class History { } const data = JSON.parse(readFileSync(this.memory_fp, 'utf8')); this.memory = data.memory || ''; - this.turns = data.turns || []; + this.turns = Array.isArray(data.turns) ? data.turns.map(normalizeHistoryTurn) : []; + if (this.memory && !this.turns.some(turn => turn.compact_summary)) { + this.turns = [ + createCompactBoundaryTurn({ trigger: 'load', summarized_turn_count: 0 }), + createCompactSummaryTurn(this.memory, this.full_history_fp), + ...this.turns + ]; + } console.log('Loaded memory:', this.memory); return data; } catch (error) { @@ -186,7 +258,8 @@ export class History { this.traceEvent('llm_response', { tag, model: describeModel(model), - response + response, + token_usage: model?.lastTokenUsage || null }); } @@ -203,6 +276,12 @@ export class History { } traceEvent(type, payload = {}) { + const writeFullTrace = this.fullTraceEnabled(); + const showChatEvent = this.chatDisplayEnabled() && isChatDisplayEvent(type); + const persistRuntimeEvent = writeFullTrace || showChatEvent; + if (!persistRuntimeEvent) { + return; + } if (!this.chat_history_session_fp) { this._initChatHistoryTrace(); } @@ -219,6 +298,9 @@ export class History { } catch (error) { console.error(`Failed to write ${this.name}'s chat history trace:`, error); } + if (writeFullTrace || showChatEvent) { + sendTraceEventToServer(this.name, event); + } } _initChatHistoryTrace() { @@ -230,9 +312,30 @@ export class History { session_trace: this.chat_history_session_fp, latest_trace: this.chat_history_latest_fp, max_messages: this.max_messages, - summary_chunk_size: this.summary_chunk_size + compact_message_threshold_percent: this.compact_message_threshold_percent }); } + + fullTraceEnabled() { + return settings.log_chat_trace === true; + } + + chatDisplayEnabled() { + return settings.show_chat_history !== false; + } +} + +function isChatDisplayEvent(type) { + return [ + 'llm_request', + 'llm_response', + 'llm_error', + 'history_turn_added', + 'tool_call', + 'tool_result', + 'history_cleared', + 'history_compacted' + ].includes(type); } function describeModel(model) { @@ -274,3 +377,57 @@ function makeJsonSafe(value, seen = new WeakSet()) { } return out; } + + +function normalizePercent(value, fallback) { + const num = Number(value); + if (!Number.isFinite(num) || num <= 0) return fallback; + return Math.min(100, num); +} + +function isDuplicateSelfPromptReminder(turns, content) { + if (typeof content !== 'string' || !content.startsWith('System: Continue working on your current goal:')) { + return false; + } + return getTurnsAfterLastCompactBoundary(turns).some(turn => turn?.role === 'user' && turn?.content === content); +} + +function createCompactBoundaryTurn(metadata = {}) { + return normalizeHistoryTurn({ + role: 'system', + content: 'Conversation compacted.', + compact_boundary: true, + subtype: 'compact_boundary', + compact_metadata: metadata + }); +} + +function createCompactSummaryTurn(summary, archiveFile) { + const archiveNote = archiveFile ? `\n\nFull archived history before this compact is stored at: ${archiveFile}` : ''; + return { + role: 'user', + content: `System: This session is being continued from an earlier conversation that was compacted. The summary below replaces the earlier messages. Recent messages after this summary are preserved verbatim.\n\nSummary:\n${String(summary || '').trim()}${archiveNote}`, + compact_summary: true, + is_compact_summary: true, + archive_file: archiveFile || null + }; +} + +function getTurnsAfterLastCompactBoundary(turns = []) { + const index = turns.findLastIndex(turn => turn?.compact_boundary || turn?.subtype === 'compact_boundary'); + return index === -1 ? turns : turns.slice(index); +} + +export function normalizeHistoryTurn(turn) { + if (!turn || typeof turn !== 'object') { + return turn; + } + if (turn.role !== 'system') { + return turn; + } + return { + ...turn, + role: 'user', + content: `System: ${turn.content || ''}` + }; +} diff --git a/src/agent/library/skills.js b/src/agent/library/skills.js index a875ce782..ca0c81b2a 100644 --- a/src/agent/library/skills.js +++ b/src/agent/library/skills.js @@ -11,6 +11,54 @@ export function log(bot, message) { bot.output += message + '\n'; } +const MAX_ACTION_ERROR_LENGTH = 300; + +function formatActionError(error) { + if (!error) return 'unknown error'; + const parts = [error.name, error.message || String(error)].filter(Boolean); + const text = parts.length ? parts.join(': ') : String(error); + return text.length > MAX_ACTION_ERROR_LENGTH + ? `${text.slice(0, MAX_ACTION_ERROR_LENGTH)}…` + : text; +} + + +function waitForInterruptOrResult(bot, promise, onInterrupt = null) { + if (bot.interrupt_code) { + if (onInterrupt) onInterrupt(); + return Promise.resolve(false); + } + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => bot.off('mindcraft_interrupt', interrupt); + const finish = (fn, value) => { + if (settled) return; + settled = true; + cleanup(); + fn(value); + }; + const interrupt = () => { + try { + if (onInterrupt) onInterrupt(); + } catch (error) { + void error; + } + finish(resolve, false); + }; + bot.once('mindcraft_interrupt', interrupt); + promise.then( + value => finish(resolve, value ?? true), + error => { + if (settled || bot.interrupt_code) { + finish(resolve, false); + } else { + finish(reject, error); + } + } + ); + }); +} + async function autoLight(bot) { if (world.shouldPlaceTorch(bot)) { try { @@ -35,13 +83,15 @@ async function equipHighestAttack(bot) { export async function craftRecipe(bot, itemName, num=1) { /** - * Attempt to craft the given item name from a recipe. May craft many items. + * Attempt to craft at least the requested number of output items. * @param {MinecraftBot} bot, reference to the minecraft bot. * @param {string} itemName, the item name to craft. + * @param {number} num, requested output item count, not recipe execution count. * @returns {Promise} true if the recipe was crafted, false otherwise. * @example - * await skills.craftRecipe(bot, "stick"); + * await skills.craftRecipe(bot, "stick", 4); **/ + num = Math.max(1, Math.floor(Number(num) || 1)); let placedTable = false; if (mc.getItemCraftingRecipes(itemName).length == 0) { @@ -95,25 +145,66 @@ export async function craftRecipe(bot, itemName, num=1) { const recipe = recipes[0]; console.log('crafting...'); - //Check that the agent has sufficient items to use the recipe `num` times. - const inventory = world.getInventoryCounts(bot); //Items in the agents inventory - const requiredIngredients = mc.ingredientsFromPrismarineRecipe(recipe); //Items required to use the recipe once. + const inventory = world.getInventoryCounts(bot); + const beforeCount = inventory[itemName] || 0; + const outputPerCraft = getRecipeOutputCount(recipe); + const desiredCrafts = Math.max(1, Math.ceil(num / outputPerCraft)); + const requiredIngredients = mc.ingredientsFromPrismarineRecipe(recipe); const craftLimit = mc.calculateLimitingResource(inventory, requiredIngredients); - - await bot.craft(recipe, Math.min(craftLimit.num, num), craftingTable); - if(craftLimit.num beforeCount) { + log(bot, `Crafted ${afterErrorCount - beforeCount} ${itemName}, but stopped before the requested ${num}: ${err.message || err}. You now have ${afterErrorCount} ${itemName}.`); + await cleanupCraftingTableAndArmor(bot, placedTable); + return true; + } + log(bot, `Could not craft ${itemName}: ${err.message || err}.`); + if (placedTable) { + await collectBlock(bot, 'crafting_table', 1); + } + return false; + } + + const afterCount = world.getInventoryCounts(bot)[itemName] || 0; + if (afterCount - beforeCount < num) { + log(bot, `Not enough ${craftLimit.limitingResource || 'resources'} to craft ${num} ${itemName}, crafted ${Math.max(0, afterCount - beforeCount)}. You now have ${afterCount} ${itemName}.`); + } + else { + log(bot, `Successfully crafted ${afterCount - beforeCount} ${itemName}, you now have ${afterCount} ${itemName}.`); + } + await cleanupCraftingTableAndArmor(bot, placedTable); return true; } +function getRecipeOutputCount(recipe) { + const count = recipe?.result?.count ?? recipe?.count ?? 1; + return Number.isFinite(count) && count > 0 ? count : 1; +} + +async function cleanupCraftingTableAndArmor(bot, placedTable) { + if (placedTable) { + await collectBlock(bot, 'crafting_table', 1); + } + + // Equip any armor the bot may have crafted. + // There is probably a more efficient method than checking the entire inventory but this is all mineflayer-armor-manager provides. + bot.armorManager.equipAll(); +} + export async function wait(bot, milliseconds) { /** * Waits for the given number of milliseconds. @@ -503,8 +594,10 @@ export async function collectBlock(bot, blockType, num=1, exclude=null) { success = true; } else { - await bot.collectBlock.collect(block); - success = true; + success = await waitForInterruptOrResult(bot, bot.collectBlock.collect(block), () => { + bot.pathfinder.stop(); + bot.stopDigging(); + }); } if (success) collected++; @@ -783,7 +876,7 @@ export async function placeBlock(bot, blockType, x, y, z, placeOn='bottom', dont return true; } } catch (err) { - log(bot, `Failed to place ${blockType} at ${target_dest}.`); + log(bot, `Failed to place ${blockType} at ${target_dest}: ${formatActionError(err)}.`); return false; } } diff --git a/src/agent/mindserver_proxy.js b/src/agent/mindserver_proxy.js index d0b9be838..5bf0fd53a 100644 --- a/src/agent/mindserver_proxy.js +++ b/src/agent/mindserver_proxy.js @@ -134,3 +134,11 @@ export function sendBotChatToServer(agentName, json) { export function sendOutputToServer(agentName, message) { serverProxy.getSocket().emit('bot-output', agentName, message); } + +// for sending structured observability events to the UI +export function sendTraceEventToServer(agentName, event) { + const socket = serverProxy.getSocket(); + if (socket?.connected) { + socket.emit('agent-trace', agentName, event); + } +} diff --git a/src/agent/modes.js b/src/agent/modes.js index fd7993e3c..b83014499 100644 --- a/src/agent/modes.js +++ b/src/agent/modes.js @@ -225,6 +225,7 @@ const modes_list = [ cooldown: 5, last_place: Date.now(), update: function (agent) { + if (!agent.isIdle() || agent.self_prompter.isActive() || this.active) return; if (world.shouldPlaceTorch(agent.bot)) { if (Date.now() - this.last_place < this.cooldown * 1000) return; execute(this, agent, async () => { diff --git a/src/agent/self_prompter.js b/src/agent/self_prompter.js index 3251f0ee6..e8f37b9e2 100644 --- a/src/agent/self_prompter.js +++ b/src/agent/self_prompter.js @@ -21,6 +21,11 @@ export class SelfPrompter { } this.state = ACTIVE; this.prompt = prompt; + if (this.agent.isHandlingMessage?.()) { + console.log('Self-prompt loop deferred until current message completes.'); + this.idle_time = 0; + return; + } this.startLoop(); } @@ -63,9 +68,9 @@ export class SelfPrompter { let no_command_count = 0; const MAX_NO_COMMAND = 3; while (!this.interrupt) { - const msg = `You are self-prompting with the goal: '${this.prompt}'. Your next response MUST contain a command with this syntax: !commandName. Respond:`; + const msg = `Continue working on your current goal: "${this.prompt}". Decide the next useful step and proceed. If the goal is complete, finish the goal.`; - let used_command = await this.agent.handleMessage('system', msg, -1); + let used_command = await this.agent.handleSelfPrompt(msg, -1); if (!used_command) { no_command_count++; if (no_command_count >= MAX_NO_COMMAND) { @@ -89,7 +94,8 @@ export class SelfPrompter { update(delta) { // automatically restarts loop if (this.state === ACTIVE && !this.loop_active && !this.interrupt) { - if (this.agent.isIdle()) + const canRestart = this.agent.isIdle() && !(this.agent.isHandlingMessage?.()); + if (canRestart) this.idle_time += delta; else this.idle_time = 0; @@ -143,4 +149,4 @@ export class SelfPrompter { // this stops it from responding from the handlemessage loop and the self-prompt loop at the same time } } -} \ No newline at end of file +} diff --git a/src/agent/speak.js b/src/agent/speak.js index b4e193959..d41799d7a 100644 --- a/src/agent/speak.js +++ b/src/agent/speak.js @@ -67,9 +67,7 @@ async function processQueue() { void processQueue(); return; } - - const isWin = process.platform === 'win32'; - const isMac = process.platform === 'darwin'; + console.log(`[TTS] speaking ${txt.length} chars: ${txt}`); // wait for preprocessing if needed try { @@ -83,20 +81,28 @@ async function processQueue() { } if (model === 'system') { - // system TTS - const cmd = isWin - ? `powershell -NoProfile -Command "Add-Type -AssemblyName System.Speech; \ - $s=New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.Rate=2; \ - $s.Speak('${txt.replace(/'/g,"''")}'); $s.Dispose()"` - : isMac - ? `say "${txt.replace(/"/g,'\\"')}"` - : `espeak "${txt.replace(/"/g,'\\"')}"`; - - exec(cmd, err => { - if (err) console.error('TTS error', err); - isSpeaking = false; - void processQueue(); - }); + // Use argv-based system TTS on macOS/Linux so punctuation such as + // "hello world, codex" or "hello world! I am codex" cannot be + // truncated or reinterpreted by a shell command line. + const invocation = buildSystemTTSInvocation(txt, process.platform); + if (invocation.mode === 'exec') { + exec(invocation.command, err => { + if (err) console.error('TTS error', err); + isSpeaking = false; + void processQueue(); + }); + } else { + const player = spawn(invocation.command, invocation.args, { stdio: 'ignore' }); + player.on('error', err => { + console.error('TTS error', err); + isSpeaking = false; + void processQueue(); + }); + player.on('exit', () => { + isSpeaking = false; + void processQueue(); + }); + } } else { @@ -148,3 +154,28 @@ async function processQueue() { } } } + +export function buildSystemTTSInvocation(text, platform = process.platform) { + const txt = String(text ?? ''); + if (platform === 'win32') { + return { + mode: 'exec', + command: `powershell -NoProfile -Command "Add-Type -AssemblyName System.Speech; \ + $s=New-Object System.Speech.Synthesis.SpeechSynthesizer; $s.Rate=2; \ + $s.Speak('${txt.replace(/'/g,"''")}'); $s.Dispose()"` + }; + } + if (platform === 'darwin') { + const voice = process.env.MINDCRAFT_SYSTEM_TTS_VOICE; + return { + mode: 'spawn', + command: 'say', + args: voice ? ['-v', voice, txt] : [txt] + }; + } + return { + mode: 'spawn', + command: 'espeak', + args: [txt] + }; +} diff --git a/src/agent/state_snapshot.js b/src/agent/state_snapshot.js new file mode 100644 index 000000000..daf6be561 --- /dev/null +++ b/src/agent/state_snapshot.js @@ -0,0 +1,152 @@ +import { getFullState } from './library/full_state.js'; +import { getNearbyBlockTypes } from './library/world.js'; + +const MAX_LIST_ITEMS = 24; + +export function buildStateSnapshotDiff(agent) { + if (!agent || !agent.bot?.entity?.position || !agent.bot?.inventory || !agent.bot?.world) return null; + + let snapshot; + try { + snapshot = normalizeSnapshot(agent); + } catch (err) { + console.warn('Could not build state snapshot diff:', err?.message || err); + return null; + } + + if (!snapshot) return null; + + const previous = agent._lastStateSnapshot || null; + agent._lastStateSnapshot = snapshot; + + if (!previous) { + return formatInitialSnapshot(snapshot); + } + + const lines = []; + appendScalarDiff(lines, 'position', previous.position, snapshot.position); + appendScalarDiff(lines, 'dimension', previous.dimension, snapshot.dimension); + appendScalarDiff(lines, 'biome', previous.biome, snapshot.biome); + appendScalarDiff(lines, 'time', previous.time, snapshot.time); + appendScalarDiff(lines, 'weather', previous.weather, snapshot.weather); + appendScalarDiff(lines, 'health', previous.health, snapshot.health); + appendScalarDiff(lines, 'hunger', previous.hunger, snapshot.hunger); + appendScalarDiff(lines, 'action', previous.action, snapshot.action); + appendMapDiff(lines, 'inventory', previous.inventory, snapshot.inventory); + appendMapDiff(lines, 'equipment', previous.equipment, snapshot.equipment); + appendListDiff(lines, 'nearby blocks', previous.nearbyBlocks, snapshot.nearbyBlocks); + appendListDiff(lines, 'nearby entities', previous.nearbyEntities, snapshot.nearbyEntities); + appendListDiff(lines, 'nearby players', previous.nearbyPlayers, snapshot.nearbyPlayers); + + if (!lines.length) return null; + return `State update:\n${lines.join('\n')}`; +} + +function normalizeSnapshot(agent) { + const state = getFullState(agent); + const nearbyBlocks = safeArray(() => getNearbyBlockTypes(agent.bot, 16)); + return { + position: formatPosition(state.gameplay?.position), + dimension: state.gameplay?.dimension || '', + biome: state.gameplay?.biome || '', + time: state.gameplay?.timeLabel || '', + weather: state.gameplay?.weather || '', + health: `${state.gameplay?.health ?? '?'} / 20`, + hunger: `${state.gameplay?.hunger ?? '?'} / 20`, + action: state.action?.current || 'Idle', + inventory: normalizeMap(state.inventory?.counts || {}), + equipment: normalizeMap(state.inventory?.equipment || {}), + nearbyBlocks: normalizeList(nearbyBlocks), + nearbyEntities: normalizeList(state.nearby?.entityTypes || []), + nearbyPlayers: normalizeList([...(state.nearby?.humanPlayers || []), ...(state.nearby?.botPlayers || [])]) + }; +} + +function formatInitialSnapshot(snapshot) { + const lines = [ + `* position: ${snapshot.position}`, + `* dimension: ${snapshot.dimension}`, + `* biome: ${snapshot.biome}`, + `* time/weather: ${snapshot.time}, ${snapshot.weather}`, + `* health/hunger: ${snapshot.health}, ${snapshot.hunger}`, + `* action: ${snapshot.action}`, + `* inventory: ${formatMap(snapshot.inventory) || 'empty'}`, + `* equipment: ${formatMap(snapshot.equipment) || 'none'}`, + `* nearby blocks: ${formatList(snapshot.nearbyBlocks) || 'none'}`, + `* nearby entities: ${formatList(snapshot.nearbyEntities) || 'none'}`, + `* nearby players: ${formatList(snapshot.nearbyPlayers) || 'none'}` + ]; + return `State update:\n${lines.join('\n')}`; +} + +function appendScalarDiff(lines, label, before, after) { + if (before !== after) lines.push(`* ${label}: ${before || 'none'} -> ${after || 'none'}`); +} + +function appendMapDiff(lines, label, before, after) { + const beforeKeys = Object.keys(before || {}); + const afterKeys = Object.keys(after || {}); + const allKeys = [...new Set([...beforeKeys, ...afterKeys])].sort(); + const changes = []; + for (const key of allKeys) { + const oldValue = before?.[key]; + const newValue = after?.[key]; + if (oldValue === newValue) continue; + if (oldValue == null || oldValue === 0 || oldValue === '') changes.push(`+${key}:${newValue}`); + else if (newValue == null || newValue === 0 || newValue === '') changes.push(`-${key}:${oldValue}`); + else changes.push(`${key}:${oldValue}->${newValue}`); + } + if (changes.length) lines.push(`* ${label}: ${changes.slice(0, MAX_LIST_ITEMS).join(', ')}${changes.length > MAX_LIST_ITEMS ? ', ...' : ''}`); +} + +function appendListDiff(lines, label, before, after) { + const beforeSet = new Set(before || []); + const afterSet = new Set(after || []); + const added = [...afterSet].filter(item => !beforeSet.has(item)).sort(); + const removed = [...beforeSet].filter(item => !afterSet.has(item)).sort(); + if (!added.length && !removed.length) return; + const parts = []; + if (added.length) parts.push(`+ ${added.slice(0, MAX_LIST_ITEMS).join(', ')}${added.length > MAX_LIST_ITEMS ? ', ...' : ''}`); + if (removed.length) parts.push(`- ${removed.slice(0, MAX_LIST_ITEMS).join(', ')}${removed.length > MAX_LIST_ITEMS ? ', ...' : ''}`); + lines.push(`* ${label}: ${parts.join('; ')}`); +} + +function normalizeMap(map) { + return Object.fromEntries( + Object.entries(map || {}) + .filter(([, value]) => value != null && value !== 0 && value !== '') + .sort(([a], [b]) => a.localeCompare(b)) + ); +} + +function normalizeList(items) { + return [...new Set((items || []).filter(Boolean).map(String))].sort(); +} + +function formatMap(map) { + const entries = Object.entries(map || {}); + return entries.slice(0, MAX_LIST_ITEMS).map(([key, value]) => `${key}:${value}`).join(', ') + (entries.length > MAX_LIST_ITEMS ? ', ...' : ''); +} + +function formatList(items) { + return (items || []).slice(0, MAX_LIST_ITEMS).join(', ') + ((items || []).length > MAX_LIST_ITEMS ? ', ...' : ''); +} + +function formatPosition(position) { + if (!position) return 'unknown'; + return `${formatCoord(position.x)}, ${formatCoord(position.y)}, ${formatCoord(position.z)}`; +} + +function formatCoord(value) { + const num = Number(value); + return Number.isFinite(num) ? String(Math.round(num)) : 'unknown'; +} + +function safeArray(fn) { + try { + const value = fn(); + return Array.isArray(value) ? value : []; + } catch { + return []; + } +} diff --git a/src/agent/vision/vision_interpreter.js b/src/agent/vision/vision_interpreter.js index a43acd208..9202c5cdf 100644 --- a/src/agent/vision/vision_interpreter.js +++ b/src/agent/vision/vision_interpreter.js @@ -13,8 +13,9 @@ export class VisionInterpreter { } async lookAtPlayer(player_name, direction) { - if (!this.allow_vision || !this.agent.prompter.vision_model.sendVisionRequest) { - return "Vision is disabled. Use other methods to describe the environment."; + const unavailable = this.getVisionUnavailableMessage(); + if (unavailable) { + return unavailable; } let result = ""; const bot = this.agent.bot; @@ -39,8 +40,9 @@ export class VisionInterpreter { } async lookAtPosition(x, y, z) { - if (!this.allow_vision || !this.agent.prompter.vision_model.sendVisionRequest) { - return "Vision is disabled. Use other methods to describe the environment."; + const unavailable = this.getVisionUnavailableMessage(); + if (unavailable) { + return unavailable; } let result = ""; const bot = this.agent.bot; @@ -56,7 +58,7 @@ export class VisionInterpreter { const bot = this.agent.bot; const maxDistance = 128; // Maximum distance to check for blocks const targetBlock = bot.blockAtCursor(maxDistance); - + if (targetBlock) { return `Block at center view: ${targetBlock.name} at (${targetBlock.position.x}, ${targetBlock.position.y}, ${targetBlock.position.z})`; } else { @@ -64,6 +66,16 @@ export class VisionInterpreter { } } + getVisionUnavailableMessage() { + if (!this.allow_vision) { + return "Vision is disabled in settings. Set allow_vision to true and restart the agent."; + } + if (!this.agent.prompter.vision_model?.sendVisionRequest) { + return "Vision model does not support image input. Configure a vision-capable model or adapter."; + } + return null; + } + async analyzeImage(filename) { try { const imageBuffer = fs.readFileSync(`${this.fp}/${filename}.jpg`); @@ -78,4 +90,4 @@ export class VisionInterpreter { return `Error reading image: ${error.message}`; } } -} \ No newline at end of file +} diff --git a/src/mindcraft/mindcraft.js b/src/mindcraft/mindcraft.js index 7fe7f9e37..f7cae51e7 100644 --- a/src/mindcraft/mindcraft.js +++ b/src/mindcraft/mindcraft.js @@ -9,12 +9,12 @@ let agent_processes = {}; let agent_count = 0; let mindserver_port = 8080; -export async function init(host_public=false, port=8080, auto_open_ui=true) { +export async function init(host_public=false, port=8080, auto_open_ui=true, runtimeSettings={}) { if (connected) { console.error('Already initiliazed!'); return; } - mindserver = createMindServer(host_public, port); + mindserver = createMindServer(host_public, port, runtimeSettings); mindserver_port = port; connected = true; if (auto_open_ui) { @@ -42,6 +42,9 @@ export async function createAgent(settings) { registerAgent(settings, viewer_port); let load_memory = settings.load_memory || false; let init_message = settings.init_message || null; + if (settings.llm_providers) { + process.env.MINDCRAFT_LLM_PROVIDERS_PATH = settings.llm_providers; + } try { try { diff --git a/src/mindcraft/mindserver.js b/src/mindcraft/mindserver.js index da2560a45..ad60d44ca 100644 --- a/src/mindcraft/mindserver.js +++ b/src/mindcraft/mindserver.js @@ -4,7 +4,7 @@ import http from 'http'; import path from 'path'; import { fileURLToPath } from 'url'; import * as mindcraft from './mindcraft.js'; -import { readFileSync } from 'fs'; +import { readFileSync, existsSync, readdirSync, statSync } from 'fs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Mindserver is: @@ -17,7 +17,111 @@ let server; const agent_connections = {}; const agent_listeners = []; -const settings_spec = JSON.parse(readFileSync(path.join(__dirname, 'public/settings_spec.json'), 'utf8')); +const base_settings_spec = JSON.parse(readFileSync(path.join(__dirname, 'public/settings_spec.json'), 'utf8')); +let active_settings_spec = base_settings_spec; +const runtime_default_settings = new Set(['llm_providers']); + +export function buildRuntimeSettingsSpec(runtimeSettings = {}) { + const spec = JSON.parse(JSON.stringify(base_settings_spec)); + for (const [key, value] of Object.entries(runtimeSettings || {})) { + if (!runtime_default_settings.has(key) || !(key in spec) || value === undefined) continue; + spec[key].default = value; + } + return spec; +} + + +function isSafeAgentName(agentName) { + return /^[A-Za-z0-9_-]+$/.test(String(agentName || '')); +} + +function resolveProjectPath(cwd, maybePath) { + if (!maybePath || typeof maybePath !== 'string') return null; + return path.isAbsolute(maybePath) ? maybePath : path.resolve(cwd, maybePath); +} + +function readJsonLines(filePath) { + const raw = readFileSync(filePath, 'utf8').trim(); + if (!raw) return []; + return raw + .split(/\r?\n/) + .map(line => { + try { + return JSON.parse(line); + } catch { + return null; + } + }) + .filter(Boolean); +} + +function newestJsonlFile(dirPath) { + if (!existsSync(dirPath)) return null; + const files = readdirSync(dirPath) + .filter(name => name.endsWith('.jsonl')) + .map(name => path.join(dirPath, name)) + .filter(file => { + try { return statSync(file).isFile(); } + catch { return false; } + }) + .sort((a, b) => statSync(b).mtimeMs - statSync(a).mtimeMs); + return files[0] || null; +} + +export function readSavedChatHistory(agentName, options = {}) { + const { loadMemory = true, cwd = process.cwd() } = options; + if (loadMemory !== true) { + return { loaded: false, reason: 'load_memory_disabled', events: [] }; + } + if (!isSafeAgentName(agentName)) { + return { loaded: false, reason: 'invalid_agent_name', events: [] }; + } + + const botDir = path.join(cwd, 'bots', agentName); + const memoryPath = path.join(botDir, 'memory.json'); + const candidates = []; + let memoryData = null; + + if (existsSync(memoryPath)) { + try { + memoryData = JSON.parse(readFileSync(memoryPath, 'utf8')); + candidates.push(resolveProjectPath(cwd, memoryData.chat_history_trace)); + candidates.push(resolveProjectPath(cwd, memoryData.chat_history_latest)); + } catch (error) { + console.warn(`Failed to read ${agentName}'s memory file for chat history: ${error.message}`); + } + } + + candidates.push(path.join(botDir, 'chat_history.jsonl')); + candidates.push(newestJsonlFile(path.join(botDir, 'chat-history'))); + + const historyFile = candidates.find(candidate => candidate && existsSync(candidate)); + if (historyFile) { + try { + return { loaded: true, source: historyFile, events: readJsonLines(historyFile) }; + } catch (error) { + return { loaded: false, reason: 'read_error', error: error.message, events: [] }; + } + } + + if (Array.isArray(memoryData?.turns) && memoryData.turns.length > 0) { + return { + loaded: true, + source: memoryPath, + restored_from_memory: true, + events: memoryData.turns.map((turn, index) => ({ + timestamp: memoryData.updated_at || null, + agent: agentName, + type: 'history_turn_added', + turn, + active_turn_count: index + 1, + restored_from_memory: true + })) + }; + } + + return { loaded: false, reason: 'not_found', events: [] }; +} class AgentConnection { constructor(settings, viewer_port) { @@ -45,11 +149,32 @@ export function logoutAgent(agentName) { } // Initialize the server -export function createMindServer(host_public = false, port = 8080) { +export function createMindServer(host_public = false, port = 8080, runtimeSettings = {}) { + active_settings_spec = buildRuntimeSettingsSpec(runtimeSettings); const app = express(); server = http.createServer(app); io = new Server(server); + // Serve runtime-aware settings spec before static files so the New Agent form + // inherits the actual settings.js defaults, including the LLM provider registry path. + app.get('/settings_spec.json', (_req, res) => { + res.json(active_settings_spec); + }); + + + app.get('/chat-history/:agent', (req, res) => { + const agentName = req.params.agent; + const conn = agent_connections[agentName]; + if (!conn) { + res.status(404).json({ loaded: false, reason: 'agent_not_found', events: [] }); + return; + } + const history = readSavedChatHistory(agentName, { + loadMemory: conn.settings?.load_memory === true + }); + res.json(history); + }); + // Serve static files const __dirname = path.dirname(fileURLToPath(import.meta.url)); app.use(express.static(path.join(__dirname, 'public'))); @@ -122,19 +247,19 @@ export function createMindServer(host_public = false, port = 8080) { socket.on('create-agent', async (settings, callback) => { console.log('API create agent...'); - for (let key in settings_spec) { + for (let key in active_settings_spec) { if (!(key in settings)) { - if (settings_spec[key].required) { + if (active_settings_spec[key].required) { callback({ success: false, error: `Setting ${key} is required` }); return; } else { - settings[key] = settings_spec[key].default; + settings[key] = active_settings_spec[key].default; } } } for (let key in settings) { - if (!(key in settings_spec)) { + if (!(key in active_settings_spec)) { delete settings[key]; } } @@ -271,6 +396,10 @@ export function createMindServer(host_public = false, port = 8080) { io.emit('bot-output', agentName, message); }); + socket.on('agent-trace', (agentName, event) => { + io.emit('agent-trace', agentName, event); + }); + socket.on('listen-to-agents', () => { addListener(socket); }); @@ -342,4 +471,4 @@ function removeListener(listener_socket) { // Optional: export these if you need access to them from other files export const getIO = () => io; export const getServer = () => server; -export const numStateListeners = () => agent_listeners.length; \ No newline at end of file +export const numStateListeners = () => agent_listeners.length; diff --git a/src/mindcraft/public/index.html b/src/mindcraft/public/index.html index 1c8f0f1be..fca91b7e1 100644 --- a/src/mindcraft/public/index.html +++ b/src/mindcraft/public/index.html @@ -299,6 +299,1282 @@ padding: 6px 8px; border-radius: 4px; } + .chat-panel { + grid-column: 1 / -1; + background: #101010; + border: 1px solid #2a2a2a; + border-radius: 12px; + padding: 0; + max-height: 720px; + overflow: auto; + font-size: 0.9em; + color: #f2f2f2; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.03), 0 10px 28px rgba(0,0,0,0.28); + } + .chat-panel > .chat-muted { + display: block; + padding: 14px 16px; + } + .chat-thread { + display: flex; + flex-direction: column; + gap: 10px; + padding: 14px 16px 18px; + background: + linear-gradient(90deg, rgba(255,255,255,0.05) 1px, transparent 1px) 25px 0 / 1px 100% no-repeat, + #101010; + } + .chat-card { + position: relative; + width: calc(100% - 24px); + margin-left: 24px; + box-sizing: border-box; + background: #171717; + border: 1px solid #2d2d2d; + border-radius: 10px; + padding: 12px 14px; + color: #f0f0f0; + box-shadow: 0 1px 0 rgba(255,255,255,0.035); + } + .chat-card::before { + content: ''; + position: absolute; + left: -27px; + top: 15px; + width: 7px; + height: 7px; + border-radius: 50%; + background: #8b8b8b; + box-shadow: 0 0 0 4px #101010; + } + .chat-system-card { + background: #18150f; + border-color: #4a3820; + } + .chat-tools-card { + background: #101821; + border-color: #28445e; + } + .chat-turn { + display: flex; + flex-direction: column; + gap: 10px; + padding: 4px 0 8px; + } + .chat-card.request, + .chat-card.response, + .chat-card.tool-status, + .chat-card.turn-card { + background: #161616; + } + .chat-card.error { + background: #1a1212; + border-color: #5d2626; + } + .chat-card.request::before { background: #7f7f7f; } + .chat-card.turn-card::before, + .chat-card.response::before { background: #d7d7d7; } + .chat-card.tool-status::before { background: #b6a268; } + .chat-card.error::before { background: #ff6b6b; } + .chat-tools-card::before { background: #a8a8a8; } + .chat-card-title { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + color: #f5f5f5; + font-weight: 650; + margin-bottom: 8px; + letter-spacing: -0.01em; + } + summary.chat-card-title { + cursor: pointer; + list-style: none; + margin-bottom: 0; + } + details[open] > summary.chat-card-title { + margin-bottom: 8px; + } + summary.chat-card-title::-webkit-details-marker { + display: none; + } + summary.chat-card-title::after { + content: '▸'; + color: #8d8d8d; + font-size: 0.85em; + transition: transform 120ms ease; + } + details[open] > summary.chat-card-title::after { + transform: rotate(90deg); + } + .chat-card-title span:first-child { + display: inline-flex; + align-items: center; + min-height: 20px; + } + .chat-card.request .chat-card-title span:first-child::before, + .chat-card.turn-card .chat-card-title span:first-child::before, + .chat-card.response .chat-card-title span:first-child::before, + .chat-card.tool-status .chat-card-title span:first-child::before, + .chat-system-card .chat-card-title span:first-child::before, + .chat-tools-card .chat-card-title span:first-child::before { + display: inline-flex; + align-items: center; + justify-content: center; + height: 18px; + padding: 0 6px; + margin-right: 8px; + border-radius: 5px; + border: 1px solid #3a3a3a; + color: #bdbdbd; + background: #202020; + font-size: 10px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + } + .chat-card.request .chat-card-title span:first-child::before { content: 'REQ'; } + .chat-card.turn-card .chat-card-title span:first-child::before, + .chat-card.response .chat-card-title span:first-child::before { content: 'AI'; } + .chat-card.tool-status .chat-card-title span:first-child::before { content: 'TOOL'; } + .chat-system-card .chat-card-title span:first-child::before { + content: 'SYS'; + background: #2a2113; + border-color: #5a421f; + color: #f0c978; + } + .chat-tools-card .chat-card-title span:first-child::before { + content: 'TOOLS'; + background: #132435; + border-color: #2b587c; + color: #9fd0f4; + } + .chat-system-card .chat-card-title { color: #f5dfb2; } + .chat-tools-card .chat-card-title { color: #c8e4f7; } + .chat-tools-card .chat-tool-def { + background: rgba(37, 75, 105, 0.18); + border: 1px solid rgba(93, 148, 190, 0.18); + border-radius: 9px; + padding: 9px 10px; + } + .chat-tools-card .chat-tool-params { + background: rgba(8, 20, 30, 0.9); + border-color: rgba(92, 151, 196, 0.28); + } + .agent-step { + background: #111820; + border-color: #294058; + } + .agent-step-header { + background: rgba(35, 65, 90, 0.28); + } + .chat-time { + color: #8d8d8d; + font-size: 0.88em; + font-weight: 400; + white-space: nowrap; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + .chat-card-body { + color: #dfdfdf; + white-space: pre-wrap; + overflow-wrap: anywhere; + line-height: 1.5; + } + .chat-muted { + color: #929292; + } + .chat-section { + margin-top: 10px; + } + .chat-section:first-of-type { + margin-top: 0; + } + .chat-section-label { + color: #8f8f8f; + font-size: 0.8em; + font-weight: 600; + letter-spacing: 0.02em; + text-transform: uppercase; + margin-bottom: 6px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + .chat-request-footer { + display: flex; + justify-content: flex-start; + margin-top: 10px; + padding-top: 8px; + border-top: 1px solid rgba(255,255,255,0.055); + } + .chat-request-footer .chat-details { + margin-top: 0; + padding-top: 0; + border-top: none; + width: 100%; + } + .chat-request-footer .chat-details summary { + font-size: 0.78em; + color: #7f7f7f; + } + .chat-message-row, + .chat-tool-row, + .chat-tool-def { + border-top: 1px solid rgba(255,255,255,0.08); + padding-top: 8px; + margin-top: 8px; + } + .chat-message-row:first-child, + .chat-tool-row:first-child, + .chat-tool-def:first-child { + border-top: none; + padding-top: 0; + margin-top: 0; + } + .chat-message-row .chat-muted:first-child { + display: inline-flex; + padding: 1px 6px; + margin-bottom: 5px; + border-radius: 999px; + background: #222222; + color: #a7a7a7; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.78em; + text-transform: lowercase; + } + .chat-response-text { + color: #e7e7e7; + white-space: pre-wrap; + overflow-wrap: anywhere; + line-height: 1.55; + margin-bottom: 8px; + } + .chat-token-usage { + display: inline-flex; + align-items: center; + gap: 6px; + margin-left: 6px; + color: #9a9a9a; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.9em; + } + .chat-token-usage::before { + content: 'tokens'; + color: #6f6f6f; + } + .chat-tool-call { + border: 1px solid #363636; + background: #111111; + border-radius: 8px; + padding: 9px 10px; + margin-top: 9px; + } + .chat-tool-name { + color: #d8d8d8; + font-weight: 650; + margin-bottom: 5px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.9em; + } + .chat-tool-name::before { + content: '› '; + color: #858585; + } + .chat-tool-description { + color: #bdbdbd; + line-height: 1.45; + margin: 3px 0 6px 15px; + } + .chat-tool-args, + .chat-tool-result, + .chat-tool-params { + white-space: pre-wrap; + overflow-wrap: anywhere; + color: #cfcfcf; + background: #0d0d0d; + border: 1px solid #282828; + border-radius: 7px; + padding: 8px 9px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.86em; + line-height: 1.45; + } + .chat-tools-list { + max-height: none; + overflow: visible; + } + .chat-tools-card .chat-json { + max-height: none; + } + .chat-system-card .chat-json { + max-height: none; + } + .chat-running { + display: inline-flex; + align-items: center; + gap: 7px; + color: #c9c9c9; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.9em; + } + .chat-running::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: #d8d8d8; + animation: chatPulse 1.2s ease-in-out infinite; + } + @keyframes chatPulse { + 0%, 100% { opacity: 0.35; transform: scale(0.78); } + 50% { opacity: 1; transform: scale(1); } + } + .chat-details { + margin-top: 10px; + border-top: 1px solid rgba(255,255,255,0.06); + padding-top: 8px; + } + .chat-details summary { + cursor: pointer; + color: #9f9f9f; + font-size: 0.86em; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + user-select: none; + } + .chat-details summary:hover { + color: #f0f0f0; + } + .chat-json { + white-space: pre-wrap; + overflow-x: auto; + background: #0b0b0b; + border: 1px solid #292929; + border-radius: 8px; + padding: 10px; + color: #d0d0d0; + margin-top: 8px; + max-height: 360px; + line-height: 1.45; + } + .chat-json-wrap { + position: relative; + } + .chat-json-wrap .chat-json { + padding-right: 42px; + } + .chat-copy-btn { + position: absolute; + top: 14px; + right: 8px; + width: 26px; + height: 24px; + border: 1px solid rgba(255,255,255,0.14); + border-radius: 6px; + background: rgba(28,28,28,0.92); + color: #a8a8a8; + cursor: pointer; + font-size: 13px; + line-height: 20px; + display: inline-flex; + align-items: center; + justify-content: center; + opacity: 0.75; + } + .chat-copy-btn:hover { + color: #f2f2f2; + border-color: rgba(255,255,255,0.28); + opacity: 1; + } + .chat-copy-btn.copied { + color: #a8d5b0; + border-color: rgba(168,213,176,0.38); + } + .chat-internal-tool-events { + margin-top: 8px; + border-top-color: rgba(255,255,255,0.04); + } + .chat-internal-tool-list { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; + } + .chat-internal-tool-event { + padding: 8px 0; + border-top: 1px solid rgba(255,255,255,0.04); + } + .chat-internal-tool-event:first-child { + border-top: none; + padding-top: 0; + } + + /* assistantspace style chat view */ + .chat-thread.agent-workspace { + gap: 14px; + padding: 16px; + background: #0f0f10; + } + .agent-context-row { + display: flex; + flex-direction: column; + gap: 10px; + } + .agent-context-card { + margin-left: 0; + width: 100%; + border-radius: 12px; + background: #151515; + border-color: #2c2c2c; + } + .agent-context-card::before { display: none; } + .agent-context-card.chat-system-card { + background: #18150f; + border-color: #4a3820; + } + .agent-context-card.chat-tools-card { + background: #101821; + border-color: #28445e; + } + .agent-run { + position: relative; + margin-left: 0; + width: 100%; + box-sizing: border-box; + background: #151515; + border: 1px solid #2c2c2c; + border-radius: 14px; + overflow: hidden; + box-shadow: 0 1px 0 rgba(255,255,255,0.03), 0 12px 28px rgba(0,0,0,0.22); + } + .agent-run-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 11px 14px; + border-bottom: 1px solid rgba(255,255,255,0.07); + background: linear-gradient(180deg, #191919, #151515); + } + .agent-run-title { + display: inline-flex; + align-items: center; + gap: 8px; + color: #f1f1f1; + font-weight: 650; + } + .agent-run-title::before { + content: 'AGENT'; + height: 18px; + padding: 0 6px; + border-radius: 5px; + border: 1px solid #3b3b3b; + background: #202020; + color: #bebebe; + font: 650 10px/18px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.03em; + } + .agent-run-meta { + color: #8d8d8d; + font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + white-space: nowrap; + } + .agent-run-body { + padding: 14px; + display: flex; + flex-direction: column; + gap: 12px; + } + .agent-message { + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + gap: 10px; + align-items: start; + } + .agent-message.user-message { + grid-template-columns: minmax(0, 1fr) 28px; + } + .agent-avatar { + width: 28px; + height: 28px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + background: #262626; + border: 1px solid #3a3a3a; + color: #cfcfcf; + font: 700 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + .agent-message.user-message .agent-avatar { + grid-column: 2; + background: #1d2a20; + border-color: #33563b; + color: #bfe6c6; + } + .agent-message.user-message .agent-bubble { + grid-column: 1; + grid-row: 1; + justify-self: end; + max-width: min(820px, 92%); + background: #18221a; + border-color: #2f4a34; + } + .agent-bubble { + min-width: 0; + background: #111111; + border: 1px solid #2a2a2a; + border-radius: 12px; + padding: 10px 12px; + color: #e8e8e8; + } + .agent-bubble-meta { + color: #7f7f7f; + font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + margin-bottom: 8px; + } + .agent-message.user-message .agent-request-detail { + padding: 8px 0 0; + margin-top: 8px; + border-top: 1px solid rgba(190, 230, 198, 0.14); + } + .agent-bubble-label { + color: #8f8f8f; + font: 650 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + text-transform: uppercase; + letter-spacing: 0.035em; + margin-bottom: 7px; + } + .agent-text { + white-space: pre-wrap; + overflow-wrap: anywhere; + line-height: 1.55; + } + .agent-steps { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 10px; + } + .agent-step { + border: 1px solid #303030; + background: #171717; + border-radius: 10px; + overflow: hidden; + } + .agent-step-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 10px; + border-bottom: 1px solid rgba(255,255,255,0.055); + } + .agent-step-name { + color: #e3e3e3; + font: 650 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + .agent-step-status { + color: #9a9a9a; + font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + white-space: nowrap; + } + .agent-step-status.running { color: #d7c077; } + .agent-step-status.done { color: #91d199; } + .agent-step-body { + padding: 9px 10px; + display: flex; + flex-direction: column; + gap: 8px; + } + .agent-steps .agent-step { + background: #111820; + border-color: #294058; + } + .agent-steps .agent-step-header { + background: rgba(35, 65, 90, 0.28); + } + .agent-request-detail { + padding: 0 14px 12px 52px; + } + .agent-request-detail .chat-details { + margin: 0; + padding: 0; + border: none; + } + .agent-request-detail .chat-details summary { + color: #777; + font-size: 0.78em; + } + + .agent-run-flat { + display: flex; + flex-direction: column; + gap: 10px; + padding: 4px 0 12px; + } + .agent-run-flat + .agent-run-flat { + border-top: 1px solid rgba(255,255,255,0.055); + padding-top: 14px; + } + .agent-inline-meta { display: none; } + .agent-message.assistant-message .agent-request-detail { + padding: 8px 0 0; + margin-top: 8px; + border-top: 1px solid rgba(255,255,255,0.055); + } + .agent-empty { + color: #8f8f8f; + font-style: italic; + } + .agent-bubble.error { + background: #1a1212; + border-color: #5d2626; + } + + /* Chat polish: keep the agent trace useful without looking like a generated demo UI. */ + .chat-thread.agent-workspace { + background: #0f0f0f; + gap: 12px; + } + .agent-context-card { + box-shadow: none; + } + .chat-system-card .chat-card-title span:first-child::before, + .chat-tools-card .chat-card-title span:first-child::before, + .chat-card.request .chat-card-title span:first-child::before, + .chat-card.turn-card .chat-card-title span:first-child::before, + .chat-card.response .chat-card-title span:first-child::before, + .chat-card.tool-status .chat-card-title span:first-child::before { + display: none; + } + .chat-system-card .chat-card-title, + .chat-tools-card .chat-card-title { + color: #e5e2da; + font-weight: 560; + } + .agent-run-flat { + gap: 8px; + padding: 2px 0 14px; + } + .agent-run-flat + .agent-run-flat { + border-top: 1px solid rgba(255,255,255,0.045); + padding-top: 14px; + } + .agent-message, + .agent-message.user-message { + display: block; + } + .agent-avatar { + display: none; + } + .agent-message.user-message .agent-bubble, + .agent-bubble { + max-width: none; + width: auto; + justify-self: auto; + grid-column: auto; + grid-row: auto; + border-radius: 9px; + padding: 11px 12px; + box-shadow: none; + } + .agent-message.user-message .agent-bubble { + margin-left: 8%; + background: #151914; + border: 1px solid #293127; + } + .agent-message.assistant-message .agent-bubble { + margin-right: 8%; + background: #141414; + border: 1px solid #292929; + } + .agent-bubble-meta { + color: #858585; + font: 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + margin-bottom: 7px; + } + .agent-bubble-label, + .tool-run-label { + color: #a5a5a5; + font: 12px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + text-transform: none; + letter-spacing: 0; + margin: 0 0 7px; + } + .chat-message-row .chat-muted:first-child { + background: transparent; + border-radius: 0; + padding: 0; + margin-bottom: 4px; + color: #858585; + font-size: 11px; + text-transform: none; + } + .agent-steps { + gap: 6px; + margin-top: 8px; + } + .agent-steps .agent-step, + .agent-step { + background: #151515; + border: 1px solid #2a2a2a; + border-radius: 8px; + } + .agent-steps .agent-step-header, + .agent-step-header { + background: transparent; + padding: 8px 10px; + border-bottom: 1px solid rgba(255,255,255,0.045); + } + .agent-step-name { + color: #d8d8d8; + font: 500 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + .agent-step-status { + color: #868686; + font-size: 11px; + } + .agent-step-status.running { + color: #b9a86a; + } + .agent-step-status.done { + color: #8fb893; + } + .agent-step-body { + padding: 8px 10px; + gap: 7px; + } + .chat-tool-args, + .chat-tool-result, + .chat-tool-params { + background: #101010; + border-color: #272727; + border-radius: 6px; + color: #cdcdcd; + } + .chat-tools-card .chat-tool-def { + background: rgba(255,255,255,0.025); + border-color: rgba(255,255,255,0.08); + } + + /* Chat de-template pass: flatter, quieter, closer to practical agent logs. */ + .chat-thread.agent-workspace { + background: #0e0e0e; + gap: 10px; + } + .agent-run-flat { + padding: 10px 0 12px; + } + .agent-run-flat + .agent-run-flat { + border-top: 1px solid rgba(255,255,255,0.055); + } + .agent-message, + .agent-message.user-message { + display: block; + } + .agent-message + .agent-message { + margin-top: 8px; + } + .agent-message.user-message .agent-bubble, + .agent-message.assistant-message .agent-bubble, + .agent-bubble { + margin: 0; + max-width: none; + width: auto; + background: #131313; + border: 1px solid #252525; + border-radius: 8px; + padding: 10px 12px; + box-shadow: none; + } + .agent-message.user-message .agent-bubble { + background: #121512; + border-color: #252d25; + } + .agent-message.assistant-message .agent-bubble { + background: #121212; + border-color: #272727; + } + .agent-bubble-meta { + color: #767676; + font: 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + margin-bottom: 8px; + } + .agent-bubble-label, + .tool-run-label { + color: #888; + font: 12px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + text-transform: none; + letter-spacing: 0; + margin-bottom: 6px; + } + .chat-message-row { + display: grid; + grid-template-columns: 52px minmax(0, 1fr); + gap: 8px; + border-top: 1px solid rgba(255,255,255,0.055); + padding-top: 8px; + margin-top: 8px; + } + .chat-message-row:first-child { + border-top: none; + padding-top: 0; + margin-top: 0; + } + .chat-message-row .chat-muted:first-child { + display: block; + padding: 0; + margin: 1px 0 0; + background: none; + border-radius: 0; + color: #747474; + font: 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + text-transform: none; + } + .chat-message-text { + white-space: pre-wrap; + overflow-wrap: anywhere; + line-height: 1.45; + } + .agent-steps { + margin-top: 8px; + gap: 0; + border: 1px solid #272727; + border-radius: 8px; + overflow: hidden; + background: #111; + } + .agent-steps .agent-step, + .agent-step { + border: none; + border-radius: 0; + background: transparent; + } + .agent-step + .agent-step { + border-top: 1px solid rgba(255,255,255,0.06); + } + .agent-steps .agent-step-header, + .agent-step-header { + background: transparent; + border-bottom: none; + padding: 8px 10px; + } + .agent-step-name { + color: #d6d6d6; + font: 500 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + .agent-step-status { + color: #777; + font-size: 11px; + } + .agent-step-status.running { color: #b5a76d; } + .agent-step-status.done { color: #8aaa8d; } + .agent-step-body { + padding: 0 10px 9px; + } + .chat-tool-args, + .chat-tool-result, + .chat-tool-params { + background: #0d0d0d; + border-color: #232323; + color: #c9c9c9; + border-radius: 6px; + font-size: 0.84em; + } + .agent-message.user-message .agent-request-detail { + border-top: 1px solid rgba(255,255,255,0.055); + margin-top: 8px; + padding-top: 7px; + } + .agent-request-detail .chat-details summary, + .chat-details summary { + color: #777; + font-size: 12px; + } + + + /* Chat transcript direction: right user bubbles, full-width agent work, inline tool activity. */ + .agent-run-flat { + gap: 12px; + padding: 12px 0 16px; + } + .agent-message.user-message { + display: flex; + justify-content: flex-end; + } + .agent-message.user-message .agent-bubble { + width: fit-content; + max-width: min(760px, 78%); + margin-left: auto; + background: #152016; + border: 1px solid #294229; + border-radius: 16px 16px 5px 16px; + padding: 11px 13px; + } + .agent-message.assistant-message { + display: block; + } + .agent-message.assistant-message .agent-bubble { + width: 100%; + max-width: none; + margin: 0; + background: transparent; + border: none; + border-radius: 0; + padding: 2px 0 0; + } + .agent-message.assistant-message .agent-bubble.error { + background: #1a1212; + border: 1px solid #5d2626; + border-radius: 10px; + padding: 10px 12px; + } + .agent-message.assistant-message .agent-bubble-label { + color: #b8b8b8; + font-size: 12px; + margin-bottom: 6px; + } + .agent-message.user-message .agent-bubble-label { + color: #b9d3ba; + font-size: 12px; + margin-bottom: 6px; + } + .agent-bubble-meta { + color: #7b7b7b; + font-size: 11px; + margin-bottom: 6px; + } + .agent-message.assistant-message .agent-bubble-meta { + margin-bottom: 4px; + } + .agent-message.assistant-message .agent-text { + background: transparent; + border: none; + border-radius: 0; + padding: 0; + margin: 4px 0 12px; + max-width: none; + } + .tool-run-label { + margin: 12px 0 6px; + color: #8f8f8f; + } + .agent-steps { + margin: 6px 0 10px; + border: 1px solid #252525; + border-radius: 10px; + background: #101010; + overflow: hidden; + } + .agent-step-header { + padding: 9px 11px 5px; + } + .agent-step-name::before { + content: '↳'; + color: #777; + margin-right: 8px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + .agent-step-name { + color: #dfdfdf; + font: 500 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + .agent-step-status.requested, + .agent-step-status.running { color: #a69763; } + .agent-step-status.done { color: #7f9c82; } + .agent-step-body { + padding: 0 11px 10px 31px; + gap: 8px; + } + .chat-tool-args { + background: #0c0c0c; + border: 1px solid #252525; + color: #d8d8d8; + border-radius: 7px; + } + .chat-tool-args::before { + content: 'arguments'; + display: block; + color: #777; + font: 11px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + margin-bottom: 5px; + } + .chat-tool-result { + background: transparent; + border: none; + color: #9a9a9a; + padding: 2px 0 0; + white-space: pre-wrap; + } + .chat-tool-result::before { + content: 'result'; + display: block; + color: #696969; + font: 11px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + margin-bottom: 4px; + } + .agent-message.assistant-message .agent-request-detail { + border-top: none; + padding: 0; + margin-top: 6px; + } + .agent-message.user-message .agent-request-detail { + border-top: 1px solid rgba(185, 211, 186, 0.14); + } + + + + /* Claude Code-style tool transcript: one line for the call, indented result/progress below. */ + .agent-steps { + margin: 6px 0 12px; + border: none; + border-radius: 0; + background: transparent; + overflow: visible; + } + .agent-step, + .agent-steps .agent-step { + background: transparent; + border: none; + border-radius: 0; + } + .agent-step + .agent-step { + border-top: none; + margin-top: 8px; + } + .agent-step-header, + .agent-steps .agent-step-header { + display: flex; + align-items: baseline; + gap: 6px; + padding: 0; + border-bottom: none; + background: transparent; + min-height: 22px; + } + .agent-step-name::before { content: none; } + .agent-step-dot { + display: inline-flex; + width: 14px; + flex: 0 0 14px; + justify-content: center; + color: #777; + font-size: 11px; + line-height: 1; + } + .agent-step-dot.running, + .agent-step-dot.requested { + animation: toolPulse 1.1s ease-in-out infinite; + color: #a69763; + } + .agent-step-dot.done { color: #7f9c82; } + @keyframes toolPulse { + 0%, 100% { opacity: .35; } + 50% { opacity: 1; } + } + .agent-step-name { + color: #e3e3e3; + font: 650 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + } + .agent-step-params { + color: #a2a2a2; + font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .agent-step-status { + margin-left: auto; + color: #777; + font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + white-space: nowrap; + } + .agent-step-status.requested, + .agent-step-status.running { color: #a69763; } + .agent-step-status.done { color: #7f9c82; } + .agent-step-body { + padding: 2px 0 0 20px; + display: flex; + flex-direction: column; + gap: 4px; + } + .chat-tool-output { + position: relative; + color: #9a9a9a; + white-space: pre-wrap; + overflow-wrap: anywhere; + line-height: 1.45; + padding-left: 28px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + .chat-tool-output::before { + content: '⎿'; + position: absolute; + left: 0; + color: #777; + } + .chat-tool-pending { color: #858585; } + .chat-tool-result { + background: transparent; + border: none; + border-radius: 0; + padding-top: 0; + } + .chat-tool-result::before { content: '⎿'; margin: 0; } + .agent-step-body .chat-details { + margin: 2px 0 0 28px; + padding: 0; + border-top: none; + } + .agent-step-body .chat-details summary { + color: #6f6f6f; + font-size: 12px; + } + .agent-step-body .chat-details[open] { + margin-top: 6px; + } + .agent-step-body .chat-details[open] .chat-json { + margin: 6px 0 0 0; + max-height: 220px; + } + .agent-step-body .chat-tool-output-extra { + margin: 4px 0 0 18px; + } + .agent-step-body .chat-tool-output-extra .chat-details { + margin-left: 0; + } + .agent-step-body .chat-tool-output-extra .chat-details[open] .chat-json { + margin-top: 6px; + } + + + .agent-step-status:empty { display: none; } + + + + /* Keep tool call name immediately after the status dot. */ + .agent-step-header, + .agent-steps .agent-step-header { + display: grid; + grid-template-columns: 16px max-content minmax(0, 1fr) auto; + column-gap: 6px; + align-items: baseline; + } + .agent-step-dot { + grid-column: 1; + justify-self: center; + } + .agent-step-name { + grid-column: 2; + display: inline-block; + min-width: max-content; + white-space: nowrap; + } + .agent-step-params { + grid-column: 3; + display: inline-block; + } + .agent-step-status { + grid-column: 4; + margin-left: 0; + } + + + + /* Align Claude-style result elbow directly with the result text. */ + .chat-tool-output { + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + column-gap: 0; + padding-left: 0; + } + .chat-tool-output::before { content: none; } + .chat-tool-output-glyph { + color: #777; + white-space: pre; + } + .chat-tool-output-text { + min-width: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + .chat-tool-result .chat-tool-output-text { + color: #777; + } + .chat-tool-result .chat-tool-output-glyph { + color: #5f5f5f; + } + .chat-tool-output-detail { + cursor: pointer; + color: #777; + } + .chat-tool-output-detail > summary { + list-style: none; + } + .chat-tool-output-detail > summary::-webkit-details-marker { + display: none; + } + .chat-tool-output-detail[open] > summary { + display: none; + } + .chat-tool-output-full { + cursor: text; + } + .chat-tool-output-extra { + margin: 4px 0 0 18px; + } + .chat-tool-result::before { content: none; } + .agent-step.tool-running .agent-step-name, + .agent-step.tool-running .agent-step-params { + animation: toolTextOpacityWave 1.35s ease-in-out infinite; + } + .agent-step.tool-running .agent-step-params { + animation-delay: .12s; + } + @keyframes toolTextOpacityWave { + 0%, 100% { opacity: .45; } + 50% { opacity: 1; } + } + + + + /* Neutralize context and user surfaces. */ + .chat-system-card, + .chat-tools-card, + .agent-context-card { + background: #151515; + border-color: #2d2d2d; + } + .chat-system-card .chat-card-title, + .chat-tools-card .chat-card-title { + color: #d8d8d8; + } + .chat-system-card .chat-card-title span:first-child::before, + .chat-tools-card .chat-card-title span:first-child::before { + background: #202020; + border-color: #3a3a3a; + color: #bdbdbd; + } + .chat-tools-card .chat-tool-def { + background: rgba(255,255,255,0.025); + border-color: rgba(255,255,255,0.08); + } + .chat-tools-card .chat-tool-params { + background: #101010; + border-color: #272727; + } + .agent-message.user-message .agent-bubble { + background: #181818; + border-color: #333333; + } + .agent-message.user-message .agent-bubble-label { + color: #d0d0d0; + } + .agent-message.user-message .agent-request-detail { + border-top-color: rgba(255,255,255,0.08); + } + .status-badge { font-size: 0.75em; margin-left: 8px; @@ -361,14 +1637,370 @@ overflow: auto; padding: 12px 16px; } - .modal-footer { - display: flex; - align-items: center; - justify-content: space-between; - padding: 12px 16px; - border-top: 1px solid #3a3a3a; + .modal-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-top: 1px solid #3a3a3a; + } + .footer-left { color: #cccccc; font-style: italic; } + + + /* Final neutral chat color override. */ + .agent-context-card.chat-system-card, + .agent-context-card.chat-tools-card, + .chat-system-card, + .chat-tools-card { + background: #151515 !important; + border-color: #2d2d2d !important; + } + .chat-system-card .chat-card-title, + .chat-tools-card .chat-card-title { + color: #d8d8d8 !important; + } + .agent-message.user-message .agent-bubble { + background: #181818 !important; + border-color: #333333 !important; + } + .agent-message.user-message .agent-bubble-label { + color: #d0d0d0 !important; + } + + + + /* Tools context is one card; individual tools are plain rows, not nested cards. */ + .chat-tools-card .chat-tools-list { + display: block; + } + .chat-tools-card .chat-tool-def { + background: transparent !important; + border: none !important; + border-radius: 0 !important; + padding: 9px 0 !important; + margin: 0 !important; + border-top: 1px solid rgba(255,255,255,0.075) !important; + } + .chat-tools-card .chat-tool-def:first-child { + border-top: none !important; + padding-top: 0 !important; + } + .chat-tools-card .chat-tool-name { + color: #e1e1e1; + font: 650 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + } + .chat-tools-card .chat-tool-description { + color: #a5a5a5; + margin-top: 3px; + line-height: 1.4; + } + .chat-tools-card .chat-tool-params { + display: block; + background: transparent !important; + border: none !important; + border-radius: 0 !important; + padding: 0 !important; + margin-top: 4px; + color: #8f8f8f; + font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + white-space: normal; + } + + + + /* No internal divider lines inside the tools context card. */ + .chat-tools-card .chat-tool-def, + .chat-tools-card .chat-tool-def:first-child { + border-top: none !important; + } + + + + /* Compact tools context list. */ + .chat-tools-card .chat-tools-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); + column-gap: 18px; + row-gap: 4px; + align-items: start; + } + .chat-tools-card .chat-tool-def, + .chat-tools-card .chat-tool-def:first-child { + padding: 3px 0 !important; + line-height: 1.25; + } + .chat-tools-card .chat-tool-name { + display: inline; + color: #dedede; + font: 600 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + .chat-tools-card .chat-tool-description { + display: inline; + color: #969696; + margin: 0 0 0 6px; + font-size: 12px; + line-height: 1.25; + } + .chat-tools-card .chat-tool-description::before { + content: '— '; + color: #666; + } + .chat-tools-card .chat-tool-params { + margin-top: 1px; + color: #777; + font-size: 11px; + line-height: 1.2; + } + + + + /* Tools list: no three-column layout and no disclosure triangle. */ + .chat-tools-card .chat-tools-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 22px; + } + @media (max-width: 1100px) { + .chat-tools-card .chat-tools-list { + grid-template-columns: 1fr; + } + } + .chat-tools-card > summary.chat-card-title::after { + content: none !important; + } + .chat-tools-card > summary.chat-card-title, + .chat-tools-card > summary.chat-card-title::marker, + .chat-tools-card > summary.chat-card-title::-webkit-details-marker { + list-style: none; + } + + + + /* Use a single compact tools column to keep spacing consistent. */ + .chat-tools-card .chat-tools-list { + grid-template-columns: 1fr !important; + row-gap: 2px; + } + .chat-tools-card .chat-tool-def, + .chat-tools-card .chat-tool-def:first-child { + padding: 2px 0 !important; + } + + + + /* Add consistent breathing room between tools without dividers. */ + .chat-tools-card .chat-tools-list { + row-gap: 7px !important; + } + .chat-tools-card .chat-tool-def, + .chat-tools-card .chat-tool-def:first-child { + padding: 3px 0 !important; + } + + + + /* Stack tool name, description, and parameters for clearer compact reading. */ + .chat-tools-card .chat-tool-name { + display: block !important; + } + .chat-tools-card .chat-tool-description { + display: block !important; + margin: 2px 0 0 0 !important; + line-height: 1.3; + } + .chat-tools-card .chat-tool-description::before { + content: none !important; + } + .chat-tools-card .chat-tool-params { + margin-top: 2px !important; + } + + + + /* Add a subtle prefix before each tool description. */ + .chat-tools-card .chat-tool-description::before { + content: 'desc: ' !important; + color: #666; + font-size: 11px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + + + + /* Prefix tool parameters and suppress any disclosure marker in the tools context. */ + .chat-tools-card .chat-tool-params::before { + content: 'params · ' !important; + color: #666; + font-size: 11px; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + .chat-tools-card summary, + .chat-tools-card summary::marker, + .chat-tools-card summary::-webkit-details-marker { + list-style: none !important; + display: block; + } + .chat-tools-card summary::after { + content: none !important; + } + + + + /* No marker before tool names in the tools context list. */ + .chat-tools-card .chat-tool-name::before { + content: none !important; + } + + + + /* Separate the Tools title from the count. */ + .chat-tools-card > summary.chat-card-title { + display: flex !important; + justify-content: flex-start !important; + align-items: baseline; + gap: 10px !important; + } + .chat-tools-card > summary.chat-card-title .chat-time::before { + content: '· '; + color: #777; + } + + + + /* Unified context panel: system prompt and tools share one status surface. */ + .agent-context-row { + display: block !important; + background: #151515; + border: 1px solid #2d2d2d; + border-radius: 10px; + padding: 8px 12px; + margin: 0 0 4px; + } + .agent-context-row .agent-context-card, + .agent-context-row .chat-system-card, + .agent-context-row .chat-tools-card { + width: 100% !important; + margin: 0 !important; + padding: 0 !important; + background: transparent !important; + border: none !important; + border-radius: 0 !important; + box-shadow: none !important; + } + .agent-context-row .agent-context-card + .agent-context-card { + margin-top: 6px !important; + padding-top: 6px !important; + border-top: 1px solid rgba(255,255,255,0.08) !important; + } + .agent-context-row summary.chat-card-title { + display: flex !important; + justify-content: flex-start !important; + align-items: baseline; + gap: 10px !important; + min-height: 22px; + margin: 0 !important; + color: #d8d8d8 !important; + cursor: pointer; + } + .agent-context-row summary.chat-card-title::before { + content: '' !important; + width: 7px; + height: 7px; + border-radius: 999px; + background: #888; + flex: 0 0 7px; + margin-right: 0; + display: inline-block !important; + } + .agent-context-row details[open] > summary.chat-card-title::before { + background: #b0b0b0; + } + .agent-context-row summary.chat-card-title::after, + .agent-context-row summary.chat-card-title::marker, + .agent-context-row summary.chat-card-title::-webkit-details-marker { + content: none !important; + list-style: none !important; + display: none !important; + } + .agent-context-row .chat-time::before { + content: '· '; + color: #777; + } + .agent-context-row .chat-card-body, + .agent-context-row .chat-tools-list { + margin-top: 8px; + padding-left: 17px; + } + .agent-context-row .chat-json { + max-height: none; + } + .agent-context-row .chat-details { + margin-left: 17px; + } + + + /* Compact boundary event: a transcript divider, not a chat bubble. */ + .chat-compact-event { + margin: 16px 0 14px; + color: #8e8e8e; + } + .chat-compact-event details { + display: block; + } + .chat-compact-event summary { + cursor: pointer; + list-style: none; + user-select: none; + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 12px; + align-items: center; + color: #888; + font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + } + .chat-compact-event summary::before, + .chat-compact-event summary::after, + .chat-compact-bottom-rule { + content: ''; + height: 1px; + background: rgba(255,255,255,0.10); + } + .chat-compact-event summary::marker, + .chat-compact-event summary::-webkit-details-marker { + display: none; + } + .chat-compact-title { + white-space: nowrap; + } + .chat-compact-title span { + color: #666; + margin-left: 6px; + } + .chat-compact-body { + padding: 10px 0 0; + } + .chat-compact-summary { + color: #9a9a9a; + white-space: pre-wrap; + line-height: 1.45; + font-size: 12px; + } + .chat-compact-file { + color: #707070; + margin-top: 6px; + font: 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + word-break: break-all; + } + .chat-compact-event .chat-details { + margin-top: 8px; + } + .chat-compact-bottom-rule { + display: none; + margin-top: 12px; + } + .chat-compact-event details[open] .chat-compact-bottom-rule { + display: block; } - .footer-left { color: #cccccc; font-style: italic; } + @@ -446,8 +2078,12 @@

Agent Settings

let profileData = null; const agentSettings = {}; const agentLastMessage = {}; + const agentChatEvents = {}; + const chatHistoryLoadState = {}; const inventoryOpen = {}; + const chatOpen = {}; let currentAgents = []; + const MAX_CHAT_EVENTS = 5000; // Item texture helpers (use PrismarineJS minecraft-assets CDN with graceful fallbacks) const BASE_ICON_VERSION_CANDIDATES = ['1.21.8']; @@ -530,6 +2166,7 @@

Agent Settings

subscribeToState(); // Clear all cached settings on reconnect Object.keys(agentSettings).forEach(name => delete agentSettings[name]); + Object.keys(chatHistoryLoadState).forEach(name => delete chatHistoryLoadState[name]); }); socket.on('disconnect', () => { updateStatus(false); @@ -553,8 +2190,8 @@

Agent Settings

form.style.gridTemplateColumns = 'repeat(auto-fit, minmax(320px, 1fr))'; form.style.gap = '8px'; Object.keys(settingsSpec).forEach(key => { - if (key === 'profile') return; // profile handled via upload const cfg = settingsSpec[key]; + if (!isEditableSetting(key, cfg)) return; // profile handled via upload; hidden settings use server defaults const wrapper = document.createElement('div'); wrapper.className = 'setting-wrapper'; const label = document.createElement('label'); @@ -575,7 +2212,7 @@

Agent Settings

default: input = document.createElement('input'); input.type = 'text'; - input.value = typeof cfg.default === 'object' ? JSON.stringify(cfg.default) : cfg.default; + input.value = cfg.default == null ? '' : (typeof cfg.default === 'object' ? JSON.stringify(cfg.default) : cfg.default); } input.title = cfg.description || ''; input.id = `setting-${key}`; @@ -614,17 +2251,18 @@

Agent Settings

if (!profileData) return; const settings = { profile: profileData }; Object.keys(settingsSpec).forEach(key => { - if (key === 'profile') return; + const cfg = settingsSpec[key]; + if (!isEditableSetting(key, cfg)) return; const input = document.getElementById(`setting-${key}`); if (!input) return; - const type = settingsSpec[key].type; + const type = cfg.type; let val; if (type === 'boolean') val = input.checked; else if (type === 'number') val = Number(input.value); else if (type === 'array' || type === 'object') { try { val = JSON.parse(input.value); } catch { val = input.value; } - } else val = input.value; + } else val = (cfg.default == null && input.value === '') ? null : input.value; settings[key] = val; }); socket.emit('create-agent', settings, res => { @@ -660,6 +2298,11 @@

Agent Settings

} }); + socket.on('agent-trace', (agentName, event) => { + appendChatEvents(agentName, [event]); + renderChatPanel(agentName); + }); + // Subscribe to aggregated state updates (re-sent on each connect) socket.on('state-update', (states) => { window.lastStates = states; @@ -756,6 +2399,69 @@

Agent Settings

}); } + function appendChatEvents(name, events, { prepend = false } = {}) { + if (!Array.isArray(events) || events.length === 0) return; + if (!agentChatEvents[name]) agentChatEvents[name] = []; + const existingKeys = new Set(agentChatEvents[name].map(getChatEventKey)); + const uniqueEvents = events.filter(event => { + const key = getChatEventKey(event); + if (existingKeys.has(key)) return false; + existingKeys.add(key); + return true; + }); + if (!uniqueEvents.length) return; + if (prepend) agentChatEvents[name].unshift(...uniqueEvents); + else agentChatEvents[name].push(...uniqueEvents); + if (agentChatEvents[name].length > MAX_CHAT_EVENTS) { + const overflow = agentChatEvents[name].length - MAX_CHAT_EVENTS; + if (prepend) agentChatEvents[name].splice(MAX_CHAT_EVENTS); + else agentChatEvents[name].splice(0, overflow); + } + } + + function getChatEventKey(event) { + if (!event) return ''; + const toolId = getToolCallId(event.tool_call) || ''; + const model = event.model?.model || event.model?.api || ''; + return [event.timestamp || '', event.type || '', event.tag || '', toolId || getToolName(event.tool_call || {}), model].join('|'); + } + + async function loadAgentChatHistory(name, options = {}) { + const state = chatHistoryLoadState[name]; + if (state?.status === 'loaded' && !options.force) return state; + if (state?.status === 'loading' && state.promise) return state.promise; + + const loadPromise = (async () => { + const settings = await fetchAgentSettings(name); + if (settings?.load_memory !== true) { + chatHistoryLoadState[name] = { status: 'disabled', reason: 'load_memory_disabled' }; + return chatHistoryLoadState[name]; + } + try { + const res = await fetch(`/chat-history/${encodeURIComponent(name)}`); + const data = await res.json().catch(() => ({ loaded: false, reason: 'invalid_response', events: [] })); + if (!res.ok) { + chatHistoryLoadState[name] = { status: 'error', reason: data.reason || res.statusText || 'load_failed' }; + return chatHistoryLoadState[name]; + } + appendChatEvents(name, Array.isArray(data.events) ? data.events : [], { prepend: true }); + chatHistoryLoadState[name] = { + status: data.loaded ? 'loaded' : 'empty', + reason: data.reason || '', + source: data.source || '', + count: Array.isArray(data.events) ? data.events.length : 0 + }; + return chatHistoryLoadState[name]; + } catch (error) { + chatHistoryLoadState[name] = { status: 'error', reason: error.message || String(error) }; + return chatHistoryLoadState[name]; + } + })(); + chatHistoryLoadState[name] = { status: 'loading', promise: loadPromise }; + renderChatPanel(name); + return loadPromise; + } + // Agent settings modal logic const agentSettingsModal = document.getElementById('agentSettingsModal'); const agentSettingsForm = document.getElementById('agentSettingsForm'); @@ -766,14 +2472,18 @@

Agent Settings

let currentAgentName = null; let originalAgentSettings = null; + function isEditableSetting(key, cfg = settingsSpec[key]) { + return key !== 'profile' && !cfg?.hidden && cfg?.ui !== 'hidden'; + } + function buildAgentSettingsForm(settings) { agentSettingsForm.innerHTML = ''; agentSettingsForm.style.display = 'grid'; agentSettingsForm.style.gridTemplateColumns = 'repeat(auto-fit, minmax(320px, 1fr))'; agentSettingsForm.style.gap = '8px'; Object.keys(settingsSpec).forEach(key => { - if (key === 'profile') return; // profile not edited here const cfg = settingsSpec[key]; + if (!isEditableSetting(key, cfg)) return; // profile and hidden settings are not edited here const wrapper = document.createElement('div'); wrapper.className = 'setting-wrapper'; const label = document.createElement('label'); @@ -819,10 +2529,10 @@

Agent Settings

window.openAgentSettings = openAgentSettings; function getEditedAgentSettings() { - const newSettings = { profile: (originalAgentSettings && originalAgentSettings.profile) || {} }; + const newSettings = { ...(originalAgentSettings || {}), profile: (originalAgentSettings && originalAgentSettings.profile) || {} }; Object.keys(settingsSpec).forEach(key => { - if (key === 'profile') return; const cfg = settingsSpec[key]; + if (!isEditableSetting(key, cfg)) return; const input = document.getElementById(`agent-setting-${key}`); if (!input) return; let val; @@ -831,7 +2541,7 @@

Agent Settings

else if (cfg.type === 'array' || cfg.type === 'object') { try { val = JSON.parse(input.value); } catch { val = input.value; } - } else val = input.value; + } else val = (cfg.default == null && input.value === '') ? null : input.value; newSettings[key] = val; }); return newSettings; @@ -839,7 +2549,7 @@

Agent Settings

function shallowEqual(a, b) { if (!a || !b) return false; - const keys = Object.keys(settingsSpec).filter(k => k !== 'profile'); + const keys = Object.keys(settingsSpec).filter(k => isEditableSetting(k, settingsSpec[k])); for (const k of keys) { const va = a[k]; const vb = b[k]; @@ -875,6 +2585,14 @@

Agent Settings

viewerContainer.parentElement.style.display = shouldShow ? '' : 'none'; } + function restoreAgentPanels(agent) { + if (agent?.in_game) updateAgentViewer(agent.name); + if (agent?.name && chatOpen[agent.name] === true) { + renderChatPanel(agent.name); + loadAgentChatHistory(agent.name).then(() => renderChatPanel(agent.name, { forceScroll: false })); + } + } + discardBtn.addEventListener('click', () => { if (!currentAgentName || !originalAgentSettings) return; buildAgentSettingsForm(originalAgentSettings); @@ -886,6 +2604,7 @@

Agent Settings

socket.emit('set-agent-settings', currentAgentName, edited); // Update local settings immediately agentSettings[currentAgentName] = { ...edited, fetched: true }; + delete chatHistoryLoadState[currentAgentName]; updateAgentViewer(currentAgentName); closeAgentSettings(); }); @@ -908,6 +2627,7 @@

Agent Settings

${agent.name}${agent.socket_connected && !agent.in_game ? 'joining...' : ''} + ${showViewer ? `
${viewerHTML}
` : ''} @@ -925,10 +2645,11 @@

Inventory

Last Message: ${lastMessage}
+
- @@ -941,6 +2662,847 @@

Inventory

`; } + window.toggleChat = function(name) { + chatOpen[name] = chatOpen[name] !== true; + const panel = document.getElementById(`chatPanel-${name}`); + if (panel) { + panel.style.display = chatOpen[name] ? '' : 'none'; + renderChatPanel(name, { forceScroll: chatOpen[name] === true }); + if (chatOpen[name] === true) { + loadAgentChatHistory(name).then(() => renderChatPanel(name, { forceScroll: false })); + } + } + }; + + function renderChatPanel(name, options = {}) { + const panel = document.getElementById(`chatPanel-${name}`); + if (!panel || chatOpen[name] !== true) return; + const shouldAutoScroll = options.forceScroll || isChatScrolledToBottom(panel); + const events = agentChatEvents[name] || []; + if (!events.length) { + panel.innerHTML = renderEmptyChatState(name); + if (shouldAutoScroll) panel.scrollTop = panel.scrollHeight; + return; + } + const openDetailIds = getOpenChatDetailIds(panel); + panel.innerHTML = renderChatThread(buildChatThread(events)); + restoreOpenChatDetails(panel, openDetailIds); + enableChatDetailBodyToggle(panel); + enableChatCopyButtons(panel); + if (shouldAutoScroll) panel.scrollTop = panel.scrollHeight; + } + + function renderEmptyChatState(name) { + const state = chatHistoryLoadState[name]; + if (state?.status === 'loading') { + return '
Loading saved runtime…
'; + } + if (state?.status === 'disabled') { + return '
New session: load_memory is false, so saved history is not loaded. Runtime messages and tool activity will appear here.
'; + } + if (state?.status === 'error') { + return `
Could not load saved chat history: ${escapeHTML(state.reason || 'unknown error')}. Live updates will still appear here.
`; + } + if (state?.status === 'empty') { + return '
No saved chat history found. Runtime messages and tool activity will appear here.
'; + } + return '
Runtime loads saved history when load_memory is true. Runtime messages and tool activity will appear here.
'; + } + + function getOpenChatDetailIds(panel) { + return new Set(Array.from(panel.querySelectorAll('details[data-chat-detail-id][open]')) + .map(details => details.dataset.chatDetailId) + .filter(Boolean)); + } + + function restoreOpenChatDetails(panel, openDetailIds) { + if (!openDetailIds || openDetailIds.size === 0) return; + panel.querySelectorAll('details[data-chat-detail-id]').forEach(details => { + if (openDetailIds.has(details.dataset.chatDetailId)) { + details.open = true; + } + }); + } + + + function enableChatDetailBodyToggle(panel) { + panel.querySelectorAll('details.chat-tool-output-detail[data-chat-detail-id]').forEach(details => { + details.addEventListener('click', event => { + if (!details.open) return; + if (event.target.closest('summary,a,button,input,textarea,select')) return; + if (event.target.closest('details') !== details) return; + const selection = window.getSelection?.().toString(); + if (selection && selection.trim()) return; + if (!event.target.closest('.chat-tool-output-full,.chat-tool-output-extra')) return; + details.open = false; + event.preventDefault(); + }); + }); + } + + function enableChatCopyButtons(panel) { + panel.querySelectorAll('.chat-copy-btn').forEach(button => { + button.addEventListener('click', async event => { + event.preventDefault(); + event.stopPropagation(); + const pre = button.closest('.chat-json-wrap')?.querySelector('.chat-json'); + const text = pre?.textContent || ''; + if (!text) return; + try { + await copyTextToClipboard(text); + const previous = button.textContent; + button.textContent = '✓'; + button.classList.add('copied'); + setTimeout(() => { + button.textContent = previous || '⧉'; + button.classList.remove('copied'); + }, 1200); + } catch (error) { + console.warn('Failed to copy chat JSON:', error); + } + }); + }); + } + + async function copyTextToClipboard(text) { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + return; + } + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + textarea.remove(); + } + + function isChatScrolledToBottom(panel) { + return panel.scrollHeight - panel.scrollTop - panel.clientHeight < 48; + } + + function buildChatThread(events) { + const thread = { systemPrompt: null, systemEvent: null, tools: null, toolsEvent: null, turns: [] }; + let current = null; + let previousRequestMessages = []; + const ensureTurn = () => { + if (!current) { + current = { request: null, response: null, toolCalls: [], toolResults: [], errors: [], historyMessages: [], compacted: null }; + thread.turns.push(current); + } + return current; + }; + + events.forEach(event => { + if (event.type === 'llm_request') { + if (event.tag === 'coding' && attachInternalToolEvent(current, event)) { + return; + } + if (!thread.systemPrompt && event.system_prompt) { + thread.systemPrompt = event.system_prompt; + thread.systemEvent = event; + } + if (!thread.tools && Array.isArray(event.tools)) { + thread.tools = event.tools; + thread.toolsEvent = event; + } + event.visible_messages = selectVisibleRequestMessages(event.messages, previousRequestMessages); + previousRequestMessages = Array.isArray(event.messages) ? event.messages : []; + const pendingHistoryMessages = isPendingHistoryOnlyTurn(current) ? current.historyMessages : []; + if (pendingHistoryMessages.length) { + thread.turns.pop(); + } + const requestMessages = Array.isArray(event.messages) ? event.messages : []; + if (current?.historyMessages?.length) { + current.historyMessages = current.historyMessages + .filter(historyEvent => !isHistoryTurnIncludedInRequest(historyEvent.turn, requestMessages)); + } + current = { request: event, response: null, toolCalls: [], toolResults: [], errors: [], historyMessages: pendingHistoryMessages, compacted: null }; + thread.turns.push(current); + return; + } + if (event.type === 'llm_response') { + if (event.tag === 'coding' && attachInternalToolEvent(current, event)) { + return; + } + ensureTurn().response = event; + return; + } + if (event.type === 'history_turn_added') { + ensureTurn().historyMessages.push(event); + return; + } + if (event.type === 'history_compacted') { + const turn = ensureTurn(); + turn.compacted = event; + current = null; + return; + } + if (event.type === 'tool_call') { + const turn = ensureTurn(); + turn.toolCalls.push({ event, call: event.tool_call, result: null, internalEvents: [] }); + return; + } + if (event.type === 'tool_result') { + const turn = ensureTurn(); + const resultId = getToolCallId(event.tool_call); + const match = turn.toolCalls.slice().reverse().find(item => { + const callId = getToolCallId(item.call); + return callId && resultId ? callId === resultId : getToolName(item.call) === getToolName(event.tool_call) && !item.result; + }); + if (match) { + match.result = event; + } else { + turn.toolResults.push(event); + } + return; + } + if (event.type === 'llm_error') { + if (event.tag === 'coding' && attachInternalToolEvent(current, event)) { + return; + } + ensureTurn().errors.push(event); + } + }); + return thread; + } + + function attachInternalToolEvent(turn, event) { + const item = findInternalToolHost(turn); + if (!item) return false; + item.internalEvents = item.internalEvents || []; + item.internalEvents.push(event); + return true; + } + + function findInternalToolHost(turn) { + if (!turn?.toolCalls?.length) return null; + const reversed = turn.toolCalls.slice().reverse(); + return reversed.find(item => getToolName(item.call) === 'newAction' && !item.result) + || reversed.find(item => !item.result) + || reversed[0]; + } + + function renderChatThread(thread) { + const system = thread.systemPrompt ? renderSystemPrompt(thread.systemPrompt, thread.systemEvent) : ''; + const tools = thread.tools ? renderToolsCard(thread.tools, thread.toolsEvent) : ''; + const turns = thread.turns.map((turn, index) => renderChatTurn(turn, index)).join(''); + return `
+
${system}${tools}
+ ${turns} +
`; + } + + function renderSystemPrompt(prompt, event) { + return ` +
+ System Prompt${escapeHTML(formatChatTime(event?.timestamp))} +
${escapeHTML(prompt)}
+ ${renderDetails('System payload', event)} +
`; + } + + function renderToolsCard(tools, event) { + return ` +
+ Tools${tools.length} available +
${tools.map(renderToolDefinition).join('')}
+ ${renderDetails('Tool schema', { timestamp: event?.timestamp, model: event?.model, tools })} +
`; + } + + function renderToolDefinition(tool) { + const def = getToolDefinition(tool); + return ` +
+
${escapeHTML(def.name)}
+ ${def.description ? `
${escapeHTML(def.description)}
` : ''} + ${def.params ? `
${escapeHTML(def.params)}
` : ''} +
`; + } + + function getToolDefinition(tool) { + const fn = tool?.function || tool || {}; + const parameters = fn.parameters || tool?.input_schema || tool?.parameters; + return { + name: fn.name || tool?.name || 'tool', + description: fn.description || tool?.description || '', + params: summarizeToolParameters(parameters) + }; + } + + function summarizeToolParameters(parameters) { + if (!parameters || typeof parameters !== 'object') return ''; + const props = parameters.properties || {}; + const names = Object.keys(props); + if (!names.length) return ''; + const required = new Set(parameters.required || []); + return names.map(name => `${name}${required.has(name) ? '*' : ''}: ${props[name]?.type || 'any'}`).join(' · '); + } + + function renderChatTurn(turn, index) { + const request = turn.request; + const response = turn.response; + const model = request?.model?.model || request?.model?.api || response?.model?.model || response?.model?.api || 'model'; + const hasRequestOrResponse = Boolean(request || response || turn.toolCalls.length || turn.toolResults.length || turn.errors.length); + if (!hasRequestOrResponse && turn.historyMessages.length) { + return `
${renderRestoredHistoryMessages(turn.historyMessages, index)}
`; + } + return ` +
+ ${renderUserInputMessage(request, index, model)} + ${renderInlineHistoryMessages(turn)} + ${renderAssistantWorkMessage(turn, index, model)} + ${turn.errors.map(renderInlineError).join('')} + ${renderCompactEvent(turn.compacted)} +
`; + } + + function renderCompactEvent(event) { + if (!event) return ''; + const detailId = getChatDetailId('Compact boundary', event); + return ` +
+
+ ---- compacted ---- ${escapeHTML(formatChatTime(event.timestamp))} +
+
${escapeHTML(event.summary || 'Earlier messages were replaced by a compact summary.')}
+ ${event.full_history_file ? `
archive: ${escapeHTML(event.full_history_file)}
` : ''} + ${renderDetails('Compact payload', event)} +
+
+
+
`; + } + + function renderInlineHistoryMessages(turn) { + if (!turn.historyMessages?.length) return ''; + const skipToolLike = turn.toolCalls.length > 0 || turn.toolResults.length > 0; + const requestMessages = Array.isArray(turn.request?.messages) ? turn.request.messages : []; + const events = turn.historyMessages.filter(event => { + const role = event.turn?.role; + if (isHistoryTurnIncludedInRequest(event.turn, requestMessages)) return false; + if (!skipToolLike) return role === 'user'; + return role === 'user' && !String(event.turn?.content || '').startsWith('System:'); + }); + if (!events.length) return ''; + return events.map((event, offset) => renderHistoryMessage(event, offset, 'Recent history')).join(''); + } + + function isPendingHistoryOnlyTurn(turn) { + return Boolean(turn) + && !turn.request + && !turn.response + && !turn.compacted + && (!turn.toolCalls || turn.toolCalls.length === 0) + && (!turn.toolResults || turn.toolResults.length === 0) + && (!turn.errors || turn.errors.length === 0) + && Array.isArray(turn.historyMessages) + && turn.historyMessages.length > 0; + } + + function isHistoryTurnIncludedInRequest(turn, requestMessages) { + const content = String(turn?.content || '').trim(); + if (!content || !Array.isArray(requestMessages)) return false; + return requestMessages.some(message => { + if (message?.role !== turn?.role || typeof message.content !== 'string') return false; + const requestText = message.content.trim(); + return requestText === content || requestText.startsWith(`${content}\n\n`); + }); + } + + function renderRestoredHistoryMessages(events, index) { + return events.map((event, offset) => renderHistoryMessage(event, offset, `Restored history · ${index + 1}`)).join(''); + } + + function renderHistoryMessage(event, offset, label) { + const turn = event.turn || {}; + const role = turn.role || 'message'; + const isUser = role === 'user'; + const isTool = role === 'tool'; + if (isTool) { + return ` +
+
T
+
+
${escapeHTML(label)} · tool result
+ ${renderToolOutput(turn.content, event)} + ${renderDetails('History turn', event)} +
+
`; + } + return ` +
+
${isUser ? 'U' : 'AI'}
+
+
${escapeHTML(label)} · ${escapeHTML(role)}${event.restored_from_memory ? ' · memory' : ''}
+
${escapeHTML(limitText(formatChatMessagePreview(turn), 1600))}
+ ${renderDetails(`History turn ${offset + 1}`, event)} +
+
`; + } + + function renderUserInputMessage(request, index, model) { + if (!request) return ''; + const messages = Array.isArray(request.messages) ? request.messages : []; + const visibleMessages = Array.isArray(request.visible_messages) ? request.visible_messages : selectVisibleRequestMessages(messages); + if (!visibleMessages.length) return ''; + return ` +
+
U
+
+
Run ${index + 1} · ${escapeHTML(summarizeRequest(request, model))} · ${escapeHTML(formatChatTime(request.timestamp))}
+
User
+ ${renderRequestMessages(visibleMessages, messages.length)} + ${renderRequestFooter(request, model)} +
+
`; + } + + function renderAssistantWorkMessage(turn, index, model) { + const response = turn.response; + const text = extractResponseText(response?.response); + const responseCalls = extractResponseToolCalls(response?.response); + const hasToolRunEvents = turn.toolCalls.length > 0 || turn.toolResults.length > 0; + const calls = hasToolRunEvents ? [] : responseCalls; + const hasAssistantDisplay = Boolean(text || calls.length); + return ` +
+
AI
+
+
Run ${index + 1} · ${escapeHTML(model || 'model')} · ${escapeHTML(formatChatTime(response?.timestamp))}${renderTokenUsage(response?.token_usage)}
+ ${hasAssistantDisplay ? '
Assistant
' : ''} + ${response ? renderAssistantContent(text, calls, response) : '
Waiting for assistant response…
'} + ${renderToolRuns(turn)} +
+
`; + } + + function renderTokenUsage(usage) { + if (!usage) return ''; + const uncached = formatTokenCount(usage.input_uncached); + const cached = formatTokenCount(usage.input_cached); + const output = formatTokenCount(usage.output); + return `input uncached ${uncached} · cached ${cached} · output ${output}`; + } + + function formatTokenCount(value) { + return Number.isFinite(value) ? String(value) : '—'; + } + + function renderAssistantContent(text, calls, response) { + return ` + ${text ? `
${escapeHTML(limitText(text, 1600))}
` : ''} + ${calls.length ? `
${calls.map(call => renderToolCallPreview(call)).join('')}
` : ''} + ${renderDetails('Response payload', response)}`; + } + + function renderToolOutput(value, event, extraDetails = '') { + const fullText = formatToolOutputText(value); + const lines = fullText.split(/\r?\n/); + if (lines.length <= 4) { + return `
${escapeHTML(limitText(fullText, 1600))}
${extraDetails}`; + } + const preview = `${lines.slice(0, 4).join('\n')}\n...`; + const detailId = getChatDetailId('Tool output', event || { type: 'tool_output' }); + return `
${escapeHTML(limitText(preview, 1600))}
${escapeHTML(limitText(fullText, 12000))}
${extraDetails ? `
${extraDetails}
` : ''}
`; + } + + function renderToolCallPreview(call) { + const name = getToolName(call); + const args = getToolArgs(call); + const argsSummary = formatToolArgsInline(args); + return ` +
+
+ + ${escapeHTML(name)} + ${argsSummary ? `(${escapeHTML(argsSummary)})` : ''} + +
+ +
`; + } + + function renderToolRuns(turn) { + const rows = mergeToolRunRows(turn); + if (!rows.length) return ''; + return ` +
Tool activity
+
${rows.map(renderToolRunStep).join('')}
`; + } + + function mergeToolRunRows(turn) { + const rows = [...turn.toolCalls]; + for (const event of turn.toolResults) { + rows.push({ event, call: event.tool_call, result: event }); + } + return rows; + } + + function renderToolRunStep(item) { + const name = getToolName(item.call); + const args = getToolArgs(item.call); + const argsSummary = formatToolArgsInline(args); + const result = item.result; + const status = result ? 'done' : 'running'; + const statusLabel = result ? '' : 'running'; + return ` +
+
+ + ${escapeHTML(name)} + ${argsSummary ? `(${escapeHTML(argsSummary)})` : ''} + ${statusLabel} +
+
+ ${renderToolStepBody(result, args, item)} +
+
`; + } + + function renderToolStepBody(result, args, item) { + const internalEvents = renderInternalToolEvents(item.internalEvents); + if (result) { + return renderToolOutput(result.result, result, internalEvents); + } + const detailId = getChatDetailId('Tool running', item.event || { type: 'tool_running' }); + const details = renderDetails('Tool call payload', item.event); + return `
Running…
${internalEvents}${details}
`; + } + + function renderInternalToolEvents(events) { + if (!Array.isArray(events) || events.length === 0) return ''; + const detailId = getChatDetailId('Internal tool requests', { + type: 'internal_tool_requests', + timestamp: events[0]?.timestamp, + tag: events[0]?.tag + }); + return ` +
+ Internal coding requests · ${events.length} +
+ ${events.map(renderInternalToolEvent).join('')} +
+
`; + } + + function renderInternalToolEvent(event) { + const label = event.type === 'llm_request' ? 'coding request' + : event.type === 'llm_response' ? 'coding response' + : event.type === 'llm_error' ? 'coding error' + : event.type || 'internal event'; + const model = event.model?.model || event.model?.api || ''; + const usage = event.type === 'llm_response' ? renderTokenUsage(event.token_usage) : ''; + const preview = event.type === 'llm_request' + ? `${Array.isArray(event.messages) ? event.messages.length : 0} messages${event.system_prompt ? ' · system prompt' : ''}` + : event.type === 'llm_response' + ? limitText(extractResponseText(event.response) || stringifyChatText(event.response), 220) + : limitText(event.error?.message || stringifyChatText(event.error), 220); + return ` +
+
${escapeHTML(label)}${model ? ` · ${escapeHTML(model)}` : ''}${usage}
+
${escapeHTML(preview || '(empty)')}
+ ${renderDetails('Internal payload', event)} +
`; + } + + function renderRequestFooter(request) { + if (!request) return ''; + return ` +
+ ${renderDetails('Request payload', request)} +
`; + } + + function renderRequestMessages(messages, totalCount = messages.length) { + if (!Array.isArray(messages) || messages.length === 0) return 'No message payload.'; + const rows = messages.map(message => { + const preview = formatChatMessagePreview(message); + const roleLabel = getMessageRoleLabel(message, preview); + return ` +
+
${escapeHTML(roleLabel)}
+
${escapeHTML(limitText(preview, 560))}
+
`; + }).join(''); + return rows; + } + + function selectVisibleRequestMessages(messages, previousMessages = []) { + if (!Array.isArray(messages)) return []; + const startIndex = getCommonRequestPrefixLength(previousMessages, messages); + const newMessages = messages.slice(startIndex); + const scope = newMessages.length || previousMessages.length ? newMessages : messages; + const userMessages = getTrailingUserMessages(scope); + const collapsed = []; + let lastSelfPrompt = ''; + for (const message of userMessages) { + const preview = formatChatMessagePreview(message); + if (isCompactCompatibilityMessage(preview)) continue; + if (isSelfPromptReminder(preview)) { + if (preview === lastSelfPrompt) continue; + lastSelfPrompt = preview; + } + collapsed.push(message); + } + return collapsed.slice(-3); + } + + function getTrailingUserMessages(messages) { + const trailing = []; + for (let index = messages.length - 1; index >= 0; index -= 1) { + const message = messages[index]; + if (message?.role !== 'user') break; + trailing.unshift(message); + } + return trailing.length > 0 ? trailing : messages.filter(message => message?.role === 'user'); + } + + function getCommonRequestPrefixLength(previousMessages, messages) { + if (!Array.isArray(previousMessages) || !Array.isArray(messages)) return 0; + const max = Math.min(previousMessages.length, messages.length); + let index = 0; + while (index < max && getRequestMessageKey(previousMessages[index]) === getRequestMessageKey(messages[index])) { + index += 1; + } + return index; + } + + function getRequestMessageKey(message) { + if (!message || typeof message !== 'object') return String(message); + return JSON.stringify({ + role: message.role || '', + content: message.content || '', + tool_call_id: message.tool_call_id || '', + name: message.name || '', + native_tool_calls: message.native_tool_calls || message.tool_calls || null + }); + } + + function getMessageRoleLabel(message, preview = formatChatMessagePreview(message)) { + return message?.role || 'message'; + } + + function isCompactCompatibilityMessage(text) { + return typeof text === 'string' && ( + text.startsWith('System: Conversation compacted.') || + text.startsWith('System: This session is being continued from an earlier conversation that was compacted.') + ); + } + + function isSelfPromptReminder(text) { + return typeof text === 'string' && text.startsWith('System: Continue working on your current goal:'); + } + + function formatChatMessagePreview(message) { + const toolCalls = message?.native_tool_calls || message?.tool_calls; + if (Array.isArray(toolCalls) && toolCalls.length) { + const names = toolCalls.map(getToolName).filter(Boolean).join(', '); + return `tool call${toolCalls.length > 1 ? 's' : ''}: ${names || 'tool'}`; + } + const text = normalizeVisibleChatMessageText(stringifyChatText(message?.content).trim()); + return text || '(empty message)'; + } + + function normalizeVisibleChatMessageText(text) { + if (typeof text !== 'string') return ''; + return text + .replace(/^Context:\s*\nState changes since your last request:\s*/i, 'State update:\n') + .replace(/^State changes since your last request:\s*/i, 'State update:\n') + .replace(/^State changes:\s*/i, 'State update:\n'); + } + + function summarizeRequest(request, model) { + if (!request) return model || 'model'; + const messages = Array.isArray(request.messages) ? request.messages.length : 0; + const tools = request.tool_count ? ` · ${request.tool_count} tools` : ''; + return `${model || 'model'} · ${messages} messages${tools}`; + } + + function renderInlineError(event) { + return ` +
+
!
+
+
Error
+
${escapeHTML(limitText(event.error?.message || stringifyChatText(event.error), 1200))}
+ ${renderDetails('Error payload', event)} +
+
`; + } + + function renderErrorCard(event) { + return ` +
+
Chat Error${escapeHTML(formatChatTime(event.timestamp))}
+
${escapeHTML(limitText(event.error?.message || stringifyChatText(event.error), 1200))}
+ ${renderDetails('Error payload', event)} +
`; + } + + function renderDetails(label, value) { + if (!value) return ''; + const detailId = getChatDetailId(label, value); + const json = JSON.stringify(value, null, 2); + return ` +
+ ${escapeHTML(label)} +
+ +
${escapeHTML(json)}
+
+
`; + } + + function getChatDetailId(label, value) { + const type = value?.type || label || 'detail'; + const timestamp = value?.timestamp || ''; + const tag = value?.tag || ''; + const agent = value?.agent || ''; + const toolId = getToolCallId(value?.tool_call) || ''; + const toolName = getToolName(value?.tool_call || {}); + const model = value?.model?.model || value?.model?.api || ''; + const safeLabel = String(label || '').replace(/\s+/g, '-').toLowerCase(); + return [safeLabel, agent, type, tag, timestamp, toolId || toolName, model].filter(Boolean).join(':') || label; + } + + function extractResponseText(response) { + if (!response) return ''; + if (typeof response === 'string') return response; + if (typeof response.message === 'string') return response.message; + if (typeof response.content === 'string') return response.content; + if (Array.isArray(response.content)) return response.content.map(part => part?.text || part?.content || '').filter(Boolean).join('\n'); + if (Array.isArray(response.choices)) { + return response.choices.map(choice => stringifyChatText(choice.message?.content ?? choice.delta?.content ?? choice.text ?? '')).filter(Boolean).join('\n'); + } + return ''; + } + + function extractResponseToolCalls(response) { + if (!response || typeof response !== 'object') return []; + if (Array.isArray(response.tool_calls)) return response.tool_calls; + if (Array.isArray(response.native_tool_calls)) return response.native_tool_calls; + if (Array.isArray(response.choices)) { + return response.choices.flatMap(choice => choice.message?.tool_calls || choice.delta?.tool_calls || []); + } + return []; + } + + function getToolCallId(call) { + return call?.id || call?.tool_call_id || call?.function?.id || null; + } + + function getToolName(call) { + return call?.name || call?.function?.name || 'tool'; + } + + function getToolArgs(call) { + const args = call?.arguments ?? call?.function?.arguments ?? call?.args; + return stringifyChatText(args ?? {}); + } + + function hasToolArgs(args) { + const text = String(args ?? '').trim(); + return text !== '' && text !== '{}' && text !== '[]' && text !== 'null'; + } + + + function parseToolArgsForDetails(args) { + const text = String(args ?? '').trim(); + if (!text) return {}; + try { + return JSON.parse(text); + } catch { + return text; + } + } + + function formatToolArgsInline(args) { + const text = String(args ?? '').trim(); + if (!hasToolArgs(text)) return ''; + let value = text; + try { + value = JSON.parse(text); + } catch { + return limitText(text.replace(/\s+/g, ' '), 180); + } + if (!value || typeof value !== 'object') return limitText(String(value), 180); + if (Array.isArray(value)) return limitText(`[${value.length} item${value.length === 1 ? '' : 's'}]`, 180); + const entries = Object.entries(value).filter(([, v]) => v !== undefined).slice(0, 5); + const suffix = Object.keys(value).length > entries.length ? ', …' : ''; + return limitText(entries.map(([key, val]) => `${key}: ${formatToolArgValue(val)}`).join(', ') + suffix, 180); + } + + function formatToolArgValue(value) { + if (value == null) return String(value); + if (typeof value === 'string') return value.length > 48 ? `${value.slice(0, 48)}…` : value; + if (typeof value === 'number' || typeof value === 'boolean') return String(value); + if (Array.isArray(value)) return `[${value.length} item${value.length === 1 ? '' : 's'}]`; + if (typeof value === 'object') return '{…}'; + return String(value); + } + + + function formatToolOutputText(value) { + const text = stringifyChatText(value).replace(/^\s+/, ''); + return text || 'Done'; + } + + function limitText(value, maxLength = 1200) { + const text = String(value ?? ''); + return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text; + } + + function formatChatTime(timestamp) { + if (!timestamp) return ''; + try { + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) return String(timestamp); + const now = new Date(); + const sameDay = date.toDateString() === now.toDateString(); + const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const options = sameDay + ? { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZoneName: 'short', timeZone } + : { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, timeZoneName: 'short', timeZone }; + return new Intl.DateTimeFormat(undefined, options).format(date); + } catch { + return String(timestamp); + } + } + + function stringifyChatText(value) { + if (value == null) return ''; + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + return value.map(part => stringifyChatText(part?.text ?? part?.content ?? part)).join('\n'); + } + if (typeof value === 'object') { + if (typeof value.text === 'string') return value.text; + if (typeof value.content === 'string') return value.content; + return JSON.stringify(value, null, 2); + } + return String(value); + } + + function summarizeValue(value) { + if (value == null) return ''; + const text = typeof value === 'string' ? value : JSON.stringify(value); + return text.length > 180 ? text.slice(0, 180) + '…' : text; + } + + function escapeHTML(value) { + return String(value ?? '') + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); + } + async function renderAgents(agents) { if (!agents.length) { agentsDiv.innerHTML = '
No agents connected
'; @@ -952,9 +3514,7 @@

Inventory

agentsDiv.innerHTML = agents.map(agent => renderAgentCard(agent)).join(''); // Update all viewers after initial render setTimeout(() => { - agents.forEach(a => { - if (a.in_game) updateAgentViewer(a.name); - }); + agents.forEach(restoreAgentPanels); }, 0); return; } @@ -972,11 +3532,11 @@

Inventory

if (el) { // Update existing card el.outerHTML = renderAgentCard(agent); - if (agent.in_game) updateAgentViewer(agent.name); + restoreAgentPanels(agent); } else { // Add new card agentsDiv.insertAdjacentHTML('beforeend', renderAgentCard(agent)); - if (agent.in_game) updateAgentViewer(agent.name); + restoreAgentPanels(agent); } }); @@ -986,6 +3546,9 @@

Inventory

if (!agents.find(a => a.name === name)) { el.remove(); delete inventoryOpen[name]; + delete chatHistoryLoadState[name]; + delete agentChatEvents[name]; + delete chatOpen[name]; } }); } @@ -1017,9 +3580,7 @@

Inventory

agentsDiv.innerHTML = agents.map(agent => renderAgentCard(agent)).join(''); // Update all viewers after initial render setTimeout(() => { - agents.forEach(a => { - if (a.in_game) updateAgentViewer(a.name); - }); + agents.forEach(restoreAgentPanels); }, 0); return; } @@ -1030,11 +3591,11 @@

Inventory

if (el) { // Update existing card el.outerHTML = renderAgentCard(agent); - if (agent.in_game) updateAgentViewer(agent.name); + restoreAgentPanels(agent); } else { // Add new card agentsDiv.insertAdjacentHTML('beforeend', renderAgentCard(agent)); - if (agent.in_game) updateAgentViewer(agent.name); + restoreAgentPanels(agent); } }); @@ -1044,13 +3605,16 @@

Inventory

if (!agents.find(a => a.name === name)) { el.remove(); delete inventoryOpen[name]; + delete chatHistoryLoadState[name]; + delete agentChatEvents[name]; + delete chatOpen[name]; } }); }); function restartAgent(n) { socket.emit('restart-agent', n); } function disconnectAgent(n) { socket.emit('stop-agent', n); } - function startAgent(n) { + function startAgent(n) { const btn = document.querySelector(`button[onclick="startAgent('${n}')"]`); if (btn) { btn.textContent = 'Connecting...'; @@ -1070,8 +3634,8 @@

Inventory

} function stopAgent(n) { socket.emit('stop-agent', n); } function destroyAgent(n) { socket.emit('destroy-agent', n); } - function disconnectAllAgents() { - socket.emit('stop-all-agents'); + function disconnectAllAgents() { + socket.emit('stop-all-agents'); } function confirmShutdown() { if (confirm('Are you sure you want to perform a full shutdown?\nThis will stop all agents and close the server.')) { @@ -1105,4 +3669,4 @@

Inventory

window.toggleDetails = toggleDetails; - + diff --git a/src/mindcraft/public/settings_spec.json b/src/mindcraft/public/settings_spec.json index c95cc2318..3d9e7f25e 100644 --- a/src/mindcraft/public/settings_spec.json +++ b/src/mindcraft/public/settings_spec.json @@ -4,6 +4,22 @@ "required": true, "description": "The profile object to use, including name, prompts, and examples" }, + "llm_providers": { + "type": "string", + "description": "Project-level LLM keys/model/embedding registry file", + "default": "settings_llm_providers.json", + "hidden": true + }, + "show_chat_history": { + "type": "boolean", + "description": "Whether to stream lightweight chat and tool events to the web UI chat panel", + "default": true + }, + "log_chat_trace": { + "type": "boolean", + "description": "Whether to write full prompt, request, response, and trace JSONL files", + "default": false + }, "minecraft_version": { "type": "string", "description": "The version of Minecraft to use. Set to 'auto' to automatically detect the version.", @@ -23,13 +39,21 @@ "type": "string", "description": "The authentication method to use", "default": "offline", - "options": ["offline", "microsoft"] + "options": [ + "offline", + "microsoft" + ] }, "base_profile": { "type": "string", "description": "Allowed values: survival, assistant, creative, god_mode. Each has fine tuned settings for different game modes.", "default": "survival", - "options": ["survival", "assistant", "creative", "god_mode"] + "options": [ + "survival", + "assistant", + "creative", + "god_mode" + ] }, "load_memory": { "type": "boolean", @@ -59,7 +83,12 @@ "blocked_actions": { "type": "array", "description": "List of actions that are blocked", - "default": ["!checkBlueprint", "!checkBlueprintLevel", "!getBlueprint", "!getBlueprintLevel"] + "default": [ + "!checkBlueprint", + "!checkBlueprintLevel", + "!getBlueprint", + "!getBlueprintLevel" + ] }, "relevant_docs_count": { "type": "number", @@ -68,13 +97,13 @@ }, "max_messages": { "type": "number", - "description": "Maximum number of recent messages to keep in context for LLM", - "default": 15 + "description": "Message-count context window; compaction considers messages after the latest compact boundary", + "default": 120 }, - "num_examples": { + "compact_message_threshold_percent": { "type": "number", - "description": "Number of examples to select to help prompt better LLM responses", - "default": 2 + "description": "Compact the whole active context when it reaches this percent of max_messages", + "default": 80 }, "max_commands": { "type": "number", @@ -95,7 +124,11 @@ "type": "string", "description": "Whether to show \"full\" command syntax, \"shortened\" command syntax, or \"none\"", "default": "full", - "options": ["full", "shortened", "none"] + "options": [ + "full", + "shortened", + "none" + ] }, "chat_ingame": { "type": "boolean", diff --git a/src/mindcraft/public/trace-demo.json b/src/mindcraft/public/trace-demo.json new file mode 100644 index 000000000..4c6f86a02 --- /dev/null +++ b/src/mindcraft/public/trace-demo.json @@ -0,0 +1 @@ +[{"timestamp": "2026-05-02T02:48:29.169Z", "agent": "codex", "type": "llm_request", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "system_prompt": "You are an AI Minecraft bot named codex that can converse with players, see, move, mine, build, and interact with the world by using native tools/function calls.\nYOUR CURRENT ASSIGNED GOAL: \"通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。\"\n 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; when you need to act, call the appropriate native tool/function instead of writing command text. Do NOT write legacy text commands, function-looking text, or fake tool-use log text in your message; call the native tool and optionally respond briefly after the tool result. Respond only as codex, 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:'中文交流;MC bot codex。行动用native工具,勿聊天输出命令;人类!可。重启/被要求先native查状态简短确认。newAction勿带```js。速通:木石→食物床火把→铁装盾桶→岩浆门→下界棒+珍珠/交易→眼找要塞→打龙,自保优先。地标:营地床(-197,67,-19);煤洞(-101,58,15);矿区(-198~-207,50~59,-1~-19);深矿炉(-192,35,-25)。深矿已清空间,工作台可放会回收;近(-178,32,-26)有矿道迹象(栅栏/蛛网)与铁/煤,铁装进度到胸裤鞋+盾;下一步做头盔桶、熔铁、继续采铁煤。'\n\nSTATS\n- Position: x: -179.59, y: 31.00, z: -28.43\n- Gamemode: survival\n- Health: 20 / 20\n- Hunger: 20 / 20\n- Biome: plains\n- Weather: Clear\n- Time: Night- Current Action: Idle\n- Nearby Human Players: None.\n- Nearby Bot Players: None.\nAgent Modes:\n- self_preservation(ON)\n- unstuck(ON)\n- cowardice(OFF)\n- self_defense(ON)\n- hunting(OFF)\n- item_collecting(ON)\n- torch_placing(ON)\n- elbow_room(ON)\n- idle_staring(ON)\n- cheat(OFF)\n\n\n\nNEARBY_ENTITIES: none\n\n\nNEARBY_BLOCKS\n- cave_air\n- stone\n- cobblestone\n- cobweb\n- oak_fence\n- oak_planks\n- furnace\n- copper_ore\n- wall_torch\n- iron_ore\n- diorite\n- dirt\n- andesite\n- torch\n- granite\n- glow_lichen\n- rail\n- Block Below: stone\n- Block at Legs: cave_air\n- Block at Head: cave_air\n- First Solid Block Above Head: stone (1 blocks up)\n\n\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n\n\n*NATIVE TOOL DOCS\nYou can use native function/tool calls to act in the Minecraft world and query state. Human users may type !commands, but AI responses must not output !command text. Use the provided tools instead.\nstats: Get your bot's location, health, hunger, and time of day.\ninventory: Get your bot's inventory.\nnearbyBlocks: Get the blocks near the bot.\ncraftable: Get the craftable items with the bot's inventory.\nentities: Get the nearby players and entities.\nmodes: Get all available modes and their docs and see which are on/off.\nsavedPlaces: List all saved locations.\ngetCraftingPlan: Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.\nParams:\ntargetItem: (string) The item that we are trying to craft\nquantity: (integer) The quantity of the item that we are trying to craft\nsearchWiki: Search the Minecraft Wiki for the given query.\nParams:\nquery: (string) The query to search for.\nhelp: Lists all available commands and their descriptions.\nnewAction: Perform new and unknown custom behaviors that are not available as a command.\nParams:\nprompt: (string) A natural language prompt to guide code generation. Make a detailed step-by-step plan.\nstop: Force stop all actions and commands that are currently executing.\nstfu: Stop all chatting and self prompting, but continue current action.\nrestart: Restart the agent process.\nclearChat: Clear the chat history.\ngoToPlayer: Go to the given player.\nParams:\nplayer_name: (string) The name of the player to go to.\ncloseness: (number) How close to get to the player.\nfollowPlayer: Endlessly follow the given player.\nParams:\nplayer_name: (string) name of the player to follow.\nfollow_dist: (number) The distance to follow from.\ngoToCoordinates: Go to the given x, y, z location.\nParams:\nx: (number) The x coordinate.\ny: (number) The y coordinate.\nz: (number) The z coordinate.\ncloseness: (number) How close to get to the location.\nsearchForBlock: Find and go to the nearest block of a given type in a given range.\nParams:\ntype: (string) The block type to go to.\nsearch_range: (number) The range to search for the block. Minimum 32.\nsearchForEntity: Find and go to the nearest entity of a given type in a given range.\nParams:\ntype: (string) The type of entity to go to.\nsearch_range: (number) The range to search for the entity.\nmoveAway: Move away from the current location in any direction by a given distance.\nParams:\ndistance: (number) The distance to move away.\nrememberHere: Save the current location with a given name.\nParams:\nname: (string) The name to remember the location as.\ngoToRememberedPlace: Go to a saved location.\nParams:\nname: (string) The name of the location to go to.\ngivePlayer: Give the specified item to the given player.\nParams:\nplayer_name: (string) The name of the player to give the item to.\nitem_name: (string) The name of the item to give.\nnum: (integer) The number of items to give.\nconsume: Eat/drink the given item.\nParams:\nitem_name: (string) The name of the item to consume.\nequip: Equip the given item.\nParams:\nitem_name: (string) The name of the item to equip.\nputInChest: Put the given item in the nearest chest.\nParams:\nitem_name: (string) The name of the item to put in the chest.\nnum: (integer) The number of items to put in the chest.\ntakeFromChest: Take the given items from the nearest chest.\nParams:\nitem_name: (string) The name of the item to take.\nnum: (integer) The number of items to take.\nviewChest: View the items/counts of the nearest chest.\nParams:\ndiscard: Discard the given item from the inventory.\nParams:\nitem_name: (string) The name of the item to discard.\nnum: (integer) The number of items to discard.\ncollectBlocks: Collect the nearest blocks of a given type.\nParams:\ntype: (string) The block type to collect.\nnum: (integer) The number of blocks to collect.\ncraftRecipe: Craft the given recipe a given number of times.\nParams:\nrecipe_name: (string) The name of the output item to craft.\nnum: (integer) The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.\nsmeltItem: Smelt the given item the given number of times.\nParams:\nitem_name: (string) The name of the input item to smelt.\nnum: (integer) The number of times to smelt the item.\nclearFurnace: Take all items out of the nearest furnace.\nParams:\nplaceHere: Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.\nParams:\ntype: (string) The block type to place.\nattack: Attack and kill the nearest entity of a given type.\nParams:\ntype: (string) The type of entity to attack.\nattackPlayer: Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.\nParams:\nplayer_name: (string) The name of the player to attack.\ngoToBed: Go to the nearest bed and sleep.\nstay: Stay in the current location no matter what. Pauses all modes.\nParams:\ntype: (integer) The number of seconds to stay. -1 for forever.\nsetMode: Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.\nParams:\nmode_name: (string) The name of the mode to enable.\non: (boolean) Whether to enable or disable the mode.\ngoal: Set a goal prompt to endlessly work towards with continuous self-prompting.\nParams:\nselfPrompt: (string) The goal prompt.\nendGoal: Call when you have accomplished your goal. It will stop self-prompting and the current action. \nshowVillagerTrades: Show trades of a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\ntradeWithVillager: Trade with a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\nindex: (integer) The index of the trade you want executed (1-indexed).\ncount: (integer) How many times that trade should be executed.\nstartConversation: Start a conversation with a bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to send the message to.\nmessage: (string) The message to send.\nendConversation: End the conversation with the given bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to end the conversation with.\nlookAtPlayer: Look at a player or look in the same direction as the player.\nParams:\nplayer_name: (string) Name of the target player\ndirection: (string) How to look (\"at\": look at the player, \"with\": look in the same direction as the player)\nlookAtPosition: Look at specified coordinates.\nParams:\nx: (integer) x coordinate\ny: (integer) y coordinate\nz: (integer) z coordinate\ndigDown: Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.\nParams:\ndistance: (integer) Distance to dig down\ngoToSurface: Moves the bot to the highest block above it (usually the surface).\nParams:\nuseOn: Use (right click) the given tool on the nearest target of the given type.\nParams:\ntool_name: (string) Name of the tool to use, or \"hand\" for no tool.\ntarget: (string) The target as an entity type, block type, or \"nothing\" for no target.\n*\n\nExamples of how to respond:\nExample 1:\nUser input: miner_32: Hey! What are you up to?\nYour output:\nNothing much miner_32, what do you need?\n\nExample 2:\nUser input: rupert: Let us work together on a small house.\nYour output:\nSure, I can coordinate with you and handle my part.\nUser input: rupert: I will build the walls if you prepare materials.\nYour output:\nSounds good. I will prepare materials first.\n\n\nConversation Begin:\n", "messages": [{"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}], "tools": [{"type": "function", "function": {"name": "stats", "description": "Get your bot's location, health, hunger, and time of day.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "inventory", "description": "Get your bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "nearbyBlocks", "description": "Get the blocks near the bot.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftable", "description": "Get the craftable items with the bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "entities", "description": "Get the nearby players and entities.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "modes", "description": "Get all available modes and their docs and see which are on/off.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "savedPlaces", "description": "List all saved locations.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "getCraftingPlan", "description": "Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.", "parameters": {"type": "object", "properties": {"targetItem": {"type": "string", "description": "The item that we are trying to craft"}, "quantity": {"type": "integer", "description": "The quantity of the item that we are trying to craft", "minimum": 1}}, "required": ["targetItem"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchWiki", "description": "Search the Minecraft Wiki for the given query.", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "The query to search for."}}, "required": ["query"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "help", "description": "Lists all available commands and their descriptions.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "newAction", "description": "Perform new and unknown custom behaviors that are not available as a command.", "parameters": {"type": "object", "properties": {"prompt": {"type": "string", "description": "A natural language prompt to guide code generation. Make a detailed step-by-step plan."}}, "required": ["prompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stop", "description": "Force stop all actions and commands that are currently executing.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stfu", "description": "Stop all chatting and self prompting, but continue current action.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "restart", "description": "Restart the agent process.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearChat", "description": "Clear the chat history.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToPlayer", "description": "Go to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to go to."}, "closeness": {"type": "number", "description": "How close to get to the player.", "minimum": 0}}, "required": ["player_name", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "followPlayer", "description": "Endlessly follow the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "name of the player to follow."}, "follow_dist": {"type": "number", "description": "The distance to follow from.", "minimum": 0}}, "required": ["player_name", "follow_dist"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToCoordinates", "description": "Go to the given x, y, z location.", "parameters": {"type": "object", "properties": {"x": {"type": "number", "description": "The x coordinate."}, "y": {"type": "number", "description": "The y coordinate.", "minimum": -64, "maximum": 320}, "z": {"type": "number", "description": "The z coordinate."}, "closeness": {"type": "number", "description": "How close to get to the location.", "minimum": 0}}, "required": ["x", "y", "z", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForBlock", "description": "Find and go to the nearest block of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to go to."}, "search_range": {"type": "number", "description": "The range to search for the block. Minimum 32.", "minimum": 10, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForEntity", "description": "Find and go to the nearest entity of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to go to."}, "search_range": {"type": "number", "description": "The range to search for the entity.", "minimum": 32, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "moveAway", "description": "Move away from the current location in any direction by a given distance.", "parameters": {"type": "object", "properties": {"distance": {"type": "number", "description": "The distance to move away.", "minimum": 0}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "rememberHere", "description": "Save the current location with a given name.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name to remember the location as."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToRememberedPlace", "description": "Go to a saved location.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name of the location to go to."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "givePlayer", "description": "Give the specified item to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to give the item to."}, "item_name": {"type": "string", "description": "The name of the item to give."}, "num": {"type": "integer", "description": "The number of items to give.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["player_name", "item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "consume", "description": "Eat/drink the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to consume."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "equip", "description": "Equip the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to equip."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "putInChest", "description": "Put the given item in the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to put in the chest."}, "num": {"type": "integer", "description": "The number of items to put in the chest.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "takeFromChest", "description": "Take the given items from the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to take."}, "num": {"type": "integer", "description": "The number of items to take.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "viewChest", "description": "View the items/counts of the nearest chest.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "discard", "description": "Discard the given item from the inventory.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to discard."}, "num": {"type": "integer", "description": "The number of items to discard.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "collectBlocks", "description": "Collect the nearest blocks of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to collect."}, "num": {"type": "integer", "description": "The number of blocks to collect.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["type", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftRecipe", "description": "Craft the given recipe a given number of times.", "parameters": {"type": "object", "properties": {"recipe_name": {"type": "string", "description": "The name of the output item to craft."}, "num": {"type": "integer", "description": "The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["recipe_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "smeltItem", "description": "Smelt the given item the given number of times.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the input item to smelt."}, "num": {"type": "integer", "description": "The number of times to smelt the item.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearFurnace", "description": "Take all items out of the nearest furnace.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "placeHere", "description": "Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to place."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attack", "description": "Attack and kill the nearest entity of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to attack."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attackPlayer", "description": "Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to attack."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToBed", "description": "Go to the nearest bed and sleep.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stay", "description": "Stay in the current location no matter what. Pauses all modes.", "parameters": {"type": "object", "properties": {"type": {"type": "integer", "description": "The number of seconds to stay. -1 for forever.", "minimum": -1, "maximum": 9007199254740991}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "setMode", "description": "Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.", "parameters": {"type": "object", "properties": {"mode_name": {"type": "string", "description": "The name of the mode to enable."}, "on": {"type": "boolean", "description": "Whether to enable or disable the mode."}}, "required": ["mode_name", "on"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goal", "description": "Set a goal prompt to endlessly work towards with continuous self-prompting.", "parameters": {"type": "object", "properties": {"selfPrompt": {"type": "string", "description": "The goal prompt."}}, "required": ["selfPrompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endGoal", "description": "Call when you have accomplished your goal. It will stop self-prompting and the current action. ", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "showVillagerTrades", "description": "Show trades of a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}}, "required": ["id"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "tradeWithVillager", "description": "Trade with a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}, "index": {"type": "integer", "description": "The index of the trade you want executed (1-indexed).", "minimum": 1, "maximum": 9007199254740991}, "count": {"type": "integer", "description": "How many times that trade should be executed.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["id", "index", "count"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "startConversation", "description": "Start a conversation with a bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to send the message to."}, "message": {"type": "string", "description": "The message to send."}}, "required": ["player_name", "message"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endConversation", "description": "End the conversation with the given bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to end the conversation with."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPlayer", "description": "Look at a player or look in the same direction as the player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "Name of the target player"}, "direction": {"type": "string", "description": "How to look (\"at\": look at the player, \"with\": look in the same direction as the player)"}}, "required": ["player_name", "direction"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPosition", "description": "Look at specified coordinates.", "parameters": {"type": "object", "properties": {"x": {"type": "integer", "description": "x coordinate"}, "y": {"type": "integer", "description": "y coordinate"}, "z": {"type": "integer", "description": "z coordinate"}}, "required": ["x", "y", "z"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "digDown", "description": "Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.", "parameters": {"type": "object", "properties": {"distance": {"type": "integer", "description": "Distance to dig down", "minimum": 1, "maximum": 9007199254740991}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToSurface", "description": "Moves the bot to the highest block above it (usually the surface).", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "useOn", "description": "Use (right click) the given tool on the nearest target of the given type.", "parameters": {"type": "object", "properties": {"tool_name": {"type": "string", "description": "Name of the tool to use, or \"hand\" for no tool."}, "target": {"type": "string", "description": "The target as an entity type, block type, or \"nothing\" for no target."}}, "required": ["tool_name", "target"], "additionalProperties": false}}}], "tool_count": 51}, {"timestamp": "2026-05-02T02:48:32.164Z", "agent": "codex", "type": "llm_request", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "system_prompt": "You are an AI Minecraft bot named codex that can converse with players, see, move, mine, build, and interact with the world by using native tools/function calls.\nYOUR CURRENT ASSIGNED GOAL: \"通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。\"\n 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; when you need to act, call the appropriate native tool/function instead of writing command text. Do NOT write legacy text commands, function-looking text, or fake tool-use log text in your message; call the native tool and optionally respond briefly after the tool result. Respond only as codex, 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:'中文交流;MC bot codex。行动用native工具,勿聊天输出命令;人类!可。重启/被要求先native查状态简短确认。newAction勿带```js。速通:木石→食物床火把→铁装盾桶→岩浆门→下界棒+珍珠/交易→眼找要塞→打龙,自保优先。地标:营地床(-197,67,-19);煤洞(-101,58,15);矿区(-198~-207,50~59,-1~-19);深矿炉(-192,35,-25)。深矿已清空间,工作台可放会回收;近(-178,32,-26)有矿道迹象(栅栏/蛛网)与铁/煤,铁装进度到胸裤鞋+盾;下一步做头盔桶、熔铁、继续采铁煤。'\n\nSTATS\n- Position: x: -179.59, y: 31.00, z: -28.43\n- Gamemode: survival\n- Health: 20 / 20\n- Hunger: 20 / 20\n- Biome: plains\n- Weather: Clear\n- Time: Night- Current Action: Idle\n- Nearby Human Players: None.\n- Nearby Bot Players: None.\nAgent Modes:\n- self_preservation(ON)\n- unstuck(ON)\n- cowardice(OFF)\n- self_defense(ON)\n- hunting(OFF)\n- item_collecting(ON)\n- torch_placing(ON)\n- elbow_room(ON)\n- idle_staring(ON)\n- cheat(OFF)\n\n\n\nNEARBY_ENTITIES\n- entities: 2 bat(s)\n\n\nNEARBY_BLOCKS\n- cave_air\n- stone\n- cobblestone\n- cobweb\n- oak_fence\n- oak_planks\n- furnace\n- copper_ore\n- wall_torch\n- iron_ore\n- diorite\n- dirt\n- andesite\n- torch\n- granite\n- glow_lichen\n- rail\n- Block Below: stone\n- Block at Legs: cave_air\n- Block at Head: cave_air\n- First Solid Block Above Head: stone (1 blocks up)\n\n\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n\n\n*NATIVE TOOL DOCS\nYou can use native function/tool calls to act in the Minecraft world and query state. Human users may type !commands, but AI responses must not output !command text. Use the provided tools instead.\nstats: Get your bot's location, health, hunger, and time of day.\ninventory: Get your bot's inventory.\nnearbyBlocks: Get the blocks near the bot.\ncraftable: Get the craftable items with the bot's inventory.\nentities: Get the nearby players and entities.\nmodes: Get all available modes and their docs and see which are on/off.\nsavedPlaces: List all saved locations.\ngetCraftingPlan: Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.\nParams:\ntargetItem: (string) The item that we are trying to craft\nquantity: (integer) The quantity of the item that we are trying to craft\nsearchWiki: Search the Minecraft Wiki for the given query.\nParams:\nquery: (string) The query to search for.\nhelp: Lists all available commands and their descriptions.\nnewAction: Perform new and unknown custom behaviors that are not available as a command.\nParams:\nprompt: (string) A natural language prompt to guide code generation. Make a detailed step-by-step plan.\nstop: Force stop all actions and commands that are currently executing.\nstfu: Stop all chatting and self prompting, but continue current action.\nrestart: Restart the agent process.\nclearChat: Clear the chat history.\ngoToPlayer: Go to the given player.\nParams:\nplayer_name: (string) The name of the player to go to.\ncloseness: (number) How close to get to the player.\nfollowPlayer: Endlessly follow the given player.\nParams:\nplayer_name: (string) name of the player to follow.\nfollow_dist: (number) The distance to follow from.\ngoToCoordinates: Go to the given x, y, z location.\nParams:\nx: (number) The x coordinate.\ny: (number) The y coordinate.\nz: (number) The z coordinate.\ncloseness: (number) How close to get to the location.\nsearchForBlock: Find and go to the nearest block of a given type in a given range.\nParams:\ntype: (string) The block type to go to.\nsearch_range: (number) The range to search for the block. Minimum 32.\nsearchForEntity: Find and go to the nearest entity of a given type in a given range.\nParams:\ntype: (string) The type of entity to go to.\nsearch_range: (number) The range to search for the entity.\nmoveAway: Move away from the current location in any direction by a given distance.\nParams:\ndistance: (number) The distance to move away.\nrememberHere: Save the current location with a given name.\nParams:\nname: (string) The name to remember the location as.\ngoToRememberedPlace: Go to a saved location.\nParams:\nname: (string) The name of the location to go to.\ngivePlayer: Give the specified item to the given player.\nParams:\nplayer_name: (string) The name of the player to give the item to.\nitem_name: (string) The name of the item to give.\nnum: (integer) The number of items to give.\nconsume: Eat/drink the given item.\nParams:\nitem_name: (string) The name of the item to consume.\nequip: Equip the given item.\nParams:\nitem_name: (string) The name of the item to equip.\nputInChest: Put the given item in the nearest chest.\nParams:\nitem_name: (string) The name of the item to put in the chest.\nnum: (integer) The number of items to put in the chest.\ntakeFromChest: Take the given items from the nearest chest.\nParams:\nitem_name: (string) The name of the item to take.\nnum: (integer) The number of items to take.\nviewChest: View the items/counts of the nearest chest.\nParams:\ndiscard: Discard the given item from the inventory.\nParams:\nitem_name: (string) The name of the item to discard.\nnum: (integer) The number of items to discard.\ncollectBlocks: Collect the nearest blocks of a given type.\nParams:\ntype: (string) The block type to collect.\nnum: (integer) The number of blocks to collect.\ncraftRecipe: Craft the given recipe a given number of times.\nParams:\nrecipe_name: (string) The name of the output item to craft.\nnum: (integer) The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.\nsmeltItem: Smelt the given item the given number of times.\nParams:\nitem_name: (string) The name of the input item to smelt.\nnum: (integer) The number of times to smelt the item.\nclearFurnace: Take all items out of the nearest furnace.\nParams:\nplaceHere: Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.\nParams:\ntype: (string) The block type to place.\nattack: Attack and kill the nearest entity of a given type.\nParams:\ntype: (string) The type of entity to attack.\nattackPlayer: Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.\nParams:\nplayer_name: (string) The name of the player to attack.\ngoToBed: Go to the nearest bed and sleep.\nstay: Stay in the current location no matter what. Pauses all modes.\nParams:\ntype: (integer) The number of seconds to stay. -1 for forever.\nsetMode: Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.\nParams:\nmode_name: (string) The name of the mode to enable.\non: (boolean) Whether to enable or disable the mode.\ngoal: Set a goal prompt to endlessly work towards with continuous self-prompting.\nParams:\nselfPrompt: (string) The goal prompt.\nendGoal: Call when you have accomplished your goal. It will stop self-prompting and the current action. \nshowVillagerTrades: Show trades of a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\ntradeWithVillager: Trade with a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\nindex: (integer) The index of the trade you want executed (1-indexed).\ncount: (integer) How many times that trade should be executed.\nstartConversation: Start a conversation with a bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to send the message to.\nmessage: (string) The message to send.\nendConversation: End the conversation with the given bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to end the conversation with.\nlookAtPlayer: Look at a player or look in the same direction as the player.\nParams:\nplayer_name: (string) Name of the target player\ndirection: (string) How to look (\"at\": look at the player, \"with\": look in the same direction as the player)\nlookAtPosition: Look at specified coordinates.\nParams:\nx: (integer) x coordinate\ny: (integer) y coordinate\nz: (integer) z coordinate\ndigDown: Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.\nParams:\ndistance: (integer) Distance to dig down\ngoToSurface: Moves the bot to the highest block above it (usually the surface).\nParams:\nuseOn: Use (right click) the given tool on the nearest target of the given type.\nParams:\ntool_name: (string) Name of the tool to use, or \"hand\" for no tool.\ntarget: (string) The target as an entity type, block type, or \"nothing\" for no target.\n*\n\nExamples of how to respond:\nExample 1:\nUser input: miner_32: Hey! What are you up to?\nYour output:\nNothing much miner_32, what do you need?\n\nExample 2:\nUser input: rupert: Let us work together on a small house.\nYour output:\nSure, I can coordinate with you and handle my part.\nUser input: rupert: I will build the walls if you prepare materials.\nYour output:\nSounds good. I will prepare materials first.\n\n\nConversation Begin:\n", "messages": [{"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}, {"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}], "tools": [{"type": "function", "function": {"name": "stats", "description": "Get your bot's location, health, hunger, and time of day.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "inventory", "description": "Get your bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "nearbyBlocks", "description": "Get the blocks near the bot.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftable", "description": "Get the craftable items with the bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "entities", "description": "Get the nearby players and entities.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "modes", "description": "Get all available modes and their docs and see which are on/off.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "savedPlaces", "description": "List all saved locations.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "getCraftingPlan", "description": "Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.", "parameters": {"type": "object", "properties": {"targetItem": {"type": "string", "description": "The item that we are trying to craft"}, "quantity": {"type": "integer", "description": "The quantity of the item that we are trying to craft", "minimum": 1}}, "required": ["targetItem"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchWiki", "description": "Search the Minecraft Wiki for the given query.", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "The query to search for."}}, "required": ["query"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "help", "description": "Lists all available commands and their descriptions.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "newAction", "description": "Perform new and unknown custom behaviors that are not available as a command.", "parameters": {"type": "object", "properties": {"prompt": {"type": "string", "description": "A natural language prompt to guide code generation. Make a detailed step-by-step plan."}}, "required": ["prompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stop", "description": "Force stop all actions and commands that are currently executing.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stfu", "description": "Stop all chatting and self prompting, but continue current action.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "restart", "description": "Restart the agent process.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearChat", "description": "Clear the chat history.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToPlayer", "description": "Go to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to go to."}, "closeness": {"type": "number", "description": "How close to get to the player.", "minimum": 0}}, "required": ["player_name", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "followPlayer", "description": "Endlessly follow the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "name of the player to follow."}, "follow_dist": {"type": "number", "description": "The distance to follow from.", "minimum": 0}}, "required": ["player_name", "follow_dist"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToCoordinates", "description": "Go to the given x, y, z location.", "parameters": {"type": "object", "properties": {"x": {"type": "number", "description": "The x coordinate."}, "y": {"type": "number", "description": "The y coordinate.", "minimum": -64, "maximum": 320}, "z": {"type": "number", "description": "The z coordinate."}, "closeness": {"type": "number", "description": "How close to get to the location.", "minimum": 0}}, "required": ["x", "y", "z", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForBlock", "description": "Find and go to the nearest block of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to go to."}, "search_range": {"type": "number", "description": "The range to search for the block. Minimum 32.", "minimum": 10, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForEntity", "description": "Find and go to the nearest entity of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to go to."}, "search_range": {"type": "number", "description": "The range to search for the entity.", "minimum": 32, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "moveAway", "description": "Move away from the current location in any direction by a given distance.", "parameters": {"type": "object", "properties": {"distance": {"type": "number", "description": "The distance to move away.", "minimum": 0}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "rememberHere", "description": "Save the current location with a given name.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name to remember the location as."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToRememberedPlace", "description": "Go to a saved location.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name of the location to go to."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "givePlayer", "description": "Give the specified item to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to give the item to."}, "item_name": {"type": "string", "description": "The name of the item to give."}, "num": {"type": "integer", "description": "The number of items to give.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["player_name", "item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "consume", "description": "Eat/drink the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to consume."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "equip", "description": "Equip the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to equip."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "putInChest", "description": "Put the given item in the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to put in the chest."}, "num": {"type": "integer", "description": "The number of items to put in the chest.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "takeFromChest", "description": "Take the given items from the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to take."}, "num": {"type": "integer", "description": "The number of items to take.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "viewChest", "description": "View the items/counts of the nearest chest.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "discard", "description": "Discard the given item from the inventory.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to discard."}, "num": {"type": "integer", "description": "The number of items to discard.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "collectBlocks", "description": "Collect the nearest blocks of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to collect."}, "num": {"type": "integer", "description": "The number of blocks to collect.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["type", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftRecipe", "description": "Craft the given recipe a given number of times.", "parameters": {"type": "object", "properties": {"recipe_name": {"type": "string", "description": "The name of the output item to craft."}, "num": {"type": "integer", "description": "The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["recipe_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "smeltItem", "description": "Smelt the given item the given number of times.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the input item to smelt."}, "num": {"type": "integer", "description": "The number of times to smelt the item.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearFurnace", "description": "Take all items out of the nearest furnace.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "placeHere", "description": "Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to place."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attack", "description": "Attack and kill the nearest entity of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to attack."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attackPlayer", "description": "Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to attack."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToBed", "description": "Go to the nearest bed and sleep.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stay", "description": "Stay in the current location no matter what. Pauses all modes.", "parameters": {"type": "object", "properties": {"type": {"type": "integer", "description": "The number of seconds to stay. -1 for forever.", "minimum": -1, "maximum": 9007199254740991}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "setMode", "description": "Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.", "parameters": {"type": "object", "properties": {"mode_name": {"type": "string", "description": "The name of the mode to enable."}, "on": {"type": "boolean", "description": "Whether to enable or disable the mode."}}, "required": ["mode_name", "on"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goal", "description": "Set a goal prompt to endlessly work towards with continuous self-prompting.", "parameters": {"type": "object", "properties": {"selfPrompt": {"type": "string", "description": "The goal prompt."}}, "required": ["selfPrompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endGoal", "description": "Call when you have accomplished your goal. It will stop self-prompting and the current action. ", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "showVillagerTrades", "description": "Show trades of a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}}, "required": ["id"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "tradeWithVillager", "description": "Trade with a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}, "index": {"type": "integer", "description": "The index of the trade you want executed (1-indexed).", "minimum": 1, "maximum": 9007199254740991}, "count": {"type": "integer", "description": "How many times that trade should be executed.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["id", "index", "count"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "startConversation", "description": "Start a conversation with a bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to send the message to."}, "message": {"type": "string", "description": "The message to send."}}, "required": ["player_name", "message"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endConversation", "description": "End the conversation with the given bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to end the conversation with."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPlayer", "description": "Look at a player or look in the same direction as the player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "Name of the target player"}, "direction": {"type": "string", "description": "How to look (\"at\": look at the player, \"with\": look in the same direction as the player)"}}, "required": ["player_name", "direction"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPosition", "description": "Look at specified coordinates.", "parameters": {"type": "object", "properties": {"x": {"type": "integer", "description": "x coordinate"}, "y": {"type": "integer", "description": "y coordinate"}, "z": {"type": "integer", "description": "z coordinate"}}, "required": ["x", "y", "z"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "digDown", "description": "Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.", "parameters": {"type": "object", "properties": {"distance": {"type": "integer", "description": "Distance to dig down", "minimum": 1, "maximum": 9007199254740991}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToSurface", "description": "Moves the bot to the highest block above it (usually the surface).", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "useOn", "description": "Use (right click) the given tool on the nearest target of the given type.", "parameters": {"type": "object", "properties": {"tool_name": {"type": "string", "description": "Name of the tool to use, or \"hand\" for no tool."}, "target": {"type": "string", "description": "The target as an entity type, block type, or \"nothing\" for no target."}}, "required": ["tool_name", "target"], "additionalProperties": false}}}], "tool_count": 51}, {"timestamp": "2026-05-02T02:48:34.484Z", "agent": "codex", "type": "llm_response", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "response": {"type": "tool_calls", "provider": "codex-chatgpt", "tool_calls": [{"id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "type": "function", "name": "inventory", "arguments": "{}"}]}}, {"timestamp": "2026-05-02T02:48:34.490Z", "agent": "codex", "type": "tool_call", "tool_call": {"id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "type": "function", "name": "inventory", "arguments": "{}"}, "content": ""}, {"timestamp": "2026-05-02T02:48:34.498Z", "agent": "codex", "type": "tool_result", "tool_call": {"id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "type": "function", "name": "inventory", "arguments": "{}"}, "result": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n"}, {"timestamp": "2026-05-02T02:48:35.194Z", "agent": "codex", "type": "llm_request", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "system_prompt": "You are an AI Minecraft bot named codex that can converse with players, see, move, mine, build, and interact with the world by using native tools/function calls.\nYOUR CURRENT ASSIGNED GOAL: \"通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。\"\n 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; when you need to act, call the appropriate native tool/function instead of writing command text. Do NOT write legacy text commands, function-looking text, or fake tool-use log text in your message; call the native tool and optionally respond briefly after the tool result. Respond only as codex, 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:'中文交流;MC bot codex。行动用native工具,勿聊天输出命令;人类!可。重启/被要求先native查状态简短确认。newAction勿带```js。速通:木石→食物床火把→铁装盾桶→岩浆门→下界棒+珍珠/交易→眼找要塞→打龙,自保优先。地标:营地床(-197,67,-19);煤洞(-101,58,15);矿区(-198~-207,50~59,-1~-19);深矿炉(-192,35,-25)。深矿已清空间,工作台可放会回收;近(-178,32,-26)有矿道迹象(栅栏/蛛网)与铁/煤,铁装进度到胸裤鞋+盾;下一步做头盔桶、熔铁、继续采铁煤。'\n\nSTATS\n- Position: x: -179.59, y: 31.00, z: -28.43\n- Gamemode: survival\n- Health: 20 / 20\n- Hunger: 20 / 20\n- Biome: plains\n- Weather: Clear\n- Time: Night- Current Action: Idle\n- Nearby Human Players: None.\n- Nearby Bot Players: None.\nAgent Modes:\n- self_preservation(ON)\n- unstuck(ON)\n- cowardice(OFF)\n- self_defense(ON)\n- hunting(OFF)\n- item_collecting(ON)\n- torch_placing(ON)\n- elbow_room(ON)\n- idle_staring(ON)\n- cheat(OFF)\n\n\n\nNEARBY_ENTITIES\n- entities: 2 bat(s)\n\n\nNEARBY_BLOCKS\n- cave_air\n- stone\n- cobblestone\n- cobweb\n- oak_fence\n- oak_planks\n- furnace\n- copper_ore\n- wall_torch\n- iron_ore\n- diorite\n- dirt\n- andesite\n- torch\n- granite\n- glow_lichen\n- rail\n- Block Below: stone\n- Block at Legs: cave_air\n- Block at Head: cave_air\n- First Solid Block Above Head: stone (1 blocks up)\n\n\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n\n\n*NATIVE TOOL DOCS\nYou can use native function/tool calls to act in the Minecraft world and query state. Human users may type !commands, but AI responses must not output !command text. Use the provided tools instead.\nstats: Get your bot's location, health, hunger, and time of day.\ninventory: Get your bot's inventory.\nnearbyBlocks: Get the blocks near the bot.\ncraftable: Get the craftable items with the bot's inventory.\nentities: Get the nearby players and entities.\nmodes: Get all available modes and their docs and see which are on/off.\nsavedPlaces: List all saved locations.\ngetCraftingPlan: Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.\nParams:\ntargetItem: (string) The item that we are trying to craft\nquantity: (integer) The quantity of the item that we are trying to craft\nsearchWiki: Search the Minecraft Wiki for the given query.\nParams:\nquery: (string) The query to search for.\nhelp: Lists all available commands and their descriptions.\nnewAction: Perform new and unknown custom behaviors that are not available as a command.\nParams:\nprompt: (string) A natural language prompt to guide code generation. Make a detailed step-by-step plan.\nstop: Force stop all actions and commands that are currently executing.\nstfu: Stop all chatting and self prompting, but continue current action.\nrestart: Restart the agent process.\nclearChat: Clear the chat history.\ngoToPlayer: Go to the given player.\nParams:\nplayer_name: (string) The name of the player to go to.\ncloseness: (number) How close to get to the player.\nfollowPlayer: Endlessly follow the given player.\nParams:\nplayer_name: (string) name of the player to follow.\nfollow_dist: (number) The distance to follow from.\ngoToCoordinates: Go to the given x, y, z location.\nParams:\nx: (number) The x coordinate.\ny: (number) The y coordinate.\nz: (number) The z coordinate.\ncloseness: (number) How close to get to the location.\nsearchForBlock: Find and go to the nearest block of a given type in a given range.\nParams:\ntype: (string) The block type to go to.\nsearch_range: (number) The range to search for the block. Minimum 32.\nsearchForEntity: Find and go to the nearest entity of a given type in a given range.\nParams:\ntype: (string) The type of entity to go to.\nsearch_range: (number) The range to search for the entity.\nmoveAway: Move away from the current location in any direction by a given distance.\nParams:\ndistance: (number) The distance to move away.\nrememberHere: Save the current location with a given name.\nParams:\nname: (string) The name to remember the location as.\ngoToRememberedPlace: Go to a saved location.\nParams:\nname: (string) The name of the location to go to.\ngivePlayer: Give the specified item to the given player.\nParams:\nplayer_name: (string) The name of the player to give the item to.\nitem_name: (string) The name of the item to give.\nnum: (integer) The number of items to give.\nconsume: Eat/drink the given item.\nParams:\nitem_name: (string) The name of the item to consume.\nequip: Equip the given item.\nParams:\nitem_name: (string) The name of the item to equip.\nputInChest: Put the given item in the nearest chest.\nParams:\nitem_name: (string) The name of the item to put in the chest.\nnum: (integer) The number of items to put in the chest.\ntakeFromChest: Take the given items from the nearest chest.\nParams:\nitem_name: (string) The name of the item to take.\nnum: (integer) The number of items to take.\nviewChest: View the items/counts of the nearest chest.\nParams:\ndiscard: Discard the given item from the inventory.\nParams:\nitem_name: (string) The name of the item to discard.\nnum: (integer) The number of items to discard.\ncollectBlocks: Collect the nearest blocks of a given type.\nParams:\ntype: (string) The block type to collect.\nnum: (integer) The number of blocks to collect.\ncraftRecipe: Craft the given recipe a given number of times.\nParams:\nrecipe_name: (string) The name of the output item to craft.\nnum: (integer) The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.\nsmeltItem: Smelt the given item the given number of times.\nParams:\nitem_name: (string) The name of the input item to smelt.\nnum: (integer) The number of times to smelt the item.\nclearFurnace: Take all items out of the nearest furnace.\nParams:\nplaceHere: Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.\nParams:\ntype: (string) The block type to place.\nattack: Attack and kill the nearest entity of a given type.\nParams:\ntype: (string) The type of entity to attack.\nattackPlayer: Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.\nParams:\nplayer_name: (string) The name of the player to attack.\ngoToBed: Go to the nearest bed and sleep.\nstay: Stay in the current location no matter what. Pauses all modes.\nParams:\ntype: (integer) The number of seconds to stay. -1 for forever.\nsetMode: Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.\nParams:\nmode_name: (string) The name of the mode to enable.\non: (boolean) Whether to enable or disable the mode.\ngoal: Set a goal prompt to endlessly work towards with continuous self-prompting.\nParams:\nselfPrompt: (string) The goal prompt.\nendGoal: Call when you have accomplished your goal. It will stop self-prompting and the current action. \nshowVillagerTrades: Show trades of a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\ntradeWithVillager: Trade with a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\nindex: (integer) The index of the trade you want executed (1-indexed).\ncount: (integer) How many times that trade should be executed.\nstartConversation: Start a conversation with a bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to send the message to.\nmessage: (string) The message to send.\nendConversation: End the conversation with the given bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to end the conversation with.\nlookAtPlayer: Look at a player or look in the same direction as the player.\nParams:\nplayer_name: (string) Name of the target player\ndirection: (string) How to look (\"at\": look at the player, \"with\": look in the same direction as the player)\nlookAtPosition: Look at specified coordinates.\nParams:\nx: (integer) x coordinate\ny: (integer) y coordinate\nz: (integer) z coordinate\ndigDown: Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.\nParams:\ndistance: (integer) Distance to dig down\ngoToSurface: Moves the bot to the highest block above it (usually the surface).\nParams:\nuseOn: Use (right click) the given tool on the nearest target of the given type.\nParams:\ntool_name: (string) Name of the tool to use, or \"hand\" for no tool.\ntarget: (string) The target as an entity type, block type, or \"nothing\" for no target.\n*\n\nExamples of how to respond:\nExample 1:\nUser input: miner_32: Hey! What are you up to?\nYour output:\nNothing much miner_32, what do you need?\n\nExample 2:\nUser input: rupert: Let us work together on a small house.\nYour output:\nSure, I can coordinate with you and handle my part.\nUser input: rupert: I will build the walls if you prepare materials.\nYour output:\nSounds good. I will prepare materials first.\n\n\nConversation Begin:\n", "messages": [{"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}, {"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "type": "function", "name": "inventory", "arguments": "{}"}]}, {"role": "tool", "content": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n", "tool_call_id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "name": "inventory"}], "tools": [{"type": "function", "function": {"name": "stats", "description": "Get your bot's location, health, hunger, and time of day.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "inventory", "description": "Get your bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "nearbyBlocks", "description": "Get the blocks near the bot.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftable", "description": "Get the craftable items with the bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "entities", "description": "Get the nearby players and entities.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "modes", "description": "Get all available modes and their docs and see which are on/off.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "savedPlaces", "description": "List all saved locations.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "getCraftingPlan", "description": "Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.", "parameters": {"type": "object", "properties": {"targetItem": {"type": "string", "description": "The item that we are trying to craft"}, "quantity": {"type": "integer", "description": "The quantity of the item that we are trying to craft", "minimum": 1}}, "required": ["targetItem"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchWiki", "description": "Search the Minecraft Wiki for the given query.", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "The query to search for."}}, "required": ["query"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "help", "description": "Lists all available commands and their descriptions.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "newAction", "description": "Perform new and unknown custom behaviors that are not available as a command.", "parameters": {"type": "object", "properties": {"prompt": {"type": "string", "description": "A natural language prompt to guide code generation. Make a detailed step-by-step plan."}}, "required": ["prompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stop", "description": "Force stop all actions and commands that are currently executing.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stfu", "description": "Stop all chatting and self prompting, but continue current action.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "restart", "description": "Restart the agent process.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearChat", "description": "Clear the chat history.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToPlayer", "description": "Go to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to go to."}, "closeness": {"type": "number", "description": "How close to get to the player.", "minimum": 0}}, "required": ["player_name", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "followPlayer", "description": "Endlessly follow the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "name of the player to follow."}, "follow_dist": {"type": "number", "description": "The distance to follow from.", "minimum": 0}}, "required": ["player_name", "follow_dist"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToCoordinates", "description": "Go to the given x, y, z location.", "parameters": {"type": "object", "properties": {"x": {"type": "number", "description": "The x coordinate."}, "y": {"type": "number", "description": "The y coordinate.", "minimum": -64, "maximum": 320}, "z": {"type": "number", "description": "The z coordinate."}, "closeness": {"type": "number", "description": "How close to get to the location.", "minimum": 0}}, "required": ["x", "y", "z", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForBlock", "description": "Find and go to the nearest block of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to go to."}, "search_range": {"type": "number", "description": "The range to search for the block. Minimum 32.", "minimum": 10, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForEntity", "description": "Find and go to the nearest entity of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to go to."}, "search_range": {"type": "number", "description": "The range to search for the entity.", "minimum": 32, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "moveAway", "description": "Move away from the current location in any direction by a given distance.", "parameters": {"type": "object", "properties": {"distance": {"type": "number", "description": "The distance to move away.", "minimum": 0}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "rememberHere", "description": "Save the current location with a given name.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name to remember the location as."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToRememberedPlace", "description": "Go to a saved location.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name of the location to go to."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "givePlayer", "description": "Give the specified item to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to give the item to."}, "item_name": {"type": "string", "description": "The name of the item to give."}, "num": {"type": "integer", "description": "The number of items to give.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["player_name", "item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "consume", "description": "Eat/drink the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to consume."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "equip", "description": "Equip the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to equip."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "putInChest", "description": "Put the given item in the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to put in the chest."}, "num": {"type": "integer", "description": "The number of items to put in the chest.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "takeFromChest", "description": "Take the given items from the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to take."}, "num": {"type": "integer", "description": "The number of items to take.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "viewChest", "description": "View the items/counts of the nearest chest.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "discard", "description": "Discard the given item from the inventory.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to discard."}, "num": {"type": "integer", "description": "The number of items to discard.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "collectBlocks", "description": "Collect the nearest blocks of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to collect."}, "num": {"type": "integer", "description": "The number of blocks to collect.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["type", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftRecipe", "description": "Craft the given recipe a given number of times.", "parameters": {"type": "object", "properties": {"recipe_name": {"type": "string", "description": "The name of the output item to craft."}, "num": {"type": "integer", "description": "The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["recipe_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "smeltItem", "description": "Smelt the given item the given number of times.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the input item to smelt."}, "num": {"type": "integer", "description": "The number of times to smelt the item.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearFurnace", "description": "Take all items out of the nearest furnace.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "placeHere", "description": "Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to place."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attack", "description": "Attack and kill the nearest entity of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to attack."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attackPlayer", "description": "Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to attack."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToBed", "description": "Go to the nearest bed and sleep.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stay", "description": "Stay in the current location no matter what. Pauses all modes.", "parameters": {"type": "object", "properties": {"type": {"type": "integer", "description": "The number of seconds to stay. -1 for forever.", "minimum": -1, "maximum": 9007199254740991}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "setMode", "description": "Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.", "parameters": {"type": "object", "properties": {"mode_name": {"type": "string", "description": "The name of the mode to enable."}, "on": {"type": "boolean", "description": "Whether to enable or disable the mode."}}, "required": ["mode_name", "on"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goal", "description": "Set a goal prompt to endlessly work towards with continuous self-prompting.", "parameters": {"type": "object", "properties": {"selfPrompt": {"type": "string", "description": "The goal prompt."}}, "required": ["selfPrompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endGoal", "description": "Call when you have accomplished your goal. It will stop self-prompting and the current action. ", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "showVillagerTrades", "description": "Show trades of a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}}, "required": ["id"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "tradeWithVillager", "description": "Trade with a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}, "index": {"type": "integer", "description": "The index of the trade you want executed (1-indexed).", "minimum": 1, "maximum": 9007199254740991}, "count": {"type": "integer", "description": "How many times that trade should be executed.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["id", "index", "count"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "startConversation", "description": "Start a conversation with a bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to send the message to."}, "message": {"type": "string", "description": "The message to send."}}, "required": ["player_name", "message"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endConversation", "description": "End the conversation with the given bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to end the conversation with."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPlayer", "description": "Look at a player or look in the same direction as the player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "Name of the target player"}, "direction": {"type": "string", "description": "How to look (\"at\": look at the player, \"with\": look in the same direction as the player)"}}, "required": ["player_name", "direction"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPosition", "description": "Look at specified coordinates.", "parameters": {"type": "object", "properties": {"x": {"type": "integer", "description": "x coordinate"}, "y": {"type": "integer", "description": "y coordinate"}, "z": {"type": "integer", "description": "z coordinate"}}, "required": ["x", "y", "z"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "digDown", "description": "Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.", "parameters": {"type": "object", "properties": {"distance": {"type": "integer", "description": "Distance to dig down", "minimum": 1, "maximum": 9007199254740991}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToSurface", "description": "Moves the bot to the highest block above it (usually the surface).", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "useOn", "description": "Use (right click) the given tool on the nearest target of the given type.", "parameters": {"type": "object", "properties": {"tool_name": {"type": "string", "description": "Name of the tool to use, or \"hand\" for no tool."}, "target": {"type": "string", "description": "The target as an entity type, block type, or \"nothing\" for no target."}}, "required": ["tool_name", "target"], "additionalProperties": false}}}], "tool_count": 51}, {"timestamp": "2026-05-02T02:48:36.324Z", "agent": "codex", "type": "llm_response", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "response": {"type": "tool_calls", "provider": "codex-chatgpt", "tool_calls": [{"id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "type": "function", "name": "inventory", "arguments": "{}"}]}}, {"timestamp": "2026-05-02T02:48:36.327Z", "agent": "codex", "type": "tool_call", "tool_call": {"id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "type": "function", "name": "inventory", "arguments": "{}"}, "content": ""}, {"timestamp": "2026-05-02T02:48:36.334Z", "agent": "codex", "type": "tool_result", "tool_call": {"id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "type": "function", "name": "inventory", "arguments": "{}"}, "result": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n"}, {"timestamp": "2026-05-02T02:48:38.171Z", "agent": "codex", "type": "llm_request", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "system_prompt": "You are an AI Minecraft bot named codex that can converse with players, see, move, mine, build, and interact with the world by using native tools/function calls.\nYOUR CURRENT ASSIGNED GOAL: \"通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。\"\n 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; when you need to act, call the appropriate native tool/function instead of writing command text. Do NOT write legacy text commands, function-looking text, or fake tool-use log text in your message; call the native tool and optionally respond briefly after the tool result. Respond only as codex, 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:'中文交流;MC bot codex。行动用native工具,勿聊天输出命令;人类!可。重启/被要求先native查状态简短确认。newAction勿带```js。速通:木石→食物床火把→铁装盾桶→岩浆门→下界棒+珍珠/交易→眼找要塞→打龙,自保优先。地标:营地床(-197,67,-19);煤洞(-101,58,15);矿区(-198~-207,50~59,-1~-19);深矿炉(-192,35,-25)。深矿已清空间,工作台可放会回收;近(-178,32,-26)有矿道迹象(栅栏/蛛网)与铁/煤,铁装进度到胸裤鞋+盾;下一步做头盔桶、熔铁、继续采铁煤。'\n\nSTATS\n- Position: x: -179.59, y: 31.00, z: -28.43\n- Gamemode: survival\n- Health: 20 / 20\n- Hunger: 20 / 20\n- Biome: plains\n- Weather: Clear\n- Time: Night- Current Action: Idle\n- Nearby Human Players: None.\n- Nearby Bot Players: None.\nAgent Modes:\n- self_preservation(ON)\n- unstuck(ON)\n- cowardice(OFF)\n- self_defense(ON)\n- hunting(OFF)\n- item_collecting(ON)\n- torch_placing(ON)\n- elbow_room(ON)\n- idle_staring(ON)\n- cheat(OFF)\n\n\n\nNEARBY_ENTITIES\n- entities: 2 bat(s)\n\n\nNEARBY_BLOCKS\n- cave_air\n- stone\n- cobblestone\n- cobweb\n- oak_fence\n- oak_planks\n- furnace\n- copper_ore\n- wall_torch\n- iron_ore\n- diorite\n- dirt\n- andesite\n- torch\n- granite\n- glow_lichen\n- rail\n- Block Below: stone\n- Block at Legs: cave_air\n- Block at Head: cave_air\n- First Solid Block Above Head: stone (1 blocks up)\n\n\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n\n\n*NATIVE TOOL DOCS\nYou can use native function/tool calls to act in the Minecraft world and query state. Human users may type !commands, but AI responses must not output !command text. Use the provided tools instead.\nstats: Get your bot's location, health, hunger, and time of day.\ninventory: Get your bot's inventory.\nnearbyBlocks: Get the blocks near the bot.\ncraftable: Get the craftable items with the bot's inventory.\nentities: Get the nearby players and entities.\nmodes: Get all available modes and their docs and see which are on/off.\nsavedPlaces: List all saved locations.\ngetCraftingPlan: Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.\nParams:\ntargetItem: (string) The item that we are trying to craft\nquantity: (integer) The quantity of the item that we are trying to craft\nsearchWiki: Search the Minecraft Wiki for the given query.\nParams:\nquery: (string) The query to search for.\nhelp: Lists all available commands and their descriptions.\nnewAction: Perform new and unknown custom behaviors that are not available as a command.\nParams:\nprompt: (string) A natural language prompt to guide code generation. Make a detailed step-by-step plan.\nstop: Force stop all actions and commands that are currently executing.\nstfu: Stop all chatting and self prompting, but continue current action.\nrestart: Restart the agent process.\nclearChat: Clear the chat history.\ngoToPlayer: Go to the given player.\nParams:\nplayer_name: (string) The name of the player to go to.\ncloseness: (number) How close to get to the player.\nfollowPlayer: Endlessly follow the given player.\nParams:\nplayer_name: (string) name of the player to follow.\nfollow_dist: (number) The distance to follow from.\ngoToCoordinates: Go to the given x, y, z location.\nParams:\nx: (number) The x coordinate.\ny: (number) The y coordinate.\nz: (number) The z coordinate.\ncloseness: (number) How close to get to the location.\nsearchForBlock: Find and go to the nearest block of a given type in a given range.\nParams:\ntype: (string) The block type to go to.\nsearch_range: (number) The range to search for the block. Minimum 32.\nsearchForEntity: Find and go to the nearest entity of a given type in a given range.\nParams:\ntype: (string) The type of entity to go to.\nsearch_range: (number) The range to search for the entity.\nmoveAway: Move away from the current location in any direction by a given distance.\nParams:\ndistance: (number) The distance to move away.\nrememberHere: Save the current location with a given name.\nParams:\nname: (string) The name to remember the location as.\ngoToRememberedPlace: Go to a saved location.\nParams:\nname: (string) The name of the location to go to.\ngivePlayer: Give the specified item to the given player.\nParams:\nplayer_name: (string) The name of the player to give the item to.\nitem_name: (string) The name of the item to give.\nnum: (integer) The number of items to give.\nconsume: Eat/drink the given item.\nParams:\nitem_name: (string) The name of the item to consume.\nequip: Equip the given item.\nParams:\nitem_name: (string) The name of the item to equip.\nputInChest: Put the given item in the nearest chest.\nParams:\nitem_name: (string) The name of the item to put in the chest.\nnum: (integer) The number of items to put in the chest.\ntakeFromChest: Take the given items from the nearest chest.\nParams:\nitem_name: (string) The name of the item to take.\nnum: (integer) The number of items to take.\nviewChest: View the items/counts of the nearest chest.\nParams:\ndiscard: Discard the given item from the inventory.\nParams:\nitem_name: (string) The name of the item to discard.\nnum: (integer) The number of items to discard.\ncollectBlocks: Collect the nearest blocks of a given type.\nParams:\ntype: (string) The block type to collect.\nnum: (integer) The number of blocks to collect.\ncraftRecipe: Craft the given recipe a given number of times.\nParams:\nrecipe_name: (string) The name of the output item to craft.\nnum: (integer) The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.\nsmeltItem: Smelt the given item the given number of times.\nParams:\nitem_name: (string) The name of the input item to smelt.\nnum: (integer) The number of times to smelt the item.\nclearFurnace: Take all items out of the nearest furnace.\nParams:\nplaceHere: Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.\nParams:\ntype: (string) The block type to place.\nattack: Attack and kill the nearest entity of a given type.\nParams:\ntype: (string) The type of entity to attack.\nattackPlayer: Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.\nParams:\nplayer_name: (string) The name of the player to attack.\ngoToBed: Go to the nearest bed and sleep.\nstay: Stay in the current location no matter what. Pauses all modes.\nParams:\ntype: (integer) The number of seconds to stay. -1 for forever.\nsetMode: Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.\nParams:\nmode_name: (string) The name of the mode to enable.\non: (boolean) Whether to enable or disable the mode.\ngoal: Set a goal prompt to endlessly work towards with continuous self-prompting.\nParams:\nselfPrompt: (string) The goal prompt.\nendGoal: Call when you have accomplished your goal. It will stop self-prompting and the current action. \nshowVillagerTrades: Show trades of a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\ntradeWithVillager: Trade with a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\nindex: (integer) The index of the trade you want executed (1-indexed).\ncount: (integer) How many times that trade should be executed.\nstartConversation: Start a conversation with a bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to send the message to.\nmessage: (string) The message to send.\nendConversation: End the conversation with the given bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to end the conversation with.\nlookAtPlayer: Look at a player or look in the same direction as the player.\nParams:\nplayer_name: (string) Name of the target player\ndirection: (string) How to look (\"at\": look at the player, \"with\": look in the same direction as the player)\nlookAtPosition: Look at specified coordinates.\nParams:\nx: (integer) x coordinate\ny: (integer) y coordinate\nz: (integer) z coordinate\ndigDown: Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.\nParams:\ndistance: (integer) Distance to dig down\ngoToSurface: Moves the bot to the highest block above it (usually the surface).\nParams:\nuseOn: Use (right click) the given tool on the nearest target of the given type.\nParams:\ntool_name: (string) Name of the tool to use, or \"hand\" for no tool.\ntarget: (string) The target as an entity type, block type, or \"nothing\" for no target.\n*\n\nExamples of how to respond:\nExample 1:\nUser input: miner_32: Hey! What are you up to?\nYour output:\nNothing much miner_32, what do you need?\n\nExample 2:\nUser input: rupert: Let us work together on a small house.\nYour output:\nSure, I can coordinate with you and handle my part.\nUser input: rupert: I will build the walls if you prepare materials.\nYour output:\nSounds good. I will prepare materials first.\n\n\nConversation Begin:\n", "messages": [{"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}, {"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "type": "function", "name": "inventory", "arguments": "{}"}]}, {"role": "tool", "content": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n", "tool_call_id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "name": "inventory"}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "type": "function", "name": "inventory", "arguments": "{}"}]}, {"role": "tool", "content": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n", "tool_call_id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "name": "inventory"}], "tools": [{"type": "function", "function": {"name": "stats", "description": "Get your bot's location, health, hunger, and time of day.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "inventory", "description": "Get your bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "nearbyBlocks", "description": "Get the blocks near the bot.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftable", "description": "Get the craftable items with the bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "entities", "description": "Get the nearby players and entities.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "modes", "description": "Get all available modes and their docs and see which are on/off.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "savedPlaces", "description": "List all saved locations.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "getCraftingPlan", "description": "Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.", "parameters": {"type": "object", "properties": {"targetItem": {"type": "string", "description": "The item that we are trying to craft"}, "quantity": {"type": "integer", "description": "The quantity of the item that we are trying to craft", "minimum": 1}}, "required": ["targetItem"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchWiki", "description": "Search the Minecraft Wiki for the given query.", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "The query to search for."}}, "required": ["query"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "help", "description": "Lists all available commands and their descriptions.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "newAction", "description": "Perform new and unknown custom behaviors that are not available as a command.", "parameters": {"type": "object", "properties": {"prompt": {"type": "string", "description": "A natural language prompt to guide code generation. Make a detailed step-by-step plan."}}, "required": ["prompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stop", "description": "Force stop all actions and commands that are currently executing.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stfu", "description": "Stop all chatting and self prompting, but continue current action.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "restart", "description": "Restart the agent process.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearChat", "description": "Clear the chat history.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToPlayer", "description": "Go to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to go to."}, "closeness": {"type": "number", "description": "How close to get to the player.", "minimum": 0}}, "required": ["player_name", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "followPlayer", "description": "Endlessly follow the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "name of the player to follow."}, "follow_dist": {"type": "number", "description": "The distance to follow from.", "minimum": 0}}, "required": ["player_name", "follow_dist"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToCoordinates", "description": "Go to the given x, y, z location.", "parameters": {"type": "object", "properties": {"x": {"type": "number", "description": "The x coordinate."}, "y": {"type": "number", "description": "The y coordinate.", "minimum": -64, "maximum": 320}, "z": {"type": "number", "description": "The z coordinate."}, "closeness": {"type": "number", "description": "How close to get to the location.", "minimum": 0}}, "required": ["x", "y", "z", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForBlock", "description": "Find and go to the nearest block of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to go to."}, "search_range": {"type": "number", "description": "The range to search for the block. Minimum 32.", "minimum": 10, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForEntity", "description": "Find and go to the nearest entity of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to go to."}, "search_range": {"type": "number", "description": "The range to search for the entity.", "minimum": 32, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "moveAway", "description": "Move away from the current location in any direction by a given distance.", "parameters": {"type": "object", "properties": {"distance": {"type": "number", "description": "The distance to move away.", "minimum": 0}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "rememberHere", "description": "Save the current location with a given name.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name to remember the location as."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToRememberedPlace", "description": "Go to a saved location.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name of the location to go to."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "givePlayer", "description": "Give the specified item to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to give the item to."}, "item_name": {"type": "string", "description": "The name of the item to give."}, "num": {"type": "integer", "description": "The number of items to give.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["player_name", "item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "consume", "description": "Eat/drink the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to consume."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "equip", "description": "Equip the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to equip."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "putInChest", "description": "Put the given item in the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to put in the chest."}, "num": {"type": "integer", "description": "The number of items to put in the chest.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "takeFromChest", "description": "Take the given items from the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to take."}, "num": {"type": "integer", "description": "The number of items to take.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "viewChest", "description": "View the items/counts of the nearest chest.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "discard", "description": "Discard the given item from the inventory.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to discard."}, "num": {"type": "integer", "description": "The number of items to discard.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "collectBlocks", "description": "Collect the nearest blocks of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to collect."}, "num": {"type": "integer", "description": "The number of blocks to collect.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["type", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftRecipe", "description": "Craft the given recipe a given number of times.", "parameters": {"type": "object", "properties": {"recipe_name": {"type": "string", "description": "The name of the output item to craft."}, "num": {"type": "integer", "description": "The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["recipe_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "smeltItem", "description": "Smelt the given item the given number of times.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the input item to smelt."}, "num": {"type": "integer", "description": "The number of times to smelt the item.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearFurnace", "description": "Take all items out of the nearest furnace.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "placeHere", "description": "Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to place."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attack", "description": "Attack and kill the nearest entity of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to attack."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attackPlayer", "description": "Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to attack."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToBed", "description": "Go to the nearest bed and sleep.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stay", "description": "Stay in the current location no matter what. Pauses all modes.", "parameters": {"type": "object", "properties": {"type": {"type": "integer", "description": "The number of seconds to stay. -1 for forever.", "minimum": -1, "maximum": 9007199254740991}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "setMode", "description": "Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.", "parameters": {"type": "object", "properties": {"mode_name": {"type": "string", "description": "The name of the mode to enable."}, "on": {"type": "boolean", "description": "Whether to enable or disable the mode."}}, "required": ["mode_name", "on"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goal", "description": "Set a goal prompt to endlessly work towards with continuous self-prompting.", "parameters": {"type": "object", "properties": {"selfPrompt": {"type": "string", "description": "The goal prompt."}}, "required": ["selfPrompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endGoal", "description": "Call when you have accomplished your goal. It will stop self-prompting and the current action. ", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "showVillagerTrades", "description": "Show trades of a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}}, "required": ["id"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "tradeWithVillager", "description": "Trade with a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}, "index": {"type": "integer", "description": "The index of the trade you want executed (1-indexed).", "minimum": 1, "maximum": 9007199254740991}, "count": {"type": "integer", "description": "How many times that trade should be executed.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["id", "index", "count"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "startConversation", "description": "Start a conversation with a bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to send the message to."}, "message": {"type": "string", "description": "The message to send."}}, "required": ["player_name", "message"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endConversation", "description": "End the conversation with the given bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to end the conversation with."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPlayer", "description": "Look at a player or look in the same direction as the player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "Name of the target player"}, "direction": {"type": "string", "description": "How to look (\"at\": look at the player, \"with\": look in the same direction as the player)"}}, "required": ["player_name", "direction"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPosition", "description": "Look at specified coordinates.", "parameters": {"type": "object", "properties": {"x": {"type": "integer", "description": "x coordinate"}, "y": {"type": "integer", "description": "y coordinate"}, "z": {"type": "integer", "description": "z coordinate"}}, "required": ["x", "y", "z"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "digDown", "description": "Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.", "parameters": {"type": "object", "properties": {"distance": {"type": "integer", "description": "Distance to dig down", "minimum": 1, "maximum": 9007199254740991}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToSurface", "description": "Moves the bot to the highest block above it (usually the surface).", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "useOn", "description": "Use (right click) the given tool on the nearest target of the given type.", "parameters": {"type": "object", "properties": {"tool_name": {"type": "string", "description": "Name of the tool to use, or \"hand\" for no tool."}, "target": {"type": "string", "description": "The target as an entity type, block type, or \"nothing\" for no target."}}, "required": ["tool_name", "target"], "additionalProperties": false}}}], "tool_count": 51}, {"timestamp": "2026-05-02T02:48:38.376Z", "agent": "codex", "type": "llm_response", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "response": "native tool check complete"}, {"timestamp": "2026-05-02T02:48:40.958Z", "agent": "codex", "type": "llm_response", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "response": "native tool check complete"}, {"timestamp": "2026-05-02T02:48:41.181Z", "agent": "codex", "type": "llm_request", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "system_prompt": "You are an AI Minecraft bot named codex that can converse with players, see, move, mine, build, and interact with the world by using native tools/function calls.\nYOUR CURRENT ASSIGNED GOAL: \"通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。\"\n 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; when you need to act, call the appropriate native tool/function instead of writing command text. Do NOT write legacy text commands, function-looking text, or fake tool-use log text in your message; call the native tool and optionally respond briefly after the tool result. Respond only as codex, 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:'中文交流;MC bot codex。行动用native工具,勿聊天输出命令;人类!可。重启/被要求先native查状态简短确认。newAction勿带```js。速通:木石→食物床火把→铁装盾桶→岩浆门→下界棒+珍珠/交易→眼找要塞→打龙,自保优先。地标:营地床(-197,67,-19);煤洞(-101,58,15);矿区(-198~-207,50~59,-1~-19);深矿炉(-192,35,-25)。深矿已清空间,工作台可放会回收;近(-178,32,-26)有矿道迹象(栅栏/蛛网)与铁/煤,铁装进度到胸裤鞋+盾;下一步做头盔桶、熔铁、继续采铁煤。'\n\nSTATS\n- Position: x: -179.59, y: 31.00, z: -28.43\n- Gamemode: survival\n- Health: 20 / 20\n- Hunger: 20 / 20\n- Biome: plains\n- Weather: Clear\n- Time: Night- Current Action: Idle\n- Nearby Human Players: None.\n- Nearby Bot Players: None.\nAgent Modes:\n- self_preservation(ON)\n- unstuck(ON)\n- cowardice(OFF)\n- self_defense(ON)\n- hunting(OFF)\n- item_collecting(ON)\n- torch_placing(ON)\n- elbow_room(ON)\n- idle_staring(ON)\n- cheat(OFF)\n\n\n\nNEARBY_ENTITIES\n- entities: 2 bat(s)\n\n\nNEARBY_BLOCKS\n- cave_air\n- stone\n- cobblestone\n- cobweb\n- oak_fence\n- oak_planks\n- furnace\n- copper_ore\n- wall_torch\n- iron_ore\n- diorite\n- dirt\n- andesite\n- torch\n- granite\n- glow_lichen\n- rail\n- Block Below: stone\n- Block at Legs: cave_air\n- Block at Head: cave_air\n- First Solid Block Above Head: stone (1 blocks up)\n\n\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n\n\n*NATIVE TOOL DOCS\nYou can use native function/tool calls to act in the Minecraft world and query state. Human users may type !commands, but AI responses must not output !command text. Use the provided tools instead.\nstats: Get your bot's location, health, hunger, and time of day.\ninventory: Get your bot's inventory.\nnearbyBlocks: Get the blocks near the bot.\ncraftable: Get the craftable items with the bot's inventory.\nentities: Get the nearby players and entities.\nmodes: Get all available modes and their docs and see which are on/off.\nsavedPlaces: List all saved locations.\ngetCraftingPlan: Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.\nParams:\ntargetItem: (string) The item that we are trying to craft\nquantity: (integer) The quantity of the item that we are trying to craft\nsearchWiki: Search the Minecraft Wiki for the given query.\nParams:\nquery: (string) The query to search for.\nhelp: Lists all available commands and their descriptions.\nnewAction: Perform new and unknown custom behaviors that are not available as a command.\nParams:\nprompt: (string) A natural language prompt to guide code generation. Make a detailed step-by-step plan.\nstop: Force stop all actions and commands that are currently executing.\nstfu: Stop all chatting and self prompting, but continue current action.\nrestart: Restart the agent process.\nclearChat: Clear the chat history.\ngoToPlayer: Go to the given player.\nParams:\nplayer_name: (string) The name of the player to go to.\ncloseness: (number) How close to get to the player.\nfollowPlayer: Endlessly follow the given player.\nParams:\nplayer_name: (string) name of the player to follow.\nfollow_dist: (number) The distance to follow from.\ngoToCoordinates: Go to the given x, y, z location.\nParams:\nx: (number) The x coordinate.\ny: (number) The y coordinate.\nz: (number) The z coordinate.\ncloseness: (number) How close to get to the location.\nsearchForBlock: Find and go to the nearest block of a given type in a given range.\nParams:\ntype: (string) The block type to go to.\nsearch_range: (number) The range to search for the block. Minimum 32.\nsearchForEntity: Find and go to the nearest entity of a given type in a given range.\nParams:\ntype: (string) The type of entity to go to.\nsearch_range: (number) The range to search for the entity.\nmoveAway: Move away from the current location in any direction by a given distance.\nParams:\ndistance: (number) The distance to move away.\nrememberHere: Save the current location with a given name.\nParams:\nname: (string) The name to remember the location as.\ngoToRememberedPlace: Go to a saved location.\nParams:\nname: (string) The name of the location to go to.\ngivePlayer: Give the specified item to the given player.\nParams:\nplayer_name: (string) The name of the player to give the item to.\nitem_name: (string) The name of the item to give.\nnum: (integer) The number of items to give.\nconsume: Eat/drink the given item.\nParams:\nitem_name: (string) The name of the item to consume.\nequip: Equip the given item.\nParams:\nitem_name: (string) The name of the item to equip.\nputInChest: Put the given item in the nearest chest.\nParams:\nitem_name: (string) The name of the item to put in the chest.\nnum: (integer) The number of items to put in the chest.\ntakeFromChest: Take the given items from the nearest chest.\nParams:\nitem_name: (string) The name of the item to take.\nnum: (integer) The number of items to take.\nviewChest: View the items/counts of the nearest chest.\nParams:\ndiscard: Discard the given item from the inventory.\nParams:\nitem_name: (string) The name of the item to discard.\nnum: (integer) The number of items to discard.\ncollectBlocks: Collect the nearest blocks of a given type.\nParams:\ntype: (string) The block type to collect.\nnum: (integer) The number of blocks to collect.\ncraftRecipe: Craft the given recipe a given number of times.\nParams:\nrecipe_name: (string) The name of the output item to craft.\nnum: (integer) The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.\nsmeltItem: Smelt the given item the given number of times.\nParams:\nitem_name: (string) The name of the input item to smelt.\nnum: (integer) The number of times to smelt the item.\nclearFurnace: Take all items out of the nearest furnace.\nParams:\nplaceHere: Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.\nParams:\ntype: (string) The block type to place.\nattack: Attack and kill the nearest entity of a given type.\nParams:\ntype: (string) The type of entity to attack.\nattackPlayer: Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.\nParams:\nplayer_name: (string) The name of the player to attack.\ngoToBed: Go to the nearest bed and sleep.\nstay: Stay in the current location no matter what. Pauses all modes.\nParams:\ntype: (integer) The number of seconds to stay. -1 for forever.\nsetMode: Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.\nParams:\nmode_name: (string) The name of the mode to enable.\non: (boolean) Whether to enable or disable the mode.\ngoal: Set a goal prompt to endlessly work towards with continuous self-prompting.\nParams:\nselfPrompt: (string) The goal prompt.\nendGoal: Call when you have accomplished your goal. It will stop self-prompting and the current action. \nshowVillagerTrades: Show trades of a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\ntradeWithVillager: Trade with a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\nindex: (integer) The index of the trade you want executed (1-indexed).\ncount: (integer) How many times that trade should be executed.\nstartConversation: Start a conversation with a bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to send the message to.\nmessage: (string) The message to send.\nendConversation: End the conversation with the given bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to end the conversation with.\nlookAtPlayer: Look at a player or look in the same direction as the player.\nParams:\nplayer_name: (string) Name of the target player\ndirection: (string) How to look (\"at\": look at the player, \"with\": look in the same direction as the player)\nlookAtPosition: Look at specified coordinates.\nParams:\nx: (integer) x coordinate\ny: (integer) y coordinate\nz: (integer) z coordinate\ndigDown: Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.\nParams:\ndistance: (integer) Distance to dig down\ngoToSurface: Moves the bot to the highest block above it (usually the surface).\nParams:\nuseOn: Use (right click) the given tool on the nearest target of the given type.\nParams:\ntool_name: (string) Name of the tool to use, or \"hand\" for no tool.\ntarget: (string) The target as an entity type, block type, or \"nothing\" for no target.\n*\n\nExamples of how to respond:\nExample 1:\nUser input: miner_32: Hey! What are you up to?\nYour output:\nNothing much miner_32, what do you need?\n\nExample 2:\nUser input: rupert: Let us work together on a small house.\nYour output:\nSure, I can coordinate with you and handle my part.\nUser input: rupert: I will build the walls if you prepare materials.\nYour output:\nSounds good. I will prepare materials first.\n\n\nConversation Begin:\n", "messages": [{"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}, {"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "type": "function", "name": "inventory", "arguments": "{}"}]}, {"role": "tool", "content": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n", "tool_call_id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "name": "inventory"}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "type": "function", "name": "inventory", "arguments": "{}"}]}, {"role": "tool", "content": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n", "tool_call_id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "name": "inventory"}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}], "tools": [{"type": "function", "function": {"name": "stats", "description": "Get your bot's location, health, hunger, and time of day.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "inventory", "description": "Get your bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "nearbyBlocks", "description": "Get the blocks near the bot.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftable", "description": "Get the craftable items with the bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "entities", "description": "Get the nearby players and entities.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "modes", "description": "Get all available modes and their docs and see which are on/off.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "savedPlaces", "description": "List all saved locations.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "getCraftingPlan", "description": "Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.", "parameters": {"type": "object", "properties": {"targetItem": {"type": "string", "description": "The item that we are trying to craft"}, "quantity": {"type": "integer", "description": "The quantity of the item that we are trying to craft", "minimum": 1}}, "required": ["targetItem"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchWiki", "description": "Search the Minecraft Wiki for the given query.", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "The query to search for."}}, "required": ["query"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "help", "description": "Lists all available commands and their descriptions.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "newAction", "description": "Perform new and unknown custom behaviors that are not available as a command.", "parameters": {"type": "object", "properties": {"prompt": {"type": "string", "description": "A natural language prompt to guide code generation. Make a detailed step-by-step plan."}}, "required": ["prompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stop", "description": "Force stop all actions and commands that are currently executing.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stfu", "description": "Stop all chatting and self prompting, but continue current action.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "restart", "description": "Restart the agent process.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearChat", "description": "Clear the chat history.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToPlayer", "description": "Go to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to go to."}, "closeness": {"type": "number", "description": "How close to get to the player.", "minimum": 0}}, "required": ["player_name", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "followPlayer", "description": "Endlessly follow the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "name of the player to follow."}, "follow_dist": {"type": "number", "description": "The distance to follow from.", "minimum": 0}}, "required": ["player_name", "follow_dist"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToCoordinates", "description": "Go to the given x, y, z location.", "parameters": {"type": "object", "properties": {"x": {"type": "number", "description": "The x coordinate."}, "y": {"type": "number", "description": "The y coordinate.", "minimum": -64, "maximum": 320}, "z": {"type": "number", "description": "The z coordinate."}, "closeness": {"type": "number", "description": "How close to get to the location.", "minimum": 0}}, "required": ["x", "y", "z", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForBlock", "description": "Find and go to the nearest block of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to go to."}, "search_range": {"type": "number", "description": "The range to search for the block. Minimum 32.", "minimum": 10, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForEntity", "description": "Find and go to the nearest entity of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to go to."}, "search_range": {"type": "number", "description": "The range to search for the entity.", "minimum": 32, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "moveAway", "description": "Move away from the current location in any direction by a given distance.", "parameters": {"type": "object", "properties": {"distance": {"type": "number", "description": "The distance to move away.", "minimum": 0}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "rememberHere", "description": "Save the current location with a given name.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name to remember the location as."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToRememberedPlace", "description": "Go to a saved location.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name of the location to go to."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "givePlayer", "description": "Give the specified item to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to give the item to."}, "item_name": {"type": "string", "description": "The name of the item to give."}, "num": {"type": "integer", "description": "The number of items to give.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["player_name", "item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "consume", "description": "Eat/drink the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to consume."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "equip", "description": "Equip the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to equip."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "putInChest", "description": "Put the given item in the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to put in the chest."}, "num": {"type": "integer", "description": "The number of items to put in the chest.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "takeFromChest", "description": "Take the given items from the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to take."}, "num": {"type": "integer", "description": "The number of items to take.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "viewChest", "description": "View the items/counts of the nearest chest.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "discard", "description": "Discard the given item from the inventory.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to discard."}, "num": {"type": "integer", "description": "The number of items to discard.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "collectBlocks", "description": "Collect the nearest blocks of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to collect."}, "num": {"type": "integer", "description": "The number of blocks to collect.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["type", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftRecipe", "description": "Craft the given recipe a given number of times.", "parameters": {"type": "object", "properties": {"recipe_name": {"type": "string", "description": "The name of the output item to craft."}, "num": {"type": "integer", "description": "The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["recipe_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "smeltItem", "description": "Smelt the given item the given number of times.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the input item to smelt."}, "num": {"type": "integer", "description": "The number of times to smelt the item.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearFurnace", "description": "Take all items out of the nearest furnace.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "placeHere", "description": "Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to place."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attack", "description": "Attack and kill the nearest entity of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to attack."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attackPlayer", "description": "Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to attack."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToBed", "description": "Go to the nearest bed and sleep.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stay", "description": "Stay in the current location no matter what. Pauses all modes.", "parameters": {"type": "object", "properties": {"type": {"type": "integer", "description": "The number of seconds to stay. -1 for forever.", "minimum": -1, "maximum": 9007199254740991}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "setMode", "description": "Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.", "parameters": {"type": "object", "properties": {"mode_name": {"type": "string", "description": "The name of the mode to enable."}, "on": {"type": "boolean", "description": "Whether to enable or disable the mode."}}, "required": ["mode_name", "on"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goal", "description": "Set a goal prompt to endlessly work towards with continuous self-prompting.", "parameters": {"type": "object", "properties": {"selfPrompt": {"type": "string", "description": "The goal prompt."}}, "required": ["selfPrompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endGoal", "description": "Call when you have accomplished your goal. It will stop self-prompting and the current action. ", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "showVillagerTrades", "description": "Show trades of a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}}, "required": ["id"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "tradeWithVillager", "description": "Trade with a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}, "index": {"type": "integer", "description": "The index of the trade you want executed (1-indexed).", "minimum": 1, "maximum": 9007199254740991}, "count": {"type": "integer", "description": "How many times that trade should be executed.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["id", "index", "count"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "startConversation", "description": "Start a conversation with a bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to send the message to."}, "message": {"type": "string", "description": "The message to send."}}, "required": ["player_name", "message"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endConversation", "description": "End the conversation with the given bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to end the conversation with."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPlayer", "description": "Look at a player or look in the same direction as the player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "Name of the target player"}, "direction": {"type": "string", "description": "How to look (\"at\": look at the player, \"with\": look in the same direction as the player)"}}, "required": ["player_name", "direction"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPosition", "description": "Look at specified coordinates.", "parameters": {"type": "object", "properties": {"x": {"type": "integer", "description": "x coordinate"}, "y": {"type": "integer", "description": "y coordinate"}, "z": {"type": "integer", "description": "z coordinate"}}, "required": ["x", "y", "z"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "digDown", "description": "Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.", "parameters": {"type": "object", "properties": {"distance": {"type": "integer", "description": "Distance to dig down", "minimum": 1, "maximum": 9007199254740991}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToSurface", "description": "Moves the bot to the highest block above it (usually the surface).", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "useOn", "description": "Use (right click) the given tool on the nearest target of the given type.", "parameters": {"type": "object", "properties": {"tool_name": {"type": "string", "description": "Name of the tool to use, or \"hand\" for no tool."}, "target": {"type": "string", "description": "The target as an entity type, block type, or \"nothing\" for no target."}}, "required": ["tool_name", "target"], "additionalProperties": false}}}], "tool_count": 51}, {"timestamp": "2026-05-02T02:48:44.288Z", "agent": "codex", "type": "llm_response", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "response": "native tool check complete"}, {"timestamp": "2026-05-02T02:48:44.349Z", "agent": "codex", "type": "llm_request", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "system_prompt": "You are an AI Minecraft bot named codex that can converse with players, see, move, mine, build, and interact with the world by using native tools/function calls.\nYOUR CURRENT ASSIGNED GOAL: \"通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。\"\n 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; when you need to act, call the appropriate native tool/function instead of writing command text. Do NOT write legacy text commands, function-looking text, or fake tool-use log text in your message; call the native tool and optionally respond briefly after the tool result. Respond only as codex, 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:'中文交流;MC bot codex。行动用native工具,勿聊天输出命令;人类!可。重启/被要求先native查状态简短确认。newAction勿带```js。速通:木石→食物床火把→铁装盾桶→岩浆门→下界棒+珍珠/交易→眼找要塞→打龙,自保优先。地标:营地床(-197,67,-19);煤洞(-101,58,15);矿区(-198~-207,50~59,-1~-19);深矿炉(-192,35,-25)。深矿已清空间,工作台可放会回收;近(-178,32,-26)有矿道迹象(栅栏/蛛网)与铁/煤,铁装进度到胸裤鞋+盾;下一步做头盔桶、熔铁、继续采铁煤。'\n\nSTATS\n- Position: x: -179.59, y: 31.00, z: -28.43\n- Gamemode: survival\n- Health: 20 / 20\n- Hunger: 20 / 20\n- Biome: plains\n- Weather: Clear\n- Time: Night- Current Action: Idle\n- Nearby Human Players: None.\n- Nearby Bot Players: None.\nAgent Modes:\n- self_preservation(ON)\n- unstuck(ON)\n- cowardice(OFF)\n- self_defense(ON)\n- hunting(OFF)\n- item_collecting(ON)\n- torch_placing(ON)\n- elbow_room(ON)\n- idle_staring(ON)\n- cheat(OFF)\n\n\n\nNEARBY_ENTITIES\n- entities: 2 bat(s)\n\n\nNEARBY_BLOCKS\n- cave_air\n- stone\n- cobblestone\n- cobweb\n- oak_fence\n- oak_planks\n- furnace\n- copper_ore\n- wall_torch\n- iron_ore\n- diorite\n- dirt\n- andesite\n- torch\n- granite\n- glow_lichen\n- rail\n- Block Below: stone\n- Block at Legs: cave_air\n- Block at Head: cave_air\n- First Solid Block Above Head: stone (1 blocks up)\n\n\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n\n\n*NATIVE TOOL DOCS\nYou can use native function/tool calls to act in the Minecraft world and query state. Human users may type !commands, but AI responses must not output !command text. Use the provided tools instead.\nstats: Get your bot's location, health, hunger, and time of day.\ninventory: Get your bot's inventory.\nnearbyBlocks: Get the blocks near the bot.\ncraftable: Get the craftable items with the bot's inventory.\nentities: Get the nearby players and entities.\nmodes: Get all available modes and their docs and see which are on/off.\nsavedPlaces: List all saved locations.\ngetCraftingPlan: Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.\nParams:\ntargetItem: (string) The item that we are trying to craft\nquantity: (integer) The quantity of the item that we are trying to craft\nsearchWiki: Search the Minecraft Wiki for the given query.\nParams:\nquery: (string) The query to search for.\nhelp: Lists all available commands and their descriptions.\nnewAction: Perform new and unknown custom behaviors that are not available as a command.\nParams:\nprompt: (string) A natural language prompt to guide code generation. Make a detailed step-by-step plan.\nstop: Force stop all actions and commands that are currently executing.\nstfu: Stop all chatting and self prompting, but continue current action.\nrestart: Restart the agent process.\nclearChat: Clear the chat history.\ngoToPlayer: Go to the given player.\nParams:\nplayer_name: (string) The name of the player to go to.\ncloseness: (number) How close to get to the player.\nfollowPlayer: Endlessly follow the given player.\nParams:\nplayer_name: (string) name of the player to follow.\nfollow_dist: (number) The distance to follow from.\ngoToCoordinates: Go to the given x, y, z location.\nParams:\nx: (number) The x coordinate.\ny: (number) The y coordinate.\nz: (number) The z coordinate.\ncloseness: (number) How close to get to the location.\nsearchForBlock: Find and go to the nearest block of a given type in a given range.\nParams:\ntype: (string) The block type to go to.\nsearch_range: (number) The range to search for the block. Minimum 32.\nsearchForEntity: Find and go to the nearest entity of a given type in a given range.\nParams:\ntype: (string) The type of entity to go to.\nsearch_range: (number) The range to search for the entity.\nmoveAway: Move away from the current location in any direction by a given distance.\nParams:\ndistance: (number) The distance to move away.\nrememberHere: Save the current location with a given name.\nParams:\nname: (string) The name to remember the location as.\ngoToRememberedPlace: Go to a saved location.\nParams:\nname: (string) The name of the location to go to.\ngivePlayer: Give the specified item to the given player.\nParams:\nplayer_name: (string) The name of the player to give the item to.\nitem_name: (string) The name of the item to give.\nnum: (integer) The number of items to give.\nconsume: Eat/drink the given item.\nParams:\nitem_name: (string) The name of the item to consume.\nequip: Equip the given item.\nParams:\nitem_name: (string) The name of the item to equip.\nputInChest: Put the given item in the nearest chest.\nParams:\nitem_name: (string) The name of the item to put in the chest.\nnum: (integer) The number of items to put in the chest.\ntakeFromChest: Take the given items from the nearest chest.\nParams:\nitem_name: (string) The name of the item to take.\nnum: (integer) The number of items to take.\nviewChest: View the items/counts of the nearest chest.\nParams:\ndiscard: Discard the given item from the inventory.\nParams:\nitem_name: (string) The name of the item to discard.\nnum: (integer) The number of items to discard.\ncollectBlocks: Collect the nearest blocks of a given type.\nParams:\ntype: (string) The block type to collect.\nnum: (integer) The number of blocks to collect.\ncraftRecipe: Craft the given recipe a given number of times.\nParams:\nrecipe_name: (string) The name of the output item to craft.\nnum: (integer) The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.\nsmeltItem: Smelt the given item the given number of times.\nParams:\nitem_name: (string) The name of the input item to smelt.\nnum: (integer) The number of times to smelt the item.\nclearFurnace: Take all items out of the nearest furnace.\nParams:\nplaceHere: Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.\nParams:\ntype: (string) The block type to place.\nattack: Attack and kill the nearest entity of a given type.\nParams:\ntype: (string) The type of entity to attack.\nattackPlayer: Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.\nParams:\nplayer_name: (string) The name of the player to attack.\ngoToBed: Go to the nearest bed and sleep.\nstay: Stay in the current location no matter what. Pauses all modes.\nParams:\ntype: (integer) The number of seconds to stay. -1 for forever.\nsetMode: Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.\nParams:\nmode_name: (string) The name of the mode to enable.\non: (boolean) Whether to enable or disable the mode.\ngoal: Set a goal prompt to endlessly work towards with continuous self-prompting.\nParams:\nselfPrompt: (string) The goal prompt.\nendGoal: Call when you have accomplished your goal. It will stop self-prompting and the current action. \nshowVillagerTrades: Show trades of a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\ntradeWithVillager: Trade with a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\nindex: (integer) The index of the trade you want executed (1-indexed).\ncount: (integer) How many times that trade should be executed.\nstartConversation: Start a conversation with a bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to send the message to.\nmessage: (string) The message to send.\nendConversation: End the conversation with the given bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to end the conversation with.\nlookAtPlayer: Look at a player or look in the same direction as the player.\nParams:\nplayer_name: (string) Name of the target player\ndirection: (string) How to look (\"at\": look at the player, \"with\": look in the same direction as the player)\nlookAtPosition: Look at specified coordinates.\nParams:\nx: (integer) x coordinate\ny: (integer) y coordinate\nz: (integer) z coordinate\ndigDown: Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.\nParams:\ndistance: (integer) Distance to dig down\ngoToSurface: Moves the bot to the highest block above it (usually the surface).\nParams:\nuseOn: Use (right click) the given tool on the nearest target of the given type.\nParams:\ntool_name: (string) Name of the tool to use, or \"hand\" for no tool.\ntarget: (string) The target as an entity type, block type, or \"nothing\" for no target.\n*\n\nExamples of how to respond:\nExample 1:\nUser input: rupert: Let us work together on a small house.\nYour output:\nSure, I can coordinate with you and handle my part.\nUser input: rupert: I will build the walls if you prepare materials.\nYour output:\nSounds good. I will prepare materials first.\n\nExample 2:\nUser input: miner_32: Hey! What are you up to?\nYour output:\nNothing much miner_32, what do you need?\n\n\nConversation Begin:\n", "messages": [{"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}, {"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "type": "function", "name": "inventory", "arguments": "{}"}]}, {"role": "tool", "content": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n", "tool_call_id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "name": "inventory"}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "type": "function", "name": "inventory", "arguments": "{}"}]}, {"role": "tool", "content": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n", "tool_call_id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "name": "inventory"}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}, {"role": "assistant", "content": "native tool check complete"}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}], "tools": [{"type": "function", "function": {"name": "stats", "description": "Get your bot's location, health, hunger, and time of day.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "inventory", "description": "Get your bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "nearbyBlocks", "description": "Get the blocks near the bot.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftable", "description": "Get the craftable items with the bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "entities", "description": "Get the nearby players and entities.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "modes", "description": "Get all available modes and their docs and see which are on/off.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "savedPlaces", "description": "List all saved locations.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "getCraftingPlan", "description": "Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.", "parameters": {"type": "object", "properties": {"targetItem": {"type": "string", "description": "The item that we are trying to craft"}, "quantity": {"type": "integer", "description": "The quantity of the item that we are trying to craft", "minimum": 1}}, "required": ["targetItem"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchWiki", "description": "Search the Minecraft Wiki for the given query.", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "The query to search for."}}, "required": ["query"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "help", "description": "Lists all available commands and their descriptions.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "newAction", "description": "Perform new and unknown custom behaviors that are not available as a command.", "parameters": {"type": "object", "properties": {"prompt": {"type": "string", "description": "A natural language prompt to guide code generation. Make a detailed step-by-step plan."}}, "required": ["prompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stop", "description": "Force stop all actions and commands that are currently executing.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stfu", "description": "Stop all chatting and self prompting, but continue current action.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "restart", "description": "Restart the agent process.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearChat", "description": "Clear the chat history.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToPlayer", "description": "Go to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to go to."}, "closeness": {"type": "number", "description": "How close to get to the player.", "minimum": 0}}, "required": ["player_name", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "followPlayer", "description": "Endlessly follow the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "name of the player to follow."}, "follow_dist": {"type": "number", "description": "The distance to follow from.", "minimum": 0}}, "required": ["player_name", "follow_dist"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToCoordinates", "description": "Go to the given x, y, z location.", "parameters": {"type": "object", "properties": {"x": {"type": "number", "description": "The x coordinate."}, "y": {"type": "number", "description": "The y coordinate.", "minimum": -64, "maximum": 320}, "z": {"type": "number", "description": "The z coordinate."}, "closeness": {"type": "number", "description": "How close to get to the location.", "minimum": 0}}, "required": ["x", "y", "z", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForBlock", "description": "Find and go to the nearest block of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to go to."}, "search_range": {"type": "number", "description": "The range to search for the block. Minimum 32.", "minimum": 10, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForEntity", "description": "Find and go to the nearest entity of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to go to."}, "search_range": {"type": "number", "description": "The range to search for the entity.", "minimum": 32, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "moveAway", "description": "Move away from the current location in any direction by a given distance.", "parameters": {"type": "object", "properties": {"distance": {"type": "number", "description": "The distance to move away.", "minimum": 0}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "rememberHere", "description": "Save the current location with a given name.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name to remember the location as."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToRememberedPlace", "description": "Go to a saved location.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name of the location to go to."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "givePlayer", "description": "Give the specified item to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to give the item to."}, "item_name": {"type": "string", "description": "The name of the item to give."}, "num": {"type": "integer", "description": "The number of items to give.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["player_name", "item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "consume", "description": "Eat/drink the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to consume."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "equip", "description": "Equip the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to equip."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "putInChest", "description": "Put the given item in the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to put in the chest."}, "num": {"type": "integer", "description": "The number of items to put in the chest.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "takeFromChest", "description": "Take the given items from the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to take."}, "num": {"type": "integer", "description": "The number of items to take.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "viewChest", "description": "View the items/counts of the nearest chest.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "discard", "description": "Discard the given item from the inventory.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to discard."}, "num": {"type": "integer", "description": "The number of items to discard.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "collectBlocks", "description": "Collect the nearest blocks of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to collect."}, "num": {"type": "integer", "description": "The number of blocks to collect.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["type", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftRecipe", "description": "Craft the given recipe a given number of times.", "parameters": {"type": "object", "properties": {"recipe_name": {"type": "string", "description": "The name of the output item to craft."}, "num": {"type": "integer", "description": "The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["recipe_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "smeltItem", "description": "Smelt the given item the given number of times.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the input item to smelt."}, "num": {"type": "integer", "description": "The number of times to smelt the item.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearFurnace", "description": "Take all items out of the nearest furnace.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "placeHere", "description": "Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to place."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attack", "description": "Attack and kill the nearest entity of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to attack."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attackPlayer", "description": "Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to attack."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToBed", "description": "Go to the nearest bed and sleep.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stay", "description": "Stay in the current location no matter what. Pauses all modes.", "parameters": {"type": "object", "properties": {"type": {"type": "integer", "description": "The number of seconds to stay. -1 for forever.", "minimum": -1, "maximum": 9007199254740991}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "setMode", "description": "Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.", "parameters": {"type": "object", "properties": {"mode_name": {"type": "string", "description": "The name of the mode to enable."}, "on": {"type": "boolean", "description": "Whether to enable or disable the mode."}}, "required": ["mode_name", "on"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goal", "description": "Set a goal prompt to endlessly work towards with continuous self-prompting.", "parameters": {"type": "object", "properties": {"selfPrompt": {"type": "string", "description": "The goal prompt."}}, "required": ["selfPrompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endGoal", "description": "Call when you have accomplished your goal. It will stop self-prompting and the current action. ", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "showVillagerTrades", "description": "Show trades of a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}}, "required": ["id"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "tradeWithVillager", "description": "Trade with a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}, "index": {"type": "integer", "description": "The index of the trade you want executed (1-indexed).", "minimum": 1, "maximum": 9007199254740991}, "count": {"type": "integer", "description": "How many times that trade should be executed.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["id", "index", "count"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "startConversation", "description": "Start a conversation with a bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to send the message to."}, "message": {"type": "string", "description": "The message to send."}}, "required": ["player_name", "message"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endConversation", "description": "End the conversation with the given bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to end the conversation with."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPlayer", "description": "Look at a player or look in the same direction as the player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "Name of the target player"}, "direction": {"type": "string", "description": "How to look (\"at\": look at the player, \"with\": look in the same direction as the player)"}}, "required": ["player_name", "direction"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPosition", "description": "Look at specified coordinates.", "parameters": {"type": "object", "properties": {"x": {"type": "integer", "description": "x coordinate"}, "y": {"type": "integer", "description": "y coordinate"}, "z": {"type": "integer", "description": "z coordinate"}}, "required": ["x", "y", "z"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "digDown", "description": "Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.", "parameters": {"type": "object", "properties": {"distance": {"type": "integer", "description": "Distance to dig down", "minimum": 1, "maximum": 9007199254740991}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToSurface", "description": "Moves the bot to the highest block above it (usually the surface).", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "useOn", "description": "Use (right click) the given tool on the nearest target of the given type.", "parameters": {"type": "object", "properties": {"tool_name": {"type": "string", "description": "Name of the tool to use, or \"hand\" for no tool."}, "target": {"type": "string", "description": "The target as an entity type, block type, or \"nothing\" for no target."}}, "required": ["tool_name", "target"], "additionalProperties": false}}}], "tool_count": 51}, {"timestamp": "2026-05-02T02:48:52.100Z", "agent": "codex", "type": "llm_response", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "response": {"type": "tool_calls", "provider": "codex-chatgpt", "tool_calls": [{"id": "call_yMfjPKQLlKPEWOEVsF1wCAz5", "type": "function", "name": "craftRecipe", "arguments": "{\"recipe_name\":\"iron_helmet\",\"num\":1}"}]}}, {"timestamp": "2026-05-02T02:48:52.106Z", "agent": "codex", "type": "tool_call", "tool_call": {"id": "call_yMfjPKQLlKPEWOEVsF1wCAz5", "type": "function", "name": "craftRecipe", "arguments": "{\"recipe_name\":\"iron_helmet\",\"num\":1}"}, "content": ""}, {"timestamp": "2026-05-02T02:48:57.969Z", "agent": "codex", "type": "tool_result", "tool_call": {"id": "call_yMfjPKQLlKPEWOEVsF1wCAz5", "type": "function", "name": "craftRecipe", "arguments": "{\"recipe_name\":\"iron_helmet\",\"num\":1}"}, "result": "Action output:\nPlaced crafting_table at (-179, 34, -27).\nSuccessfully crafted iron_helmet, you now have 1 iron_helmet.\nFailed to place torch at (-179, 34, -27): Error: Event blockUpdate:(-179, 34, -27) did not fire within timeout of 500ms.\nCollected 1 crafting_table.\n"}, {"timestamp": "2026-05-02T02:48:57.997Z", "agent": "codex", "type": "llm_request", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "system_prompt": "You are an AI Minecraft bot named codex that can converse with players, see, move, mine, build, and interact with the world by using native tools/function calls.\nYOUR CURRENT ASSIGNED GOAL: \"通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。\"\n 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; when you need to act, call the appropriate native tool/function instead of writing command text. Do NOT write legacy text commands, function-looking text, or fake tool-use log text in your message; call the native tool and optionally respond briefly after the tool result. Respond only as codex, 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:'中文交流;MC bot codex。行动用native工具,勿聊天输出命令;人类!可。重启/被要求先native查状态简短确认。newAction勿带```js。速通:木石→食物床火把→铁装盾桶→岩浆门→下界棒+珍珠/交易→眼找要塞→打龙,自保优先。地标:营地床(-197,67,-19);煤洞(-101,58,15);矿区(-198~-207,50~59,-1~-19);深矿炉(-192,35,-25)。深矿已清空间,工作台可放会回收;近(-178,32,-26)有矿道迹象(栅栏/蛛网)与铁/煤,铁装进度到胸裤鞋+盾;下一步做头盔桶、熔铁、继续采铁煤。'\n\nSTATS\n- Position: x: -178.48, y: 33.00, z: -26.49\n- Gamemode: survival\n- Health: 20 / 20\n- Hunger: 20 / 20\n- Biome: plains\n- Weather: Clear\n- Time: Night- Current Action: Idle\n- Nearby Human Players: None.\n- Nearby Bot Players: None.\nAgent Modes:\n- self_preservation(ON)\n- unstuck(ON)\n- cowardice(OFF)\n- self_defense(ON)\n- hunting(OFF)\n- item_collecting(ON)\n- torch_placing(ON)\n- elbow_room(ON)\n- idle_staring(ON)\n- cheat(OFF)\n\n\n\nNEARBY_ENTITIES\n- entities: 2 bat(s)\n- entities: 1 chest_minecart(s)\n\n\nNEARBY_BLOCKS\n- stone\n- furnace\n- cave_air\n- iron_ore\n- oak_planks\n- oak_fence\n- cobblestone\n- cobweb\n- copper_ore\n- gravel\n- diorite\n- granite\n- dirt\n- torch\n- glow_lichen\n- andesite\n- Block Below: furnace\n- Block at Legs: air\n- Block at Head: air\n- First Solid Block Above Head: stone (1 blocks up)\n\n\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 3\n- torch: 4\n- iron_helmet: 1\n- crafting_table: 1\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- wooden_pickaxe: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n\n\n*NATIVE TOOL DOCS\nYou can use native function/tool calls to act in the Minecraft world and query state. Human users may type !commands, but AI responses must not output !command text. Use the provided tools instead.\nstats: Get your bot's location, health, hunger, and time of day.\ninventory: Get your bot's inventory.\nnearbyBlocks: Get the blocks near the bot.\ncraftable: Get the craftable items with the bot's inventory.\nentities: Get the nearby players and entities.\nmodes: Get all available modes and their docs and see which are on/off.\nsavedPlaces: List all saved locations.\ngetCraftingPlan: Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.\nParams:\ntargetItem: (string) The item that we are trying to craft\nquantity: (integer) The quantity of the item that we are trying to craft\nsearchWiki: Search the Minecraft Wiki for the given query.\nParams:\nquery: (string) The query to search for.\nhelp: Lists all available commands and their descriptions.\nnewAction: Perform new and unknown custom behaviors that are not available as a command.\nParams:\nprompt: (string) A natural language prompt to guide code generation. Make a detailed step-by-step plan.\nstop: Force stop all actions and commands that are currently executing.\nstfu: Stop all chatting and self prompting, but continue current action.\nrestart: Restart the agent process.\nclearChat: Clear the chat history.\ngoToPlayer: Go to the given player.\nParams:\nplayer_name: (string) The name of the player to go to.\ncloseness: (number) How close to get to the player.\nfollowPlayer: Endlessly follow the given player.\nParams:\nplayer_name: (string) name of the player to follow.\nfollow_dist: (number) The distance to follow from.\ngoToCoordinates: Go to the given x, y, z location.\nParams:\nx: (number) The x coordinate.\ny: (number) The y coordinate.\nz: (number) The z coordinate.\ncloseness: (number) How close to get to the location.\nsearchForBlock: Find and go to the nearest block of a given type in a given range.\nParams:\ntype: (string) The block type to go to.\nsearch_range: (number) The range to search for the block. Minimum 32.\nsearchForEntity: Find and go to the nearest entity of a given type in a given range.\nParams:\ntype: (string) The type of entity to go to.\nsearch_range: (number) The range to search for the entity.\nmoveAway: Move away from the current location in any direction by a given distance.\nParams:\ndistance: (number) The distance to move away.\nrememberHere: Save the current location with a given name.\nParams:\nname: (string) The name to remember the location as.\ngoToRememberedPlace: Go to a saved location.\nParams:\nname: (string) The name of the location to go to.\ngivePlayer: Give the specified item to the given player.\nParams:\nplayer_name: (string) The name of the player to give the item to.\nitem_name: (string) The name of the item to give.\nnum: (integer) The number of items to give.\nconsume: Eat/drink the given item.\nParams:\nitem_name: (string) The name of the item to consume.\nequip: Equip the given item.\nParams:\nitem_name: (string) The name of the item to equip.\nputInChest: Put the given item in the nearest chest.\nParams:\nitem_name: (string) The name of the item to put in the chest.\nnum: (integer) The number of items to put in the chest.\ntakeFromChest: Take the given items from the nearest chest.\nParams:\nitem_name: (string) The name of the item to take.\nnum: (integer) The number of items to take.\nviewChest: View the items/counts of the nearest chest.\nParams:\ndiscard: Discard the given item from the inventory.\nParams:\nitem_name: (string) The name of the item to discard.\nnum: (integer) The number of items to discard.\ncollectBlocks: Collect the nearest blocks of a given type.\nParams:\ntype: (string) The block type to collect.\nnum: (integer) The number of blocks to collect.\ncraftRecipe: Craft the given recipe a given number of times.\nParams:\nrecipe_name: (string) The name of the output item to craft.\nnum: (integer) The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.\nsmeltItem: Smelt the given item the given number of times.\nParams:\nitem_name: (string) The name of the input item to smelt.\nnum: (integer) The number of times to smelt the item.\nclearFurnace: Take all items out of the nearest furnace.\nParams:\nplaceHere: Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.\nParams:\ntype: (string) The block type to place.\nattack: Attack and kill the nearest entity of a given type.\nParams:\ntype: (string) The type of entity to attack.\nattackPlayer: Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.\nParams:\nplayer_name: (string) The name of the player to attack.\ngoToBed: Go to the nearest bed and sleep.\nstay: Stay in the current location no matter what. Pauses all modes.\nParams:\ntype: (integer) The number of seconds to stay. -1 for forever.\nsetMode: Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.\nParams:\nmode_name: (string) The name of the mode to enable.\non: (boolean) Whether to enable or disable the mode.\ngoal: Set a goal prompt to endlessly work towards with continuous self-prompting.\nParams:\nselfPrompt: (string) The goal prompt.\nendGoal: Call when you have accomplished your goal. It will stop self-prompting and the current action. \nshowVillagerTrades: Show trades of a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\ntradeWithVillager: Trade with a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\nindex: (integer) The index of the trade you want executed (1-indexed).\ncount: (integer) How many times that trade should be executed.\nstartConversation: Start a conversation with a bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to send the message to.\nmessage: (string) The message to send.\nendConversation: End the conversation with the given bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to end the conversation with.\nlookAtPlayer: Look at a player or look in the same direction as the player.\nParams:\nplayer_name: (string) Name of the target player\ndirection: (string) How to look (\"at\": look at the player, \"with\": look in the same direction as the player)\nlookAtPosition: Look at specified coordinates.\nParams:\nx: (integer) x coordinate\ny: (integer) y coordinate\nz: (integer) z coordinate\ndigDown: Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.\nParams:\ndistance: (integer) Distance to dig down\ngoToSurface: Moves the bot to the highest block above it (usually the surface).\nParams:\nuseOn: Use (right click) the given tool on the nearest target of the given type.\nParams:\ntool_name: (string) Name of the tool to use, or \"hand\" for no tool.\ntarget: (string) The target as an entity type, block type, or \"nothing\" for no target.\n*\n\nExamples of how to respond:\nExample 1:\nUser input: miner_32: Hey! What are you up to?\nYour output:\nNothing much miner_32, what do you need?\n\nExample 2:\nUser input: rupert: Let us work together on a small house.\nYour output:\nSure, I can coordinate with you and handle my part.\nUser input: rupert: I will build the walls if you prepare materials.\nYour output:\nSounds good. I will prepare materials first.\n\n\nConversation Begin:\n", "messages": [{"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}, {"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "type": "function", "name": "inventory", "arguments": "{}"}]}, {"role": "tool", "content": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n", "tool_call_id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "name": "inventory"}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "type": "function", "name": "inventory", "arguments": "{}"}]}, {"role": "tool", "content": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n", "tool_call_id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "name": "inventory"}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}, {"role": "assistant", "content": "native tool check complete"}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_yMfjPKQLlKPEWOEVsF1wCAz5", "type": "function", "name": "craftRecipe", "arguments": "{\"recipe_name\":\"iron_helmet\",\"num\":1}"}]}, {"role": "tool", "content": "Action output:\nPlaced crafting_table at (-179, 34, -27).\nSuccessfully crafted iron_helmet, you now have 1 iron_helmet.\nFailed to place torch at (-179, 34, -27): Error: Event blockUpdate:(-179, 34, -27) did not fire within timeout of 500ms.\nCollected 1 crafting_table.\n", "tool_call_id": "call_yMfjPKQLlKPEWOEVsF1wCAz5", "name": "craftRecipe"}], "tools": [{"type": "function", "function": {"name": "stats", "description": "Get your bot's location, health, hunger, and time of day.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "inventory", "description": "Get your bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "nearbyBlocks", "description": "Get the blocks near the bot.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftable", "description": "Get the craftable items with the bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "entities", "description": "Get the nearby players and entities.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "modes", "description": "Get all available modes and their docs and see which are on/off.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "savedPlaces", "description": "List all saved locations.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "getCraftingPlan", "description": "Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.", "parameters": {"type": "object", "properties": {"targetItem": {"type": "string", "description": "The item that we are trying to craft"}, "quantity": {"type": "integer", "description": "The quantity of the item that we are trying to craft", "minimum": 1}}, "required": ["targetItem"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchWiki", "description": "Search the Minecraft Wiki for the given query.", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "The query to search for."}}, "required": ["query"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "help", "description": "Lists all available commands and their descriptions.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "newAction", "description": "Perform new and unknown custom behaviors that are not available as a command.", "parameters": {"type": "object", "properties": {"prompt": {"type": "string", "description": "A natural language prompt to guide code generation. Make a detailed step-by-step plan."}}, "required": ["prompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stop", "description": "Force stop all actions and commands that are currently executing.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stfu", "description": "Stop all chatting and self prompting, but continue current action.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "restart", "description": "Restart the agent process.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearChat", "description": "Clear the chat history.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToPlayer", "description": "Go to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to go to."}, "closeness": {"type": "number", "description": "How close to get to the player.", "minimum": 0}}, "required": ["player_name", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "followPlayer", "description": "Endlessly follow the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "name of the player to follow."}, "follow_dist": {"type": "number", "description": "The distance to follow from.", "minimum": 0}}, "required": ["player_name", "follow_dist"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToCoordinates", "description": "Go to the given x, y, z location.", "parameters": {"type": "object", "properties": {"x": {"type": "number", "description": "The x coordinate."}, "y": {"type": "number", "description": "The y coordinate.", "minimum": -64, "maximum": 320}, "z": {"type": "number", "description": "The z coordinate."}, "closeness": {"type": "number", "description": "How close to get to the location.", "minimum": 0}}, "required": ["x", "y", "z", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForBlock", "description": "Find and go to the nearest block of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to go to."}, "search_range": {"type": "number", "description": "The range to search for the block. Minimum 32.", "minimum": 10, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForEntity", "description": "Find and go to the nearest entity of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to go to."}, "search_range": {"type": "number", "description": "The range to search for the entity.", "minimum": 32, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "moveAway", "description": "Move away from the current location in any direction by a given distance.", "parameters": {"type": "object", "properties": {"distance": {"type": "number", "description": "The distance to move away.", "minimum": 0}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "rememberHere", "description": "Save the current location with a given name.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name to remember the location as."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToRememberedPlace", "description": "Go to a saved location.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name of the location to go to."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "givePlayer", "description": "Give the specified item to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to give the item to."}, "item_name": {"type": "string", "description": "The name of the item to give."}, "num": {"type": "integer", "description": "The number of items to give.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["player_name", "item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "consume", "description": "Eat/drink the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to consume."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "equip", "description": "Equip the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to equip."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "putInChest", "description": "Put the given item in the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to put in the chest."}, "num": {"type": "integer", "description": "The number of items to put in the chest.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "takeFromChest", "description": "Take the given items from the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to take."}, "num": {"type": "integer", "description": "The number of items to take.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "viewChest", "description": "View the items/counts of the nearest chest.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "discard", "description": "Discard the given item from the inventory.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to discard."}, "num": {"type": "integer", "description": "The number of items to discard.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "collectBlocks", "description": "Collect the nearest blocks of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to collect."}, "num": {"type": "integer", "description": "The number of blocks to collect.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["type", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftRecipe", "description": "Craft the given recipe a given number of times.", "parameters": {"type": "object", "properties": {"recipe_name": {"type": "string", "description": "The name of the output item to craft."}, "num": {"type": "integer", "description": "The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["recipe_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "smeltItem", "description": "Smelt the given item the given number of times.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the input item to smelt."}, "num": {"type": "integer", "description": "The number of times to smelt the item.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearFurnace", "description": "Take all items out of the nearest furnace.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "placeHere", "description": "Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to place."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attack", "description": "Attack and kill the nearest entity of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to attack."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attackPlayer", "description": "Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to attack."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToBed", "description": "Go to the nearest bed and sleep.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stay", "description": "Stay in the current location no matter what. Pauses all modes.", "parameters": {"type": "object", "properties": {"type": {"type": "integer", "description": "The number of seconds to stay. -1 for forever.", "minimum": -1, "maximum": 9007199254740991}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "setMode", "description": "Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.", "parameters": {"type": "object", "properties": {"mode_name": {"type": "string", "description": "The name of the mode to enable."}, "on": {"type": "boolean", "description": "Whether to enable or disable the mode."}}, "required": ["mode_name", "on"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goal", "description": "Set a goal prompt to endlessly work towards with continuous self-prompting.", "parameters": {"type": "object", "properties": {"selfPrompt": {"type": "string", "description": "The goal prompt."}}, "required": ["selfPrompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endGoal", "description": "Call when you have accomplished your goal. It will stop self-prompting and the current action. ", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "showVillagerTrades", "description": "Show trades of a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}}, "required": ["id"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "tradeWithVillager", "description": "Trade with a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}, "index": {"type": "integer", "description": "The index of the trade you want executed (1-indexed).", "minimum": 1, "maximum": 9007199254740991}, "count": {"type": "integer", "description": "How many times that trade should be executed.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["id", "index", "count"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "startConversation", "description": "Start a conversation with a bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to send the message to."}, "message": {"type": "string", "description": "The message to send."}}, "required": ["player_name", "message"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endConversation", "description": "End the conversation with the given bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to end the conversation with."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPlayer", "description": "Look at a player or look in the same direction as the player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "Name of the target player"}, "direction": {"type": "string", "description": "How to look (\"at\": look at the player, \"with\": look in the same direction as the player)"}}, "required": ["player_name", "direction"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPosition", "description": "Look at specified coordinates.", "parameters": {"type": "object", "properties": {"x": {"type": "integer", "description": "x coordinate"}, "y": {"type": "integer", "description": "y coordinate"}, "z": {"type": "integer", "description": "z coordinate"}}, "required": ["x", "y", "z"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "digDown", "description": "Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.", "parameters": {"type": "object", "properties": {"distance": {"type": "integer", "description": "Distance to dig down", "minimum": 1, "maximum": 9007199254740991}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToSurface", "description": "Moves the bot to the highest block above it (usually the surface).", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "useOn", "description": "Use (right click) the given tool on the nearest target of the given type.", "parameters": {"type": "object", "properties": {"tool_name": {"type": "string", "description": "Name of the tool to use, or \"hand\" for no tool."}, "target": {"type": "string", "description": "The target as an entity type, block type, or \"nothing\" for no target."}}, "required": ["tool_name", "target"], "additionalProperties": false}}}], "tool_count": 51}, {"timestamp": "2026-05-02T02:49:02.695Z", "agent": "codex", "type": "llm_response", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "response": {"type": "tool_calls", "provider": "codex-chatgpt", "tool_calls": [{"id": "call_dMEgzMMJyhvj3Xq7tebtnKjw", "type": "function", "name": "craftRecipe", "arguments": "{\"recipe_name\":\"bucket\",\"num\":1}"}]}}, {"timestamp": "2026-05-02T02:49:02.700Z", "agent": "codex", "type": "tool_call", "tool_call": {"id": "call_dMEgzMMJyhvj3Xq7tebtnKjw", "type": "function", "name": "craftRecipe", "arguments": "{\"recipe_name\":\"bucket\",\"num\":1}"}, "content": ""}, {"timestamp": "2026-05-02T02:49:04.979Z", "agent": "codex", "type": "tool_result", "tool_call": {"id": "call_dMEgzMMJyhvj3Xq7tebtnKjw", "type": "function", "name": "craftRecipe", "arguments": "{\"recipe_name\":\"bucket\",\"num\":1}"}, "result": "Action output:\nFailed to place crafting_table at (-179, 33, -27): Error: Event blockUpdate:(-179, 33, -27) did not fire within timeout of 500ms.\n!!Code threw exception!!\nError: Error: Recipe requires craftingTable, but one was not supplied: {\"result\":{\"id\":941,\"metadata\":null,\"count\":1},\"inShape\":[[{\"id\":842,\"metadata\":null,\"count\":1},{\"id\":-1,\"metadata\":null,\"count\":1},{\"id\":842,\"metadata\":null,\"count\":1}],[{\"id\":-1,\"metadata\":null,\"count\":1},{\"id\":842,\"metadata\":null,\"count\":1},{\"id\":-1,\"metadata\":null,\"count\":1}]],\"outShape\":null,\"ingredients\":null,\"delta\":[{\"id\":842,\"metadata\":null,\"count\":-3},{\"id\":941,\"metadata\":null,\"count\":1}],\"requiresTable\":true}\nStack trace:\nError: Recipe requires craftingTable, but one was not supplied: {\"result\":{\"id\":941,\"metadata\":null,\"count\":1},\"inShape\":[[{\"id\":842,\"metadata\":null,\"count\":1},{\"id\":-1,\"metadata\":null,\"count\":1},{\"id\":842,\"metadata\":null,\"count\":1}],[{\"id\":-1,\"metadata\":null,\"count\":1},{\"id\":842,\"metadata\":null,\"count\":1},{\"id\":-1,\"metadata\":null,\"count\":1}]],\"outShape\":null,\"ingredients\":null,\"delta\":[{\"id\":842,\"metadata\":null,\"count\":-3},{\"id\":941,\"metadata\":null,\"count\":1}],\"requiresTable\":true}\n at EventEmitter.craft (/Users/Ninot/NinotQuyi/mindcraft-native-tool/node_modules/mineflayer/lib/plugins/craft.js:15:13)\n at Module.craftRecipe (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/library/skills.js:151:15)\n at async file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/commands/actions.js:274:13\n at async actionFnWithAgent (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/commands/actions.js:17:13)\n at async ActionManager._executeAction (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/action_manager.js:113:13)\n at async Object.wrappedAction [as perform] (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/commands/actions.js:19:29)\n at async executeCommandToolCall (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/commands/tool_adapter.js:103:24)\n at async Agent._executeNativeToolCalls (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/agent.js:425:33)\n at async Agent.handleMessage (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/agent.js:332:37)\n at async SelfPrompter.startLoop (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/self_prompter.js:68:32)\n"}, {"timestamp": "2026-05-02T02:49:05.026Z", "agent": "codex", "type": "llm_request", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "system_prompt": "You are an AI Minecraft bot named codex that can converse with players, see, move, mine, build, and interact with the world by using native tools/function calls.\nYOUR CURRENT ASSIGNED GOAL: \"通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。\"\n 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; when you need to act, call the appropriate native tool/function instead of writing command text. Do NOT write legacy text commands, function-looking text, or fake tool-use log text in your message; call the native tool and optionally respond briefly after the tool result. Respond only as codex, 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:'中文交流;MC bot codex。行动用native工具,勿聊天输出命令;人类!可。重启/被要求先native查状态简短确认。newAction勿带```js。速通:木石→食物床火把→铁装盾桶→岩浆门→下界棒+珍珠/交易→眼找要塞→打龙,自保优先。地标:营地床(-197,67,-19);煤洞(-101,58,15);矿区(-198~-207,50~59,-1~-19);深矿炉(-192,35,-25)。深矿已清空间,工作台可放会回收;近(-178,32,-26)有矿道迹象(栅栏/蛛网)与铁/煤,铁装进度到胸裤鞋+盾;下一步做头盔桶、熔铁、继续采铁煤。'\n\nSTATS\n- Position: x: -179.58, y: 31.00, z: -28.45\n- Gamemode: survival\n- Health: 20 / 20\n- Hunger: 20 / 20\n- Biome: plains\n- Weather: Clear\n- Time: Night- Current Action: Idle\n- Nearby Human Players: None.\n- Nearby Bot Players: None.\nAgent Modes:\n- self_preservation(ON)\n- unstuck(ON)\n- cowardice(OFF)\n- self_defense(ON)\n- hunting(OFF)\n- item_collecting(ON)\n- torch_placing(ON)\n- elbow_room(ON)\n- idle_staring(ON)\n- cheat(OFF)\n\n\n\nNEARBY_ENTITIES\n- entities: 2 bat(s)\n\n\nNEARBY_BLOCKS\n- cave_air\n- stone\n- cobblestone\n- cobweb\n- oak_fence\n- oak_planks\n- furnace\n- copper_ore\n- iron_ore\n- diorite\n- dirt\n- andesite\n- torch\n- granite\n- glow_lichen\n- rail\n- Block Below: stone\n- Block at Legs: cave_air\n- Block at Head: cave_air\n- First Solid Block Above Head: stone (1 blocks up)\n\n\nINVENTORY\n- oak_planks: 8\n- iron_leggings: 1\n- iron_boots: 1\n- oak_sapling: 1\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 3\n- torch: 4\n- iron_helmet: 1\n- crafting_table: 1\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- wooden_pickaxe: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: oak_planks\nLegs: iron_leggings\nFeet: iron_boots\n\n\n*NATIVE TOOL DOCS\nYou can use native function/tool calls to act in the Minecraft world and query state. Human users may type !commands, but AI responses must not output !command text. Use the provided tools instead.\nstats: Get your bot's location, health, hunger, and time of day.\ninventory: Get your bot's inventory.\nnearbyBlocks: Get the blocks near the bot.\ncraftable: Get the craftable items with the bot's inventory.\nentities: Get the nearby players and entities.\nmodes: Get all available modes and their docs and see which are on/off.\nsavedPlaces: List all saved locations.\ngetCraftingPlan: Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.\nParams:\ntargetItem: (string) The item that we are trying to craft\nquantity: (integer) The quantity of the item that we are trying to craft\nsearchWiki: Search the Minecraft Wiki for the given query.\nParams:\nquery: (string) The query to search for.\nhelp: Lists all available commands and their descriptions.\nnewAction: Perform new and unknown custom behaviors that are not available as a command.\nParams:\nprompt: (string) A natural language prompt to guide code generation. Make a detailed step-by-step plan.\nstop: Force stop all actions and commands that are currently executing.\nstfu: Stop all chatting and self prompting, but continue current action.\nrestart: Restart the agent process.\nclearChat: Clear the chat history.\ngoToPlayer: Go to the given player.\nParams:\nplayer_name: (string) The name of the player to go to.\ncloseness: (number) How close to get to the player.\nfollowPlayer: Endlessly follow the given player.\nParams:\nplayer_name: (string) name of the player to follow.\nfollow_dist: (number) The distance to follow from.\ngoToCoordinates: Go to the given x, y, z location.\nParams:\nx: (number) The x coordinate.\ny: (number) The y coordinate.\nz: (number) The z coordinate.\ncloseness: (number) How close to get to the location.\nsearchForBlock: Find and go to the nearest block of a given type in a given range.\nParams:\ntype: (string) The block type to go to.\nsearch_range: (number) The range to search for the block. Minimum 32.\nsearchForEntity: Find and go to the nearest entity of a given type in a given range.\nParams:\ntype: (string) The type of entity to go to.\nsearch_range: (number) The range to search for the entity.\nmoveAway: Move away from the current location in any direction by a given distance.\nParams:\ndistance: (number) The distance to move away.\nrememberHere: Save the current location with a given name.\nParams:\nname: (string) The name to remember the location as.\ngoToRememberedPlace: Go to a saved location.\nParams:\nname: (string) The name of the location to go to.\ngivePlayer: Give the specified item to the given player.\nParams:\nplayer_name: (string) The name of the player to give the item to.\nitem_name: (string) The name of the item to give.\nnum: (integer) The number of items to give.\nconsume: Eat/drink the given item.\nParams:\nitem_name: (string) The name of the item to consume.\nequip: Equip the given item.\nParams:\nitem_name: (string) The name of the item to equip.\nputInChest: Put the given item in the nearest chest.\nParams:\nitem_name: (string) The name of the item to put in the chest.\nnum: (integer) The number of items to put in the chest.\ntakeFromChest: Take the given items from the nearest chest.\nParams:\nitem_name: (string) The name of the item to take.\nnum: (integer) The number of items to take.\nviewChest: View the items/counts of the nearest chest.\nParams:\ndiscard: Discard the given item from the inventory.\nParams:\nitem_name: (string) The name of the item to discard.\nnum: (integer) The number of items to discard.\ncollectBlocks: Collect the nearest blocks of a given type.\nParams:\ntype: (string) The block type to collect.\nnum: (integer) The number of blocks to collect.\ncraftRecipe: Craft the given recipe a given number of times.\nParams:\nrecipe_name: (string) The name of the output item to craft.\nnum: (integer) The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.\nsmeltItem: Smelt the given item the given number of times.\nParams:\nitem_name: (string) The name of the input item to smelt.\nnum: (integer) The number of times to smelt the item.\nclearFurnace: Take all items out of the nearest furnace.\nParams:\nplaceHere: Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.\nParams:\ntype: (string) The block type to place.\nattack: Attack and kill the nearest entity of a given type.\nParams:\ntype: (string) The type of entity to attack.\nattackPlayer: Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.\nParams:\nplayer_name: (string) The name of the player to attack.\ngoToBed: Go to the nearest bed and sleep.\nstay: Stay in the current location no matter what. Pauses all modes.\nParams:\ntype: (integer) The number of seconds to stay. -1 for forever.\nsetMode: Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.\nParams:\nmode_name: (string) The name of the mode to enable.\non: (boolean) Whether to enable or disable the mode.\ngoal: Set a goal prompt to endlessly work towards with continuous self-prompting.\nParams:\nselfPrompt: (string) The goal prompt.\nendGoal: Call when you have accomplished your goal. It will stop self-prompting and the current action. \nshowVillagerTrades: Show trades of a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\ntradeWithVillager: Trade with a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\nindex: (integer) The index of the trade you want executed (1-indexed).\ncount: (integer) How many times that trade should be executed.\nstartConversation: Start a conversation with a bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to send the message to.\nmessage: (string) The message to send.\nendConversation: End the conversation with the given bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to end the conversation with.\nlookAtPlayer: Look at a player or look in the same direction as the player.\nParams:\nplayer_name: (string) Name of the target player\ndirection: (string) How to look (\"at\": look at the player, \"with\": look in the same direction as the player)\nlookAtPosition: Look at specified coordinates.\nParams:\nx: (integer) x coordinate\ny: (integer) y coordinate\nz: (integer) z coordinate\ndigDown: Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.\nParams:\ndistance: (integer) Distance to dig down\ngoToSurface: Moves the bot to the highest block above it (usually the surface).\nParams:\nuseOn: Use (right click) the given tool on the nearest target of the given type.\nParams:\ntool_name: (string) Name of the tool to use, or \"hand\" for no tool.\ntarget: (string) The target as an entity type, block type, or \"nothing\" for no target.\n*\n\nExamples of how to respond:\nExample 1:\nUser input: miner_32: Hey! What are you up to?\nYour output:\nNothing much miner_32, what do you need?\n\nExample 2:\nUser input: rupert: Let us work together on a small house.\nYour output:\nSure, I can coordinate with you and handle my part.\nUser input: rupert: I will build the walls if you prepare materials.\nYour output:\nSounds good. I will prepare materials first.\n\n\nConversation Begin:\n", "messages": [{"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}, {"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "type": "function", "name": "inventory", "arguments": "{}"}]}, {"role": "tool", "content": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n", "tool_call_id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "name": "inventory"}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "type": "function", "name": "inventory", "arguments": "{}"}]}, {"role": "tool", "content": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n", "tool_call_id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "name": "inventory"}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}, {"role": "assistant", "content": "native tool check complete"}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_yMfjPKQLlKPEWOEVsF1wCAz5", "type": "function", "name": "craftRecipe", "arguments": "{\"recipe_name\":\"iron_helmet\",\"num\":1}"}]}, {"role": "tool", "content": "Action output:\nPlaced crafting_table at (-179, 34, -27).\nSuccessfully crafted iron_helmet, you now have 1 iron_helmet.\nFailed to place torch at (-179, 34, -27): Error: Event blockUpdate:(-179, 34, -27) did not fire within timeout of 500ms.\nCollected 1 crafting_table.\n", "tool_call_id": "call_yMfjPKQLlKPEWOEVsF1wCAz5", "name": "craftRecipe"}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_dMEgzMMJyhvj3Xq7tebtnKjw", "type": "function", "name": "craftRecipe", "arguments": "{\"recipe_name\":\"bucket\",\"num\":1}"}]}, {"role": "tool", "content": "Action output:\nFailed to place crafting_table at (-179, 33, -27): Error: Event blockUpdate:(-179, 33, -27) did not fire within timeout of 500ms.\n!!Code threw exception!!\nError: Error: Recipe requires craftingTable, but one was not supplied: {\"result\":{\"id\":941,\"metadata\":null,\"count\":1},\"inShape\":[[{\"id\":842,\"metadata\":null,\"count\":1},{\"id\":-1,\"metadata\":null,\"count\":1},{\"id\":842,\"metadata\":null,\"count\":1}],[{\"id\":-1,\"metadata\":null,\"count\":1},{\"id\":842,\"metadata\":null,\"count\":1},{\"id\":-1,\"metadata\":null,\"count\":1}]],\"outShape\":null,\"ingredients\":null,\"delta\":[{\"id\":842,\"metadata\":null,\"count\":-3},{\"id\":941,\"metadata\":null,\"count\":1}],\"requiresTable\":true}\nStack trace:\nError: Recipe requires craftingTable, but one was not supplied: {\"result\":{\"id\":941,\"metadata\":null,\"count\":1},\"inShape\":[[{\"id\":842,\"metadata\":null,\"count\":1},{\"id\":-1,\"metadata\":null,\"count\":1},{\"id\":842,\"metadata\":null,\"count\":1}],[{\"id\":-1,\"metadata\":null,\"count\":1},{\"id\":842,\"metadata\":null,\"count\":1},{\"id\":-1,\"metadata\":null,\"count\":1}]],\"outShape\":null,\"ingredients\":null,\"delta\":[{\"id\":842,\"metadata\":null,\"count\":-3},{\"id\":941,\"metadata\":null,\"count\":1}],\"requiresTable\":true}\n at EventEmitter.craft (/Users/Ninot/NinotQuyi/mindcraft-native-tool/node_modules/mineflayer/lib/plugins/craft.js:15:13)\n at Module.craftRecipe (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/library/skills.js:151:15)\n at async file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/commands/actions.js:274:13\n at async actionFnWithAgent (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/commands/actions.js:17:13)\n at async ActionManager._executeAction (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/action_manager.js:113:13)\n at async Object.wrappedAction [as perform] (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/commands/actions.js:19:29)\n at async executeCommandToolCall (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/commands/tool_adapter.js:103:24)\n at async Agent._executeNativeToolCalls (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/agent.js:425:33)\n at async Agent.handleMessage (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/agent.js:332:37)\n at async SelfPrompter.startLoop (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/self_prompter.js:68:32)\n", "tool_call_id": "call_dMEgzMMJyhvj3Xq7tebtnKjw", "name": "craftRecipe"}], "tools": [{"type": "function", "function": {"name": "stats", "description": "Get your bot's location, health, hunger, and time of day.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "inventory", "description": "Get your bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "nearbyBlocks", "description": "Get the blocks near the bot.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftable", "description": "Get the craftable items with the bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "entities", "description": "Get the nearby players and entities.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "modes", "description": "Get all available modes and their docs and see which are on/off.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "savedPlaces", "description": "List all saved locations.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "getCraftingPlan", "description": "Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.", "parameters": {"type": "object", "properties": {"targetItem": {"type": "string", "description": "The item that we are trying to craft"}, "quantity": {"type": "integer", "description": "The quantity of the item that we are trying to craft", "minimum": 1}}, "required": ["targetItem"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchWiki", "description": "Search the Minecraft Wiki for the given query.", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "The query to search for."}}, "required": ["query"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "help", "description": "Lists all available commands and their descriptions.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "newAction", "description": "Perform new and unknown custom behaviors that are not available as a command.", "parameters": {"type": "object", "properties": {"prompt": {"type": "string", "description": "A natural language prompt to guide code generation. Make a detailed step-by-step plan."}}, "required": ["prompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stop", "description": "Force stop all actions and commands that are currently executing.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stfu", "description": "Stop all chatting and self prompting, but continue current action.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "restart", "description": "Restart the agent process.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearChat", "description": "Clear the chat history.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToPlayer", "description": "Go to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to go to."}, "closeness": {"type": "number", "description": "How close to get to the player.", "minimum": 0}}, "required": ["player_name", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "followPlayer", "description": "Endlessly follow the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "name of the player to follow."}, "follow_dist": {"type": "number", "description": "The distance to follow from.", "minimum": 0}}, "required": ["player_name", "follow_dist"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToCoordinates", "description": "Go to the given x, y, z location.", "parameters": {"type": "object", "properties": {"x": {"type": "number", "description": "The x coordinate."}, "y": {"type": "number", "description": "The y coordinate.", "minimum": -64, "maximum": 320}, "z": {"type": "number", "description": "The z coordinate."}, "closeness": {"type": "number", "description": "How close to get to the location.", "minimum": 0}}, "required": ["x", "y", "z", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForBlock", "description": "Find and go to the nearest block of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to go to."}, "search_range": {"type": "number", "description": "The range to search for the block. Minimum 32.", "minimum": 10, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForEntity", "description": "Find and go to the nearest entity of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to go to."}, "search_range": {"type": "number", "description": "The range to search for the entity.", "minimum": 32, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "moveAway", "description": "Move away from the current location in any direction by a given distance.", "parameters": {"type": "object", "properties": {"distance": {"type": "number", "description": "The distance to move away.", "minimum": 0}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "rememberHere", "description": "Save the current location with a given name.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name to remember the location as."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToRememberedPlace", "description": "Go to a saved location.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name of the location to go to."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "givePlayer", "description": "Give the specified item to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to give the item to."}, "item_name": {"type": "string", "description": "The name of the item to give."}, "num": {"type": "integer", "description": "The number of items to give.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["player_name", "item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "consume", "description": "Eat/drink the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to consume."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "equip", "description": "Equip the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to equip."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "putInChest", "description": "Put the given item in the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to put in the chest."}, "num": {"type": "integer", "description": "The number of items to put in the chest.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "takeFromChest", "description": "Take the given items from the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to take."}, "num": {"type": "integer", "description": "The number of items to take.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "viewChest", "description": "View the items/counts of the nearest chest.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "discard", "description": "Discard the given item from the inventory.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to discard."}, "num": {"type": "integer", "description": "The number of items to discard.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "collectBlocks", "description": "Collect the nearest blocks of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to collect."}, "num": {"type": "integer", "description": "The number of blocks to collect.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["type", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftRecipe", "description": "Craft the given recipe a given number of times.", "parameters": {"type": "object", "properties": {"recipe_name": {"type": "string", "description": "The name of the output item to craft."}, "num": {"type": "integer", "description": "The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["recipe_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "smeltItem", "description": "Smelt the given item the given number of times.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the input item to smelt."}, "num": {"type": "integer", "description": "The number of times to smelt the item.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearFurnace", "description": "Take all items out of the nearest furnace.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "placeHere", "description": "Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to place."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attack", "description": "Attack and kill the nearest entity of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to attack."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attackPlayer", "description": "Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to attack."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToBed", "description": "Go to the nearest bed and sleep.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stay", "description": "Stay in the current location no matter what. Pauses all modes.", "parameters": {"type": "object", "properties": {"type": {"type": "integer", "description": "The number of seconds to stay. -1 for forever.", "minimum": -1, "maximum": 9007199254740991}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "setMode", "description": "Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.", "parameters": {"type": "object", "properties": {"mode_name": {"type": "string", "description": "The name of the mode to enable."}, "on": {"type": "boolean", "description": "Whether to enable or disable the mode."}}, "required": ["mode_name", "on"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goal", "description": "Set a goal prompt to endlessly work towards with continuous self-prompting.", "parameters": {"type": "object", "properties": {"selfPrompt": {"type": "string", "description": "The goal prompt."}}, "required": ["selfPrompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endGoal", "description": "Call when you have accomplished your goal. It will stop self-prompting and the current action. ", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "showVillagerTrades", "description": "Show trades of a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}}, "required": ["id"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "tradeWithVillager", "description": "Trade with a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}, "index": {"type": "integer", "description": "The index of the trade you want executed (1-indexed).", "minimum": 1, "maximum": 9007199254740991}, "count": {"type": "integer", "description": "How many times that trade should be executed.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["id", "index", "count"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "startConversation", "description": "Start a conversation with a bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to send the message to."}, "message": {"type": "string", "description": "The message to send."}}, "required": ["player_name", "message"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endConversation", "description": "End the conversation with the given bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to end the conversation with."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPlayer", "description": "Look at a player or look in the same direction as the player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "Name of the target player"}, "direction": {"type": "string", "description": "How to look (\"at\": look at the player, \"with\": look in the same direction as the player)"}}, "required": ["player_name", "direction"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPosition", "description": "Look at specified coordinates.", "parameters": {"type": "object", "properties": {"x": {"type": "integer", "description": "x coordinate"}, "y": {"type": "integer", "description": "y coordinate"}, "z": {"type": "integer", "description": "z coordinate"}}, "required": ["x", "y", "z"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "digDown", "description": "Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.", "parameters": {"type": "object", "properties": {"distance": {"type": "integer", "description": "Distance to dig down", "minimum": 1, "maximum": 9007199254740991}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToSurface", "description": "Moves the bot to the highest block above it (usually the surface).", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "useOn", "description": "Use (right click) the given tool on the nearest target of the given type.", "parameters": {"type": "object", "properties": {"tool_name": {"type": "string", "description": "Name of the tool to use, or \"hand\" for no tool."}, "target": {"type": "string", "description": "The target as an entity type, block type, or \"nothing\" for no target."}}, "required": ["tool_name", "target"], "additionalProperties": false}}}], "tool_count": 51}, {"timestamp": "2026-05-02T02:49:11.936Z", "agent": "codex", "type": "llm_response", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "response": {"type": "tool_calls", "provider": "codex-chatgpt", "tool_calls": [{"id": "call_yfmSKsyHrOdCpn7TQyTrdJ0F", "type": "function", "name": "inventory", "arguments": "{}"}, {"id": "call_B3PqghWouGkJbuelal2hDT77", "type": "function", "name": "stats", "arguments": "{}"}, {"id": "call_sNHPGuzme6lBrVorqgV3IVbb", "type": "function", "name": "nearbyBlocks", "arguments": "{}"}]}}, {"timestamp": "2026-05-02T02:49:11.945Z", "agent": "codex", "type": "llm_request", "tag": "memSaving", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "system_prompt": "You are a minecraft bot named codex 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: '中文交流;MC bot codex。行动用native工具,勿聊天输出命令;人类!可。重启/被要求先native查状态简短确认。newAction勿带```js。速通:木石→食物床火把→铁装盾桶→岩浆门→下界棒+珍珠/交易→眼找要塞→打龙,自保优先。地标:营地床(-197,67,-19);煤洞(-101,58,15);矿区(-198~-207,50~59,-1~-19);深矿炉(-192,35,-25)。深矿已清空间,工作台可放会回收;近(-178,32,-26)有矿道迹象(栅栏/蛛网)与铁/煤,铁装进度到胸裤鞋+盾;下一步做头盔桶、熔铁、继续采铁煤。'\nRecent conversation:\nUser input: System: Use native tool calling to check your inventory, then say: native tool check complete.\nUser input: System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:\nUser input: System: Use native tool calling to check your inventory, then say: native tool check complete.\nTool call (inventory): {}\nTool result (inventory): \nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n\nTool call (inventory): {}\nTool result (inventory): \nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\nSummarize your old memory and recent conversation into a new memory, and respond only with the unwrapped memory text:\n", "messages": [{"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}, {"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "type": "function", "name": "inventory", "arguments": "{}"}]}, {"role": "tool", "content": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n", "tool_call_id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "name": "inventory"}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "type": "function", "name": "inventory", "arguments": "{}"}]}, {"role": "tool", "content": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n", "tool_call_id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "name": "inventory"}], "tools": null, "tool_count": 0}] \ No newline at end of file diff --git a/src/models/_model_map.js b/src/models/_model_map.js index 39a6ea734..e95c2ee57 100644 --- a/src/models/_model_map.js +++ b/src/models/_model_map.js @@ -47,7 +47,7 @@ const apiMap = await (async () => { function loadRegistry(unifiedPathOverride, registryPathOverride, filename, label, section) { - const unifiedPath = unifiedPathOverride || path.join(process.cwd(), 'llm_providers.json'); + const unifiedPath = unifiedPathOverride || path.join(process.cwd(), 'settings_llm_providers.json'); if (existsSync(unifiedPath)) { try { const unified = JSON.parse(readFileSync(unifiedPath, 'utf8')); @@ -125,7 +125,7 @@ function normalizeProviderConfig(providerName, provider) { function apiFromFormat(format) { const normalized = String(format || '').toLowerCase(); const aliases = { - // Canonical protocol names used by llm_providers.json. + // Canonical protocol names used by settings_llm_providers.json. 'openai-completions': 'openai-completions', 'openai-responses': 'openai-responses', 'anthropic-messages': 'anthropic-messages', diff --git a/src/models/anthropic_messages.js b/src/models/anthropic_messages.js index c7f09732b..8a1df0a95 100644 --- a/src/models/anthropic_messages.js +++ b/src/models/anthropic_messages.js @@ -1,6 +1,7 @@ import Anthropic from '@anthropic-ai/sdk'; import { getKey } from '../utils/keys.js'; import { createNativeToolResponse, normalizeAnthropicToolUse, toAnthropicMessages, toAnthropicTools } from './native_tools.js'; +import { setLastTokenUsage } from './token_usage.js'; // Anthropic Messages protocol implementation. export class AnthropicMessages { @@ -27,6 +28,7 @@ export class AnthropicMessages { } async sendRequest(turns, systemMessage, stop_seq='***', tools=null) { + this.lastTokenUsage = null; const messages = toAnthropicMessages(turns); let res = null; try { @@ -51,6 +53,7 @@ export class AnthropicMessages { const resp = await this.anthropic.messages.create(requestConfig); console.log('Received.'); + setLastTokenUsage(this, resp?.usage); const toolCalls = normalizeAnthropicToolUse(resp.content); if (toolCalls.length > 0) { return createNativeToolResponse(toolCalls, this.provider); diff --git a/src/models/codex_chatgpt.js b/src/models/codex_chatgpt.js index 3f279f4aa..b66321cb9 100644 --- a/src/models/codex_chatgpt.js +++ b/src/models/codex_chatgpt.js @@ -1,12 +1,15 @@ -import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; import path from 'path'; +import { tmpdir } from 'os'; +import { spawn } from 'child_process'; import { createServer } from 'http'; import { createHash, randomBytes, randomUUID } from 'crypto'; import open from 'open'; import { createNativeToolResponse, toResponsesInputItems } from './native_tools.js'; +import { setLastTokenUsage } from './token_usage.js'; const DEFAULT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex'; -const DEFAULT_KEYS_PATH = 'llm_providers.json'; +const DEFAULT_KEYS_PATH = 'settings_llm_providers.json'; const CODEX_REFRESH_TOKEN_URL = 'https://auth.openai.com/oauth/token'; const CODEX_ISSUER = 'https://auth.openai.com'; const CODEX_OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; @@ -14,6 +17,7 @@ const CODEX_AUTH_KEY = 'CODEX_CHATGPT_AUTH'; const DEFAULT_ORIGINATOR = 'codex_cli_rs'; const LOGIN_TIMEOUT_MS = 15 * 60 * 1000; const DEFAULT_LOGIN_PORT = 1455; +const DEFAULT_FETCH = globalThis.fetch; export class CodexChatGPT { static prefix = 'codex'; @@ -70,6 +74,7 @@ export class CodexChatGPT { } async sendRequest(turns, systemMessage, stop_seq='***', tools=null) { + this.lastTokenUsage = null; const model = this.model_name || this.default_model; const hasTools = Array.isArray(tools) && tools.length > 0; const body = this.buildRequestBody(model, turns, systemMessage, tools); @@ -102,6 +107,7 @@ export class CodexChatGPT { const parsed = await parseCodexResponsesSse(await response.text()); console.log('Received.'); + setLastTokenUsage(this, parsed.usage); if (parsed.toolCalls.length > 0) { return createNativeToolResponse(parsed.toolCalls, this.provider); } @@ -116,6 +122,19 @@ export class CodexChatGPT { } } + async sendVisionRequest(turns, systemMessage, imageBuffer) { + const imageMessages = [...(turns || [])]; + imageMessages.push({ + role: 'user', + content: [ + { type: 'input_text', text: '' }, + { type: 'input_image', image_url: `data:image/jpeg;base64,${imageBuffer.toString('base64')}` }, + { type: 'input_text', text: `\n${systemMessage || 'Describe the image.'}` } + ] + }); + return this.sendRequest(imageMessages, systemMessage); + } + buildRequestBody(model, turns, systemMessage, tools=null) { const body = { model, @@ -139,7 +158,7 @@ export class CodexChatGPT { } async fetchResponses(endpoint, body, auth) { - return await fetch(endpoint, { + return await codexFetch(endpoint, { method: 'POST', headers: this.buildHeaders(auth), body: JSON.stringify(body) @@ -212,7 +231,7 @@ export async function refreshCodexChatGPTAuth(auth, authPath = auth.authPath || if (!auth.refreshToken) { throw new Error('Codex ChatGPT auth has no refresh token. Login again from this project.'); } - const response = await fetch(CODEX_REFRESH_TOKEN_URL, { + const response = await codexFetch(CODEX_REFRESH_TOKEN_URL, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -307,9 +326,11 @@ export function writeKeysCodexAuth(authPath, authJson) { const section = getKeysSection(existing, true); section[CODEX_AUTH_KEY] = normalized; writeFileSync(resolvedAuthPath, `${JSON.stringify(existing, null, 4)}\n`, { mode: 0o600 }); + console.log(`Saved Codex ChatGPT auth to ${resolvedAuthPath} at keys.${CODEX_AUTH_KEY}`); return; } writeFileSync(resolvedAuthPath, `${JSON.stringify(normalized, null, 4)}\n`, { mode: 0o600 }); + console.log(`Saved Codex ChatGPT auth to ${resolvedAuthPath}`); } function extractCodexAuth(config) { @@ -364,6 +385,7 @@ export async function parseCodexResponsesSse(sseText) { const toolCalls = []; const textDeltas = []; const messageTexts = []; + let usage = null; const events = sseText.split(/\n\n+/); for (const eventBlock of events) { const dataLines = eventBlock @@ -396,17 +418,22 @@ export async function parseCodexResponsesSse(sseText) { if (event.type === 'response.output_item.done' && item?.type === 'message') { messageTexts.push(extractMessageText(item)); } + if (event.response?.usage) { + usage = event.response.usage; + } else if (event.usage) { + usage = event.usage; + } if (event.type === 'response.failed') { const message = event.response?.error?.message || 'Codex Responses stream failed'; throw new Error(message); } } const text = textDeltas.length > 0 ? textDeltas.join('') : messageTexts.join(''); - return { text, toolCalls }; + return { text, toolCalls, usage }; } async function requestDeviceCode(baseUrl, clientId) { - const response = await fetch(`${baseUrl}/api/accounts/deviceauth/usercode`, { + const response = await codexFetch(`${baseUrl}/api/accounts/deviceauth/usercode`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: clientId }) @@ -426,7 +453,7 @@ async function requestDeviceCode(baseUrl, clientId) { async function pollDeviceAuthorization(baseUrl, device) { const started = Date.now(); while (Date.now() - started < LOGIN_TIMEOUT_MS) { - const response = await fetch(`${baseUrl}/api/accounts/deviceauth/token`, { + const response = await codexFetch(`${baseUrl}/api/accounts/deviceauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -453,7 +480,7 @@ async function exchangeAuthorizationCodeForTokens(baseUrl, clientId, redirectUri client_id: clientId, code_verifier: pkce.code_verifier }); - const response = await fetch(`${baseUrl}/oauth/token`, { + const response = await codexFetch(`${baseUrl}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body @@ -595,7 +622,7 @@ function waitForOAuthCallback(serverInfo, expectedState) { finish(res, 400, '

Codex login failed

Missing authorization code.

', () => reject(new Error('Codex browser login callback did not include an authorization code.'))); return; } - finish(res, 200, 'Login Successful

Login Successful

If this page does not close automatically, you can close it manually.

', () => resolve(code)); + finish(res, 200, codexLoginClosePage(), () => resolve(code)); }; server.on('request', onRequest); @@ -689,6 +716,30 @@ You will be redirected back to Mindcraft after login; waiting for login to compl `); } + +function codexLoginClosePage() { + return '' + + '' + + 'Codex login complete' + + '' + + '

Codex login complete

Mindcraft is connected to ChatGPT/Codex.

You can close this page and return to the terminal.

' + + ''; +} + function escapeHtml(value) { return String(value) .replace(/&/g, '&') @@ -702,6 +753,142 @@ function printDeviceCodePrompt(verificationUrl, code) { console.log(`\nCodex ChatGPT login required for this project.\nOpen this URL and sign in:\n\n ${verificationUrl}\n\nEnter this one-time code:\n\n ${code}\n\nWaiting for login to complete...\n`); } + +async function codexFetch(url, init = {}) { + if (shouldUseFetch(url)) { + return await fetch(url, init); + } + try { + return await curlFetch(url, init); + } catch (curlError) { + console.warn(`System curl transport failed for Codex HTTP request; retrying with Node fetch: ${sanitizeCodexError(curlError)}`); + return await fetch(url, init); + } +} + +function shouldUseFetch(url) { + if (globalThis.fetch !== DEFAULT_FETCH) { + return true; + } + try { + const { hostname } = new URL(String(url)); + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'; + } catch { + return false; + } +} + +function isFetchTransportError(error) { + return error?.message === 'fetch failed' || error?.cause?.code || Array.isArray(error?.cause?.errors); +} + +async function curlFetch(url, init = {}) { + const tempDir = mkdtempSync(path.join(tmpdir(), 'mindcraft-codex-curl-')); + const headersPath = path.join(tempDir, 'headers.txt'); + const bodyPath = path.join(tempDir, 'body.bin'); + const requestBodyPath = path.join(tempDir, 'request-body.bin'); + const configPath = path.join(tempDir, 'curl.conf'); + try { + const method = init.method || (init.body ? 'POST' : 'GET'); + const config = [ + `url = ${curlQuote(String(url))}`, + `request = ${curlQuote(method)}`, + `dump-header = ${curlQuote(headersPath)}`, + `output = ${curlQuote(bodyPath)}`, + 'silent', + 'show-error', + 'location', + 'max-time = 300' + ]; + + for (const [name, value] of headerEntries(init.headers)) { + config.push(`header = ${curlQuote(`${name}: ${value}`)}`); + } + + if (init.body !== undefined && init.body !== null) { + writeFileSync(requestBodyPath, bodyToString(init.body)); + config.push(`data-binary = ${curlQuote(`@${requestBodyPath}`)}`); + } + + writeFileSync(configPath, `${config.join('\n')}\n`, { mode: 0o600 }); + await runCurl(configPath); + const headersText = readFileSync(headersPath, 'utf8'); + const body = readFileSync(bodyPath); + const { status, headers } = parseCurlHeaders(headersText); + return new Response(body, { status, headers }); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +function runCurl(configPath) { + return new Promise((resolve, reject) => { + const child = spawn('curl', ['--config', configPath], { stdio: ['ignore', 'ignore', 'pipe'] }); + let stderr = ''; + child.stderr.on('data', chunk => { + stderr += chunk.toString(); + }); + child.on('error', reject); + child.on('close', code => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`curl exited with code ${code}: ${stderr.trim()}`)); + } + }); + }); +} + +function headerEntries(headers = {}) { + if (headers instanceof Headers) { + return Array.from(headers.entries()); + } + if (Array.isArray(headers)) { + return headers; + } + return Object.entries(headers || {}); +} + +function bodyToString(body) { + if (body instanceof URLSearchParams) { + return body.toString(); + } + if (Buffer.isBuffer(body)) { + return body; + } + return String(body); +} + +function curlQuote(value) { + return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + +function parseCurlHeaders(headersText) { + const blocks = headersText.trim().split(/\r?\n\r?\n/).filter(Boolean); + const block = blocks[blocks.length - 1] || ''; + const lines = block.split(/\r?\n/); + const statusMatch = lines.shift()?.match(/^HTTP\/\S+\s+(\d+)/); + const status = statusMatch ? Number.parseInt(statusMatch[1], 10) : 0; + const headers = new Headers(); + for (const line of lines) { + const idx = line.indexOf(':'); + if (idx > 0) { + headers.append(line.slice(0, idx).trim(), line.slice(idx + 1).trim()); + } + } + return { status, headers }; +} + +function formatFetchError(error) { + const cause = error?.cause; + const nestedCodes = Array.isArray(cause?.errors) + ? cause.errors.map(item => item.code).filter(Boolean).join(',') + : ''; + return [error?.message || String(error), cause?.code, nestedCodes, cause?.message] + .filter(Boolean) + .join(' | '); +} + function isInteractiveTerminal() { return Boolean(process.stdin.isTTY && process.stdout.isTTY); } @@ -743,7 +930,7 @@ function extractErrorMessage(body) { } function sanitizeCodexError(error) { - return String(error?.message || error) + return formatFetchError(error) .replace(/Bearer\s+[A-Za-z0-9._-]+/g, 'Bearer [REDACTED_TOKEN]') .replace(/(access_token|refresh_token|id_token)":"[^"]+"/g, '$1":"[REDACTED_TOKEN]"') .slice(0, 500); diff --git a/src/models/google_generative_ai.js b/src/models/google_generative_ai.js index 18f6473c7..488e53f5d 100644 --- a/src/models/google_generative_ai.js +++ b/src/models/google_generative_ai.js @@ -2,6 +2,7 @@ import { GoogleGenAI } from '@google/genai'; import { getKey } from '../utils/keys.js'; import { createNativeToolResponse, normalizeGeminiFunctionCalls, toGeminiContents, toGeminiFunctionDeclarations } from './native_tools.js'; import { ProxyAgent, setGlobalDispatcher } from 'undici'; +import { setLastTokenUsage } from './token_usage.js'; function setupGeminiProxy() { const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy; @@ -92,6 +93,7 @@ export class GoogleGenerativeAI { } async sendRequest(turns, systemMessage, stop_seq='***', tools=null) { + this.lastTokenUsage = null; console.log(tools?.length ? `Awaiting Google API response with native tool calling (${tools.length} tools)...` : 'Awaiting Google API response...'); const contents = toGeminiContents(turns); @@ -109,6 +111,7 @@ export class GoogleGenerativeAI { requestConfig.config.tools = [{ functionDeclarations: toGeminiFunctionDeclarations(tools) }]; } const result = await this.genAI.models.generateContent(requestConfig); + setLastTokenUsage(this, result?.usageMetadata); const parts = result.candidates?.[0]?.content?.parts || []; const toolCalls = normalizeGeminiFunctionCalls(parts); if (toolCalls.length > 0) { @@ -125,6 +128,7 @@ export class GoogleGenerativeAI { } async sendVisionRequest(turns, systemMessage, imageBuffer) { + this.lastTokenUsage = null; const imagePart = { inlineData: { data: imageBuffer.toString('base64'), @@ -150,6 +154,7 @@ export class GoogleGenerativeAI { }, systemInstruction: systemMessage }); + setLastTokenUsage(this, result?.usageMetadata); res = await result.text; console.log('Received.'); } catch (err) { diff --git a/src/models/native_tools.js b/src/models/native_tools.js index ade6a5951..98c8b7ea3 100644 --- a/src/models/native_tools.js +++ b/src/models/native_tools.js @@ -266,12 +266,12 @@ export function toOpenAIChatMessages(turns = [], systemMessage = '') { content: stringifyToolResult(turn.content) }); } else if (turn?.role === 'system') { - messages.push({ role: 'system', content: stringifyToolResult(turn.content) }); + messages.push({ role: 'user', content: `SYSTEM: ${stringifyToolResult(turn.content)}` }); } else if (turn?.role === 'assistant' || turn?.role === 'user') { - messages.push({ role: turn.role, content: stringifyToolResult(turn.content) }); + messages.push({ role: turn.role, content: toOpenAIChatContent(turn.content) }); } } - if (messages.length === 0 || messages.every(message => message.role === 'system')) { + if (messages.length === 0) { messages.push({ role: 'user', content: '_' }); } return messages; @@ -299,7 +299,7 @@ export function toResponsesInputItems(turns = []) { items.push({ type: 'message', role: turn.role, - content: [{ type: turn.role === 'assistant' ? 'output_text' : 'input_text', text: stringifyToolResult(turn.content) }] + content: toResponsesMessageContent(turn.content, turn.role) }); } else if (turn?.role === 'system') { items.push({ @@ -342,9 +342,9 @@ export function toAnthropicMessages(turns = []) { }] }); } else if (turn?.role === 'assistant') { - messages.push({ role: 'assistant', content: stringifyToolResult(turn.content) }); + messages.push({ role: 'assistant', content: toAnthropicMessageContent(turn.content) }); } else if (turn?.role === 'user') { - messages.push({ role: 'user', content: stringifyToolResult(turn.content) }); + messages.push({ role: 'user', content: toAnthropicMessageContent(turn.content) }); } else if (turn?.role === 'system') { messages.push({ role: 'user', content: `SYSTEM: ${stringifyToolResult(turn.content)}` }); } @@ -404,6 +404,47 @@ function toOpenAIChatToolCall(call) { }; } +function toOpenAIChatContent(content) { + if (!Array.isArray(content)) { + return stringifyToolResult(content); + } + return content.map(part => { + if (part?.type === 'input_text') { + return { type: 'text', text: stringifyToolResult(part.text) }; + } + if (part?.type === 'input_image') { + return { type: 'image_url', image_url: { url: part.image_url || part.imageUrl || part.url } }; + } + if (part?.type === 'image_url' && typeof part.image_url === 'string') { + return { ...part, image_url: { url: part.image_url } }; + } + return part; + }); +} + +function toResponsesMessageContent(content, role) { + if (!Array.isArray(content)) { + return [{ type: role === 'assistant' ? 'output_text' : 'input_text', text: stringifyToolResult(content) }]; + } + return content.map(part => { + if (part?.type === 'text') { + return { type: role === 'assistant' ? 'output_text' : 'input_text', text: stringifyToolResult(part.text) }; + } + if (part?.type === 'image_url') { + const imageUrl = typeof part.image_url === 'string' ? part.image_url : part.image_url?.url; + return { type: 'input_image', image_url: imageUrl }; + } + return part; + }); +} + +function toAnthropicMessageContent(content) { + if (!Array.isArray(content)) { + return stringifyToolResult(content); + } + return content; +} + function normalizeAlternatingMessages(messages) { const normalized = []; for (const message of messages) { diff --git a/src/models/openai_compatible.js b/src/models/openai_compatible.js index 2a52b50cc..ed7ac8752 100644 --- a/src/models/openai_compatible.js +++ b/src/models/openai_compatible.js @@ -2,6 +2,7 @@ import OpenAIApi from 'openai'; import { getKey, hasKey } from '../utils/keys.js'; import { createNativeToolResponse, toOpenAIChatMessages } from './native_tools.js'; import { HttpsProxyAgent } from 'https-proxy-agent'; +import { setLastTokenUsage } from './token_usage.js'; function getProxyAgent() { const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.HTTP_PROXY || process.env.http_proxy; @@ -17,7 +18,7 @@ function getProxyAgent() { * This is the single transport used by OpenAI and OpenAI-compatible hosted * providers such as OpenRouter, SiliconFlow, Qwen, DeepSeek, Groq, Mistral, * Mercury, Hyperbolic, Novita, HuggingFace router, Ollama /v1, vLLM /v1. - * Provider identity, baseUrl and keyName live in llm_providers.json; profiles + * Provider identity, baseUrl and keyName live in settings_llm_providers.json; profiles * only select provider/model. */ export class OpenAICompletions { @@ -57,6 +58,7 @@ export class OpenAICompletions { } async sendRequest(turns, systemMessage, stop_seq='***', tools=null) { + this.lastTokenUsage = null; const model = this.model_name || this.default_model; const hasTools = Array.isArray(tools) && tools.length > 0; let res = null; @@ -84,6 +86,7 @@ export class OpenAICompletions { if (!choice) return 'No response received.'; if (choice.finish_reason === 'length') throw new Error('Context length exceeded'); console.log('Received.'); + setLastTokenUsage(this, completion?.usage); const message = choice.message; if (message?.tool_calls?.length) { return createNativeToolResponse(message.tool_calls, this.provider); @@ -109,8 +112,8 @@ export class OpenAICompletions { imageMessages.push({ role: 'user', content: [ - { type: 'input_text', text: systemMessage }, - { type: 'input_image', image_url: `data:image/jpeg;base64,${imageBuffer.toString('base64')}` } + { type: 'text', text: systemMessage }, + { type: 'image_url', image_url: { url: `data:image/jpeg;base64,${imageBuffer.toString('base64')}` } } ] }); return this.sendRequest(imageMessages, systemMessage); diff --git a/src/models/openai_responses.js b/src/models/openai_responses.js index 5086e5515..a324ba022 100644 --- a/src/models/openai_responses.js +++ b/src/models/openai_responses.js @@ -1,5 +1,6 @@ import { OpenAICompletions } from './openai_compatible.js'; import { createNativeToolResponse, toResponsesInputItems } from './native_tools.js'; +import { setLastTokenUsage } from './token_usage.js'; // OpenAI Responses protocol. For native tool calls this class // uses Responses API function-call items directly instead of the legacy GPT file. @@ -7,6 +8,7 @@ export class OpenAIResponses extends OpenAICompletions { static prefix = 'openai-responses'; async sendRequest(turns, systemMessage, stop_seq='***', tools=null) { + this.lastTokenUsage = null; const model = this.model_name || this.default_model; const hasTools = Array.isArray(tools) && tools.length > 0; const input = toResponsesInputItems(turns); @@ -29,6 +31,7 @@ export class OpenAIResponses extends OpenAICompletions { : `Awaiting ${this.provider} Responses API from model ${model}`); const response = await this.openai.responses.create(request); console.log('Received.'); + setLastTokenUsage(this, response?.usage); const toolCalls = normalizeResponsesToolCalls(response); if (toolCalls.length > 0) { return createNativeToolResponse(toolCalls, this.provider); @@ -45,6 +48,18 @@ export class OpenAIResponses extends OpenAICompletions { return 'My brain disconnected, try again.'; } } + + async sendVisionRequest(messages, systemMessage, imageBuffer) { + const imageMessages = [...messages]; + imageMessages.push({ + role: 'user', + content: [ + { type: 'input_text', text: systemMessage }, + { type: 'input_image', image_url: `data:image/jpeg;base64,${imageBuffer.toString('base64')}` } + ] + }); + return this.sendRequest(imageMessages, systemMessage); + } } function appendStopSequence(input, stopSeq) { diff --git a/src/models/prompter.js b/src/models/prompter.js index 25f711acf..c08808691 100644 --- a/src/models/prompter.js +++ b/src/models/prompter.js @@ -1,5 +1,4 @@ import { readFileSync, mkdirSync, writeFileSync} from 'fs'; -import { Examples } from '../utils/examples.js'; import { getCommandDocs } from '../agent/commands/index.js'; import { getCommandToolDefinitions, getNativeToolDocs } from '../agent/commands/tool_adapter.js'; import { isNativeToolResponse } from './native_tools.js'; @@ -23,6 +22,19 @@ const PROMPT_FILE_KEYS = [ 'goal_setting' ]; + +export function stripVolatileConversationPlaceholders(prompt) { + return String(prompt || '') + .replaceAll('$SELF_PROMPT', '') + .replace(/^.*\$MEMORY.*(?:\r?\n)?/gm, '') + .replace(/^\s*\$STATS\s*(?:\r?\n)?/gm, '') + .replace(/^\s*\$INVENTORY\s*(?:\r?\n)?/gm, '') + .replace(/^.*\$COMMAND_DOCS.*(?:\r?\n)?/gm, '') + .replace(/^.*\$EXAMPLES.*(?:\r?\n)?/gm, '') + .replace(/\n{3,}/g, '\n\n') + .trimEnd(); +} + export class Prompter { constructor(agent, profile) { this.agent = agent; @@ -54,9 +66,6 @@ export class Prompter { // base overrides default, individual overrides base resolvePromptFileRefs(this.profile, defaults_dir); - this.convo_examples = null; - this.coding_examples = null; - let name = this.profile.name; this.cooldown = this.profile.cooldown ? this.profile.cooldown : 0; this.last_prompt_time = 0; @@ -124,35 +133,18 @@ export class Prompter { return this.profile.use_native_tools !== false && Boolean(this.chat_model?.supportsNativeToolCalls); } - async initExamples() { + async initPromptResources() { try { - this.convo_examples = new Examples(this.embedding_model, settings.num_examples); - this.coding_examples = new Examples(this.embedding_model, settings.num_examples); - const conversationExamples = this.isNativeToolMode() - ? sanitizeNativeToolExamples(this.profile.conversation_examples) - : this.profile.conversation_examples; - - // Wait for both examples to load before proceeding - await Promise.all([ - this.convo_examples.load(conversationExamples), - this.coding_examples.load(this.profile.coding_examples), - this.skill_libary.initSkillLibrary() - ]).catch(error => { - // Preserve error details - console.error('Failed to initialize examples. Error details:', error); - console.error('Stack trace:', error.stack); - throw error; - }); - - console.log('Examples initialized.'); + await this.skill_libary.initSkillLibrary(); + console.log('Prompt resources initialized.'); } catch (error) { - console.error('Failed to initialize examples:', error); + console.error('Failed to initialize prompt resources:', error); console.error('Stack trace:', error.stack); - throw error; // Re-throw with preserved details + throw error; } } - async replaceStrings(prompt, messages, examples=null, to_summarize=[], last_goals=null) { + async replaceStrings(prompt, messages, to_summarize=[], last_goals=null) { prompt = prompt.replaceAll('$NAME', this.agent.name); if (prompt.includes('$STATS')) { @@ -173,17 +165,13 @@ export class Prompter { prompt = prompt.replaceAll('$COMMAND_DOCS', docs); } if (prompt.includes('$CODE_DOCS')) { - const code_task_content = messages.slice().reverse().find(msg => - msg.role !== 'system' && msg.content.includes('!newAction(') - )?.content?.match(/!newAction\((.*?)\)/)?.[1] || ''; + const code_task_content = extractCodeTaskContent(messages); 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)); if (prompt.includes('$MEMORY')) prompt = prompt.replaceAll('$MEMORY', this.agent.history.memory); if (prompt.includes('$TO_SUMMARIZE')) @@ -241,17 +229,17 @@ export class Prompter { return ''; } - let prompt = this.profile.conversing; - prompt = await this.replaceStrings(prompt, messages, this.convo_examples); + const prompt = await this.buildConversationSystemPrompt(messages); + const requestMessages = await this.buildConversationMessages(messages); let generation; try { const tools = this.isNativeToolMode() ? getCommandToolDefinitions(this.agent) : null; - this.agent.history.traceLLMRequest('conversation', this.chat_model, prompt, messages, tools); - generation = await this.chat_model.sendRequest(messages, prompt, '***', tools); + this.agent.history.traceLLMRequest('conversation', this.chat_model, prompt, requestMessages, tools); + generation = await this.chat_model.sendRequest(requestMessages, prompt, '***', tools); this.agent.history.traceLLMResponse('conversation', this.chat_model, generation); if (isNativeToolResponse(generation)) { - await this._saveLog(prompt, messages, JSON.stringify(generation), 'conversation'); + await this._saveLog(prompt, requestMessages, JSON.stringify(generation), 'conversation'); return generation; } if (typeof generation !== 'string') { @@ -259,7 +247,7 @@ export class Prompter { throw new Error('Generated response is not a string'); } console.log("Generated response:", generation); - await this._saveLog(prompt, messages, generation, 'conversation'); + await this._saveLog(prompt, requestMessages, generation, 'conversation'); } catch (error) { this.agent.history.traceLLMError('conversation', this.chat_model, error); @@ -289,6 +277,15 @@ export class Prompter { return ''; } + async buildConversationSystemPrompt(messages) { + const stableTemplate = stripVolatileConversationPlaceholders(this.profile.conversing); + return await this.replaceStrings(stableTemplate, messages); + } + + async buildConversationMessages(messages) { + return messages; + } + getTextCommandFallbackDocs() { const docs = getCommandDocs(this.agent); if (this.profile.use_native_tools === false) { @@ -305,7 +302,7 @@ export class Prompter { this.awaiting_coding = true; await this.checkCooldown(); let prompt = this.profile.coding; - prompt = await this.replaceStrings(prompt, messages, this.coding_examples); + prompt = await this.replaceStrings(prompt, messages); this.agent.history.traceLLMRequest('coding', this.code_model, prompt, messages); let resp = await this.code_model.sendRequest(messages, prompt); @@ -315,14 +312,14 @@ export class Prompter { return resp; } - async promptMemSaving(to_summarize) { + async promptCompactSummary(to_summarize) { await this.checkCooldown(); let prompt = this.profile.saving_memory; - prompt = await this.replaceStrings(prompt, null, null, to_summarize); - this.agent.history.traceLLMRequest('memSaving', this.chat_model, prompt, to_summarize); + prompt = await this.replaceStrings(prompt, null, to_summarize); + this.agent.history.traceLLMRequest('compactSummary', this.chat_model, prompt, to_summarize); let resp = await this.chat_model.sendRequest([], prompt); - this.agent.history.traceLLMResponse('memSaving', this.chat_model, resp); - await this._saveLog(prompt, to_summarize, resp, 'memSaving'); + this.agent.history.traceLLMResponse('compactSummary', this.chat_model, resp); + await this._saveLog(prompt, to_summarize, resp, 'compactSummary'); if (resp?.includes('
')) { const [_, afterThink] = resp.split('
'); resp = afterThink; @@ -330,12 +327,16 @@ export class Prompter { return resp; } + async promptMemSaving(to_summarize) { + return this.promptCompactSummary(to_summarize); + } + async promptShouldRespondToBot(new_message) { await this.checkCooldown(); let prompt = this.profile.bot_responder; let messages = this.agent.history.getHistory(); messages.push({role: 'user', content: new_message}); - prompt = await this.replaceStrings(prompt, null, null, messages); + prompt = await this.replaceStrings(prompt, null, messages); this.agent.history.traceLLMRequest('botResponder', this.chat_model, prompt, messages); let res = await this.chat_model.sendRequest([], prompt); this.agent.history.traceLLMResponse('botResponder', this.chat_model, res); @@ -345,7 +346,7 @@ export class Prompter { async promptVision(messages, imageBuffer) { await this.checkCooldown(); let prompt = this.profile.image_analysis; - prompt = await this.replaceStrings(prompt, messages, null, null, null); + prompt = await this.replaceStrings(prompt, messages); this.agent.history.traceLLMRequest('vision', this.vision_model, prompt, messages); const res = await this.vision_model.sendVisionRequest(messages, prompt, imageBuffer); this.agent.history.traceLLMResponse('vision', this.vision_model, res); @@ -359,7 +360,7 @@ export class Prompter { 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 = await this.replaceStrings(user_message, messages, null, null, last_goals); + user_message = await this.replaceStrings(user_message, messages, null, last_goals); let user_messages = [{role: 'user', content: user_message}]; this.agent.history.traceLLMRequest('goalSetting', this.chat_model, system_message, user_messages); @@ -412,18 +413,6 @@ export class Prompter { } } -export function sanitizeNativeToolExamples(examples = []) { - if (!Array.isArray(examples)) return []; - return examples.filter(example => - Array.isArray(example) && !example.some(turn => containsLegacyToolSyntax(turn?.content)) - ); -} - -function containsLegacyToolSyntax(content) { - if (typeof content !== 'string') return false; - return /(^|\s)![A-Za-z_][A-Za-z0-9_]*\b|\*used\s+[A-Za-z_][A-Za-z0-9_]*\*/i.test(content); -} - function resolvePromptFileRefs(profile, defaultBaseDir) { for (const key of PROMPT_FILE_KEYS) { const value = profile[key]; @@ -460,3 +449,19 @@ function hasModelSelection(profile) { typeof profile[key] === 'string' && profile[key].trim().length > 0 ); } + +function extractCodeTaskContent(messages) { + const content = messages?.slice?.().reverse?.().find(msg => + msg?.role !== 'system' + && typeof msg?.content === 'string' + && (msg.content.includes('!newAction(') || msg.content.startsWith('Code generation task:')) + )?.content || ''; + + const legacyMatch = content.match(/!newAction\((.*?)\)/); + if (legacyMatch) return legacyMatch[1]; + + return content + .replace(/^Code generation task:\s*/i, '') + .replace(/\n\nWrite the implementation as a JavaScript code block\.\s*$/i, '') + .trim(); +} diff --git a/src/models/token_usage.js b/src/models/token_usage.js new file mode 100644 index 000000000..c2ea61cac --- /dev/null +++ b/src/models/token_usage.js @@ -0,0 +1,81 @@ +export function normalizeTokenUsage(rawUsage) { + if (!rawUsage || typeof rawUsage !== 'object') return null; + + const inputTotal = firstNumber( + rawUsage.input_tokens, + rawUsage.prompt_tokens, + rawUsage.promptTokenCount, + rawUsage.prompt_token_count + ); + const output = firstNumber( + rawUsage.output_tokens, + rawUsage.completion_tokens, + rawUsage.candidatesTokenCount, + rawUsage.candidates_token_count + ); + const cachedInput = firstNumber( + rawUsage.input_tokens_details?.cached_tokens, + rawUsage.prompt_tokens_details?.cached_tokens, + rawUsage.cache_read_input_tokens, + rawUsage.cachedContentTokenCount, + rawUsage.cached_content_token_count, + 0 + ); + const cacheCreationInput = firstNumber(rawUsage.cache_creation_input_tokens, 0); + + let uncachedInput = firstNumber( + rawUsage.input_tokens_details?.uncached_tokens, + rawUsage.prompt_tokens_details?.uncached_tokens + ); + if (uncachedInput == null && inputTotal != null && rawUsage.cache_read_input_tokens != null) { + uncachedInput = inputTotal + cacheCreationInput; + } else if (uncachedInput == null && inputTotal != null) { + uncachedInput = Math.max(inputTotal - cachedInput, 0); + } else if (cacheCreationInput) { + uncachedInput = (uncachedInput || 0) + cacheCreationInput; + } + + const total = firstNumber( + rawUsage.total_tokens, + rawUsage.totalTokenCount, + rawUsage.total_token_count, + sumKnown(inputTotal, output, cacheCreationInput) + ); + + if (inputTotal == null && output == null && cachedInput === 0 && cacheCreationInput === 0 && total == null) { + return null; + } + + return { + input_total: inputTotal, + input_uncached: uncachedInput, + input_cached: cachedInput, + output, + total, + raw: rawUsage + }; +} + +export function setLastTokenUsage(model, rawUsage) { + if (!model || typeof model !== 'object') return null; + const usage = normalizeTokenUsage(rawUsage); + model.lastTokenUsage = usage; + return usage; +} + +function firstNumber(...values) { + for (const value of values) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + } + return null; +} + +function sumKnown(...values) { + const known = values.filter(value => typeof value === 'number' && Number.isFinite(value)); + if (!known.length) return null; + return known.reduce((sum, value) => sum + value, 0); +} diff --git a/src/process/agent_process.js b/src/process/agent_process.js index 4f7823e77..b14adf610 100644 --- a/src/process/agent_process.js +++ b/src/process/agent_process.js @@ -46,7 +46,7 @@ export class AgentProcess { return; } console.log('Restarting agent...'); - this.start(true, 'Agent process restarted.', count_id, this.port); + this.start(true, null, count_id, this.port); last_restart = Date.now(); } }); @@ -74,11 +74,11 @@ export class AgentProcess { this.process.once('exit', () => { clearTimeout(restartTimeout); console.log(`Stopped hanging agent ${this.name}. Now restarting.`); - this.start(true, 'Agent process restarted.', this.count_id); + this.start(true, null, this.count_id); }); this.stop(); // sends SIGINT } else { - this.start(true, 'Agent process restarted.', this.count_id); + this.start(true, null, this.count_id); } } } \ No newline at end of file diff --git a/src/utils/examples.js b/src/utils/examples.js deleted file mode 100644 index 470663d20..000000000 --- a/src/utils/examples.js +++ /dev/null @@ -1,83 +0,0 @@ -import { cosineSimilarity } from './math.js'; -import { stringifyTurns, wordOverlapScore } from './text.js'; - -export class Examples { - constructor(model, select_num=2) { - this.examples = []; - this.model = model; - this.select_num = select_num; - this.embeddings = {}; - } - - turnsToText(turns) { - let messages = ''; - for (let turn of turns) { - if (turn.role !== 'assistant') - messages += turn.content.substring(turn.content.indexOf(':')+1).trim() + '\n'; - } - return messages.trim(); - } - - async load(examples) { - this.examples = examples; - if (!this.model) return; // Early return if no embedding model - - if (this.select_num === 0) - return; - - try { - // Create array of promises first - const embeddingPromises = examples.map(example => { - const turn_text = this.turnsToText(example); - return this.model.embed(turn_text) - .then(embedding => { - this.embeddings[turn_text] = embedding; - }); - }); - - // Wait for all embeddings to complete - await Promise.all(embeddingPromises); - } catch (err) { - console.warn('Error with embedding model, using word-overlap instead.'); - this.model = null; - } - } - - async getRelevant(turns) { - if (this.select_num === 0) - return []; - - let turn_text = this.turnsToText(turns); - if (this.model !== null) { - let embedding = await this.model.embed(turn_text); - this.examples.sort((a, b) => - cosineSimilarity(embedding, this.embeddings[this.turnsToText(b)]) - - cosineSimilarity(embedding, this.embeddings[this.turnsToText(a)]) - ); - } - else { - this.examples.sort((a, b) => - wordOverlapScore(turn_text, this.turnsToText(b)) - - wordOverlapScore(turn_text, this.turnsToText(a)) - ); - } - let selected = this.examples.slice(0, this.select_num); - return JSON.parse(JSON.stringify(selected)); // deep copy - } - - async createExampleMessage(turns) { - let selected_examples = await this.getRelevant(turns); - - console.log('selected examples:'); - for (let example of selected_examples) { - console.log('Example:', example[0].content) - } - - let msg = 'Examples of how to respond:\n'; - for (let i=0; i { assert.ok(agentSource.includes('return `Tool ${name} completed.`')); assert.ok(agentSource.includes('await this.history.addNativeToolResult(toolCall, msg)')); }); + +test('speech does not truncate normal exclamation text as an unknown command', () => { + const agentSource = readFileSync('src/agent/agent.js', 'utf8'); + + assert.ok(agentSource.includes('prepareChatMessageForOutput(message)')); + assert.ok(agentSource.includes('if (command_name && !commandExists(command_name))')); + assert.ok(agentSource.includes('command_name = null;')); + assert.ok(agentSource.includes('speak(spokenMessage, this.prompter.profile.speak_model);')); +}); + + +test('system TTS uses argv-based say/espeak invocation for full text', () => { + const speakSource = readFileSync('src/agent/speak.js', 'utf8'); + + assert.ok(speakSource.includes('buildSystemTTSInvocation(txt, process.platform)')); + assert.ok(speakSource.includes('args: [txt]')); + assert.equal(speakSource.includes('? `say "${txt'), false); + assert.equal(speakSource.includes(': `espeak "${txt'), false); +}); + +test('chat output preparation preserves normal full speech text', async () => { + const { prepareChatMessageForOutput } = await import('../src/agent/agent.js'); + + assert.equal(prepareChatMessageForOutput('hello world, codex').spokenMessage, 'hello world, codex'); + assert.equal(prepareChatMessageForOutput('Hello world!I am codex').spokenMessage, 'Hello world!I am codex'); + assert.equal(prepareChatMessageForOutput('hello !stats').spokenMessage, 'hello '); +}); + +test('system TTS invocation passes full text as one macOS say argument', async () => { + const { buildSystemTTSInvocation } = await import('../src/agent/speak.js'); + const text = 'hello world, codex'; + const excited = 'Hello world! I am codex'; + + assert.deepEqual(buildSystemTTSInvocation(text, 'darwin'), { + mode: 'spawn', + command: 'say', + args: [text] + }); + assert.deepEqual(buildSystemTTSInvocation(excited, 'darwin').args, [excited]); + assert.deepEqual(buildSystemTTSInvocation(text, 'linux'), { + mode: 'spawn', + command: 'espeak', + args: [text] + }); +}); + +test('init message is not resent when memory already restored conversation history', () => { + const agentSource = readFileSync('src/agent/agent.js', 'utf8'); + + assert.ok(agentSource.includes('init_message && !hasLoadedConversation(save_data)')); + assert.ok(agentSource.includes('function hasLoadedConversation(saveData)')); + assert.ok(agentSource.includes('Array.isArray(saveData.turns) && saveData.turns.length > 0')); +}); + +test('self-prompt continuation is transient and not persisted as history', async () => { + const { Agent } = await import('../src/agent/agent.js'); + const capturedRequests = []; + const persisted = []; + const turns = []; + const agent = Object.create(Agent.prototype); + agent.name = 'bot'; + agent.shut_up = false; + agent.last_sender = null; + agent.checkTaskDone = async () => {}; + agent.self_prompter = { + shouldInterrupt: () => false, + isActive: () => false, + handleUserPromptedCmd: () => {} + }; + agent.bot = { modes: { flushBehaviorLog: () => '' } }; + agent.history = { + add: async (source, message) => persisted.push({ source, message }), + save: () => {}, + getHistory: () => [{ role: 'user', content: 'Steve: ready' }] + }; + agent.prompter = { + promptConvo: async messages => { + capturedRequests.push(messages); + return ''; + } + }; + + await agent._handleMessageImpl('system', 'Continue working on your current goal: "mine".', 1, { transient: true }); + + assert.deepEqual(persisted, []); + assert.equal(capturedRequests.length, 1); + assert.deepEqual(capturedRequests[0], [ + { role: 'user', content: 'Steve: ready' }, + { role: 'user', content: 'System: Continue working on your current goal: "mine".' } + ]); +}); + +test('starting a goal during native tool execution defers the self-prompt loop', async () => { + const { SelfPrompter } = await import('../src/agent/self_prompter.js'); + let handleSelfPromptCalls = 0; + const agent = { + isHandlingMessage: () => true, + isIdle: () => true, + handleSelfPrompt: async () => { + handleSelfPromptCalls++; + return false; + } + }; + const selfPrompter = new SelfPrompter(agent); + + selfPrompter.start('mine iron'); + + assert.equal(selfPrompter.isActive(), true); + assert.equal(selfPrompter.loop_active, false); + assert.equal(selfPrompter.prompt, 'mine iron'); + assert.equal(handleSelfPromptCalls, 0); +}); + +test('newAction code generation uses an isolated tool-internal prompt', async () => { + const { createCodeGenerationMessages } = await import('../src/agent/coder.js'); + const coderSource = readFileSync('src/agent/coder.js', 'utf8'); + const actionsSource = readFileSync('src/agent/commands/actions.js', 'utf8'); + const prompterSource = readFileSync('src/models/prompter.js', 'utf8'); + const messages = createCodeGenerationMessages('build a tower'); + + assert.deepEqual(messages, [{ + role: 'user', + content: 'Code generation task:\nbuild a tower\n\nWrite the implementation as a JavaScript code block.' + }]); + assert.ok(actionsSource.includes('agent.coder.generateCode(prompt)')); + assert.equal(actionsSource.includes('agent.coder.generateCode(agent.history)'), false); + assert.equal(coderSource.includes('agent_history.getHistory()'), false); + assert.equal(messages.some(message => message.content.includes('Code generation started')), false); + assert.ok(prompterSource.includes('extractCodeTaskContent(messages)')); + assert.ok(prompterSource.includes("msg.content.startsWith('Code generation task:')")); +}); + +test('chat UI adds copy controls to expanded JSON payloads', () => { + const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); + + assert.ok(html.includes('function enableChatCopyButtons(panel)')); + assert.ok(html.includes('enableChatCopyButtons(panel);')); + assert.ok(html.includes('class="chat-copy-btn"')); + assert.ok(html.includes('navigator.clipboard?.writeText')); + assert.ok(html.includes("document.execCommand('copy')")); +}); + +test('chat UI nests coding requests under the active tool instead of top-level turns', () => { + const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); + + assert.ok(html.includes("event.tag === 'coding' && attachInternalToolEvent(current, event)")); + assert.ok(html.includes('function attachInternalToolEvent(turn, event)')); + assert.ok(html.includes('function findInternalToolHost(turn)')); + assert.ok(html.includes("getToolName(item.call) === 'newAction'")); + assert.ok(html.includes('function renderInternalToolEvents(events)')); + assert.ok(html.includes('Internal coding requests')); +}); + +test('chat request role labels preserve provider message role', () => { + const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); + const roleLabelSection = html.slice(html.indexOf('function getMessageRoleLabel'), html.indexOf('function isCompactCompatibilityMessage')); + + assert.ok(roleLabelSection.includes("return message?.role || 'message';")); + assert.equal(roleLabelSection.includes("return 'system'"), false); +}); + + +test('behavior logs are persisted with the outbound user turn for append-only cache stability', async () => { + const { Agent } = await import('../src/agent/agent.js'); + const capturedRequests = []; + const persisted = []; + const turns = []; + const agent = Object.create(Agent.prototype); + agent.name = 'bot'; + agent.shut_up = false; + agent.last_sender = null; + agent.checkTaskDone = async () => {}; + agent.self_prompter = { + shouldInterrupt: () => false, + isActive: () => false, + handleUserPromptedCmd: () => {} + }; + agent.bot = { modes: { flushBehaviorLog: () => "I'm stuck! I'm free." } }; + agent.history = { + add: async (source, message) => { + persisted.push({ source, message }); + turns.push({ role: 'user', content: `${source}: ${message}` }); + }, + addUserContext: async content => { + persisted.push({ source: 'user_context', message: content }); + turns.push({ role: 'user', content }); + }, + save: () => {}, + getHistory: () => turns.map(turn => ({ ...turn })) + }; + agent.prompter = { + promptConvo: async messages => { + capturedRequests.push(messages); + return ''; + } + }; + + await agent._handleMessageImpl('Steve', 'hello', 1); + + assert.deepEqual(persisted, [{ + source: 'user_context', + message: "Steve: hello\n\nSystem: Recent behaviors log: \nI'm stuck! I'm free." + }]); + assert.deepEqual(capturedRequests[0], [ + { role: 'user', content: "Steve: hello\n\nSystem: Recent behaviors log: \nI'm stuck! I'm free." } + ]); + assert.deepEqual(turns, [{ role: 'user', content: "Steve: hello\n\nSystem: Recent behaviors log: \nI'm stuck! I'm free." }]); +}); + + +test('chat UI does not render redundant tool argument expanders', () => { + const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); + + assert.equal(html.includes("renderDetails('Tool arguments'"), false); + assert.equal(html.includes('Tool arguments'), false); +}); + +test('New Agent forms hide hidden settings and keep profile upload separate', () => { + const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); + + assert.ok(html.includes('Profile: Not uploaded')); + assert.ok(html.includes('id="uploadProfileBtn"')); + assert.ok(html.includes('function isEditableSetting(key')); + assert.ok(html.includes('!cfg?.hidden')); + assert.ok(html.includes("cfg?.ui !== 'hidden'")); + assert.ok(html.includes('if (!isEditableSetting(key, cfg)) return; // profile handled via upload; hidden settings use server defaults')); + assert.ok(html.includes('if (!isEditableSetting(key, cfg)) return; // profile and hidden settings are not edited here')); +}); + +test('agent persists merged state/request context as one append-only user turn', () => { + const agentSource = readFileSync('src/agent/agent.js', 'utf8'); + const snapshotSource = readFileSync('src/agent/state_snapshot.js', 'utf8'); + const settingsSource = readFileSync('settings.js', 'utf8'); + const settingsSpec = readFileSync('src/mindcraft/public/settings_spec.json', 'utf8'); + + assert.ok(agentSource.includes('buildStateSnapshotDiff(this)')); + assert.ok(agentSource.includes('createTransientRequestMessage(transientParts)')); + assert.ok(agentSource.includes('pendingPersistedParts.push(createHistoryUserMessageForRequest')); + assert.ok(agentSource.includes('this.history.addUserContext(pendingPersistedParts.join')); + assert.ok(agentSource.includes('await this.history.addUserContext(stateDiff)')); + assert.equal(agentSource.includes('historyBeforeCurrentMessage = this.history.getHistory()'), false); + assert.equal(agentSource.includes('removeLastMatchingMessage'), false); + assert.ok(agentSource.includes("join(\'\\n\\n\')")); + assert.ok(snapshotSource.includes('State update:\\n')); + assert.ok(snapshotSource.includes('`* inventory: ${formatMap(snapshot.inventory)')); + assert.ok(snapshotSource.includes('lines.push(`* ${label}:')); + assert.equal(snapshotSource.includes('lines.push(`- ${label}:'), false); + assert.equal(snapshotSource.includes('Current state:'), false); + assert.equal(snapshotSource.includes('State changes:'), false); + assert.equal(snapshotSource.includes('Use this instead of re-checking unchanged state'), false); + assert.equal(settingsSource.includes('state_snapshot_diff'), false); + assert.equal(settingsSpec.includes('state_snapshot_diff'), false); +}); + + +test('system prompts carry state snapshot usage guidance', () => { + const prompt = readFileSync('profiles/defaults/prompts/conversing.md', 'utf8'); + + assert.ok(prompt.includes('Use transient state snapshots/diffs as your current baseline')); +}); + +test('chat UI suppresses history turn duplicated by the following request', () => { + const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); + + assert.ok(html.includes('isPendingHistoryOnlyTurn(current)')); + assert.ok(html.includes('thread.turns.pop()')); + assert.ok(html.includes('isHistoryTurnIncludedInRequest(event.turn, requestMessages)')); + assert.ok(html.includes('.filter(historyEvent => !isHistoryTurnIncludedInRequest(historyEvent.turn, requestMessages))')); + assert.ok(html.includes('requestText.startsWith(`${content}\\n\\n`)')); +}); + +test('chat UI renders only request message deltas instead of repeated full history', () => { + const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); + + assert.ok(html.includes('let previousRequestMessages = []')); + assert.ok(html.includes('event.visible_messages = selectVisibleRequestMessages(event.messages, previousRequestMessages)')); + assert.ok(html.includes('getCommonRequestPrefixLength(previousMessages, messages)')); + assert.ok(html.includes('const newMessages = messages.slice(startIndex);')); + assert.ok(html.includes('getTrailingUserMessages(scope)')); + assert.ok(html.includes("if (message?.role !== 'user') break;")); + assert.ok(html.includes('function getMessageRoleLabel(message, preview = formatChatMessagePreview(message))')); + assert.equal(html.includes("preview.startsWith('Context:')"), false); + assert.ok(html.includes('replace(/^Context:')); + assert.ok(html.includes("State update:\\n')")); + assert.ok(html.includes('class="chat-message-text"')); +}); diff --git a/tests/chat_history_trace.test.js b/tests/chat_history_trace.test.js index e161ede85..12a3e2a9d 100644 --- a/tests/chat_history_trace.test.js +++ b/tests/chat_history_trace.test.js @@ -1,9 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { History } from '../src/agent/history.js'; +import { setSettings } from '../src/agent/settings.js'; class FakeModel { static prefix = 'fake-protocol'; @@ -15,11 +16,12 @@ class FakeModel { } } -test('chat history trace records prompts, messages, tool calls and tool results', async () => { +test('runtime chat history persists when Runtime is enabled without full trace logging', async () => { const originalCwd = process.cwd(); const dir = mkdtempSync(path.join(tmpdir(), 'mindcraft-trace-')); try { process.chdir(dir); + setSettings({ show_chat_history: true, log_chat_trace: false }); const history = new History({ name: 'tracebot', self_prompter: { state: {} }, @@ -27,12 +29,71 @@ test('chat history trace records prompts, messages, tool calls and tool results' }); const model = new FakeModel(); + model.lastTokenUsage = { + input_total: 42, + input_uncached: 10, + input_cached: 32, + output: 7, + total: 49 + }; const messages = [{ role: 'user', content: 'Steve: check inventory' }]; const tools = [{ type: 'function', function: { name: 'inventory', parameters: { type: 'object' } } }]; history.traceLLMRequest('conversation', model, 'system prompt text', messages, tools); history.traceLLMResponse('conversation', model, { type: 'tool_calls', tool_calls: [{ name: 'inventory' }] }); await history.add('Steve', 'check inventory'); + await history.add('system', 'Action was interrupted by unstuck.'); + const toolCall = { id: 'call_1', type: 'function', name: 'inventory', arguments: '{}' }; + await history.addNativeToolCall(toolCall); + await history.addNativeToolResult(toolCall, 'Action output:\nInventory is empty.'); + + assert.ok(history.chat_history_session_fp); + assert.ok(existsSync(history.chat_history_session_fp)); + assert.ok(existsSync(history.chat_history_latest_fp)); + const events = readFileSync(history.chat_history_latest_fp, 'utf8') + .trim() + .split('\n') + .map(line => JSON.parse(line)); + assert.ok(events.some(event => event.type === 'llm_request')); + assert.ok(events.some(event => event.type === 'llm_response')); + assert.ok(events.some(event => event.type === 'history_turn_added')); + assert.ok(events.some(event => event.type === 'tool_call')); + assert.ok(events.some(event => event.type === 'tool_result')); + assert.equal(history.turns.length, 4); + } finally { + setSettings({}); + process.chdir(originalCwd); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('chat history trace records prompts, messages, tool calls and tool results when enabled', async () => { + const originalCwd = process.cwd(); + const dir = mkdtempSync(path.join(tmpdir(), 'mindcraft-trace-')); + try { + process.chdir(dir); + setSettings({ show_chat_history: true, log_chat_trace: true }); + const history = new History({ + name: 'tracebot', + self_prompter: { state: {} }, + task: {} + }); + + const model = new FakeModel(); + model.lastTokenUsage = { + input_total: 42, + input_uncached: 10, + input_cached: 32, + output: 7, + total: 49 + }; + const messages = [{ role: 'user', content: 'Steve: check inventory' }]; + const tools = [{ type: 'function', function: { name: 'inventory', parameters: { type: 'object' } } }]; + history.traceLLMRequest('conversation', model, 'system prompt text', messages, tools); + history.traceLLMResponse('conversation', model, { type: 'tool_calls', tool_calls: [{ name: 'inventory' }] }); + + await history.add('Steve', 'check inventory'); + await history.add('system', 'Action was interrupted by unstuck.'); const toolCall = { id: 'call_1', type: 'function', name: 'inventory', arguments: '{}' }; await history.addNativeToolCall(toolCall); await history.addNativeToolResult(toolCall, 'Action output:\nInventory is empty.'); @@ -50,10 +111,136 @@ test('chat history trace records prompts, messages, tool calls and tool results' assert.equal(request.model.api, 'fake-protocol'); assert.ok(events.some(event => event.type === 'llm_response')); + const response = events.find(event => event.type === 'llm_response'); + assert.deepEqual(response.token_usage, model.lastTokenUsage); assert.ok(events.some(event => event.type === 'history_turn_added' && event.turn.role === 'user')); + assert.ok(events.some(event => event.type === 'history_turn_added' && event.turn.role === 'user' && event.turn.content.startsWith('System: Action was interrupted'))); + assert.ok(!events.some(event => event.type === 'history_turn_added' && event.turn.role === 'system')); assert.ok(events.some(event => event.type === 'tool_call' && event.tool_call.name === 'inventory')); assert.ok(events.some(event => event.type === 'tool_result' && event.result.includes('Inventory is empty'))); } finally { + setSettings({}); + process.chdir(originalCwd); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('history dedupes repeated self-prompt reminders from active context', async () => { + const originalCwd = process.cwd(); + const dir = mkdtempSync(path.join(tmpdir(), 'mindcraft-selfprompt-')); + try { + process.chdir(dir); + setSettings({ show_chat_history: true, log_chat_trace: true }); + const history = new History({ + name: 'tracebot', + self_prompter: { state: {} }, + task: {} + }); + + const reminder = 'Continue working on your current goal: "mine". Decide the next useful step and proceed. If the goal is complete, finish the goal.'; + await history.add('system', reminder); + await history.addNativeToolCall({ id: 'call_1', type: 'function', name: 'inventory', arguments: '{}' }); + await history.addNativeToolResult({ id: 'call_1', type: 'function', name: 'inventory', arguments: '{}' }, 'Action output:\nempty'); + await history.add('system', reminder); + + const selfPromptTurns = history.turns.filter(turn => turn.content === `System: ${reminder}`); + assert.equal(selfPromptTurns.length, 1); + + const events = readFileSync(history.chat_history_latest_fp, 'utf8') + .trim() + .split('\n') + .map(line => JSON.parse(line)); + assert.ok(events.some(event => event.type === 'history_turn_deduped' && event.reason === 'duplicate_self_prompt_reminder')); + } finally { + setSettings({}); + process.chdir(originalCwd); + rmSync(dir, { recursive: true, force: true }); + } +}); + + + +test('history compaction replaces active context with boundary and summary', async () => { + const originalCwd = process.cwd(); + const dir = mkdtempSync(path.join(tmpdir(), 'mindcraft-compact-')); + try { + process.chdir(dir); + setSettings({ + show_chat_history: false, + log_chat_trace: false, + max_messages: 4, + compact_message_threshold_percent: 100 + }); + const summarized = []; + const history = new History({ + name: 'compactbot', + prompter: { + promptCompactSummary: async turns => { + summarized.push(turns.map(t => t.content || t.name).join('|')); + return `summary of ${turns.length} turns`; + } + }, + self_prompter: { state: {} }, + task: {} + }); + + await history.add('Steve', 'one'); + await history.add('compactbot', 'two'); + await history.add('Steve', 'three'); + await history.add('compactbot', 'four'); + + assert.equal(summarized.length, 1); + assert.equal(history.memory, 'summary of 4 turns'); + assert.equal(history.turns.length, 2); + assert.equal(history.turns[0].compact_boundary, true); + assert.equal(history.turns[1].compact_summary, true); + assert.match(history.turns[1].content, /summary of 4 turns/); + assert.deepEqual(history.getHistory(), history.turns); + assert.ok(history.full_history_fp); + assert.ok(existsSync(history.full_history_fp)); + } finally { + setSettings({}); + process.chdir(originalCwd); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('history compaction waits for native tool results before compacting', async () => { + const originalCwd = process.cwd(); + const dir = mkdtempSync(path.join(tmpdir(), 'mindcraft-compact-tools-')); + try { + process.chdir(dir); + setSettings({ + show_chat_history: false, + log_chat_trace: false, + max_messages: 2, + compact_message_threshold_percent: 100 + }); + let summaries = 0; + const history = new History({ + name: 'compactbot', + prompter: { + promptCompactSummary: async turns => { + summaries += 1; + return `summary after ${turns.length}`; + } + }, + self_prompter: { state: {} }, + task: {} + }); + + const toolCall = { id: 'call_1', type: 'function', name: 'inventory', arguments: '{}' }; + await history.add('Steve', 'check inventory'); + await history.addNativeToolCall(toolCall); + assert.equal(summaries, 0); + assert.equal(history.turns.length, 2); + + await history.addNativeToolResult(toolCall, 'empty'); + assert.equal(summaries, 1); + assert.equal(history.turns[0].compact_boundary, true); + assert.equal(history.turns[1].compact_summary, true); + } finally { + setSettings({}); process.chdir(originalCwd); rmSync(dir, { recursive: true, force: true }); } diff --git a/tests/codex_chatgpt.test.js b/tests/codex_chatgpt.test.js index 26e1ea5d6..f91ca2b73 100644 --- a/tests/codex_chatgpt.test.js +++ b/tests/codex_chatgpt.test.js @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { @@ -60,13 +60,15 @@ test('Codex SSE parser extracts Responses function_call events', async () => { 'data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_1","name":"report_status","arguments":"{\\"status\\":\\"ok\\"}"}}', '', 'event: response.completed', - 'data: {"type":"response.completed","response":{"id":"resp_1"}}', + 'data: {"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":100,"input_tokens_details":{"cached_tokens":60},"output_tokens":8}}}', '' ].join('\n'); const parsed = await parseCodexResponsesSse(sse); assert.equal(parsed.toolCalls[0].function.name, 'report_status'); assert.equal(parsed.toolCalls[0].function.arguments, '{"status":"ok"}'); + assert.equal(parsed.usage.input_tokens, 100); + assert.equal(parsed.usage.input_tokens_details.cached_tokens, 60); }); test('Codex SSE parser prefers text deltas over final message to avoid duplicate text', async () => { @@ -191,7 +193,35 @@ test('Codex adapter can still read raw auth.json style files when explicitly con } }); -test('Codex adapter still supports legacy unified llm_providers.json auth storage', () => { + +test('Codex login persists auth inside the unified settings LLM provider registry', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'codex-unified-settings-test-')); + const keysPath = path.join(dir, 'settings_llm_providers.json'); + writeFileSync(keysPath, JSON.stringify({ + keys: { CODEX_CHATGPT_AUTH: {} }, + models: { codex: { format: 'openai-codex-responses' } }, + embeddings: {} + }, null, 4)); + try { + const auth = await ensureCodexChatGPTAuth({ + keysPath, + allowLogin: true, + loginRunner: async ({ keysPath: loginKeysPath }) => { + assert.equal(loginKeysPath, keysPath); + return authFixture(); + } + }); + const saved = JSON.parse(readFileSync(keysPath, 'utf8')); + assert.equal(auth.accessToken, 'access-token-test'); + assert.equal(saved.models.codex.format, 'openai-codex-responses'); + assert.equal(saved.keys.CODEX_CHATGPT_AUTH.tokens.access_token, 'access-token-test'); + assert.equal(saved.tokens, undefined); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('Codex adapter still supports legacy unified settings_llm_providers.json auth storage', () => { const { keysPath, dir, cleanup } = writeTempKeys({ includeAuth: false }); try { writeKeysCodexAuth(keysPath, authFixture()); @@ -204,10 +234,10 @@ test('Codex adapter still supports legacy unified llm_providers.json auth storag }); -test('Codex adapter defaults to project llm_providers.json instead of ~/.codex auth', () => { +test('Codex adapter defaults to project settings_llm_providers.json instead of ~/.codex auth', () => { const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', {}); - assert.equal(model.authPath, 'llm_providers.json'); - assert.equal(model.keysPath, 'llm_providers.json'); + assert.equal(model.authPath, 'settings_llm_providers.json'); + assert.equal(model.keysPath, 'settings_llm_providers.json'); }); test('Codex adapter starts local login runner when configured auth path is missing', async () => { @@ -272,9 +302,51 @@ test('Codex adapter sends native-login Responses request and normalizes tool cal } }); +test('Codex adapter sends vision images as Responses input_image content', async () => { + const { keysPath, cleanup } = writeTempKeys(); + const originalFetch = globalThis.fetch; + const requests = []; + globalThis.fetch = async (url, init) => { + requests.push({ url, init, body: JSON.parse(init.body) }); + return new Response([ + 'event: response.output_text.delta', + 'data: {"type":"response.output_text.delta","delta":"I see stone."}', + '', + 'event: response.completed', + 'data: {"type":"response.completed","response":{"id":"resp_1"}}', + '' + ].join('\n'), { + status: 200, + headers: { 'content-type': 'text/event-stream' } + }); + }; + + try { + const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', { keysPath, sessionId: 'vision-session-test' }); + const response = await model.sendVisionRequest( + [{ role: 'user', content: 'look around' }], + 'Describe the Minecraft screenshot.', + Buffer.from('image-bytes') + ); + assert.equal(response, 'I see stone.'); + + const visionMessage = requests[0].body.input.at(-1); + assert.equal(visionMessage.type, 'message'); + assert.equal(visionMessage.role, 'user'); + assert.deepEqual(visionMessage.content, [ + { type: 'input_text', text: '' }, + { type: 'input_image', image_url: `data:image/jpeg;base64,${Buffer.from('image-bytes').toString('base64')}` }, + { type: 'input_text', text: '\nDescribe the Minecraft screenshot.' } + ]); + } finally { + globalThis.fetch = originalFetch; + cleanup(); + } +}); + test('Codex adapter keeps prompt cache key stable across multi-turn tool replay', () => { const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', { - keysPath: 'llm_providers.json', + keysPath: 'settings_llm_providers.json', sessionId: 'stable-cache-session' }); const turns = [ @@ -318,7 +390,7 @@ async function waitFor(fn, timeoutMs = 1000) { function writeTempKeys({ includeAuth = true } = {}) { const dir = mkdtempSync(path.join(tmpdir(), 'codex-keys-test-')); - const keysPath = path.join(dir, 'llm_providers.json'); + const keysPath = path.join(dir, 'settings_llm_providers.json'); const keys = { OPENAI_API_KEY: '' }; if (includeAuth) keys.CODEX_CHATGPT_AUTH = authFixture(); writeFileSync(keysPath, JSON.stringify(keys)); diff --git a/tests/llm_providers_config.test.js b/tests/llm_providers_config.test.js index 8363633dd..e64703d9a 100644 --- a/tests/llm_providers_config.test.js +++ b/tests/llm_providers_config.test.js @@ -2,8 +2,8 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -const config = JSON.parse(readFileSync('llm_providers.json', 'utf8')); -const example = JSON.parse(readFileSync('llm_providers.example.json', 'utf8')); +const config = JSON.parse(readFileSync('settings_llm_providers.json', 'utf8')); +const example = JSON.parse(readFileSync('settings_llm_providers.example.json', 'utf8')); test('llm provider config uses readable format/baseUrl/keyName schema', () => { assert.equal(config.schemaVersion, 1); @@ -126,11 +126,13 @@ test('llm provider example mirrors provider ids without real key material', () = test('codex provider stores auth in the unified project config instead of a fixed user path', () => { assert.equal(config.models.codex.format, 'openai-codex-responses'); assert.equal(config.models.codex.adapter, 'codex'); - assert.equal(config.models.codex.params.keysPath, 'llm_providers.json'); + assert.equal(config.models.codex.params.keysPath, 'settings_llm_providers.json'); assert.equal('authPath' in config.models.codex.params, false); - assert.ok(Object.hasOwn(config.keys, 'CODEX_CHATGPT_AUTH')); + if (Object.hasOwn(config.keys, 'CODEX_CHATGPT_AUTH')) { + assert.equal(typeof config.keys.CODEX_CHATGPT_AUTH, 'object'); + } - assert.equal(example.models.codex.params.keysPath, 'llm_providers.json'); + assert.equal(example.models.codex.params.keysPath, 'settings_llm_providers.json'); assert.equal('authPath' in example.models.codex.params, false); assert.ok(Object.hasOwn(example.keys, 'CODEX_CHATGPT_AUTH')); assert.deepEqual(example.keys.CODEX_CHATGPT_AUTH, {}); diff --git a/tests/mindserver_chat_history.test.js b/tests/mindserver_chat_history.test.js new file mode 100644 index 000000000..fb73ed177 --- /dev/null +++ b/tests/mindserver_chat_history.test.js @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { readSavedChatHistory } from '../src/mindcraft/mindserver.js'; + +function withTempProject(fn) { + const dir = mkdtempSync(path.join(tmpdir(), 'mindcraft-chat-history-')); + try { + return fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +test('saved chat history loads from memory trace when load_memory is true', () => withTempProject((dir) => { + const botDir = path.join(dir, 'bots', 'tracebot'); + const traceDir = path.join(botDir, 'chat-history'); + mkdirSync(traceDir, { recursive: true }); + const tracePath = path.join(traceDir, 'session.jsonl'); + writeFileSync(tracePath, [ + JSON.stringify({ timestamp: '2026-05-02T00:00:00.000Z', agent: 'tracebot', type: 'llm_request', messages: [] }), + JSON.stringify({ timestamp: '2026-05-02T00:00:01.000Z', agent: 'tracebot', type: 'llm_response', response: { content: 'ok' } }) + ].join('\n') + '\n'); + writeFileSync(path.join(botDir, 'memory.json'), JSON.stringify({ + chat_history_trace: './bots/tracebot/chat-history/session.jsonl', + chat_history_latest: './bots/tracebot/chat_history.jsonl' + })); + + const result = readSavedChatHistory('tracebot', { loadMemory: true, cwd: dir }); + + assert.equal(result.loaded, true); + assert.equal(result.events.length, 2); + assert.equal(result.events[0].type, 'llm_request'); + assert.equal(result.events[1].response.content, 'ok'); +})); + +test('saved chat history is intentionally skipped when load_memory is false', () => withTempProject((dir) => { + const botDir = path.join(dir, 'bots', 'tracebot'); + mkdirSync(botDir, { recursive: true }); + writeFileSync(path.join(botDir, 'chat_history.jsonl'), JSON.stringify({ type: 'llm_request' }) + '\n'); + + const result = readSavedChatHistory('tracebot', { loadMemory: false, cwd: dir }); + + assert.equal(result.loaded, false); + assert.equal(result.reason, 'load_memory_disabled'); + assert.deepEqual(result.events, []); +})); + +test('saved chat history rejects unsafe agent names', () => withTempProject((dir) => { + const result = readSavedChatHistory('../tracebot', { loadMemory: true, cwd: dir }); + + assert.equal(result.loaded, false); + assert.equal(result.reason, 'invalid_agent_name'); + assert.deepEqual(result.events, []); +})); + +test('saved chat history falls back to memory turns when no trace file exists', () => withTempProject((dir) => { + const botDir = path.join(dir, 'bots', 'tracebot'); + mkdirSync(botDir, { recursive: true }); + writeFileSync(path.join(botDir, 'memory.json'), JSON.stringify({ + turns: [ + { role: 'user', content: 'Steve: hello' }, + { role: 'assistant', content: 'Hi Steve.' } + ] + })); + + const result = readSavedChatHistory('tracebot', { loadMemory: true, cwd: dir }); + + assert.equal(result.loaded, true); + assert.equal(result.restored_from_memory, true); + assert.equal(result.source, path.join(botDir, 'memory.json')); + assert.equal(result.events.length, 2); + assert.equal(result.events[0].type, 'history_turn_added'); + assert.equal(result.events[0].turn.content, 'Steve: hello'); +})); diff --git a/tests/mindserver_settings_spec.test.js b/tests/mindserver_settings_spec.test.js new file mode 100644 index 000000000..4bde70dae --- /dev/null +++ b/tests/mindserver_settings_spec.test.js @@ -0,0 +1,17 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildRuntimeSettingsSpec } from '../src/mindcraft/mindserver.js'; + +test('New Agent settings spec only inherits hidden runtime LLM provider registry path', () => { + const spec = buildRuntimeSettingsSpec({ + llm_providers: 'settings_llm_providers.json', + host: 'example.test', + profile: { name: 'ignored' } + }); + + assert.equal(spec.llm_providers.default, 'settings_llm_providers.json'); + assert.equal(spec.llm_providers.hidden, true); + assert.equal(spec.state_snapshot_diff, undefined); + assert.equal(spec.host.default, '127.0.0.1'); + assert.equal(spec.profile.default, undefined); +}); diff --git a/tests/native_prompt_hygiene.test.js b/tests/native_prompt_hygiene.test.js index cff98ab2b..d126a0e76 100644 --- a/tests/native_prompt_hygiene.test.js +++ b/tests/native_prompt_hygiene.test.js @@ -1,35 +1,95 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import { sanitizeNativeToolExamples } from '../src/models/prompter.js'; +import { readdirSync, readFileSync } from 'node:fs'; +import path from 'node:path'; +import { stripVolatileConversationPlaceholders } from '../src/models/prompter.js'; const legacyToolSyntax = /(^|\s)![A-Za-z_][A-Za-z0-9_]*\b|\*used\s+[A-Za-z_][A-Za-z0-9_]*\*/i; -test('default native conversation examples do not teach legacy text tool syntax', () => { +function readPromptRef(value) { + if (typeof value === 'string') return value; + if (value?.prompt_file) return readFileSync(value.prompt_file, 'utf8'); + return ''; +} + +test('default native conversation examples are fixed in markdown instead of JSON', () => { + const profile = JSON.parse(readFileSync('profiles/defaults/_default.json', 'utf8')); + const conversing = readFileSync('profiles/defaults/prompts/conversing.md', 'utf8'); + + assert.equal('conversation_examples' in profile, false); + assert.match(conversing, /Fixed examples of how to respond:/); + assert.match(conversing, /miner_32: Hey! What are you up to\?/); + assert.match(conversing, /rupert: Let us work together on a small house\./); +}); + +test('default coding examples are fixed in markdown instead of JSON', () => { const profile = JSON.parse(readFileSync('profiles/defaults/_default.json', 'utf8')); - const legacyTurns = profile.conversation_examples - .flat() - .filter(turn => legacyToolSyntax.test(turn.content || '')); + const coding = readFileSync('profiles/defaults/prompts/coding.md', 'utf8'); - assert.deepEqual(legacyTurns, []); + assert.equal('coding_examples' in profile, false); + assert.match(coding, /Fixed coding examples:/); + assert.match(coding, /greg: Collect 10 wood/); + assert.match(coding, /brug: build a dirt house/); }); -test('native prompt markdown rejects text-command and fake tool-call examples', () => { - const conversing = readFileSync('profiles/defaults/prompts/_default/conversing.md', 'utf8'); - const botResponder = readFileSync('profiles/defaults/prompts/_default/bot_responder.md', 'utf8'); +test('native prompt markdown rejects text-command and fake tool-call examples in conversation prompt', () => { + const conversing = readFileSync('profiles/defaults/prompts/conversing.md', 'utf8'); + const botResponder = readFileSync('profiles/defaults/prompts/bot_responder.md', 'utf8'); - assert.match(conversing, /native tool\/function/i); + assert.match(conversing, /native tools\/function calls/i); assert.match(conversing, /Do NOT write legacy text commands/i); assert.doesNotMatch(conversing, legacyToolSyntax); assert.doesNotMatch(botResponder, legacyToolSyntax); }); -test('native example sanitizer drops any profile examples with legacy tool syntax', () => { - const examples = [ - [{ role: 'user', content: 'hello' }, { role: 'assistant', content: 'hi' }], - [{ role: 'user', content: 'collect wood' }, { role: 'assistant', content: '!collectBlocks("oak_log", 3)' }], - [{ role: 'assistant', content: '*used craftRecipe*' }] - ]; - assert.deepEqual(sanitizeNativeToolExamples(examples), [examples[0]]); +test('conversation system prompt no longer uses dynamic example or command-doc placeholders', () => { + const conversing = readFileSync('profiles/defaults/prompts/conversing.md', 'utf8'); + const stable = stripVolatileConversationPlaceholders(conversing); + + assert.doesNotMatch(stable, /\$SELF_PROMPT/); + assert.doesNotMatch(stable, /\$MEMORY/); + assert.doesNotMatch(stable, /\$STATS/); + assert.doesNotMatch(stable, /\$INVENTORY/); + assert.doesNotMatch(stable, /\$COMMAND_DOCS/); + assert.doesNotMatch(stable, /stale prompt text/i); +}); + +test('conversation prompt sanitizer strips legacy dynamic context placeholders from task profiles', () => { + const construction = JSON.parse(readFileSync('profiles/tasks/construction_profile.json', 'utf8')); + const stable = stripVolatileConversationPlaceholders(readPromptRef(construction.conversing)); + + assert.doesNotMatch(stable, /\$SELF_PROMPT/); + assert.doesNotMatch(stable, /\$MEMORY/); + assert.doesNotMatch(stable, /\$STATS/); + assert.doesNotMatch(stable, /\$INVENTORY/); + assert.doesNotMatch(stable, /\$COMMAND_DOCS/); + assert.doesNotMatch(stable, /\$EXAMPLES/); +}); + +test('task profile conversation prompts are native-tool cache-safe', () => { + const taskDir = 'profiles/tasks'; + const volatile = /\$(SELF_PROMPT|MEMORY|STATS|INVENTORY|COMMAND_DOCS|EXAMPLES)/; + + for (const file of readdirSync(taskDir).filter(name => name.endsWith('.json'))) { + const profile = JSON.parse(readFileSync(path.join(taskDir, file), 'utf8')); + if (!profile.conversing) continue; + const conversing = readPromptRef(profile.conversing); + + assert.equal(profile.conversing.prompt_file, path.join(taskDir, file.replace(/_profile\.json$/, '_prompt.md'))); + assert.doesNotMatch(conversing, volatile, `${file} should not inject dynamic conversation context`); + assert.doesNotMatch(conversing, legacyToolSyntax, `${file} should not include legacy command examples`); + assert.match(conversing, /native tools\/function calls/i, `${file} should describe native tools`); + assert.equal('saving_memory' in profile, false, `${file} should inherit the shared compact prompt`); + } +}); + +test('conversation request messages do not append runtime context', async () => { + const { Prompter } = await import('../src/models/prompter.js'); + const messages = [{ role: 'user', content: 'Steve: what now?' }]; + const result = await Prompter.prototype.buildConversationMessages.call({}, messages); + + assert.equal(result, messages); + assert.equal(result.length, 1); + assert.doesNotMatch(JSON.stringify(result), /SYSTEM CONTEXT FOR THE PREVIOUS USER MESSAGE|CURRENT WORLD STATE|CURRENT INVENTORY|SUMMARIZED MEMORY/); }); diff --git a/tests/native_tools.test.js b/tests/native_tools.test.js index 4fbff3a80..74c8c54b5 100644 --- a/tests/native_tools.test.js +++ b/tests/native_tools.test.js @@ -2,6 +2,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { commandToToolDefinition, + getCommandToolDefinitions, executeCommandToolCall } from '../src/agent/commands/tool_adapter.js'; import { @@ -36,6 +37,44 @@ test('command schema conversion preserves required and optional parameters', () assert.equal(tool.function.parameters.properties.count.maximum, 5); }); +test('native command tool schemas are stable across repeated builds', () => { + const agent = { blocked_actions: ['!stop', '!stats'] }; + const first = getCommandToolDefinitions(agent); + const firstJson = JSON.stringify(first); + + for (let i = 0; i < 5; i++) { + assert.equal(JSON.stringify(getCommandToolDefinitions(agent)), firstJson); + } + + assert.deepEqual( + getCommandToolDefinitions({ blocked_actions: ['!stats', '!stop'] }), + first, + 'blocked action input order must not affect schema order or content' + ); +}); + +test('blocked native tools preserve source command order as a subsequence', () => { + const allTools = getCommandToolDefinitions({ blocked_actions: [] }); + const blocked = new Set(['!stats', '!inventory', '!nearbyBlocks']); + const filteredTools = getCommandToolDefinitions({ blocked_actions: Array.from(blocked) }); + + const allNames = allTools.map(tool => tool.function.name); + const filteredNames = filteredTools.map(tool => tool.function.name); + const expectedNames = allNames.filter(name => !blocked.has(`!${name}`)); + + assert.deepEqual(filteredNames, expectedNames); +}); + +test('craftRecipe tool asks for output item count', () => { + const craftTool = getCommandToolDefinitions({ blocked_actions: [] }) + .find(tool => tool.function.name === 'craftRecipe'); + + assert.ok(craftTool); + assert.match(craftTool.function.description, /output items/i); + assert.match(craftTool.function.parameters.properties.num.description, /output items/i); + assert.doesNotMatch(craftTool.function.parameters.properties.num.description, /NOT the number of output items/); +}); + test('native tool response normalizes and parses OpenAI-compatible tool calls', () => { const response = createNativeToolResponse([ { @@ -117,6 +156,80 @@ test('native tool turns serialize to protocol-specific tool result fields', () = assert.equal(gemini[2].parts[0].functionResponse.name, 'sample'); }); +test('multimodal message content keeps protocol-specific image payloads', () => { + const imageUrl = 'data:image/jpeg;base64,abc123'; + const turns = [{ + role: 'user', + content: [ + { type: 'text', text: 'describe this' }, + { type: 'image_url', image_url: { url: imageUrl } } + ] + }]; + + const openAI = nativeTools.toOpenAIChatMessages(turns); + assert.deepEqual(openAI[0].content, turns[0].content); + + const responses = nativeTools.toResponsesInputItems(turns); + assert.deepEqual(responses[0].content, [ + { type: 'input_text', text: 'describe this' }, + { type: 'input_image', image_url: imageUrl } + ]); + + const anthropicTurns = [{ + role: 'user', + content: [ + { type: 'text', text: 'describe this' }, + { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: 'abc123' } } + ] + }]; + const anthropic = nativeTools.toAnthropicMessages(anthropicTurns); + assert.deepEqual(anthropic[0].content, anthropicTurns[0].content); +}); + +test('Responses multimodal content converts back to Chat Completions shape', () => { + const imageUrl = 'data:image/jpeg;base64,abc123'; + const turns = [{ + role: 'user', + content: [ + { type: 'input_text', text: 'describe this' }, + { type: 'input_image', image_url: imageUrl } + ] + }]; + + const openAI = nativeTools.toOpenAIChatMessages(turns); + assert.deepEqual(openAI[0].content, [ + { type: 'text', text: 'describe this' }, + { type: 'image_url', image_url: { url: imageUrl } } + ]); +}); + +test('history system turns are downgraded to user messages for provider protocols', () => { + const turns = [ + { role: 'system', content: 'runtime state changed' }, + { role: 'user', content: 'hello' } + ]; + + const openAI = nativeTools.toOpenAIChatMessages(turns, 'stable system prompt'); + assert.equal(openAI.filter(message => message.role === 'system').length, 1); + assert.equal(openAI[0].content, 'stable system prompt'); + assert.equal(openAI[1].role, 'user'); + assert.match(openAI[1].content, /^SYSTEM: runtime state changed/); + + const responses = nativeTools.toResponsesInputItems(turns); + assert.equal(responses[0].role, 'user'); + assert.match(responses[0].content[0].text, /^SYSTEM: runtime state changed/); + + const anthropic = nativeTools.toAnthropicMessages(turns); + assert.ok(anthropic.every(message => message.role !== 'system')); + assert.equal(anthropic[0].role, 'user'); + assert.match(anthropic[0].content, /^SYSTEM: runtime state changed/); + + const gemini = nativeTools.toGeminiContents(turns); + assert.ok(gemini.every(content => content.role !== 'system')); + assert.equal(gemini[0].role, 'user'); + assert.match(gemini[0].parts[0].text, /^SYSTEM: runtime state changed/); +}); + test('native tool turn repair drops orphan results and can synthesize missing results', () => { const call = { id: 'call_1', diff --git a/tests/openai_compatible.test.js b/tests/openai_compatible.test.js index cd2edee24..856b9cdcc 100644 --- a/tests/openai_compatible.test.js +++ b/tests/openai_compatible.test.js @@ -144,6 +144,11 @@ test('openai-compatible transport sends Chat Completions tools and normalizes to create: async pack => { requestPack = pack; return { + usage: { + prompt_tokens: 100, + completion_tokens: 12, + prompt_tokens_details: { cached_tokens: 70 } + }, choices: [{ message: { tool_calls: [{ @@ -177,6 +182,9 @@ test('openai-compatible transport sends Chat Completions tools and normalizes to assert.equal(isNativeToolResponse(response), true); assert.equal(response.provider, 'example-provider'); assert.equal(response.tool_calls[0].name, 'report_status'); + assert.equal(model.lastTokenUsage.input_uncached, 30); + assert.equal(model.lastTokenUsage.input_cached, 70); + assert.equal(model.lastTokenUsage.output, 12); }); test('openai-completions transport strips tool choice even if configured', async () => { diff --git a/tests/token_usage.test.js b/tests/token_usage.test.js new file mode 100644 index 000000000..bd89668ef --- /dev/null +++ b/tests/token_usage.test.js @@ -0,0 +1,50 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { normalizeTokenUsage } from '../src/models/token_usage.js'; + +test('token usage normalizes OpenAI cached prompt tokens', () => { + assert.deepEqual(normalizeTokenUsage({ + prompt_tokens: 100, + completion_tokens: 20, + total_tokens: 120, + prompt_tokens_details: { cached_tokens: 64 } + }), { + input_total: 100, + input_uncached: 36, + input_cached: 64, + output: 20, + total: 120, + raw: { + prompt_tokens: 100, + completion_tokens: 20, + total_tokens: 120, + prompt_tokens_details: { cached_tokens: 64 } + } + }); +}); + +test('token usage normalizes Anthropic cache creation and cache reads', () => { + const usage = normalizeTokenUsage({ + input_tokens: 12, + cache_creation_input_tokens: 30, + cache_read_input_tokens: 80, + output_tokens: 7 + }); + + assert.equal(usage.input_uncached, 42); + assert.equal(usage.input_cached, 80); + assert.equal(usage.output, 7); +}); + +test('token usage normalizes Gemini cached content tokens', () => { + const usage = normalizeTokenUsage({ + promptTokenCount: 50, + cachedContentTokenCount: 35, + candidatesTokenCount: 9, + totalTokenCount: 59 + }); + + assert.equal(usage.input_uncached, 15); + assert.equal(usage.input_cached, 35); + assert.equal(usage.output, 9); +}); diff --git a/tests/tool_result_policy.test.js b/tests/tool_result_policy.test.js index 0ecbcdc58..f4787c388 100644 --- a/tests/tool_result_policy.test.js +++ b/tests/tool_result_policy.test.js @@ -11,18 +11,50 @@ test('action tools provide explicit LLM-visible results even when game output is assert.ok(actionsSource.includes('Action completed.')); }); -test('stuck action interruption reports busy state instead of killing the process', () => { +test('uninterruptible actions still restart as a last-resort fallback', () => { const actionManagerSource = readFileSync('src/agent/action_manager.js', 'utf8'); const actionsSource = readFileSync('src/agent/commands/actions.js', 'utf8'); - assert.equal(actionManagerSource.includes('Code execution refused stop after 10 seconds. Killing process.'), false); - assert.ok(actionManagerSource.includes('could not start')); - assert.ok(actionManagerSource.includes('leaving current action running')); - assert.ok(actionsSource.includes('The agent process was kept alive.')); + assert.ok(actionManagerSource.includes('Code execution refused stop after ${timeoutMs}ms. Killing process.')); + assert.ok(actionManagerSource.includes('this.agent.cleanKill')); + assert.equal(actionManagerSource.includes('could not start'), false); + assert.equal(actionManagerSource.includes('leaving current action running'), false); + assert.equal(actionsSource.includes('The agent process was kept alive.'), false); assert.ok(actionsSource.includes('code_return.message')); assert.ok(actionsSource.includes('newAction did not produce code or a tool result.')); }); + +test('collect block actions can resolve gracefully on interrupt', () => { + const skillsSource = readFileSync('src/agent/library/skills.js', 'utf8'); + const agentSource = readFileSync('src/agent/agent.js', 'utf8'); + + assert.ok(agentSource.includes("this.bot.emit('mindcraft_interrupt')")); + assert.ok(agentSource.includes('this.collectBlockCancelPromise')); + assert.ok(skillsSource.includes('waitForInterruptOrResult(bot, bot.collectBlock.collect(block)')); + assert.ok(skillsSource.includes("bot.once('mindcraft_interrupt', interrupt)")); +}); + + + +test('place block failures include the underlying action error', () => { + const skillsSource = readFileSync('src/agent/library/skills.js', 'utf8'); + + assert.ok(skillsSource.includes('const MAX_ACTION_ERROR_LENGTH = 300;')); + assert.ok(skillsSource.includes('function formatActionError')); + assert.ok(skillsSource.includes('text.slice(0, MAX_ACTION_ERROR_LENGTH)')); + assert.ok(skillsSource.includes('Failed to place ${blockType} at ${target_dest}: ${formatActionError(err)}.')); +}); + +test('torch placing mode does not interrupt active self prompting or actions', () => { + const modesSource = readFileSync('src/agent/modes.js', 'utf8'); + const torchSection = modesSource.slice(modesSource.indexOf("name: 'torch_placing'"), modesSource.indexOf("name: 'elbow_room'")); + + assert.ok(torchSection.includes('!agent.isIdle()')); + assert.ok(torchSection.includes('agent.self_prompter.isActive()')); + assert.ok(torchSection.includes('this.active')); +}); + test('conversation action reports status through tool result instead of system history', () => { const actionsSource = readFileSync('src/agent/commands/actions.js', 'utf8'); const startConversationSection = actionsSource.slice(actionsSource.indexOf("name: '!startConversation'")); diff --git a/tests/vision_interpreter.test.js b/tests/vision_interpreter.test.js new file mode 100644 index 000000000..f0f26de69 --- /dev/null +++ b/tests/vision_interpreter.test.js @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { VisionInterpreter } from '../src/agent/vision/vision_interpreter.js'; + +test('VisionInterpreter reports settings-disabled vision separately from adapter support', () => { + const interpreter = Object.create(VisionInterpreter.prototype); + interpreter.allow_vision = false; + interpreter.agent = { prompter: { vision_model: { sendVisionRequest: async () => 'ok' } } }; + + assert.equal( + interpreter.getVisionUnavailableMessage(), + 'Vision is disabled in settings. Set allow_vision to true and restart the agent.' + ); +}); + +test('VisionInterpreter reports missing image support when settings allow vision', () => { + const interpreter = Object.create(VisionInterpreter.prototype); + interpreter.allow_vision = true; + interpreter.agent = { prompter: { vision_model: {} } }; + + assert.equal( + interpreter.getVisionUnavailableMessage(), + 'Vision model does not support image input. Configure a vision-capable model or adapter.' + ); +}); + +test('VisionInterpreter accepts adapters that implement sendVisionRequest', () => { + const interpreter = Object.create(VisionInterpreter.prototype); + interpreter.allow_vision = true; + interpreter.agent = { prompter: { vision_model: { sendVisionRequest: async () => 'ok' } } }; + + assert.equal(interpreter.getVisionUnavailableMessage(), null); +}); From f23d8436e03a434d0fc4ed9ee08e71134cb9d468 Mon Sep 17 00:00:00 2001 From: NinotQuyi Date: Sat, 2 May 2026 20:48:29 +0800 Subject: [PATCH 17/56] Stabilize native tool traces without breaking cache prefixes The ReAct request assembly and Runtime trace rendering had grown across several call sites, making it hard to tell whether UI duplication, internal coding requests, or transport metadata were mutating the active conversation prefix. This commit centralizes ReAct message assembly, separates internal coding cache scope from conversation cache scope, records instruction contexts in trace, and moves chat trace projection into a dedicated display-only module. Constraint: Prompt-cache debugging requires trace evidence without logging auth secrets or mutating model history. Constraint: newAction coding requests must remain tool-internal and not become top-level ReAct history. Rejected: Keep fixing individual UI/request edge cases inline | scattered patches made cache-prefix behavior harder to reason about. Rejected: Store runtime instructions only in notepad | user explicitly needed them in trace. Confidence: high Scope-risk: moderate Directive: Do not add transient state or coding requests directly to ReAct history; route outbound user context through ReactMessageManager and keep chat_trace_projector display-only. Tested: node --check src/agent/history.js Tested: node --check src/mindcraft/public/chat_trace_projector.js Tested: npm test (119 passing) Tested: git diff --check --- src/agent/agent.js | 114 +++------ src/agent/history.js | 103 +++++++- src/agent/react_message_manager.js | 118 +++++++++ src/mindcraft/public/chat_trace_projector.js | 241 +++++++++++++++++++ src/mindcraft/public/index.html | 211 +++------------- src/models/codex_chatgpt.js | 64 ++++- src/models/prompter.js | 17 +- tests/agent_native_text_policy.test.js | 124 +++++++--- tests/chat_history_trace.test.js | 46 ++++ tests/codex_chatgpt.test.js | 57 +++++ 10 files changed, 787 insertions(+), 308 deletions(-) create mode 100644 src/agent/react_message_manager.js create mode 100644 src/mindcraft/public/chat_trace_projector.js diff --git a/src/agent/agent.js b/src/agent/agent.js index 81a7a2fd9..637ab1c60 100644 --- a/src/agent/agent.js +++ b/src/agent/agent.js @@ -11,7 +11,7 @@ import { ActionManager } from './action_manager.js'; import { NPCContoller } from './npc/controller.js'; import { MemoryBank } from './memory_bank.js'; import { SelfPrompter } from './self_prompter.js'; -import { buildStateSnapshotDiff } from './state_snapshot.js'; +import { ReactMessageManager } from './react_message_manager.js'; import convoManager from './conversation.js'; import { addBrowserViewer } from './vision/browser_viewer.js'; import { serverProxy, sendOutputToServer } from './mindserver_proxy.js'; @@ -43,6 +43,7 @@ export class Agent { } this.history = new History(this); + this.react_messages = new ReactMessageManager(this); this.coder = new Coder(this); this.npc = new NPCContoller(this); this.memory_bank = new MemoryBank(); @@ -163,6 +164,7 @@ export class Agent { if (settings.only_chat_with.length > 0 && !settings.only_chat_with.includes(username)) return; try { if (ignore_messages.some((m) => message.startsWith(m))) return; + if (isMinecraftCommandEchoMessage(message)) return; this.shut_up = false; @@ -316,71 +318,18 @@ export class Agent { const checkInterrupt = () => this.self_prompter.shouldInterrupt(self_prompt) || this.shut_up || convoManager.responseScheduledFor(source); - // Runtime behavior notes are request context. For non-transient user turns - // they are persisted with the outbound message so future prompts remain - // append-only for prompt-cache stability. - const transientMessages = []; - let behavior_log = this.bot.modes.flushBehaviorLog().trim(); - if (behavior_log.length > 0) { - const MAX_LOG = 500; - if (behavior_log.length > MAX_LOG) { - behavior_log = '...' + behavior_log.substring(behavior_log.length - MAX_LOG); - } - behavior_log = 'Recent behaviors log: \n' + behavior_log; - transientMessages.push(createTransientSystemUserMessage(behavior_log)); - } - - // Handle other user messages. Self-prompt continuation nudges stay - // transient, but normal user/runtime context is persisted exactly as it is - // sent to the model. Prompt caches are append-only sensitive: if we send a - // state update in one request and omit it from future history, the next - // request rewrites the prior prefix and cache reads can drop to zero. - let pendingPersistedParts = []; - if (options.transient) { - transientMessages.push(createTransientSystemUserMessage(message)); - } - else { - pendingPersistedParts.push(createHistoryUserMessageForRequest(source, message, this.name).content); - if (transientMessages.length > 0) { - pendingPersistedParts.push(...transientMessages.map(message => message?.content ?? message)); - transientMessages.length = 0; - } + if (!this.react_messages) { + this.react_messages = new ReactMessageManager(this); } + const behaviorLog = this.bot.modes.flushBehaviorLog(); + const reactTurn = this.react_messages.startTurn({ source, message, options, behaviorLog }); if (!self_prompt && this.self_prompter.isActive()) // message is from user during self-prompting max_responses = 1; // force only respond to this message, then let self-prompting take over - let includeTransientMessages = transientMessages.length > 0; for (let i=0; i 0) { - pendingPersistedParts.push(stateDiff); - } - else if (!options.transient) { - await this.history.addUserContext(stateDiff); - this.history.save(); - } - else { - transientParts.push(stateDiff); - } - } - if (pendingPersistedParts.length > 0) { - await this.history.addUserContext(pendingPersistedParts.join('\n\n')); - this.history.save(); - pendingPersistedParts = []; - } - if (includeTransientMessages) { - transientParts.push(...transientMessages.map(message => message?.content ?? message)); - } - let history = this.history.getHistory(); - const transientRequest = createTransientRequestMessage(transientParts); - if (transientRequest) { - history.push(transientRequest); - } - let res = await this.prompter.promptConvo(history); - includeTransientMessages = false; + let history = await reactTurn.buildRequestMessages(); + let res = await this.prompter.promptConvo(history, { turnStateKey: reactTurn.turnStateKey }); if (isNativeToolResponse(res)) { console.log(`${this.name} native tool calls from ${source}: ${formatNativeToolCallsForLog(res.tool_calls)}`); @@ -664,6 +613,28 @@ function hasLoadedConversation(saveData) { || (Array.isArray(saveData.turns) && saveData.turns.length > 0)); } +const MINECRAFT_COMMAND_ECHO_PATTERNS = [ + /^Removed \d+ (?:items?|item\(s\)) from .+\]?$/i, + /^Gave \d+ .+ to .+$/i, + /^Cleared (?:the )?inventory of .+$/i, + /^Killed .+$/i, + /^Summoned new .+$/i, + /^Set block .+$/i, + /^Changed the block at .+$/i, + /^Applied effect .+$/i, + /^Made .+ say .+$/i, + /^Played sound .+$/i, + /^Stopped sound .+$/i, + /^Located .+ at .+$/i +]; + +export function isMinecraftCommandEchoMessage(message) { + const text = String(message ?? '').trim(); + if (!text) return false; + if (text.startsWith('/')) return true; + return MINECRAFT_COMMAND_ECHO_PATTERNS.some(pattern => pattern.test(text)); +} + export function prepareChatMessageForOutput(message) { let spokenMessage = String(message ?? ''); let remaining = ''; @@ -682,29 +653,6 @@ export function prepareChatMessageForOutput(message) { }; } -function createHistoryUserMessageForRequest(source, message, agentName) { - const content = source === 'system' - ? `System: ${message}` - : source !== agentName - ? `${source}: ${message}` - : message; - return { role: 'user', content }; -} - - -function createTransientSystemUserMessage(message) { - return { role: 'user', content: `System: ${message}` }; -} - -function createTransientRequestMessage(parts) { - const content = parts - .map(part => String(part || '').trim()) - .filter(Boolean) - .join('\n\n'); - return content ? { role: 'user', content } : null; -} - - function formatNativeToolCallsForLog(toolCalls = []) { if (!Array.isArray(toolCalls) || toolCalls.length === 0) { return ''; diff --git a/src/agent/history.js b/src/agent/history.js index 2d4e43b6c..9f04ecb5f 100644 --- a/src/agent/history.js +++ b/src/agent/history.js @@ -1,4 +1,5 @@ import { writeFileSync, readFileSync, mkdirSync, existsSync, appendFileSync } from 'fs'; +import path from 'path'; import { NPCData } from './npc/data.js'; import settings from './settings.js'; import { createNativeToolCallTurn, createNativeToolResultTurn, hasNativeToolCalls, isNativeToolResultTurn } from '../models/native_tools.js'; @@ -28,6 +29,7 @@ export class History { // latest compact boundary and summarizes that whole active context. this.max_messages = Number.isFinite(settings.max_messages) ? settings.max_messages : Infinity; this.compact_message_threshold_percent = normalizePercent(settings.compact_message_threshold_percent, 100); + this.instruction_contexts_traced = false; if (this.fullTraceEnabled()) { this._initChatHistoryTrace(); @@ -243,6 +245,16 @@ export class History { this.traceEvent('history_cleared', {}); } + traceInstructionContext(title, content, metadata = {}) { + const text = String(content || '').trim(); + if (!text) return; + this.traceEvent('instruction_context', { + title: String(title || 'Instructions'), + content: text, + metadata: makeJsonSafe(metadata || {}) + }); + } + traceLLMRequest(tag, model, systemPrompt, messages, tools = null) { this.traceEvent('llm_request', { tag, @@ -303,6 +315,14 @@ export class History { } } + _traceInstructionContextsAtSessionStart() { + if (this.instruction_contexts_traced) return; + this.instruction_contexts_traced = true; + for (const context of collectInstructionContexts()) { + this.traceInstructionContext(context.title, context.content, context.metadata); + } + } + _initChatHistoryTrace() { const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); this.chat_history_session_fp = `${this.chat_history_dir}/${timestamp}.jsonl`; @@ -314,6 +334,7 @@ export class History { max_messages: this.max_messages, compact_message_threshold_percent: this.compact_message_threshold_percent }); + this._traceInstructionContextsAtSessionStart(); } fullTraceEnabled() { @@ -325,6 +346,85 @@ export class History { } } + +function collectInstructionContexts() { + const contexts = []; + for (const item of normalizeConfiguredInstructionContexts(settings.trace_instruction_contexts || settings.trace_instructions)) { + contexts.push(item); + } + for (const filePath of collectInstructionFiles()) { + const content = readInstructionFile(filePath); + if (!content) continue; + contexts.push({ + title: `AGENTS.md · ${path.relative(process.cwd(), filePath) || path.basename(filePath)}`, + content, + metadata: { source: filePath } + }); + } + return contexts; +} + +function normalizeConfiguredInstructionContexts(value) { + const list = Array.isArray(value) ? value : value ? [value] : []; + return list.map((item, index) => { + if (typeof item === 'string') { + return { + title: index === 0 ? 'Runtime instructions' : `Runtime instructions ${index + 1}`, + content: item, + metadata: { source: 'settings.trace_instruction_contexts' } + }; + } + if (!item || typeof item !== 'object') return null; + return { + title: item.title || item.name || 'Runtime instructions', + content: item.content || item.text || '', + metadata: item.metadata || { source: 'settings.trace_instruction_contexts' } + }; + }).filter(item => String(item?.content || '').trim()); +} + +function collectInstructionFiles() { + const explicit = Array.isArray(settings.trace_instruction_files) + ? settings.trace_instruction_files.map(file => path.resolve(process.cwd(), file)) + : []; + return uniquePaths([...findScopedAgentInstructionFiles(process.cwd()), ...explicit]); +} + +function findScopedAgentInstructionFiles(startDir) { + const files = []; + let dir = path.resolve(startDir || process.cwd()); + while (true) { + const candidate = path.join(dir, 'AGENTS.md'); + if (existsSync(candidate)) files.push(candidate); + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return files.reverse(); +} + +function uniquePaths(paths) { + const seen = new Set(); + const out = []; + for (const item of paths) { + const resolved = path.resolve(item); + if (seen.has(resolved)) continue; + seen.add(resolved); + out.push(resolved); + } + return out; +} + +function readInstructionFile(filePath) { + try { + if (!existsSync(filePath)) return ''; + return readFileSync(filePath, 'utf8').trim(); + } catch (error) { + console.warn(`Failed to read instruction context ${filePath}:`, error?.message || error); + return ''; + } +} + function isChatDisplayEvent(type) { return [ 'llm_request', @@ -334,7 +434,8 @@ function isChatDisplayEvent(type) { 'tool_call', 'tool_result', 'history_cleared', - 'history_compacted' + 'history_compacted', + 'instruction_context' ].includes(type); } diff --git a/src/agent/react_message_manager.js b/src/agent/react_message_manager.js new file mode 100644 index 000000000..167bb55c8 --- /dev/null +++ b/src/agent/react_message_manager.js @@ -0,0 +1,118 @@ +import { buildStateSnapshotDiff } from './state_snapshot.js'; + +const MAX_BEHAVIOR_LOG_CHARS = 500; + +/** + * Factory for ReAct message sessions. + * + * History remains the append-only persistence layer, but each ReAct turn gets a + * dedicated session object that owns request-context assembly. Keeping per-turn + * state off the Agent prevents concurrent message handlers from overwriting one + * another while still centralizing the message lifecycle in one place. + */ +export class ReactMessageManager { + constructor(agent) { + this.agent = agent; + this.nextTurnId = 0; + } + + startTurn({ source, message, options = {}, behaviorLog = '' }) { + this.nextTurnId += 1; + return new ReactMessageTurn(this.agent, { source, message, options, behaviorLog, turnId: `react-${this.nextTurnId}` }); + } +} + +class ReactMessageTurn { + constructor(agent, { source, message, options = {}, behaviorLog = '', turnId }) { + this.agent = agent; + this.options = options || {}; + this.turnStateKey = turnId; + this.pendingPersistedParts = []; + this.transientParts = []; + this.includeTransientParts = false; + this.initialize({ source, message, behaviorLog }); + } + + initialize({ source, message, behaviorLog = '' }) { + const runtimeParts = []; + const formattedBehaviorLog = formatBehaviorLog(behaviorLog); + if (formattedBehaviorLog) { + runtimeParts.push(formatSystemUserContent(formattedBehaviorLog)); + } + + if (this.options.transient) { + this.transientParts.push(formatSystemUserContent(message)); + this.transientParts.push(...runtimeParts); + this.includeTransientParts = this.transientParts.length > 0; + return; + } + + this.pendingPersistedParts.push(formatHistoryUserContent(source, message, this.agent.name)); + this.pendingPersistedParts.push(...runtimeParts); + } + + async buildRequestMessages() { + const requestTransientParts = []; + const stateDiff = buildStateSnapshotDiff(this.agent); + if (stateDiff) { + if (this.pendingPersistedParts.length > 0) { + this.pendingPersistedParts.push(stateDiff); + } + else if (!this.options.transient) { + await this.persistUserContext(stateDiff); + } + else { + requestTransientParts.push(stateDiff); + } + } + + if (this.pendingPersistedParts.length > 0) { + await this.persistUserContext(this.pendingPersistedParts.join('\n\n')); + this.pendingPersistedParts = []; + } + + if (this.includeTransientParts) { + requestTransientParts.push(...this.transientParts); + this.includeTransientParts = false; + } + + const messages = this.agent.history.getHistory(); + const transientRequest = createTransientRequestMessage(requestTransientParts); + if (transientRequest) { + messages.push(transientRequest); + } + return messages; + } + + async persistUserContext(content) { + await this.agent.history.addUserContext(content); + this.agent.history.save(); + } +} + +export function formatHistoryUserContent(source, message, agentName) { + if (source === 'system') return `System: ${message}`; + if (source !== agentName) return `${source}: ${message}`; + return String(message ?? ''); +} + +export function formatSystemUserContent(message) { + return `System: ${message}`; +} + +export function createTransientRequestMessage(parts) { + const content = parts + .map(part => String(part || '').trim()) + .filter(Boolean) + .join('\n\n'); + return content ? { role: 'user', content } : null; +} + +export function formatBehaviorLog(behaviorLog) { + let text = String(behaviorLog || '').trim(); + if (!text) return ''; + if (text.length > MAX_BEHAVIOR_LOG_CHARS) { + text = '...' + text.substring(text.length - MAX_BEHAVIOR_LOG_CHARS); + } + return 'Recent behaviors log: \n' + text; +} diff --git a/src/mindcraft/public/chat_trace_projector.js b/src/mindcraft/public/chat_trace_projector.js new file mode 100644 index 000000000..a61bc2438 --- /dev/null +++ b/src/mindcraft/public/chat_trace_projector.js @@ -0,0 +1,241 @@ +(function attachChatTraceProjector(global) { + 'use strict'; + + function callHelper(name, ...args) { + const helper = global[name]; + if (typeof helper !== 'function') { + throw new Error(`ChatTraceProjector helper ${name} is not available`); + } + return helper(...args); + } + +class ChatTraceProjector { + constructor(events = []) { + this.events = Array.isArray(events) ? events : []; + this.thread = { systemPrompt: null, systemEvent: null, tools: null, toolsEvent: null, instructionContexts: [], turns: [] }; + this.current = null; + this.previousRequestMessages = []; + } + + build() { + this.events.forEach(event => this.addEvent(event)); + return this.thread; + } + + addEvent(event) { + if (!event || typeof event !== 'object') return; + switch (event.type) { + case 'instruction_context': + this.addInstructionContext(event); + break; + case 'llm_request': + this.addRequest(event); + break; + case 'llm_response': + this.addResponse(event); + break; + case 'history_turn_added': + this.addHistoryTurn(event); + break; + case 'history_compacted': + this.addCompactEvent(event); + break; + case 'tool_call': + this.addToolCall(event); + break; + case 'tool_result': + this.addToolResult(event); + break; + case 'llm_error': + this.addError(event); + break; + default: + break; + } + } + + createTurn(seed = {}) { + return { + request: null, + response: null, + errors: [], + historyMessages: [], + inlineHistoryMessages: [], + compacted: null, + toolRuns: [], + visibleRequestMessages: [], + requestMessageCount: 0, + assistantText: '', + assistantToolCalls: [], + modelLabel: 'model', + ...seed + }; + } + + ensureTurn() { + if (!this.current) { + this.current = this.createTurn(); + this.thread.turns.push(this.current); + } + return this.current; + } + + addInstructionContext(event) { + this.thread.instructionContexts.push(event); + } + + addRequest(event) { + if (event.tag === 'coding' && this.attachInternalToolEvent(event)) return; + this.captureThreadContext(event); + + const requestMessages = Array.isArray(event.messages) ? event.messages : []; + const pendingHistoryMessages = this.takePendingHistoryOnlyTurn(); + this.removeRequestIncludedHistory(this.current, requestMessages); + + this.current = this.createTurn({ + request: event, + historyMessages: pendingHistoryMessages, + visibleRequestMessages: callHelper('selectVisibleRequestMessages', requestMessages, this.previousRequestMessages), + requestMessageCount: requestMessages.length + }); + this.previousRequestMessages = requestMessages; + this.updateTurnProjection(this.current); + this.thread.turns.push(this.current); + } + + addResponse(event) { + if (event.tag === 'coding' && this.attachInternalToolEvent(event)) return; + const turn = this.ensureTurn(); + turn.response = event; + this.updateTurnProjection(turn); + } + + addHistoryTurn(event) { + const turn = this.ensureTurn(); + turn.historyMessages.push(event); + this.updateTurnProjection(turn); + } + + addCompactEvent(event) { + const turn = this.ensureTurn(); + turn.compacted = event; + this.updateTurnProjection(turn); + this.current = null; + } + + addToolCall(event) { + const turn = this.ensureTurn(); + turn.toolRuns.push({ event, call: event.tool_call, result: null, internalEvents: [] }); + this.updateTurnProjection(turn); + } + + addToolResult(event) { + const turn = this.ensureTurn(); + const match = this.findToolRunForResult(turn, event); + if (match) { + match.result = event; + } else { + turn.toolRuns.push({ event, call: event.tool_call, result: event, internalEvents: [] }); + } + this.updateTurnProjection(turn); + } + + addError(event) { + if (event.tag === 'coding' && this.attachInternalToolEvent(event)) return; + const turn = this.ensureTurn(); + turn.errors.push(event); + this.updateTurnProjection(turn); + } + + captureThreadContext(event) { + if (!this.thread.systemPrompt && event.system_prompt) { + this.thread.systemPrompt = event.system_prompt; + this.thread.systemEvent = event; + } + if (!this.thread.tools && Array.isArray(event.tools)) { + this.thread.tools = event.tools; + this.thread.toolsEvent = event; + } + } + + takePendingHistoryOnlyTurn() { + if (!isHistoryOnlyProjectionTurn(this.current)) return []; + const pendingHistoryMessages = this.current.historyMessages; + this.thread.turns.pop(); + return pendingHistoryMessages; + } + + removeRequestIncludedHistory(turn, requestMessages) { + if (!turn?.historyMessages?.length) return; + turn.historyMessages = turn.historyMessages + .filter(historyEvent => !callHelper('isHistoryTurnIncludedInRequest', historyEvent.turn, requestMessages)); + this.updateTurnProjection(turn); + } + + attachInternalToolEvent(event) { + const item = this.findInternalToolHost(this.current); + if (!item) return false; + item.internalEvents = item.internalEvents || []; + item.internalEvents.push(event); + this.updateTurnProjection(this.current); + return true; + } + + findInternalToolHost(turn) { + if (!turn?.toolRuns?.length) return null; + const reversed = turn.toolRuns.slice().reverse(); + return reversed.find(item => callHelper('getToolName', item.call) === 'newAction' && !item.result) + || reversed.find(item => !item.result) + || reversed[0]; + } + + findToolRunForResult(turn, event) { + const resultId = callHelper('getToolCallId', event.tool_call); + return turn.toolRuns.slice().reverse().find(item => { + const callId = callHelper('getToolCallId', item.call); + return callId && resultId ? callId === resultId : callHelper('getToolName', item.call) === callHelper('getToolName', event.tool_call) && !item.result; + }); + } + + updateTurnProjection(turn) { + if (!turn) return; + const requestMessages = Array.isArray(turn.request?.messages) ? turn.request.messages : []; + const hasToolRuns = turn.toolRuns.length > 0; + turn.inlineHistoryMessages = turn.historyMessages.filter(event => shouldRenderInlineHistoryEvent(event, requestMessages, hasToolRuns)); + turn.modelLabel = getTurnModelLabel(turn); + turn.assistantText = callHelper('extractResponseText', turn.response?.response); + const responseCalls = callHelper('extractResponseToolCalls', turn.response?.response); + turn.assistantToolCalls = hasToolRuns ? [] : responseCalls; + } +} + +function isHistoryOnlyProjectionTurn(turn) { + return Boolean(turn) + && !turn.request + && !turn.response + && !turn.compacted + && (!turn.toolRuns || turn.toolRuns.length === 0) + && (!turn.errors || turn.errors.length === 0) + && Array.isArray(turn.historyMessages) + && turn.historyMessages.length > 0; +} + +function shouldRenderInlineHistoryEvent(event, requestMessages, hasToolRuns) { + const role = event?.turn?.role; + if (callHelper('isHistoryTurnIncludedInRequest', event?.turn, requestMessages)) return false; + if (!hasToolRuns) return role === 'user'; + return role === 'user' && !String(event?.turn?.content || '').startsWith('System:'); +} + +function getTurnModelLabel(turn) { + const request = turn?.request; + const response = turn?.response; + return request?.model?.model || request?.model?.api || response?.model?.model || response?.model?.api || 'model'; +} + + + global.ChatTraceProjector = ChatTraceProjector; + global.buildChatThread = function buildChatThread(events) { + return new ChatTraceProjector(events).build(); + }; +})(window); diff --git a/src/mindcraft/public/index.html b/src/mindcraft/public/index.html index fca91b7e1..8aad63f34 100644 --- a/src/mindcraft/public/index.html +++ b/src/mindcraft/public/index.html @@ -3,6 +3,7 @@ Mindcraft + @@ -2890,6 +2941,7 @@

Inventory

} function renderChatTurn(turn, index) { + if (turn.branchDecision) return renderBranchDecision(turn.branchDecision, index); const hasRunContent = Boolean(turn.request || turn.response || turn.toolRuns.length || turn.errors.length); if (!hasRunContent && turn.historyMessages.length) { return `
${renderRestoredHistoryMessages(turn.historyMessages, index)}
`; @@ -2904,6 +2956,49 @@

Inventory

`; } + + function renderBranchDecision(branch, index) { + const request = branch?.request; + const response = branch?.response; + const errors = Array.isArray(branch?.errors) ? branch.errors : []; + const model = request?.model?.display_label + || response?.model?.display_label + || request?.model?.model + || response?.model?.model + || request?.model?.api + || response?.model?.api + || 'model'; + const scope = request?.cache_scope || request?.cacheScope || response?.cache_scope || response?.cacheScope || request?.tag || response?.tag || 'branch'; + const timestamp = response?.timestamp || request?.timestamp; + const question = getBranchQuestionText(request); + const decision = getBranchDecisionText(response); + const tokenUsage = renderTokenUsage(response?.token_usage); + const errorText = errors.map(error => error.error?.message || stringifyChatText(error.error)).filter(Boolean).join('\n'); + return ` +
+
+
Branch decision${escapeHTML(scope)}Run ${index + 1}${escapeHTML(model)}${escapeHTML(formatChatTime(timestamp))}${tokenUsage}
+ ${question ? `
${escapeHTML(question)}
` : '
No branch question recorded yet.
'} + ${decision ? `
decision: ${escapeHTML(decision)}
` : '
waiting for branch response…
'} + ${errorText ? `
${escapeHTML(limitText(errorText, 300))}
` : ''} + ${renderDetails('Branch request payload', request)} + ${renderDetails('Branch response payload', response)} + ${errors.map(error => renderDetails('Branch error payload', error)).join('')} +
+
`; + } + + function getBranchQuestionText(request) { + const messages = Array.isArray(request?.messages) ? request.messages : []; + const lastUser = messages.slice().reverse().find(message => message?.role === 'user'); + return lastUser ? formatChatMessagePreview(lastUser) : ''; + } + + function getBranchDecisionText(response) { + const text = extractResponseText(response?.response) || stringifyChatText(response?.response); + return limitText(String(text || '').trim(), 300); + } + function renderCompactEvent(event) { if (!event) return ''; const detailId = getChatDetailId('Compact boundary', event); diff --git a/tests/agent_native_text_policy.test.js b/tests/agent_native_text_policy.test.js index 0e212741d..d1b898aac 100644 --- a/tests/agent_native_text_policy.test.js +++ b/tests/agent_native_text_policy.test.js @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; test('agent contains explicit AI text-command block in native tool mode', () => { const agentSource = readFileSync('src/agent/agent.js', 'utf8'); @@ -488,16 +489,53 @@ test('chat UI and trace projection render model thinking separately', () => { assert.equal(html.includes('renderThinking(item.event?.thinking)'), false); }); -test('chat trace projection hides ephemeral branch decisions from the main timeline', () => { +test('chat trace projection renders ephemeral branch decisions outside the main timeline', () => { const projector = readFileSync('src/mindcraft/public/chat_trace_projector.js', 'utf8'); + const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); const prompterSource = readFileSync('src/models/prompter.js', 'utf8'); + assert.ok(projector.includes('isBranchDecisionEvent(event)')); + assert.ok(projector.includes('addBranchEvent(event)')); + assert.ok(projector.includes('branchDecision')); assert.ok(projector.includes('if (event.ephemeral) return;')); + assert.equal(projector.includes('if (event.ephemeral) return;\n switch'), true); + assert.ok(html.includes('function renderBranchDecision')); + assert.ok(html.includes('class="chat-branch-event"')); + assert.ok(html.includes('Branch decision')); + assert.ok(html.includes('renderTokenUsage(response?.token_usage)')); assert.ok(prompterSource.includes('ephemeral: true')); assert.ok(prompterSource.includes('branch: true')); assert.ok(prompterSource.includes("cache_scope: options.cacheScope || 'botResponder'")); }); +test('ephemeral branch decisions do not update the main request delta baseline', () => { + const projectorSource = readFileSync('src/mindcraft/public/chat_trace_projector.js', 'utf8'); + const window = { + selectVisibleRequestMessages: (messages, previousMessages = []) => messages.slice(previousMessages.length), + extractResponseText: response => typeof response === 'string' ? response : '', + extractResponseThinking: () => '', + extractResponseToolCalls: () => [], + getToolCallId: call => call?.id || null, + getToolName: call => call?.name || call?.function?.name || 'tool', + isHistoryTurnIncludedInRequest: () => false + }; + vm.runInNewContext(projectorSource, { window }); + + const first = { role: 'user', content: 'Steve: start' }; + const branchQuestion = { role: 'user', content: 'buddy: (FROM OTHER BOT)\nhello while busy' }; + const thread = window.buildChatThread([ + { type: 'llm_request', tag: 'conversation', messages: [first], model: { model: 'gpt-5.5' }, timestamp: 't1' }, + { type: 'llm_response', tag: 'conversation', response: 'ok', model: { model: 'gpt-5.5' }, timestamp: 't2' }, + { type: 'llm_request', tag: 'botResponder', branch: true, ephemeral: true, cache_scope: 'botResponder', messages: [first, branchQuestion], model: { model: 'gpt-5.5' }, timestamp: 't3' }, + { type: 'llm_response', tag: 'botResponder', branch: true, ephemeral: true, cache_scope: 'botResponder', response: 'respond', token_usage: { input_uncached: 1, input_cached: 2, output: 3 }, model: { model: 'gpt-5.5' }, timestamp: 't4' }, + { type: 'llm_request', tag: 'conversation', messages: [first, branchQuestion], model: { model: 'gpt-5.5' }, timestamp: 't5' } + ]); + + assert.equal(thread.turns.length, 3); + assert.equal(thread.turns[1].branchDecision.response.token_usage.input_cached, 2); + assert.deepEqual(thread.turns[2].visibleRequestMessages, [branchQuestion]); +}); + test('chat trace projection can show reasoning effort in the model label', () => { const projector = readFileSync('src/mindcraft/public/chat_trace_projector.js', 'utf8'); From 44e66ab3f4b421f235c2e3f6c32f4631d7143f46 Mon Sep 17 00:00:00 2001 From: NinotQuyi Date: Sun, 3 May 2026 11:39:16 +0800 Subject: [PATCH 37/56] Preserve Codex cache continuity across agent turns Codex-backed agents were sending every ReAct turn as a full Responses request with a process-random session id, so accepted bot messages and restarts could make the backend treat the request as cold even when the logical history prefix was append-only. The adapter now receives a stable project/agent/purpose session identity and follows the Codex client pattern of reusing previous_response_id when the next request is a prefix extension of the previous input plus model output. The runtime trace also records request fingerprints and transport-cache metadata so future cache misses can distinguish changed prompts/tools from provider-side cold starts. Constraint: Agent-to-agent messages must remain normal user turns and not use a special system prompt. Rejected: Treat accepted bot messages as branch-only context | that would preserve cache at the cost of hiding collaboration from the main ReAct history. Confidence: medium Scope-risk: moderate Tested: npx eslint src/models/codex_chatgpt.js src/models/prompter.js src/agent/history.js tests/codex_chatgpt.test.js tests/chat_history_trace.test.js Tested: npm test Not-tested: Live ChatGPT backend cache accounting after previous_response_id reuse. Co-authored-by: OmX --- src/agent/history.js | 19 +++ src/models/codex_chatgpt.js | 212 ++++++++++++++++++++++++++++++- src/models/prompter.js | 38 +++++- tests/chat_history_trace.test.js | 5 + tests/codex_chatgpt.test.js | 122 ++++++++++++++++++ 5 files changed, 391 insertions(+), 5 deletions(-) diff --git a/src/agent/history.js b/src/agent/history.js index d5a0b17e3..aea5035f4 100644 --- a/src/agent/history.js +++ b/src/agent/history.js @@ -1,5 +1,6 @@ import { writeFileSync, readFileSync, mkdirSync, existsSync, appendFileSync } from 'fs'; import path from 'path'; +import { createHash } from 'crypto'; import { NPCData } from './npc/data.js'; import settings from './settings.js'; import { createNativeToolCallTurn, createNativeToolResultTurn, hasNativeToolCalls, isNativeToolResultTurn, normalizeThinkingText } from '../models/native_tools.js'; @@ -265,6 +266,7 @@ export class History { messages, tools: Array.isArray(tools) ? tools : null, tool_count: Array.isArray(tools) ? tools.length : 0, + request_fingerprint: buildRequestFingerprint(systemPrompt, messages, tools), ...makeJsonSafe(metadata || {}) }); } @@ -476,6 +478,23 @@ function extractThinkingForTrace(response, model) { ); } +function buildRequestFingerprint(systemPrompt, messages, tools) { + const list = Array.isArray(messages) ? messages : []; + return { + system_prompt_hash: hashTraceValue(systemPrompt || ''), + messages_hash: hashTraceValue(list), + tools_hash: hashTraceValue(Array.isArray(tools) ? tools : []), + first_message_hash: list.length > 0 ? hashTraceValue(list[0]) : null, + last_message_hash: list.length > 0 ? hashTraceValue(list[list.length - 1]) : null, + message_count: list.length, + tool_count: Array.isArray(tools) ? tools.length : 0 + }; +} + +function hashTraceValue(value) { + return createHash('sha256').update(safeStringify(value)).digest('hex').slice(0, 16); +} + function safeStringify(value) { try { return JSON.stringify(value); diff --git a/src/models/codex_chatgpt.js b/src/models/codex_chatgpt.js index 7ec9ff869..3234e6a20 100644 --- a/src/models/codex_chatgpt.js +++ b/src/models/codex_chatgpt.js @@ -66,12 +66,21 @@ export class CodexChatGPT { delete this.params.open_browser; delete this.params.forcedChatgptWorkspaceId; delete this.params.forced_chatgpt_workspace_id; + this.sessionIdWasExplicit = Boolean(this.params.sessionId || this.params.session_id); this.sessionId = this.params.sessionId || this.params.session_id || randomUUID(); delete this.params.sessionId; delete this.params.session_id; this.originator = this.params.originator || process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE || DEFAULT_ORIGINATOR; delete this.params.originator; this.turnStateByKey = new Map(); + this.responseContinuityByKey = new Map(); + this.lastRequestCacheTrace = null; + } + + setSessionIdentity(identity) { + if (this.sessionIdWasExplicit) return; + const value = String(identity || '').trim(); + if (value) this.sessionId = value; } async sendRequest(turns, systemMessage, stop_seq='***', tools=null, options = {}) { @@ -109,6 +118,7 @@ export class CodexChatGPT { this.rememberTurnState(options, response); const parsed = await parseCodexResponsesSse(await response.text()); + this.rememberResponseContinuity(options, body, parsed); console.log('Received.'); setLastTokenUsage(this, parsed.usage); this.lastThinking = parsed.thinking || ''; @@ -165,9 +175,31 @@ export class CodexChatGPT { body[key] = value; } } + this.applyPreviousResponseContinuity(options, body); return body; } + getCacheTraceMetadata(options = {}) { + const scopedSessionId = buildScopedPromptCacheKey(this.sessionId, options?.cacheScope); + return { + cache_scope: options?.cacheScope || null, + turn_state_key: options?.turnStateKey || null, + transport_cache: { + protocol: 'openai-codex-responses', + prompt_cache_key: scopedSessionId, + session_id: scopedSessionId, + turn_state_present: Boolean(this.getTurnState(options)), + previous_response_id_available: Boolean(this.responseContinuityByKey.get(scopedSessionId)?.responseId) + } + }; + } + + consumeLastRequestCacheTrace() { + const value = this.lastRequestCacheTrace; + this.lastRequestCacheTrace = null; + return value || null; + } + async fetchResponses(endpoint, body, auth, options = {}) { return await codexFetch(endpoint, { method: 'POST', @@ -225,6 +257,86 @@ export class CodexChatGPT { return buildScopedPromptCacheKey(this.sessionId, options?.cacheScope); } + getResponseContinuityKey(options = {}) { + return buildScopedPromptCacheKey(this.sessionId, options?.cacheScope); + } + + applyPreviousResponseContinuity(options = {}, body) { + const key = this.getResponseContinuityKey(options); + const previous = this.responseContinuityByKey.get(key); + const baseTrace = { + protocol: 'openai-codex-responses', + prompt_cache_key: body.prompt_cache_key, + session_id: buildScopedPromptCacheKey(this.sessionId, options?.cacheScope), + turn_state_present: Boolean(this.getTurnState(options)), + previous_response_id: null, + incremental_input_items: null, + full_input_items: Array.isArray(body.input) ? body.input.length : 0, + incremental_reuse: false, + incremental_reuse_reason: 'no_previous_response' + }; + + if (!previous?.responseId) { + this.lastRequestCacheTrace = baseTrace; + return; + } + + const requestSignature = codexRequestSignature(body); + if (requestSignature !== previous.requestSignature) { + this.lastRequestCacheTrace = { + ...baseTrace, + incremental_reuse_reason: 'non_input_fields_changed' + }; + return; + } + + const delta = getIncrementalResponsesInput(body.input, previous.baselineInput); + if (!delta) { + this.lastRequestCacheTrace = { + ...baseTrace, + incremental_reuse_reason: 'input_not_previous_prefix' + }; + return; + } + + body.previous_response_id = previous.responseId; + body.input = delta; + this.lastRequestCacheTrace = { + ...baseTrace, + previous_response_id: previous.responseId, + incremental_input_items: delta.length, + incremental_reuse: true, + incremental_reuse_reason: 'prefix_reused' + }; + } + + rememberResponseContinuity(options = {}, body, parsed = {}) { + const responseId = parsed.responseId; + if (!responseId) return; + const key = this.getResponseContinuityKey(options); + const sentInput = body.previous_response_id + ? [ + ...(this.responseContinuityByKey.get(key)?.baselineInput || []), + ...(body.input || []) + ] + : (body.input || []); + const outputItems = parsed.outputItems?.length + ? parsed.outputItems + : synthesizeCodexOutputItems(parsed); + this.responseContinuityByKey.set(key, { + responseId, + requestSignature: codexRequestSignature(body), + baselineInput: normalizeResponsesItemsForContinuity([ + ...sentInput, + ...outputItems + ]) + }); + if (this.responseContinuityByKey.size > 64) { + const oldestKey = this.responseContinuityByKey.keys().next().value; + this.responseContinuityByKey.delete(oldestKey); + } + } + async embed() { throw new Error('Codex ChatGPT native-login adapter does not support embeddings. Configure an embedding provider separately.'); } @@ -419,6 +531,93 @@ export function buildScopedPromptCacheKey(baseKey, cacheScope) { return `${base}:${scope}`; } +function codexRequestSignature(body = {}) { + const copy = { ...(body || {}) }; + delete copy.input; + delete copy.previous_response_id; + return stableJson(copy); +} + +function getIncrementalResponsesInput(input = [], previousBaseline = []) { + const normalizedInput = normalizeResponsesItemsForContinuity(input); + const normalizedBaseline = normalizeResponsesItemsForContinuity(previousBaseline); + if (normalizedBaseline.length > normalizedInput.length) return null; + for (let i = 0; i < normalizedBaseline.length; i++) { + if (stableJson(normalizedInput[i]) !== stableJson(normalizedBaseline[i])) { + return null; + } + } + return input.slice(normalizedBaseline.length); +} + +function normalizeResponsesItemsForContinuity(items = []) { + return (items || []).map(normalizeResponsesItemForContinuity); +} + +function normalizeResponsesItemForContinuity(item) { + if (!item || typeof item !== 'object') return item; + const clone = structuredCloneSafe(item); + stripVolatileResponsesFields(clone); + return clone; +} + +function stripVolatileResponsesFields(value) { + if (!value || typeof value !== 'object') return; + if (Array.isArray(value)) { + for (const item of value) stripVolatileResponsesFields(item); + return; + } + delete value.id; + delete value.status; + delete value.object; + for (const item of Object.values(value)) { + stripVolatileResponsesFields(item); + } +} + +function synthesizeCodexOutputItems(parsed = {}) { + if (parsed.toolCalls?.length) { + return parsed.toolCalls.map(call => ({ + type: 'function_call', + call_id: call.id, + name: call.function?.name || call.name, + arguments: call.function?.arguments || call.arguments || '{}' + })).filter(item => item.call_id && item.name); + } + if (parsed.text) { + return [{ + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: parsed.text }] + }]; + } + return []; +} + +function structuredCloneSafe(value) { + if (typeof structuredClone === 'function') { + try { + return structuredClone(value); + } catch { + // Fall through to JSON clone. + } + } + return JSON.parse(JSON.stringify(value)); +} + +function stableJson(value) { + return JSON.stringify(sortJsonKeys(value)); +} + +function sortJsonKeys(value) { + if (Array.isArray(value)) return value.map(sortJsonKeys); + if (!value || typeof value !== 'object') return value; + return Object.keys(value).sort().reduce((out, key) => { + out[key] = sortJsonKeys(value[key]); + return out; + }, {}); +} + export function toCodexResponseItem(message) { const role = message.role === 'assistant' ? 'assistant' : 'user'; return { @@ -437,6 +636,8 @@ export async function parseCodexResponsesSse(sseText) { const messageTexts = []; const thinkingDeltas = []; const reasoningItems = []; + const outputItems = []; + let responseId = null; let usage = null; const events = sseText.split(/\n\n+/); for (const eventBlock of events) { @@ -460,6 +661,9 @@ export async function parseCodexResponsesSse(sseText) { thinkingDeltas.push(event.delta); } const item = event.item; + if (event.type === 'response.output_item.done' && item) { + outputItems.push(item); + } if (event.type === 'response.output_item.done' && item?.type === 'function_call') { toolCalls.push({ id: item.call_id, @@ -481,6 +685,12 @@ export async function parseCodexResponsesSse(sseText) { } else if (event.usage) { usage = event.usage; } + if (event.response?.id) { + responseId = event.response.id; + } + if (event.response_id) { + responseId = event.response_id; + } if (event.type === 'response.failed') { const message = event.response?.error?.message || 'Codex Responses stream failed'; throw new Error(message); @@ -490,7 +700,7 @@ export async function parseCodexResponsesSse(sseText) { const thinking = thinkingDeltas.length > 0 ? normalizeThinkingText(thinkingDeltas.join('')) : normalizeThinkingText(reasoningItems); - return { text, toolCalls, usage, thinking }; + return { text, toolCalls, usage, thinking, responseId, outputItems }; } async function requestDeviceCode(baseUrl, clientId) { diff --git a/src/models/prompter.js b/src/models/prompter.js index cfadcdd3e..fc4f23629 100644 --- a/src/models/prompter.js +++ b/src/models/prompter.js @@ -10,6 +10,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; import { selectAPI, selectEmbeddingAPI, createModel } from './_model_map.js'; +import { createHash } from 'crypto'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -79,10 +80,12 @@ export class Prompter { let chat_model_profile = selectAPI(this.profile.model); this.chat_model = createModel(chat_model_profile); + this.applyModelSessionIdentity(this.chat_model, chat_model_profile, 'conversation'); if (hasModelSelection(this.profile.code_model)) { let code_model_profile = selectAPI(this.profile.code_model); this.code_model = createModel(code_model_profile); + this.applyModelSessionIdentity(this.code_model, code_model_profile, 'coding'); } else { this.code_model = this.chat_model; @@ -91,6 +94,7 @@ export class Prompter { if (hasModelSelection(this.profile.vision_model)) { let vision_model_profile = selectAPI(this.profile.vision_model); this.vision_model = createModel(vision_model_profile); + this.applyModelSessionIdentity(this.vision_model, vision_model_profile, 'vision'); } else { this.vision_model = this.chat_model; @@ -126,6 +130,18 @@ export class Prompter { return this.profile.name; } + applyModelSessionIdentity(model, modelProfile, purpose) { + if (typeof model?.setSessionIdentity !== 'function') return; + model.setSessionIdentity(stableModelSessionIdentity({ + cwd: process.cwd(), + agent: this.profile.name, + purpose, + provider: modelProfile?.provider || model?.provider || null, + api: modelProfile?.api || model?.constructor?.prefix || null, + model: modelProfile?.model || model?.model_name || null + })); + } + getInitModes() { return this.profile.modes; } @@ -237,14 +253,23 @@ export class Prompter { try { const tools = this.isNativeToolMode() ? getCommandToolDefinitions(this.agent) : null; - this.agent.history.traceLLMRequest('conversation', this.chat_model, prompt, requestMessages, tools); - generation = await this.chat_model.sendRequest(requestMessages, prompt, '***', tools, { + const requestOptions = { cacheScope: 'conversation', turnStateKey: options.turnStateKey, signal: options.signal - }); + }; + const requestTraceMetadata = this.chat_model.getCacheTraceMetadata?.(requestOptions) || { + cache_scope: requestOptions.cacheScope, + turn_state_key: requestOptions.turnStateKey + }; + this.agent.history.traceLLMRequest('conversation', this.chat_model, prompt, requestMessages, tools, requestTraceMetadata); + generation = await this.chat_model.sendRequest(requestMessages, prompt, '***', tools, requestOptions); this.captureConversationResponseMetadata(this.chat_model, generation); - this.agent.history.traceLLMResponse('conversation', this.chat_model, generation); + const lastRequestCacheTrace = this.chat_model.consumeLastRequestCacheTrace?.(); + const responseTraceMetadata = lastRequestCacheTrace + ? { transport_cache: lastRequestCacheTrace } + : requestTraceMetadata; + this.agent.history.traceLLMResponse('conversation', this.chat_model, generation, responseTraceMetadata); if (isNativeToolResponse(generation)) { await this._saveLog(prompt, requestMessages, JSON.stringify(generation), 'conversation'); return generation; @@ -504,6 +529,11 @@ function extractCodeTaskContent(messages) { .trim(); } +function stableModelSessionIdentity(parts) { + const text = JSON.stringify(parts || {}); + return `mindcraft-${createHash('sha256').update(text).digest('hex').slice(0, 24)}`; +} + function isAbortError(error) { return error?.name === 'AbortError' || String(error?.message || error || '').includes('aborted'); } diff --git a/tests/chat_history_trace.test.js b/tests/chat_history_trace.test.js index ea3a684f7..705a13d17 100644 --- a/tests/chat_history_trace.test.js +++ b/tests/chat_history_trace.test.js @@ -119,6 +119,11 @@ test('chat history trace records prompts, messages, tool calls and tool results assert.deepEqual(request.messages, messages); assert.equal(request.tool_count, 1); assert.equal(request.model.api, 'fake-protocol'); + assert.equal(request.request_fingerprint.message_count, 1); + assert.equal(request.request_fingerprint.tool_count, 1); + assert.ok(request.request_fingerprint.system_prompt_hash); + assert.ok(request.request_fingerprint.messages_hash); + assert.ok(request.request_fingerprint.tools_hash); assert.ok(events.some(event => event.type === 'llm_response')); const response = events.find(event => event.type === 'llm_response'); diff --git a/tests/codex_chatgpt.test.js b/tests/codex_chatgpt.test.js index 687b3e8e5..be5c3e88e 100644 --- a/tests/codex_chatgpt.test.js +++ b/tests/codex_chatgpt.test.js @@ -69,6 +69,9 @@ test('Codex SSE parser extracts Responses function_call events', async () => { assert.equal(parsed.toolCalls[0].function.arguments, '{"status":"ok"}'); assert.equal(parsed.usage.input_tokens, 100); assert.equal(parsed.usage.input_tokens_details.cached_tokens, 60); + assert.equal(parsed.responseId, 'resp_1'); + assert.equal(parsed.outputItems.length, 1); + assert.equal(parsed.outputItems[0].type, 'function_call'); }); test('Codex SSE parser prefers text deltas over final message to avoid duplicate text', async () => { @@ -349,6 +352,125 @@ test('Codex adapter sends native-login Responses request and normalizes tool cal } }); + + +test('Codex adapter reuses previous_response_id for prefix-extension turns', async () => { + const { keysPath, cleanup } = writeTempKeys(); + const originalFetch = globalThis.fetch; + const requests = []; + const responses = [ + [ + 'event: response.output_item.done', + 'data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}}', + '', + 'event: response.completed', + 'data: {"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":100,"input_tokens_details":{"cached_tokens":0},"output_tokens":3}}}', + '' + ].join('\n'), + [ + 'event: response.output_item.done', + 'data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_2","name":"report_status","arguments":"{\\"status\\":\\"done\\"}"}}', + '', + 'event: response.completed', + 'data: {"type":"response.completed","response":{"id":"resp_2","usage":{"input_tokens":140,"input_tokens_details":{"cached_tokens":120},"output_tokens":5}}}', + '' + ].join('\n') + ]; + globalThis.fetch = async (url, init) => { + requests.push({ url, init, body: JSON.parse(init.body) }); + return new Response(responses.shift(), { + status: 200, + headers: { 'content-type': 'text/event-stream' } + }); + }; + + try { + const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', { keysPath, sessionId: 'session-test' }); + const first = await model.sendRequest( + [{ role: 'user', content: 'first' }], + 'Use tools.', + '***', + [tool], + { cacheScope: 'conversation' } + ); + assert.equal(first, 'ok'); + + const second = await model.sendRequest( + [ + { role: 'user', content: 'first' }, + { role: 'assistant', content: 'ok' }, + { role: 'user', content: 'second' } + ], + 'Use tools.', + '***', + [tool], + { cacheScope: 'conversation' } + ); + assert.equal(isNativeToolResponse(second), true); + + assert.equal(requests[0].body.previous_response_id, undefined); + assert.equal(requests[0].body.input.length, 1); + assert.equal(requests[1].body.previous_response_id, 'resp_1'); + assert.equal(requests[1].body.input.length, 1); + assert.equal(requests[1].body.input[0].role, 'user'); + assert.equal(requests[1].body.input[0].content[0].text, 'second'); + assert.equal(requests[1].body.prompt_cache_key, 'session-test:conversation'); + assert.equal(requests[1].init.headers.session_id, 'session-test:conversation'); + assert.deepEqual(model.consumeLastRequestCacheTrace(), { + protocol: 'openai-codex-responses', + prompt_cache_key: 'session-test:conversation', + session_id: 'session-test:conversation', + turn_state_present: false, + previous_response_id: 'resp_1', + incremental_input_items: 1, + full_input_items: 3, + incremental_reuse: true, + incremental_reuse_reason: 'prefix_reused' + }); + } finally { + globalThis.fetch = originalFetch; + cleanup(); + } +}); + +test('Codex adapter does not reuse previous_response_id when non-input fields change', async () => { + const { keysPath, cleanup } = writeTempKeys(); + const originalFetch = globalThis.fetch; + const requests = []; + globalThis.fetch = async (url, init) => { + requests.push({ url, init, body: JSON.parse(init.body) }); + const responseId = `resp_${requests.length}`; + return new Response([ + 'event: response.output_item.done', + 'data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}}', + '', + 'event: response.completed', + `data: {"type":"response.completed","response":{"id":"${responseId}"}}`, + '' + ].join('\n'), { + status: 200, + headers: { 'content-type': 'text/event-stream' } + }); + }; + + try { + const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', { keysPath, sessionId: 'session-test' }); + await model.sendRequest([{ role: 'user', content: 'first' }], 'Use tools.', '***', [tool], { cacheScope: 'conversation' }); + await model.sendRequest([ + { role: 'user', content: 'first' }, + { role: 'assistant', content: 'ok' }, + { role: 'user', content: 'second' } + ], 'Changed instructions.', '***', [tool], { cacheScope: 'conversation' }); + + assert.equal(requests[1].body.previous_response_id, undefined); + assert.equal(requests[1].body.input.length, 3); + assert.equal(model.consumeLastRequestCacheTrace().incremental_reuse_reason, 'non_input_fields_changed'); + } finally { + globalThis.fetch = originalFetch; + cleanup(); + } +}); + test('Codex adapter sends vision images as Responses input_image content', async () => { const { keysPath, cleanup } = writeTempKeys(); const originalFetch = globalThis.fetch; From 3c7926e9df321d690c939029eef3bd1a74c16cf4 Mon Sep 17 00:00:00 2001 From: NinotQuyi Date: Sun, 3 May 2026 12:01:36 +0800 Subject: [PATCH 38/56] Keep stale tool calls from blocking compaction Conversation compaction was gated on zero pending native tool calls. If an old tool call never received a result, every later turn kept the pending set non-empty and compaction never triggered, even after hundreds of messages. History now synthesizes an explicit interrupted tool result only when a pending call is followed by a later conversation turn, while leaving a trailing active tool call untouched. The message-count settings are also parsed numerically so runtime/web/env string values such as "120" do not silently disable compaction. Constraint: Active tool calls must not be closed prematurely just to force compaction. Rejected: Ignore pending tool calls in compaction | would allow summaries to split assistant tool calls from their results. Confidence: high Scope-risk: narrow Tested: npx eslint src/agent/history.js tests/chat_history_trace.test.js Tested: npm test Not-tested: Live long-running Minecraft session after old memory files already contain orphan tool calls. Co-authored-by: OmX --- src/agent/history.js | 68 +++++++++++++++++++++++- tests/chat_history_trace.test.js | 89 ++++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+), 1 deletion(-) diff --git a/src/agent/history.js b/src/agent/history.js index aea5035f4..94aa74457 100644 --- a/src/agent/history.js +++ b/src/agent/history.js @@ -28,7 +28,7 @@ export class History { // Message-count context window. Compaction uses the active context after the // latest compact boundary and summarizes that whole active context. - this.max_messages = Number.isFinite(settings.max_messages) ? settings.max_messages : Infinity; + this.max_messages = normalizePositiveNumber(settings.max_messages, Infinity); this.compact_message_threshold_percent = normalizePercent(settings.compact_message_threshold_percent, 100); this.instruction_contexts_traced = false; @@ -186,9 +186,23 @@ export class History { }); } + + closeStalePendingToolCalls() { + const { turns, inserted } = synthesizeStalePendingToolResults(this.turns); + if (inserted.length === 0) return false; + this.turns = turns; + this.traceEvent('history_tool_results_synthesized', { + reason: 'stale_pending_native_tool_calls_before_next_turn', + inserted_results: inserted, + active_turn_count: this.turns.length + }); + return true; + } + async _pushTurn(turn) { turn = normalizeHistoryTurn(turn); this.turns.push(turn); + this.closeStalePendingToolCalls(); this.traceEvent('history_turn_added', { turn, active_turn_count: this.turns.length @@ -524,6 +538,12 @@ function makeJsonSafe(value, seen = new WeakSet()) { } +function normalizePositiveNumber(value, fallback) { + const num = Number(value); + if (!Number.isFinite(num) || num <= 0) return fallback; + return num; +} + function normalizePercent(value, fallback) { const num = Number(value); if (!Number.isFinite(num) || num <= 0) return fallback; @@ -558,6 +578,52 @@ function createCompactSummaryTurn(summary, archiveFile) { }; } + +function synthesizeStalePendingToolResults(turns = []) { + const repaired = []; + const pending = new Map(); + const inserted = []; + + const flushPending = () => { + if (pending.size === 0) return; + for (const call of pending.values()) { + const resultTurn = createNativeToolResultTurn(call, 'Tool result was not recorded before the next conversation turn. Treat the tool call as interrupted and continue from the latest state.'); + resultTurn.synthetic_tool_result = true; + resultTurn.synthetic_reason = 'stale_pending_native_tool_call'; + repaired.push(resultTurn); + inserted.push(resultTurn); + } + pending.clear(); + }; + + for (const turn of turns || []) { + if (hasNativeToolCalls(turn)) { + flushPending(); + repaired.push(turn); + for (const call of turn.native_tool_calls) { + pending.set(call.id, call); + } + continue; + } + + if (isNativeToolResultTurn(turn)) { + if (turn.tool_call_id && pending.has(turn.tool_call_id)) { + pending.delete(turn.tool_call_id); + } + repaired.push(turn); + continue; + } + + flushPending(); + repaired.push(turn); + } + + // Leave trailing pending calls untouched. They may still be executing, and + // action interruption code is responsible for closing them before the next + // conversational turn is appended. + return { turns: repaired, inserted }; +} + function getModelContextTurns(turns = []) { const index = turns.findLastIndex(turn => turn?.compact_boundary || turn?.subtype === 'compact_boundary'); return index === -1 ? turns : turns.slice(index + 1); diff --git a/tests/chat_history_trace.test.js b/tests/chat_history_trace.test.js index 705a13d17..0e5749126 100644 --- a/tests/chat_history_trace.test.js +++ b/tests/chat_history_trace.test.js @@ -308,6 +308,95 @@ test('history compaction counts only new turns after the latest compact summary' } }); + +test('history compaction accepts numeric settings from web/runtime strings', async () => { + const originalCwd = process.cwd(); + const dir = mkdtempSync(path.join(tmpdir(), 'mindcraft-compact-string-settings-')); + try { + process.chdir(dir); + setSettings({ + show_chat_history: false, + log_chat_trace: false, + max_messages: '4', + compact_message_threshold_percent: '100' + }); + const summarized = []; + const history = new History({ + name: 'compactbot', + prompter: { + promptCompactSummary: async turns => { + summarized.push(turns.map(t => t.content || t.name).join('|')); + return `summary of ${turns.length} turns`; + } + }, + self_prompter: { state: {} }, + task: {} + }); + + await history.add('Steve', 'one'); + await history.add('compactbot', 'two'); + await history.add('Steve', 'three'); + await history.add('compactbot', 'four'); + + assert.equal(summarized.length, 1); + assert.equal(history.memory, 'summary of 4 turns'); + assert.equal(history.turns.length, 2); + assert.equal(history.turns[0].compact_boundary, true); + } finally { + setSettings({}); + process.chdir(originalCwd); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('history compaction closes stale pending native tool calls before later turns', async () => { + const originalCwd = process.cwd(); + const dir = mkdtempSync(path.join(tmpdir(), 'mindcraft-compact-stale-tools-')); + try { + process.chdir(dir); + setSettings({ + show_chat_history: true, + log_chat_trace: true, + max_messages: 4, + compact_message_threshold_percent: 100 + }); + const summarized = []; + const history = new History({ + name: 'compactbot', + prompter: { + promptCompactSummary: async turns => { + summarized.push(turns.map(t => t.content || t.name).join('|')); + return `summary of ${turns.length} turns`; + } + }, + self_prompter: { state: {} }, + task: {} + }); + + const toolCall = { id: 'call_1', type: 'function', name: 'collectBlocks', arguments: '{"type":"oak_log"}' }; + await history.add('Steve', 'collect wood'); + await history.addNativeToolCall(toolCall); + assert.equal(summarized.length, 0); + + await history.add('Steve', 'new request after interrupted tool'); + + assert.equal(summarized.length, 1); + assert.match(summarized[0], /Tool result was not recorded before the next conversation turn/); + assert.equal(history.hasPendingToolCall(), false); + assert.equal(history.turns[0].compact_boundary, true); + + const events = readFileSync(history.chat_history_latest_fp, 'utf8') + .trim() + .split('\n') + .map(line => JSON.parse(line)); + assert.ok(events.some(event => event.type === 'history_tool_results_synthesized')); + } finally { + setSettings({}); + process.chdir(originalCwd); + rmSync(dir, { recursive: true, force: true }); + } +}); + test('history compaction waits for native tool results before compacting', async () => { const originalCwd = process.cwd(); const dir = mkdtempSync(path.join(tmpdir(), 'mindcraft-compact-tools-')); From 6eadc9bb6070839f1f3fe73e839e5b37215e959f Mon Sep 17 00:00:00 2001 From: NinotQuyi Date: Sun, 3 May 2026 13:01:05 +0800 Subject: [PATCH 39/56] Preserve Codex cache across forked turns Codex only supports previous_response_id on the Responses WebSocket transport, and only for linear extensions of the latest response. The adapter now mirrors that contract: WebSocket handles linear incremental requests, HTTP expands back to full input, and forked bot-message branches share the prompt cache key without inheriting stale turn-state. Constraint: ChatGPT Codex HTTP rejects previous_response_id Constraint: x-codex-turn-state must stay scoped to one ReAct turn Rejected: Use old fork-point previous_response_id for sibling branches | server rejects non-latest response ids on the active WebSocket path Confidence: high Scope-risk: moderate Directive: Keep previous_response_id transport-scoped and linear; use prompt_cache_key, not response ids, for divergent branches Tested: node --test tests/codex_chatgpt.test.js Tested: npx eslint src/models/codex_chatgpt.js tests/codex_chatgpt.test.js Tested: npm test Tested: Live Codex WebSocket fork probe showed branch-a, branch-b, and main-after-branch cached 15104 input tokens without previous_response_id Co-authored-by: OmX --- package.json | 1 + src/models/codex_chatgpt.js | 383 +++++++++++++++++++++++++++++++++--- tests/codex_chatgpt.test.js | 356 +++++++++++++++++++++++++++++++-- 3 files changed, 697 insertions(+), 43 deletions(-) diff --git a/package.json b/package.json index be16f3a31..fd1d89437 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "socket.io-client": "^4.7.2", "three": "^0.128.0", "vec3": "^0.1.10", + "ws": "^8.20.0", "yargs": "^17.7.2" }, "overrides": { diff --git a/src/models/codex_chatgpt.js b/src/models/codex_chatgpt.js index 3234e6a20..039d4a2fb 100644 --- a/src/models/codex_chatgpt.js +++ b/src/models/codex_chatgpt.js @@ -4,6 +4,7 @@ import { tmpdir } from 'os'; import { spawn } from 'child_process'; import { createServer } from 'http'; import { createHash, randomBytes, randomUUID } from 'crypto'; +import WebSocket from 'ws'; import open from 'open'; import { createNativeToolResponse, normalizeThinkingText, toResponsesInputItems } from './native_tools.js'; import { setLastTokenUsage } from './token_usage.js'; @@ -18,6 +19,9 @@ const DEFAULT_ORIGINATOR = 'codex_cli_rs'; const LOGIN_TIMEOUT_MS = 15 * 60 * 1000; const DEFAULT_LOGIN_PORT = 1455; const DEFAULT_FETCH = globalThis.fetch; +const RESPONSES_WEBSOCKET_BETA_HEADER_VALUE = 'responses_websockets=2026-02-06'; +const DEFAULT_WEBSOCKET_IDLE_TIMEOUT_MS = 5 * 60 * 1000; +const CONTINUITY_BASELINE_INPUT = Symbol('codexContinuityBaselineInput'); export class CodexChatGPT { static prefix = 'codex'; @@ -72,6 +76,25 @@ export class CodexChatGPT { delete this.params.session_id; this.originator = this.params.originator || process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE || DEFAULT_ORIGINATOR; delete this.params.originator; + const transport = String(this.params.transport || this.params.codexTransport || this.params.codex_transport || '').toLowerCase(); + const webSocketParam = this.params.responsesWebSocket ?? this.params.responses_websocket ?? this.params.useResponsesWebSocket ?? this.params.use_responses_websocket; + this.useResponsesWebSocket = webSocketParam ?? (transport ? transport === 'websocket' || transport === 'ws' : isChatGptCodexUrl(this.url)); + this.responsesWebSocketDisabled = transport === 'http' || transport === 'https'; + this.responsesWebSocket = null; + this.responsesWebSocketHeaders = null; + this.responsesWebSocketIdleTimeoutMs = Number.parseInt(this.params.responsesWebSocketIdleTimeoutMs || this.params.responses_websocket_idle_timeout_ms || DEFAULT_WEBSOCKET_IDLE_TIMEOUT_MS, 10); + delete this.params.transport; + delete this.params.codexTransport; + delete this.params.codex_transport; + delete this.params.responsesWebSocket; + delete this.params.responses_websocket; + delete this.params.useResponsesWebSocket; + delete this.params.use_responses_websocket; + delete this.params.responsesWebSocketIdleTimeoutMs; + delete this.params.responses_websocket_idle_timeout_ms; + this.enablePreviousResponseId = Boolean(this.params.enablePreviousResponseId || this.params.enable_previous_response_id); + delete this.params.enablePreviousResponseId; + delete this.params.enable_previous_response_id; this.turnStateByKey = new Map(); this.responseContinuityByKey = new Map(); this.lastRequestCacheTrace = null; @@ -118,7 +141,7 @@ export class CodexChatGPT { this.rememberTurnState(options, response); const parsed = await parseCodexResponsesSse(await response.text()); - this.rememberResponseContinuity(options, body, parsed); + this.rememberResponseContinuity(this.lastSentResponsesOptions || options, this.lastSentResponsesBody || body, parsed); console.log('Received.'); setLastTokenUsage(this, parsed.usage); this.lastThinking = parsed.thinking || ''; @@ -181,6 +204,10 @@ export class CodexChatGPT { getCacheTraceMetadata(options = {}) { const scopedSessionId = buildScopedPromptCacheKey(this.sessionId, options?.cacheScope); + const responseContinuityKey = this.getResponseContinuityKey(options); + const responseContinuityEntries = responseContinuityKey + ? this.responseContinuityByKey.get(responseContinuityKey) + : null; return { cache_scope: options?.cacheScope || null, turn_state_key: options?.turnStateKey || null, @@ -189,7 +216,7 @@ export class CodexChatGPT { prompt_cache_key: scopedSessionId, session_id: scopedSessionId, turn_state_present: Boolean(this.getTurnState(options)), - previous_response_id_available: Boolean(this.responseContinuityByKey.get(scopedSessionId)?.responseId) + previous_response_id_available: Boolean(responseContinuityEntries?.some(entry => entry.responseId)) } }; } @@ -201,14 +228,104 @@ export class CodexChatGPT { } async fetchResponses(endpoint, body, auth, options = {}) { + this.lastSentResponsesBody = body; + this.lastSentResponsesOptions = options; + if (this.useResponsesWebSocket && !this.responsesWebSocketDisabled) { + try { + return await this.fetchResponsesWebSocket(endpoint, body, auth, options); + } catch (err) { + if (isAbortError(err)) throw err; + this.closeResponsesWebSocket(); + this.responsesWebSocketDisabled = true; + console.log(`Codex Responses WebSocket failed; falling back to HTTP. ${sanitizeCodexError(err)}`); + } + } + const httpBody = expandContinuityRequestBody(body); + if (httpBody !== body && this.lastRequestCacheTrace?.incremental_reuse) { + this.lastRequestCacheTrace = { + ...this.lastRequestCacheTrace, + previous_response_id: null, + incremental_input_items: null, + full_input_items: Array.isArray(httpBody.input) ? httpBody.input.length : this.lastRequestCacheTrace.full_input_items, + incremental_reuse: false, + incremental_reuse_reason: 'http_previous_response_unsupported' + }; + } + this.lastSentResponsesBody = httpBody; + this.lastSentResponsesOptions = options; return await codexFetch(endpoint, { method: 'POST', headers: this.buildHeaders(auth, options), - body: JSON.stringify(body), + body: JSON.stringify(httpBody), signal: options?.signal }); } + async fetchResponsesWebSocket(endpoint, body, auth, options = {}) { + const wsOptions = { + ...(options || {}), + transportSupportsPreviousResponseId: true, + responseContinuityLatestOnly: true + }; + const wsBody = structuredCloneSafe(expandContinuityRequestBody(body)); + this.applyPreviousResponseContinuity(wsOptions, wsBody); + this.lastSentResponsesBody = wsBody; + this.lastSentResponsesOptions = wsOptions; + const ws = await this.ensureResponsesWebSocket(endpoint, auth, options); + const responseText = await streamCodexResponsesWebSocket(ws, toResponseCreateWebSocketRequest(wsBody), { + signal: options?.signal, + idleTimeoutMs: this.responsesWebSocketIdleTimeoutMs, + onClosed: () => { + this.responsesWebSocket = null; + this.responsesWebSocketHeaders = null; + } + }); + return new Response(responseText, { + status: 200, + headers: this.responsesWebSocketHeaders || {} + }); + } + + async ensureResponsesWebSocket(endpoint, auth, options = {}) { + if (this.responsesWebSocket?.readyState === WebSocket.OPEN) { + return this.responsesWebSocket; + } + this.closeResponsesWebSocket(); + const headers = this.buildWebSocketHeaders(auth, options); + const { ws, headers: responseHeaders } = await connectCodexResponsesWebSocket(toWebSocketUrl(endpoint), headers, { + signal: options?.signal, + timeoutMs: this.responsesWebSocketIdleTimeoutMs + }); + this.responsesWebSocket = ws; + this.responsesWebSocketHeaders = responseHeaders; + ws.once('close', () => { + if (this.responsesWebSocket === ws) { + this.responsesWebSocket = null; + this.responsesWebSocketHeaders = null; + } + }); + return ws; + } + + closeResponsesWebSocket() { + if (this.responsesWebSocket) { + try { + this.responsesWebSocket.close(); + } catch { + // Best-effort cleanup. + } + } + this.responsesWebSocket = null; + this.responsesWebSocketHeaders = null; + } + + buildWebSocketHeaders(auth, options = {}) { + const headers = { ...this.buildHeaders(auth, options) }; + delete headers['Content-Type']; + headers['OpenAI-Beta'] = RESPONSES_WEBSOCKET_BETA_HEADER_VALUE; + return headers; + } + buildHeaders(auth, options = {}) { const scopedSessionId = buildScopedPromptCacheKey(this.sessionId, options?.cacheScope); const headers = { @@ -248,22 +365,33 @@ export class CodexChatGPT { } getTurnStateKey(options = {}) { - // Match Codex CLI's session/conversation-level continuity. The backend may - // return x-codex-turn-state as a sticky-routing token; if we key it by - // individual ReAct request IDs, a new human message starts a fresh route - // and prompt-cache accounting can fall back to zero even when the history - // prefix is stable. Scope it only by prompt-cache scope so conversation, - // coding, vision, etc. do not leak into one another. - return buildScopedPromptCacheKey(this.sessionId, options?.cacheScope); + // Match Codex CLI's turn-scoped sticky-routing contract. The backend may + // return x-codex-turn-state during a ReAct turn; replay it only for + // follow-up requests in that same turn. Leaking it into the next inbound + // bot/user message can route an otherwise cacheable prompt to the wrong + // backend state and cause full prompt-cache misses. + return this.getTurnScopedContinuityKey(options); } getResponseContinuityKey(options = {}) { + // The ChatGPT Codex HTTP endpoint rejects previous_response_id; Codex + // CLI uses that field only on its websocket transport. Keep the + // branch-aware continuity machinery behind an explicit transport opt-in + // so the default HTTP path relies on prompt_cache_key and never 400s. + if (!this.enablePreviousResponseId && !options?.transportSupportsPreviousResponseId) return null; return buildScopedPromptCacheKey(this.sessionId, options?.cacheScope); } + getTurnScopedContinuityKey(options = {}) { + const turnStateKey = String(options?.turnStateKey || '').trim(); + if (!turnStateKey) return null; + const scopedSessionId = buildScopedPromptCacheKey(this.sessionId, options?.cacheScope); + return buildScopedPromptCacheKey(scopedSessionId, `turn:${turnStateKey}`); + } + applyPreviousResponseContinuity(options = {}, body) { const key = this.getResponseContinuityKey(options); - const previous = this.responseContinuityByKey.get(key); + const previousEntries = key ? this.responseContinuityByKey.get(key) : null; const baseTrace = { protocol: 'openai-codex-responses', prompt_cache_key: body.prompt_cache_key, @@ -276,13 +404,28 @@ export class CodexChatGPT { incremental_reuse_reason: 'no_previous_response' }; - if (!previous?.responseId) { + if (!previousEntries?.length) { this.lastRequestCacheTrace = baseTrace; return; } const requestSignature = codexRequestSignature(body); - if (requestSignature !== previous.requestSignature) { + const candidateEntries = options?.responseContinuityLatestOnly + ? previousEntries.slice(0, 1) + : previousEntries; + let sawMatchingSignature = false; + let bestMatch = null; + for (const entry of candidateEntries) { + if (requestSignature !== entry.requestSignature) continue; + sawMatchingSignature = true; + const delta = getIncrementalResponsesInput(body.input, entry.baselineInput); + if (!delta) continue; + if (!bestMatch || entry.baselineInput.length > bestMatch.entry.baselineInput.length) { + bestMatch = { entry, delta }; + } + } + + if (!sawMatchingSignature) { this.lastRequestCacheTrace = { ...baseTrace, incremental_reuse_reason: 'non_input_fields_changed' @@ -290,8 +433,7 @@ export class CodexChatGPT { return; } - const delta = getIncrementalResponsesInput(body.input, previous.baselineInput); - if (!delta) { + if (!bestMatch) { this.lastRequestCacheTrace = { ...baseTrace, incremental_reuse_reason: 'input_not_previous_prefix' @@ -299,12 +441,13 @@ export class CodexChatGPT { return; } - body.previous_response_id = previous.responseId; - body.input = delta; + body.previous_response_id = bestMatch.entry.responseId; + body.input = bestMatch.delta; + body[CONTINUITY_BASELINE_INPUT] = bestMatch.entry.baselineInput; this.lastRequestCacheTrace = { ...baseTrace, - previous_response_id: previous.responseId, - incremental_input_items: delta.length, + previous_response_id: bestMatch.entry.responseId, + incremental_input_items: bestMatch.delta.length, incremental_reuse: true, incremental_reuse_reason: 'prefix_reused' }; @@ -314,16 +457,16 @@ export class CodexChatGPT { const responseId = parsed.responseId; if (!responseId) return; const key = this.getResponseContinuityKey(options); + if (!key) return; const sentInput = body.previous_response_id ? [ - ...(this.responseContinuityByKey.get(key)?.baselineInput || []), + ...(body[CONTINUITY_BASELINE_INPUT] || []), ...(body.input || []) ] : (body.input || []); - const outputItems = parsed.outputItems?.length - ? parsed.outputItems - : synthesizeCodexOutputItems(parsed); - this.responseContinuityByKey.set(key, { + const outputItems = synthesizeCodexOutputItems(parsed); + const entries = this.responseContinuityByKey.get(key) || []; + entries.unshift({ responseId, requestSignature: codexRequestSignature(body), baselineInput: normalizeResponsesItemsForContinuity([ @@ -331,6 +474,8 @@ export class CodexChatGPT { ...outputItems ]) }); + entries.length = Math.min(entries.length, 32); + this.responseContinuityByKey.set(key, entries); if (this.responseContinuityByKey.size > 64) { const oldestKey = this.responseContinuityByKey.keys().next().value; this.responseContinuityByKey.delete(oldestKey); @@ -531,6 +676,196 @@ export function buildScopedPromptCacheKey(baseKey, cacheScope) { return `${base}:${scope}`; } +function toResponseCreateWebSocketRequest(body = {}) { + return { + type: 'response.create', + ...body, + tool_choice: body.tool_choice || 'auto' + }; +} + +function expandContinuityRequestBody(body = {}) { + if (!body?.previous_response_id) return body; + const expanded = { + ...body, + input: [ + ...(body[CONTINUITY_BASELINE_INPUT] || []), + ...(body.input || []) + ] + }; + delete expanded.previous_response_id; + return expanded; +} + +function toWebSocketUrl(endpoint) { + const url = new URL(endpoint); + if (url.protocol === 'https:') url.protocol = 'wss:'; + if (url.protocol === 'http:') url.protocol = 'ws:'; + return url.toString(); +} + +function isChatGptCodexUrl(url) { + try { + const parsed = new URL(url); + return parsed.hostname === 'chatgpt.com' && parsed.pathname.includes('/backend-api/codex'); + } catch { + return false; + } +} + +async function connectCodexResponsesWebSocket(url, headers, { signal, timeoutMs = DEFAULT_WEBSOCKET_IDLE_TIMEOUT_MS } = {}) { + if (signal?.aborted) throw abortError(); + return await new Promise((resolve, reject) => { + let settled = false; + let responseHeaders = {}; + const ws = new WebSocket(url, { + headers, + perMessageDeflate: true, + family: 4, + handshakeTimeout: Math.min(Math.max(timeoutMs, 1000), 30000) + }); + + const cleanup = () => { + ws.off('open', onOpen); + ws.off('upgrade', onUpgrade); + ws.off('unexpected-response', onUnexpectedResponse); + ws.off('error', onError); + signal?.removeEventListener?.('abort', onAbort); + }; + const fail = (error) => { + if (settled) return; + settled = true; + cleanup(); + try { + ws.close(); + } catch { + // Best-effort cleanup. + } + reject(error); + }; + const onAbort = () => fail(abortError()); + const onUpgrade = response => { + responseHeaders = normalizeNodeHeaders(response?.headers || {}); + }; + const onOpen = () => { + if (settled) return; + settled = true; + cleanup(); + resolve({ ws, headers: responseHeaders }); + }; + const onUnexpectedResponse = (_request, response) => { + const chunks = []; + response.on('data', chunk => chunks.push(Buffer.from(chunk))); + response.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8'); + const error = new Error(`WebSocket upgrade failed with status=${response.statusCode} ${body.slice(0, 300)}`); + error.status = response.statusCode; + fail(error); + }); + response.on('error', fail); + }; + const onError = error => fail(error); + + ws.once('open', onOpen); + ws.once('upgrade', onUpgrade); + ws.once('unexpected-response', onUnexpectedResponse); + ws.once('error', onError); + signal?.addEventListener?.('abort', onAbort, { once: true }); + }); +} + +async function streamCodexResponsesWebSocket(ws, payload, { signal, idleTimeoutMs = DEFAULT_WEBSOCKET_IDLE_TIMEOUT_MS, onClosed } = {}) { + if (signal?.aborted) throw abortError(); + return await new Promise((resolve, reject) => { + let settled = false; + const chunks = []; + const timeout = setTimeout(() => { + fail(new Error('idle timeout waiting for Codex Responses WebSocket')); + }, Math.max(1000, idleTimeoutMs)); + + const cleanup = () => { + clearTimeout(timeout); + ws.off('message', onMessage); + ws.off('error', onError); + ws.off('close', onClose); + signal?.removeEventListener?.('abort', onAbort); + }; + const fail = (error) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + const finish = () => { + if (settled) return; + settled = true; + cleanup(); + resolve(chunks.join('')); + }; + const onAbort = () => { + try { + ws.close(); + } catch { + // Best-effort cleanup. + } + fail(abortError()); + }; + const onError = error => fail(error); + const onClose = () => { + onClosed?.(); + fail(new Error('websocket closed before response.completed')); + }; + const onMessage = data => { + const text = Buffer.isBuffer(data) ? data.toString('utf8') : String(data); + let event = null; + try { + event = JSON.parse(text); + } catch { + return; + } + if (event?.type === 'error') { + const message = event.error?.message || event.message || text; + const error = new Error(message); + error.status = event.status || event.status_code; + fail(error); + return; + } + chunks.push(`data: ${text}\n\n`); + if (event?.type === 'response.failed') { + const error = new Error(event.response?.error?.message || 'Codex Responses WebSocket failed'); + error.status = event.response?.status; + fail(error); + return; + } + if (event?.type === 'response.completed') { + finish(); + } + }; + + ws.on('message', onMessage); + ws.once('error', onError); + ws.once('close', onClose); + signal?.addEventListener?.('abort', onAbort, { once: true }); + ws.send(JSON.stringify(payload), error => { + if (error) fail(error); + }); + }); +} + +function normalizeNodeHeaders(headers = {}) { + const normalized = {}; + for (const [key, value] of Object.entries(headers)) { + normalized[key] = Array.isArray(value) ? value.join(', ') : String(value); + } + return normalized; +} + +function abortError() { + const error = new Error('aborted'); + error.name = 'AbortError'; + return error; +} + function codexRequestSignature(body = {}) { const copy = { ...(body || {}) }; delete copy.input; diff --git a/tests/codex_chatgpt.test.js b/tests/codex_chatgpt.test.js index be5c3e88e..3e9c4d25d 100644 --- a/tests/codex_chatgpt.test.js +++ b/tests/codex_chatgpt.test.js @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; +import { WebSocketServer } from 'ws'; import { CodexChatGPT, buildAuthorizeUrl, @@ -299,7 +300,11 @@ test('Codex adapter forwards abort signals to the Responses request', async () = }; try { - const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', { keysPath, sessionId: 'session-test' }); + const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', { + keysPath, + sessionId: 'session-test', + enablePreviousResponseId: true + }); await model.sendRequest([{ role: 'user', content: 'hi' }], 'Say ok.', '***', null, { signal: controller.signal }); assert.equal(requests[0].init.signal, controller.signal); } finally { @@ -328,7 +333,11 @@ test('Codex adapter sends native-login Responses request and normalizes tool cal }; try { - const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', { keysPath, sessionId: 'session-test' }); + const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', { + keysPath, + sessionId: 'session-test', + enablePreviousResponseId: true + }); const response = await model.sendRequest( [{ role: 'user', content: 'call the tool' }], 'Use the function.', @@ -354,7 +363,7 @@ test('Codex adapter sends native-login Responses request and normalizes tool cal -test('Codex adapter reuses previous_response_id for prefix-extension turns', async () => { +test('Codex adapter expands response continuity when using HTTP transport', async () => { const { keysPath, cleanup } = writeTempKeys(); const originalFetch = globalThis.fetch; const requests = []; @@ -385,13 +394,17 @@ test('Codex adapter reuses previous_response_id for prefix-extension turns', asy }; try { - const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', { keysPath, sessionId: 'session-test' }); + const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', { + keysPath, + sessionId: 'session-test', + enablePreviousResponseId: true + }); const first = await model.sendRequest( [{ role: 'user', content: 'first' }], 'Use tools.', '***', [tool], - { cacheScope: 'conversation' } + { cacheScope: 'conversation', turnStateKey: 'react-1' } ); assert.equal(first, 'ok'); @@ -404,16 +417,16 @@ test('Codex adapter reuses previous_response_id for prefix-extension turns', asy 'Use tools.', '***', [tool], - { cacheScope: 'conversation' } + { cacheScope: 'conversation', turnStateKey: 'react-1' } ); assert.equal(isNativeToolResponse(second), true); assert.equal(requests[0].body.previous_response_id, undefined); assert.equal(requests[0].body.input.length, 1); - assert.equal(requests[1].body.previous_response_id, 'resp_1'); - assert.equal(requests[1].body.input.length, 1); - assert.equal(requests[1].body.input[0].role, 'user'); - assert.equal(requests[1].body.input[0].content[0].text, 'second'); + assert.equal(requests[1].body.previous_response_id, undefined); + assert.equal(requests[1].body.input.length, 3); + assert.equal(requests[1].body.input[2].role, 'user'); + assert.equal(requests[1].body.input[2].content[0].text, 'second'); assert.equal(requests[1].body.prompt_cache_key, 'session-test:conversation'); assert.equal(requests[1].init.headers.session_id, 'session-test:conversation'); assert.deepEqual(model.consumeLastRequestCacheTrace(), { @@ -421,11 +434,11 @@ test('Codex adapter reuses previous_response_id for prefix-extension turns', asy prompt_cache_key: 'session-test:conversation', session_id: 'session-test:conversation', turn_state_present: false, - previous_response_id: 'resp_1', - incremental_input_items: 1, + previous_response_id: null, + incremental_input_items: null, full_input_items: 3, - incremental_reuse: true, - incremental_reuse_reason: 'prefix_reused' + incremental_reuse: false, + incremental_reuse_reason: 'http_previous_response_unsupported' }); } finally { globalThis.fetch = originalFetch; @@ -433,6 +446,301 @@ test('Codex adapter reuses previous_response_id for prefix-extension turns', asy } }); +test('Codex adapter does not carry turn-state into a new bot-message branch', async () => { + const { keysPath, cleanup } = writeTempKeys(); + const originalFetch = globalThis.fetch; + const requests = []; + const responses = [ + [ + 'event: response.output_item.done', + 'data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_1","name":"report_status","arguments":"{}"}}', + '', + 'event: response.completed', + 'data: {"type":"response.completed","response":{"id":"resp_1"}}', + '' + ].join('\n'), + [ + 'event: response.output_text.delta', + 'data: {"type":"response.output_text.delta","delta":"ok"}', + '', + 'event: response.completed', + 'data: {"type":"response.completed","response":{"id":"resp_2"}}', + '' + ].join('\n') + ]; + globalThis.fetch = async (url, init) => { + requests.push({ url, init, body: JSON.parse(init.body) }); + return new Response(responses.shift(), { + status: 200, + headers: { + 'content-type': 'text/event-stream', + 'x-codex-turn-state': 'sticky-route-1' + } + }); + }; + + try { + const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', { + keysPath, + sessionId: 'session-test', + enablePreviousResponseId: true + }); + const first = await model.sendRequest( + [{ role: 'user', content: 'start a long action' }], + 'Use tools.', + '***', + [tool], + { cacheScope: 'conversation', turnStateKey: 'react-1' } + ); + assert.equal(isNativeToolResponse(first), true); + + await model.sendRequest( + [ + { role: 'user', content: 'start a long action' }, + { + role: 'assistant', + content: '', + native_tool_calls: [{ id: 'call_1', type: 'function', name: 'report_status', arguments: '{}' }] + }, + { role: 'user', content: 'kimi: (FROM OTHER BOT)\nwhat resources do you have?' } + ], + 'Use tools.', + '***', + [tool], + { cacheScope: 'conversation', turnStateKey: 'react-2' } + ); + + assert.equal(requests[0].body.previous_response_id, undefined); + assert.equal(requests[1].body.previous_response_id, undefined); + assert.equal(requests[1].init.headers['x-codex-turn-state'], undefined); + assert.equal(requests[1].body.prompt_cache_key, 'session-test:conversation'); + assert.equal(requests[1].body.input.length, 4); + assert.equal(requests[1].body.input[1].type, 'function_call'); + assert.equal(requests[1].body.input[2].type, 'function_call_output'); + assert.equal(requests[1].body.input[3].content[0].text, 'kimi: (FROM OTHER BOT)\nwhat resources do you have?'); + assert.equal(model.consumeLastRequestCacheTrace().incremental_reuse_reason, 'http_previous_response_unsupported'); + } finally { + globalThis.fetch = originalFetch; + cleanup(); + } +}); + +test('Codex adapter keeps forked branches on the shared prompt cache key without sharing turn-state', async () => { + const { keysPath, cleanup } = writeTempKeys(); + const originalFetch = globalThis.fetch; + const requests = []; + const responseTexts = ['root ok', 'branch A ok', 'branch B ok', 'main after branch ok']; + const turnStates = ['sticky-root', 'sticky-branch-a', 'sticky-branch-b', 'sticky-main-after-branch']; + globalThis.fetch = async (url, init) => { + const index = requests.length; + requests.push({ url, init, body: JSON.parse(init.body) }); + return new Response([ + 'event: response.output_item.done', + `data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"${responseTexts[index]}"}]}}`, + '', + 'event: response.completed', + `data: {"type":"response.completed","response":{"id":"resp_${index + 1}"}}`, + '' + ].join('\n'), { + status: 200, + headers: { + 'content-type': 'text/event-stream', + 'x-codex-turn-state': turnStates[index] + } + }); + }; + + try { + const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', { + keysPath, + sessionId: 'session-test', + enablePreviousResponseId: true + }); + await model.sendRequest([{ role: 'user', content: 'root' }], 'Use tools.', '***', [tool], { + cacheScope: 'conversation', + turnStateKey: 'root' + }); + await model.sendRequest([ + { role: 'user', content: 'root' }, + { role: 'assistant', content: 'root ok' }, + { role: 'user', content: 'branch A' } + ], 'Use tools.', '***', [tool], { + cacheScope: 'conversation', + turnStateKey: 'branch-a' + }); + await model.sendRequest([ + { role: 'user', content: 'root' }, + { role: 'assistant', content: 'root ok' }, + { role: 'user', content: 'branch B' } + ], 'Use tools.', '***', [tool], { + cacheScope: 'conversation', + turnStateKey: 'branch-b' + }); + await model.sendRequest([ + { role: 'user', content: 'root' }, + { role: 'assistant', content: 'root ok' }, + { role: 'user', content: 'main after branch' } + ], 'Use tools.', '***', [tool], { + cacheScope: 'conversation', + turnStateKey: 'main-after-branch' + }); + + for (const request of requests) { + assert.equal(request.body.prompt_cache_key, 'session-test:conversation'); + assert.equal(request.init.headers.session_id, 'session-test:conversation'); + assert.equal(request.init.headers['x-client-request-id'], 'session-test:conversation'); + } + assert.equal(requests[1].body.previous_response_id, undefined); + assert.equal(requests[1].body.input.length, 3); + assert.equal(requests[1].body.input[2].content[0].text, 'branch A'); + assert.equal(requests[1].init.headers['x-codex-turn-state'], undefined); + + assert.equal(requests[2].body.previous_response_id, undefined); + assert.equal(requests[2].body.input.length, 3); + assert.equal(requests[2].body.input[2].content[0].text, 'branch B'); + assert.equal(requests[2].init.headers['x-codex-turn-state'], undefined); + + assert.equal(requests[3].body.previous_response_id, undefined); + assert.equal(requests[3].body.input.length, 3); + assert.equal(requests[3].body.input[2].content[0].text, 'main after branch'); + assert.equal(requests[3].init.headers['x-codex-turn-state'], undefined); + assert.equal(model.consumeLastRequestCacheTrace().incremental_reuse_reason, 'http_previous_response_unsupported'); + } finally { + globalThis.fetch = originalFetch; + cleanup(); + } +}); + +test('Codex adapter keeps forked WebSocket branches on the shared prompt cache key', async () => { + const { keysPath, cleanup } = writeTempKeys(); + const wss = new WebSocketServer({ port: 0 }); + const payloads = []; + const handshakes = []; + const texts = ['root ok', 'main 1 ok', 'main 2 ok', 'branch A ok', 'branch B ok', 'main after branch ok']; + wss.on('connection', (ws, request) => { + handshakes.push(request.headers); + ws.on('message', data => { + const payload = JSON.parse(data.toString('utf8')); + const index = payloads.length; + payloads.push(payload); + ws.send(JSON.stringify({ + type: 'response.output_item.done', + item: { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: texts[index] }] + } + })); + ws.send(JSON.stringify({ + type: 'response.completed', + response: { + id: `resp_${index + 1}`, + usage: { + input_tokens: 100 + index, + input_tokens_details: { cached_tokens: index === 0 ? 0 : 80 }, + output_tokens: 3 + } + } + })); + }); + }); + await new Promise(resolve => wss.once('listening', resolve)); + + try { + const { port } = wss.address(); + const model = new CodexChatGPT('gpt-5.5', `http://127.0.0.1:${port}/backend-api/codex`, { + keysPath, + sessionId: 'session-test', + useResponsesWebSocket: true, + responsesWebSocketIdleTimeoutMs: 5000 + }); + await model.sendRequest([{ role: 'user', content: 'root' }], 'Use tools.', '***', [tool], { + cacheScope: 'conversation', + turnStateKey: 'root' + }); + await model.sendRequest([ + { role: 'user', content: 'root' }, + { role: 'assistant', content: 'root ok' }, + { role: 'user', content: 'main 1' } + ], 'Use tools.', '***', [tool], { + cacheScope: 'conversation', + turnStateKey: 'main-1' + }); + await model.sendRequest([ + { role: 'user', content: 'root' }, + { role: 'assistant', content: 'root ok' }, + { role: 'user', content: 'main 1' }, + { role: 'assistant', content: 'main 1 ok' }, + { role: 'user', content: 'main 2 before fork' } + ], 'Use tools.', '***', [tool], { + cacheScope: 'conversation', + turnStateKey: 'main-2' + }); + await model.sendRequest([ + { role: 'user', content: 'root' }, + { role: 'assistant', content: 'root ok' }, + { role: 'user', content: 'main 1' }, + { role: 'assistant', content: 'main 1 ok' }, + { role: 'user', content: 'branch A' } + ], 'Use tools.', '***', [tool], { + cacheScope: 'conversation', + turnStateKey: 'branch-a' + }); + await model.sendRequest([ + { role: 'user', content: 'root' }, + { role: 'assistant', content: 'root ok' }, + { role: 'user', content: 'main 1' }, + { role: 'assistant', content: 'main 1 ok' }, + { role: 'user', content: 'branch B' } + ], 'Use tools.', '***', [tool], { + cacheScope: 'conversation', + turnStateKey: 'branch-b' + }); + await model.sendRequest([ + { role: 'user', content: 'root' }, + { role: 'assistant', content: 'root ok' }, + { role: 'user', content: 'main 1' }, + { role: 'assistant', content: 'main 1 ok' }, + { role: 'user', content: 'main after branch' } + ], 'Use tools.', '***', [tool], { + cacheScope: 'conversation', + turnStateKey: 'main-after-branch' + }); + + assert.equal(handshakes.length, 1); + assert.equal(handshakes[0].authorization, 'Bearer access-token-test'); + assert.equal(handshakes[0]['openai-beta'], 'responses_websockets=2026-02-06'); + assert.equal(handshakes[0].session_id, 'session-test:conversation'); + assert.equal(payloads[0].type, 'response.create'); + assert.equal(payloads[0].previous_response_id, undefined); + assert.equal(payloads[0].prompt_cache_key, 'session-test:conversation'); + assert.equal(payloads[0].tool_choice, 'auto'); + for (const payload of payloads) { + assert.equal(payload.prompt_cache_key, 'session-test:conversation'); + } + assert.equal(payloads[1].previous_response_id, 'resp_1'); + assert.equal(payloads[1].input.length, 1); + assert.equal(payloads[1].input[0].content[0].text, 'main 1'); + assert.equal(payloads[2].previous_response_id, 'resp_2'); + assert.equal(payloads[2].input.length, 1); + assert.equal(payloads[2].input[0].content[0].text, 'main 2 before fork'); + assert.equal(payloads[3].previous_response_id, undefined); + assert.equal(payloads[3].input.length, 5); + assert.equal(payloads[3].input[4].content[0].text, 'branch A'); + assert.equal(payloads[4].previous_response_id, undefined); + assert.equal(payloads[4].input.length, 5); + assert.equal(payloads[4].input[4].content[0].text, 'branch B'); + assert.equal(payloads[5].previous_response_id, undefined); + assert.equal(payloads[5].input.length, 5); + assert.equal(payloads[5].input[4].content[0].text, 'main after branch'); + assert.equal(model.lastTokenUsage.input_cached, 80); + model.closeResponsesWebSocket(); + } finally { + await new Promise(resolve => wss.close(resolve)); + cleanup(); + } +}); + test('Codex adapter does not reuse previous_response_id when non-input fields change', async () => { const { keysPath, cleanup } = writeTempKeys(); const originalFetch = globalThis.fetch; @@ -454,13 +762,23 @@ test('Codex adapter does not reuse previous_response_id when non-input fields ch }; try { - const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', { keysPath, sessionId: 'session-test' }); - await model.sendRequest([{ role: 'user', content: 'first' }], 'Use tools.', '***', [tool], { cacheScope: 'conversation' }); + const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', { + keysPath, + sessionId: 'session-test', + enablePreviousResponseId: true + }); + await model.sendRequest([{ role: 'user', content: 'first' }], 'Use tools.', '***', [tool], { + cacheScope: 'conversation', + turnStateKey: 'react-1' + }); await model.sendRequest([ { role: 'user', content: 'first' }, { role: 'assistant', content: 'ok' }, { role: 'user', content: 'second' } - ], 'Changed instructions.', '***', [tool], { cacheScope: 'conversation' }); + ], 'Changed instructions.', '***', [tool], { + cacheScope: 'conversation', + turnStateKey: 'react-1' + }); assert.equal(requests[1].body.previous_response_id, undefined); assert.equal(requests[1].body.input.length, 3); @@ -566,7 +884,7 @@ test('Codex adapter requests reasoning summaries when reasoning effort is config assert.ok(body.include.includes('reasoning.encrypted_content')); }); -test('Codex adapter replays turn-state across conversation scope for sticky cache routing', async () => { +test('Codex adapter replays turn-state only inside one ReAct turn', async () => { const { keysPath, cleanup } = writeTempKeys(); const originalFetch = globalThis.fetch; const requests = []; @@ -607,7 +925,7 @@ test('Codex adapter replays turn-state across conversation scope for sticky cach assert.equal(requests[0].init.headers['x-client-request-id'], 'session-test:conversation'); assert.equal(requests[0].init.headers['x-codex-turn-state'], undefined); assert.equal(requests[1].init.headers['x-codex-turn-state'], 'sticky-route-1'); - assert.equal(requests[2].init.headers['x-codex-turn-state'], 'sticky-route-1'); + assert.equal(requests[2].init.headers['x-codex-turn-state'], undefined); } finally { globalThis.fetch = originalFetch; cleanup(); From 4351760650b4446e77b48122edcb20558545d037 Mon Sep 17 00:00:00 2001 From: NinotQuyi Date: Sun, 3 May 2026 13:11:37 +0800 Subject: [PATCH 40/56] Clarify the Codex adapter boundary The cache fix now lives behind a small compatibility facade while the implementation is split into auth, protocol, transport, constants, and utility modules. This keeps the public import path stable and makes the WebSocket/HTTP continuity contract easier to audit separately from login and SSE parsing. Constraint: External callers must continue importing from src/models/codex_chatgpt.js Rejected: Leave the adapter as one monolithic file | the cache-lineage and WebSocket semantics were too easy to obscure among login and curl helpers Confidence: high Scope-risk: narrow Directive: Keep src/models/codex_chatgpt.js as the unified public facade when adding Codex protocol internals Tested: node --test tests/codex_chatgpt.test.js Tested: npx eslint src/models/codex_chatgpt.js src/models/codex_chatgpt/*.js tests/codex_chatgpt.test.js Tested: npm test Co-authored-by: OmX --- src/models/codex_chatgpt.js | 1599 +------------------------ src/models/codex_chatgpt/auth.js | 500 ++++++++ src/models/codex_chatgpt/client.js | 524 ++++++++ src/models/codex_chatgpt/constants.js | 12 + src/models/codex_chatgpt/protocol.js | 249 ++++ src/models/codex_chatgpt/transport.js | 288 +++++ src/models/codex_chatgpt/utils.js | 86 ++ 7 files changed, 1674 insertions(+), 1584 deletions(-) create mode 100644 src/models/codex_chatgpt/auth.js create mode 100644 src/models/codex_chatgpt/client.js create mode 100644 src/models/codex_chatgpt/constants.js create mode 100644 src/models/codex_chatgpt/protocol.js create mode 100644 src/models/codex_chatgpt/transport.js create mode 100644 src/models/codex_chatgpt/utils.js diff --git a/src/models/codex_chatgpt.js b/src/models/codex_chatgpt.js index 039d4a2fb..e513c53eb 100644 --- a/src/models/codex_chatgpt.js +++ b/src/models/codex_chatgpt.js @@ -1,1584 +1,15 @@ -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; -import path from 'path'; -import { tmpdir } from 'os'; -import { spawn } from 'child_process'; -import { createServer } from 'http'; -import { createHash, randomBytes, randomUUID } from 'crypto'; -import WebSocket from 'ws'; -import open from 'open'; -import { createNativeToolResponse, normalizeThinkingText, toResponsesInputItems } from './native_tools.js'; -import { setLastTokenUsage } from './token_usage.js'; - -const DEFAULT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex'; -const DEFAULT_KEYS_PATH = 'settings_llm_providers.json'; -const CODEX_REFRESH_TOKEN_URL = 'https://auth.openai.com/oauth/token'; -const CODEX_ISSUER = 'https://auth.openai.com'; -const CODEX_OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; -const CODEX_AUTH_KEY = 'CODEX_CHATGPT_AUTH'; -const DEFAULT_ORIGINATOR = 'codex_cli_rs'; -const LOGIN_TIMEOUT_MS = 15 * 60 * 1000; -const DEFAULT_LOGIN_PORT = 1455; -const DEFAULT_FETCH = globalThis.fetch; -const RESPONSES_WEBSOCKET_BETA_HEADER_VALUE = 'responses_websockets=2026-02-06'; -const DEFAULT_WEBSOCKET_IDLE_TIMEOUT_MS = 5 * 60 * 1000; -const CONTINUITY_BASELINE_INPUT = Symbol('codexContinuityBaselineInput'); - -export class CodexChatGPT { - static prefix = 'codex'; - - constructor(model_name, url, params = {}) { - this.model_name = model_name; - this.url = trimTrailingSlash(url || params?.baseUrl || params?.base_url || DEFAULT_CODEX_BASE_URL); - this.params = { ...(params || {}) }; - delete this.params.baseUrl; - delete this.params.base_url; - this.provider = 'codex-chatgpt'; - this.default_model = 'gpt-5.5'; - this.supportsNativeToolCalls = true; - this.authPath = expandHomePath( - this.params.authPath || - this.params.auth_path || - this.params.codexAuthPath || - this.params.codex_auth_path || - this.params.keysPath || - this.params.keys_path || - DEFAULT_KEYS_PATH - ); - this.keysPath = this.authPath; - this.allowLogin = this.params.allowLogin ?? this.params.allow_login ?? true; - this.loginRunner = this.params.loginRunner; - this.issuer = this.params.issuer || CODEX_ISSUER; - this.clientId = this.params.clientId || this.params.client_id || CODEX_OAUTH_CLIENT_ID; - this.loginPort = Number.parseInt(this.params.loginPort || this.params.login_port || DEFAULT_LOGIN_PORT, 10); - this.openBrowser = this.params.openBrowser ?? this.params.open_browser ?? true; - this.forcedChatgptWorkspaceId = this.params.forcedChatgptWorkspaceId || this.params.forced_chatgpt_workspace_id; - delete this.params.keysPath; - delete this.params.keys_path; - delete this.params.authPath; - delete this.params.auth_path; - delete this.params.codexAuthPath; - delete this.params.codex_auth_path; - delete this.params.allowLogin; - delete this.params.allow_login; - delete this.params.loginRunner; - delete this.params.issuer; - delete this.params.clientId; - delete this.params.client_id; - delete this.params.loginPort; - delete this.params.login_port; - delete this.params.openBrowser; - delete this.params.open_browser; - delete this.params.forcedChatgptWorkspaceId; - delete this.params.forced_chatgpt_workspace_id; - this.sessionIdWasExplicit = Boolean(this.params.sessionId || this.params.session_id); - this.sessionId = this.params.sessionId || this.params.session_id || randomUUID(); - delete this.params.sessionId; - delete this.params.session_id; - this.originator = this.params.originator || process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE || DEFAULT_ORIGINATOR; - delete this.params.originator; - const transport = String(this.params.transport || this.params.codexTransport || this.params.codex_transport || '').toLowerCase(); - const webSocketParam = this.params.responsesWebSocket ?? this.params.responses_websocket ?? this.params.useResponsesWebSocket ?? this.params.use_responses_websocket; - this.useResponsesWebSocket = webSocketParam ?? (transport ? transport === 'websocket' || transport === 'ws' : isChatGptCodexUrl(this.url)); - this.responsesWebSocketDisabled = transport === 'http' || transport === 'https'; - this.responsesWebSocket = null; - this.responsesWebSocketHeaders = null; - this.responsesWebSocketIdleTimeoutMs = Number.parseInt(this.params.responsesWebSocketIdleTimeoutMs || this.params.responses_websocket_idle_timeout_ms || DEFAULT_WEBSOCKET_IDLE_TIMEOUT_MS, 10); - delete this.params.transport; - delete this.params.codexTransport; - delete this.params.codex_transport; - delete this.params.responsesWebSocket; - delete this.params.responses_websocket; - delete this.params.useResponsesWebSocket; - delete this.params.use_responses_websocket; - delete this.params.responsesWebSocketIdleTimeoutMs; - delete this.params.responses_websocket_idle_timeout_ms; - this.enablePreviousResponseId = Boolean(this.params.enablePreviousResponseId || this.params.enable_previous_response_id); - delete this.params.enablePreviousResponseId; - delete this.params.enable_previous_response_id; - this.turnStateByKey = new Map(); - this.responseContinuityByKey = new Map(); - this.lastRequestCacheTrace = null; - } - - setSessionIdentity(identity) { - if (this.sessionIdWasExplicit) return; - const value = String(identity || '').trim(); - if (value) this.sessionId = value; - } - - async sendRequest(turns, systemMessage, stop_seq='***', tools=null, options = {}) { - this.lastTokenUsage = null; - this.lastThinking = ''; - const model = this.model_name || this.default_model; - const hasTools = Array.isArray(tools) && tools.length > 0; - const body = this.buildRequestBody(model, turns, systemMessage, tools, options); - const endpoint = `${this.url}/responses`; - - console.log(hasTools - ? `Awaiting Codex ChatGPT native-login response with tool calling (${tools.length} tools) from model ${model}` - : `Awaiting Codex ChatGPT native-login response from model ${model}`); - - try { - let auth = await ensureCodexChatGPTAuth({ - authPath: this.authPath, - allowLogin: this.allowLogin, - loginRunner: this.loginRunner, - issuer: this.issuer, - clientId: this.clientId, - port: this.loginPort, - openBrowser: this.openBrowser, - forcedChatgptWorkspaceId: this.forcedChatgptWorkspaceId, - originator: this.originator - }); - let response = await this.fetchResponses(endpoint, body, auth, options); - if (response.status === 401 && auth.refreshToken) { - auth = await refreshCodexChatGPTAuth(auth, this.authPath, this.originator); - response = await this.fetchResponses(endpoint, body, auth, options); - } - if (!response.ok) { - throw await codexHttpError(response); - } - this.rememberTurnState(options, response); - - const parsed = await parseCodexResponsesSse(await response.text()); - this.rememberResponseContinuity(this.lastSentResponsesOptions || options, this.lastSentResponsesBody || body, parsed); - console.log('Received.'); - setLastTokenUsage(this, parsed.usage); - this.lastThinking = parsed.thinking || ''; - if (parsed.toolCalls.length > 0) { - return createNativeToolResponse(parsed.toolCalls, this.provider, { thinking: this.lastThinking }); - } - let text = parsed.text; - if (stop_seq && text.includes(stop_seq)) { - text = text.slice(0, text.indexOf(stop_seq)); - } - return text || 'No response received.'; - } catch (err) { - if (isAbortError(err)) { - console.log('Codex ChatGPT request aborted.'); - throw err; - } - console.log(sanitizeCodexError(err)); - return 'My brain disconnected, try again.'; - } - } - - async sendVisionRequest(turns, systemMessage, imageBuffer, options = {}) { - const imageMessages = [...(turns || [])]; - imageMessages.push({ - role: 'user', - content: [ - { type: 'input_text', text: '' }, - { type: 'input_image', image_url: `data:image/jpeg;base64,${imageBuffer.toString('base64')}` }, - { type: 'input_text', text: `\n${systemMessage || 'Describe the image.'}` } - ] - }); - return this.sendRequest(imageMessages, systemMessage, '***', null, options); - } - - buildRequestBody(model, turns, systemMessage, tools=null, options = {}) { - const promptCacheKey = buildScopedPromptCacheKey(this.sessionId, options?.cacheScope); - const reasoning = buildCodexReasoning(this.params.reasoning); - const include = buildCodexInclude(this.params.include, reasoning); - const body = { - model, - instructions: systemMessage || '', - input: toResponsesInputItems(turns || []), - tools: toCodexResponsesTools(tools || []), - parallel_tool_calls: this.params.parallel_tool_calls ?? true, - reasoning, - store: false, - stream: true, - include, - prompt_cache_key: promptCacheKey - }; - - for (const [key, value] of Object.entries(this.params)) { - if (!['tool_choice', 'toolChoice', 'apiKeyName', 'api_key_name', 'defaultModel', 'default_model', 'parallel_tool_calls', 'reasoning', 'include'].includes(key)) { - body[key] = value; - } - } - this.applyPreviousResponseContinuity(options, body); - return body; - } - - getCacheTraceMetadata(options = {}) { - const scopedSessionId = buildScopedPromptCacheKey(this.sessionId, options?.cacheScope); - const responseContinuityKey = this.getResponseContinuityKey(options); - const responseContinuityEntries = responseContinuityKey - ? this.responseContinuityByKey.get(responseContinuityKey) - : null; - return { - cache_scope: options?.cacheScope || null, - turn_state_key: options?.turnStateKey || null, - transport_cache: { - protocol: 'openai-codex-responses', - prompt_cache_key: scopedSessionId, - session_id: scopedSessionId, - turn_state_present: Boolean(this.getTurnState(options)), - previous_response_id_available: Boolean(responseContinuityEntries?.some(entry => entry.responseId)) - } - }; - } - - consumeLastRequestCacheTrace() { - const value = this.lastRequestCacheTrace; - this.lastRequestCacheTrace = null; - return value || null; - } - - async fetchResponses(endpoint, body, auth, options = {}) { - this.lastSentResponsesBody = body; - this.lastSentResponsesOptions = options; - if (this.useResponsesWebSocket && !this.responsesWebSocketDisabled) { - try { - return await this.fetchResponsesWebSocket(endpoint, body, auth, options); - } catch (err) { - if (isAbortError(err)) throw err; - this.closeResponsesWebSocket(); - this.responsesWebSocketDisabled = true; - console.log(`Codex Responses WebSocket failed; falling back to HTTP. ${sanitizeCodexError(err)}`); - } - } - const httpBody = expandContinuityRequestBody(body); - if (httpBody !== body && this.lastRequestCacheTrace?.incremental_reuse) { - this.lastRequestCacheTrace = { - ...this.lastRequestCacheTrace, - previous_response_id: null, - incremental_input_items: null, - full_input_items: Array.isArray(httpBody.input) ? httpBody.input.length : this.lastRequestCacheTrace.full_input_items, - incremental_reuse: false, - incremental_reuse_reason: 'http_previous_response_unsupported' - }; - } - this.lastSentResponsesBody = httpBody; - this.lastSentResponsesOptions = options; - return await codexFetch(endpoint, { - method: 'POST', - headers: this.buildHeaders(auth, options), - body: JSON.stringify(httpBody), - signal: options?.signal - }); - } - - async fetchResponsesWebSocket(endpoint, body, auth, options = {}) { - const wsOptions = { - ...(options || {}), - transportSupportsPreviousResponseId: true, - responseContinuityLatestOnly: true - }; - const wsBody = structuredCloneSafe(expandContinuityRequestBody(body)); - this.applyPreviousResponseContinuity(wsOptions, wsBody); - this.lastSentResponsesBody = wsBody; - this.lastSentResponsesOptions = wsOptions; - const ws = await this.ensureResponsesWebSocket(endpoint, auth, options); - const responseText = await streamCodexResponsesWebSocket(ws, toResponseCreateWebSocketRequest(wsBody), { - signal: options?.signal, - idleTimeoutMs: this.responsesWebSocketIdleTimeoutMs, - onClosed: () => { - this.responsesWebSocket = null; - this.responsesWebSocketHeaders = null; - } - }); - return new Response(responseText, { - status: 200, - headers: this.responsesWebSocketHeaders || {} - }); - } - - async ensureResponsesWebSocket(endpoint, auth, options = {}) { - if (this.responsesWebSocket?.readyState === WebSocket.OPEN) { - return this.responsesWebSocket; - } - this.closeResponsesWebSocket(); - const headers = this.buildWebSocketHeaders(auth, options); - const { ws, headers: responseHeaders } = await connectCodexResponsesWebSocket(toWebSocketUrl(endpoint), headers, { - signal: options?.signal, - timeoutMs: this.responsesWebSocketIdleTimeoutMs - }); - this.responsesWebSocket = ws; - this.responsesWebSocketHeaders = responseHeaders; - ws.once('close', () => { - if (this.responsesWebSocket === ws) { - this.responsesWebSocket = null; - this.responsesWebSocketHeaders = null; - } - }); - return ws; - } - - closeResponsesWebSocket() { - if (this.responsesWebSocket) { - try { - this.responsesWebSocket.close(); - } catch { - // Best-effort cleanup. - } - } - this.responsesWebSocket = null; - this.responsesWebSocketHeaders = null; - } - - buildWebSocketHeaders(auth, options = {}) { - const headers = { ...this.buildHeaders(auth, options) }; - delete headers['Content-Type']; - headers['OpenAI-Beta'] = RESPONSES_WEBSOCKET_BETA_HEADER_VALUE; - return headers; - } - - buildHeaders(auth, options = {}) { - const scopedSessionId = buildScopedPromptCacheKey(this.sessionId, options?.cacheScope); - const headers = { - 'Authorization': `Bearer ${auth.accessToken}`, - 'ChatGPT-Account-ID': auth.accountId, - 'Content-Type': 'application/json', - 'Accept': 'text/event-stream', - 'originator': this.originator, - 'session_id': scopedSessionId, - 'x-client-request-id': scopedSessionId, - 'User-Agent': `${this.originator}/mindcraft-native-tool` - }; - if (!auth.accountId) { - delete headers['ChatGPT-Account-ID']; - } - const turnState = this.getTurnState(options); - if (turnState) { - headers['x-codex-turn-state'] = turnState; - } - return headers; - } - - getTurnState(options = {}) { - const key = this.getTurnStateKey(options); - return key ? this.turnStateByKey.get(key) : null; - } - - rememberTurnState(options = {}, response) { - const key = this.getTurnStateKey(options); - const value = response?.headers?.get?.('x-codex-turn-state'); - if (!key || !value) return; - this.turnStateByKey.set(key, value); - if (this.turnStateByKey.size > 64) { - const oldestKey = this.turnStateByKey.keys().next().value; - this.turnStateByKey.delete(oldestKey); - } - } - - getTurnStateKey(options = {}) { - // Match Codex CLI's turn-scoped sticky-routing contract. The backend may - // return x-codex-turn-state during a ReAct turn; replay it only for - // follow-up requests in that same turn. Leaking it into the next inbound - // bot/user message can route an otherwise cacheable prompt to the wrong - // backend state and cause full prompt-cache misses. - return this.getTurnScopedContinuityKey(options); - } - - getResponseContinuityKey(options = {}) { - // The ChatGPT Codex HTTP endpoint rejects previous_response_id; Codex - // CLI uses that field only on its websocket transport. Keep the - // branch-aware continuity machinery behind an explicit transport opt-in - // so the default HTTP path relies on prompt_cache_key and never 400s. - if (!this.enablePreviousResponseId && !options?.transportSupportsPreviousResponseId) return null; - return buildScopedPromptCacheKey(this.sessionId, options?.cacheScope); - } - - getTurnScopedContinuityKey(options = {}) { - const turnStateKey = String(options?.turnStateKey || '').trim(); - if (!turnStateKey) return null; - const scopedSessionId = buildScopedPromptCacheKey(this.sessionId, options?.cacheScope); - return buildScopedPromptCacheKey(scopedSessionId, `turn:${turnStateKey}`); - } - - applyPreviousResponseContinuity(options = {}, body) { - const key = this.getResponseContinuityKey(options); - const previousEntries = key ? this.responseContinuityByKey.get(key) : null; - const baseTrace = { - protocol: 'openai-codex-responses', - prompt_cache_key: body.prompt_cache_key, - session_id: buildScopedPromptCacheKey(this.sessionId, options?.cacheScope), - turn_state_present: Boolean(this.getTurnState(options)), - previous_response_id: null, - incremental_input_items: null, - full_input_items: Array.isArray(body.input) ? body.input.length : 0, - incremental_reuse: false, - incremental_reuse_reason: 'no_previous_response' - }; - - if (!previousEntries?.length) { - this.lastRequestCacheTrace = baseTrace; - return; - } - - const requestSignature = codexRequestSignature(body); - const candidateEntries = options?.responseContinuityLatestOnly - ? previousEntries.slice(0, 1) - : previousEntries; - let sawMatchingSignature = false; - let bestMatch = null; - for (const entry of candidateEntries) { - if (requestSignature !== entry.requestSignature) continue; - sawMatchingSignature = true; - const delta = getIncrementalResponsesInput(body.input, entry.baselineInput); - if (!delta) continue; - if (!bestMatch || entry.baselineInput.length > bestMatch.entry.baselineInput.length) { - bestMatch = { entry, delta }; - } - } - - if (!sawMatchingSignature) { - this.lastRequestCacheTrace = { - ...baseTrace, - incremental_reuse_reason: 'non_input_fields_changed' - }; - return; - } - - if (!bestMatch) { - this.lastRequestCacheTrace = { - ...baseTrace, - incremental_reuse_reason: 'input_not_previous_prefix' - }; - return; - } - - body.previous_response_id = bestMatch.entry.responseId; - body.input = bestMatch.delta; - body[CONTINUITY_BASELINE_INPUT] = bestMatch.entry.baselineInput; - this.lastRequestCacheTrace = { - ...baseTrace, - previous_response_id: bestMatch.entry.responseId, - incremental_input_items: bestMatch.delta.length, - incremental_reuse: true, - incremental_reuse_reason: 'prefix_reused' - }; - } - - rememberResponseContinuity(options = {}, body, parsed = {}) { - const responseId = parsed.responseId; - if (!responseId) return; - const key = this.getResponseContinuityKey(options); - if (!key) return; - const sentInput = body.previous_response_id - ? [ - ...(body[CONTINUITY_BASELINE_INPUT] || []), - ...(body.input || []) - ] - : (body.input || []); - const outputItems = synthesizeCodexOutputItems(parsed); - const entries = this.responseContinuityByKey.get(key) || []; - entries.unshift({ - responseId, - requestSignature: codexRequestSignature(body), - baselineInput: normalizeResponsesItemsForContinuity([ - ...sentInput, - ...outputItems - ]) - }); - entries.length = Math.min(entries.length, 32); - this.responseContinuityByKey.set(key, entries); - if (this.responseContinuityByKey.size > 64) { - const oldestKey = this.responseContinuityByKey.keys().next().value; - this.responseContinuityByKey.delete(oldestKey); - } - } - - async embed() { - throw new Error('Codex ChatGPT native-login adapter does not support embeddings. Configure an embedding provider separately.'); - } -} - -export function hasCodexChatGPTAuth(authPath = DEFAULT_KEYS_PATH) { - return canReadCodexChatGPTAuth(expandHomePath(authPath)); -} - -export async function ensureCodexChatGPTAuth({ - authPath, - keysPath, - allowLogin = true, - loginRunner = runCodexBrowserLogin, - issuer = CODEX_ISSUER, - clientId = CODEX_OAUTH_CLIENT_ID, - port = DEFAULT_LOGIN_PORT, - openBrowser = true, - forcedChatgptWorkspaceId = null, - originator = DEFAULT_ORIGINATOR -} = {}) { - const resolvedAuthPath = expandHomePath(authPath || keysPath || DEFAULT_KEYS_PATH); - if (canReadCodexChatGPTAuth(resolvedAuthPath)) { - return readCodexChatGPTAuth(resolvedAuthPath); - } - if (allowLogin && (isInteractiveTerminal() || loginRunner !== runCodexBrowserLogin)) { - const authJson = await loginRunner({ authPath: resolvedAuthPath, keysPath: resolvedAuthPath, issuer, clientId, port, openBrowser, forcedChatgptWorkspaceId, originator }); - if (authJson) { - writeKeysCodexAuth(resolvedAuthPath, authJson); - } - return readCodexChatGPTAuth(resolvedAuthPath); - } - throw new Error(`Codex ChatGPT auth is missing in ${resolvedAuthPath}. Start with an interactive terminal and choose the codex profile to login here.`); -} - -export function readCodexChatGPTAuth(authPath = DEFAULT_KEYS_PATH) { - const resolvedAuthPath = expandHomePath(authPath); - const config = readJsonFile(resolvedAuthPath); - const authJson = extractCodexAuth(config); - if (!authJson || typeof authJson !== 'object') { - throw new Error(`Missing Codex ChatGPT auth in ${resolvedAuthPath}.`); - } - return normalizeCodexAuth(authJson, resolvedAuthPath); -} - -export async function refreshCodexChatGPTAuth(auth, authPath = auth.authPath || DEFAULT_KEYS_PATH, originator = DEFAULT_ORIGINATOR) { - if (!auth.refreshToken) { - throw new Error('Codex ChatGPT auth has no refresh token. Login again from this project.'); - } - const response = await codexFetch(CODEX_REFRESH_TOKEN_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'originator': originator, - 'User-Agent': `${originator}/mindcraft-native-tool` - }, - body: JSON.stringify({ - client_id: CODEX_OAUTH_CLIENT_ID, - grant_type: 'refresh_token', - refresh_token: auth.refreshToken - }) - }); - - if (!response.ok) { - throw await codexHttpError(response); - } - const refreshed = await response.json(); - const next = structuredClone(auth.raw || {}); - next.tokens = next.tokens || {}; - if (refreshed.id_token) next.tokens.id_token = refreshed.id_token; - if (refreshed.access_token) next.tokens.access_token = refreshed.access_token; - if (refreshed.refresh_token) next.tokens.refresh_token = refreshed.refresh_token; - next.last_refresh = new Date().toISOString(); - writeKeysCodexAuth(authPath, next); - return readCodexChatGPTAuth(authPath); -} - -export async function runCodexBrowserLogin({ - authPath, - keysPath, - issuer = CODEX_ISSUER, - clientId = CODEX_OAUTH_CLIENT_ID, - port = DEFAULT_LOGIN_PORT, - openBrowser = true, - forcedChatgptWorkspaceId = null, - originator = DEFAULT_ORIGINATOR -} = {}) { - const resolvedAuthPath = expandHomePath(authPath || keysPath || DEFAULT_KEYS_PATH); - const baseUrl = trimTrailingSlash(issuer); - const pkce = generatePkce(); - const state = generateState(); - const requestedPort = port === 0 ? 0 : Number.parseInt(port || DEFAULT_LOGIN_PORT, 10); - const server = await startCallbackServer(requestedPort); - const redirectUri = `http://localhost:${server.port}/auth/callback`; - const authUrl = buildAuthorizeUrl({ - issuer: baseUrl, - clientId, - redirectUri, - pkce, - state, - forcedChatgptWorkspaceId, - originator - }); - - try { - printBrowserLoginPrompt(authUrl, redirectUri); - if (openBrowser) { - open(authUrl).catch(err => { - console.log(`Could not open browser automatically: ${sanitizeCodexError(err)}`); - }); - } - const code = await waitForOAuthCallback(server, state); - const tokens = await exchangeAuthorizationCodeForTokens(baseUrl, clientId, redirectUri, pkce, code); - const authJson = toCodexAuthJson(tokens); - writeKeysCodexAuth(resolvedAuthPath, authJson); - return authJson; - } finally { - await closeServer(server.server); - } -} - -// Compatibility helper for explicit tests/dev flows. The default login path intentionally -// uses the local browser callback flow above, matching `codex login` rather than -// `codex login --device-auth`. -export async function runCodexDeviceLogin({ authPath, keysPath, issuer = CODEX_ISSUER, clientId = CODEX_OAUTH_CLIENT_ID } = {}) { - const resolvedAuthPath = expandHomePath(authPath || keysPath || DEFAULT_KEYS_PATH); - const baseUrl = trimTrailingSlash(issuer); - const device = await requestDeviceCode(baseUrl, clientId); - printDeviceCodePrompt(device.verification_url, device.user_code); - const code = await pollDeviceAuthorization(baseUrl, device); - const tokens = await exchangeAuthorizationCodeForTokens(baseUrl, clientId, `${baseUrl}/deviceauth/callback`, { code_verifier: code.code_verifier }, code.authorization_code); - const authJson = toCodexAuthJson(tokens); - writeKeysCodexAuth(resolvedAuthPath, authJson); - return authJson; -} - -export function writeKeysCodexAuth(authPath, authJson) { - const resolvedAuthPath = expandHomePath(authPath); - const normalized = toCodexAuthJson(authJson); - const existing = existsSync(resolvedAuthPath) ? readJsonFile(resolvedAuthPath) : {}; - if (looksLikeUnifiedKeysConfig(existing) || Object.prototype.hasOwnProperty.call(existing, CODEX_AUTH_KEY)) { - const section = getKeysSection(existing, true); - section[CODEX_AUTH_KEY] = normalized; - writeFileSync(resolvedAuthPath, `${JSON.stringify(existing, null, 4)}\n`, { mode: 0o600 }); - console.log(`Saved Codex ChatGPT auth to ${resolvedAuthPath} at keys.${CODEX_AUTH_KEY}`); - return; - } - writeFileSync(resolvedAuthPath, `${JSON.stringify(normalized, null, 4)}\n`, { mode: 0o600 }); - console.log(`Saved Codex ChatGPT auth to ${resolvedAuthPath}`); -} - -function extractCodexAuth(config) { - if (config?.tokens?.access_token) { - return config; - } - const section = getKeysSection(config); - return section?.[CODEX_AUTH_KEY]; -} - -function looksLikeUnifiedKeysConfig(config) { - return Boolean(config?.keys || config?.models || config?.embeddings); -} - -function getKeysSection(config, create = false) { - if (config?.keys && typeof config.keys === 'object') { - return config.keys; - } - if (create && (config.models || config.embeddings)) { - config.keys = {}; - return config.keys; - } - return config; -} - -export function toCodexResponsesTools(tools = []) { - return tools.map(tool => { - const fn = tool.function || tool; - return { - type: 'function', - name: fn.name, - description: fn.description || '', - strict: Boolean(fn.strict), - parameters: fn.parameters || { type: 'object', properties: {} } - }; - }).filter(tool => tool.name); -} - -export function buildScopedPromptCacheKey(baseKey, cacheScope) { - const base = String(baseKey || '').trim(); - const scope = String(cacheScope || '').trim(); - if (!scope) return base; - return `${base}:${scope}`; -} - -function toResponseCreateWebSocketRequest(body = {}) { - return { - type: 'response.create', - ...body, - tool_choice: body.tool_choice || 'auto' - }; -} - -function expandContinuityRequestBody(body = {}) { - if (!body?.previous_response_id) return body; - const expanded = { - ...body, - input: [ - ...(body[CONTINUITY_BASELINE_INPUT] || []), - ...(body.input || []) - ] - }; - delete expanded.previous_response_id; - return expanded; -} - -function toWebSocketUrl(endpoint) { - const url = new URL(endpoint); - if (url.protocol === 'https:') url.protocol = 'wss:'; - if (url.protocol === 'http:') url.protocol = 'ws:'; - return url.toString(); -} - -function isChatGptCodexUrl(url) { - try { - const parsed = new URL(url); - return parsed.hostname === 'chatgpt.com' && parsed.pathname.includes('/backend-api/codex'); - } catch { - return false; - } -} - -async function connectCodexResponsesWebSocket(url, headers, { signal, timeoutMs = DEFAULT_WEBSOCKET_IDLE_TIMEOUT_MS } = {}) { - if (signal?.aborted) throw abortError(); - return await new Promise((resolve, reject) => { - let settled = false; - let responseHeaders = {}; - const ws = new WebSocket(url, { - headers, - perMessageDeflate: true, - family: 4, - handshakeTimeout: Math.min(Math.max(timeoutMs, 1000), 30000) - }); - - const cleanup = () => { - ws.off('open', onOpen); - ws.off('upgrade', onUpgrade); - ws.off('unexpected-response', onUnexpectedResponse); - ws.off('error', onError); - signal?.removeEventListener?.('abort', onAbort); - }; - const fail = (error) => { - if (settled) return; - settled = true; - cleanup(); - try { - ws.close(); - } catch { - // Best-effort cleanup. - } - reject(error); - }; - const onAbort = () => fail(abortError()); - const onUpgrade = response => { - responseHeaders = normalizeNodeHeaders(response?.headers || {}); - }; - const onOpen = () => { - if (settled) return; - settled = true; - cleanup(); - resolve({ ws, headers: responseHeaders }); - }; - const onUnexpectedResponse = (_request, response) => { - const chunks = []; - response.on('data', chunk => chunks.push(Buffer.from(chunk))); - response.on('end', () => { - const body = Buffer.concat(chunks).toString('utf8'); - const error = new Error(`WebSocket upgrade failed with status=${response.statusCode} ${body.slice(0, 300)}`); - error.status = response.statusCode; - fail(error); - }); - response.on('error', fail); - }; - const onError = error => fail(error); - - ws.once('open', onOpen); - ws.once('upgrade', onUpgrade); - ws.once('unexpected-response', onUnexpectedResponse); - ws.once('error', onError); - signal?.addEventListener?.('abort', onAbort, { once: true }); - }); -} - -async function streamCodexResponsesWebSocket(ws, payload, { signal, idleTimeoutMs = DEFAULT_WEBSOCKET_IDLE_TIMEOUT_MS, onClosed } = {}) { - if (signal?.aborted) throw abortError(); - return await new Promise((resolve, reject) => { - let settled = false; - const chunks = []; - const timeout = setTimeout(() => { - fail(new Error('idle timeout waiting for Codex Responses WebSocket')); - }, Math.max(1000, idleTimeoutMs)); - - const cleanup = () => { - clearTimeout(timeout); - ws.off('message', onMessage); - ws.off('error', onError); - ws.off('close', onClose); - signal?.removeEventListener?.('abort', onAbort); - }; - const fail = (error) => { - if (settled) return; - settled = true; - cleanup(); - reject(error); - }; - const finish = () => { - if (settled) return; - settled = true; - cleanup(); - resolve(chunks.join('')); - }; - const onAbort = () => { - try { - ws.close(); - } catch { - // Best-effort cleanup. - } - fail(abortError()); - }; - const onError = error => fail(error); - const onClose = () => { - onClosed?.(); - fail(new Error('websocket closed before response.completed')); - }; - const onMessage = data => { - const text = Buffer.isBuffer(data) ? data.toString('utf8') : String(data); - let event = null; - try { - event = JSON.parse(text); - } catch { - return; - } - if (event?.type === 'error') { - const message = event.error?.message || event.message || text; - const error = new Error(message); - error.status = event.status || event.status_code; - fail(error); - return; - } - chunks.push(`data: ${text}\n\n`); - if (event?.type === 'response.failed') { - const error = new Error(event.response?.error?.message || 'Codex Responses WebSocket failed'); - error.status = event.response?.status; - fail(error); - return; - } - if (event?.type === 'response.completed') { - finish(); - } - }; - - ws.on('message', onMessage); - ws.once('error', onError); - ws.once('close', onClose); - signal?.addEventListener?.('abort', onAbort, { once: true }); - ws.send(JSON.stringify(payload), error => { - if (error) fail(error); - }); - }); -} - -function normalizeNodeHeaders(headers = {}) { - const normalized = {}; - for (const [key, value] of Object.entries(headers)) { - normalized[key] = Array.isArray(value) ? value.join(', ') : String(value); - } - return normalized; -} - -function abortError() { - const error = new Error('aborted'); - error.name = 'AbortError'; - return error; -} - -function codexRequestSignature(body = {}) { - const copy = { ...(body || {}) }; - delete copy.input; - delete copy.previous_response_id; - return stableJson(copy); -} - -function getIncrementalResponsesInput(input = [], previousBaseline = []) { - const normalizedInput = normalizeResponsesItemsForContinuity(input); - const normalizedBaseline = normalizeResponsesItemsForContinuity(previousBaseline); - if (normalizedBaseline.length > normalizedInput.length) return null; - for (let i = 0; i < normalizedBaseline.length; i++) { - if (stableJson(normalizedInput[i]) !== stableJson(normalizedBaseline[i])) { - return null; - } - } - return input.slice(normalizedBaseline.length); -} - -function normalizeResponsesItemsForContinuity(items = []) { - return (items || []).map(normalizeResponsesItemForContinuity); -} - -function normalizeResponsesItemForContinuity(item) { - if (!item || typeof item !== 'object') return item; - const clone = structuredCloneSafe(item); - stripVolatileResponsesFields(clone); - return clone; -} - -function stripVolatileResponsesFields(value) { - if (!value || typeof value !== 'object') return; - if (Array.isArray(value)) { - for (const item of value) stripVolatileResponsesFields(item); - return; - } - delete value.id; - delete value.status; - delete value.object; - for (const item of Object.values(value)) { - stripVolatileResponsesFields(item); - } -} - -function synthesizeCodexOutputItems(parsed = {}) { - if (parsed.toolCalls?.length) { - return parsed.toolCalls.map(call => ({ - type: 'function_call', - call_id: call.id, - name: call.function?.name || call.name, - arguments: call.function?.arguments || call.arguments || '{}' - })).filter(item => item.call_id && item.name); - } - if (parsed.text) { - return [{ - type: 'message', - role: 'assistant', - content: [{ type: 'output_text', text: parsed.text }] - }]; - } - return []; -} - -function structuredCloneSafe(value) { - if (typeof structuredClone === 'function') { - try { - return structuredClone(value); - } catch { - // Fall through to JSON clone. - } - } - return JSON.parse(JSON.stringify(value)); -} - -function stableJson(value) { - return JSON.stringify(sortJsonKeys(value)); -} - -function sortJsonKeys(value) { - if (Array.isArray(value)) return value.map(sortJsonKeys); - if (!value || typeof value !== 'object') return value; - return Object.keys(value).sort().reduce((out, key) => { - out[key] = sortJsonKeys(value[key]); - return out; - }, {}); -} - -export function toCodexResponseItem(message) { - const role = message.role === 'assistant' ? 'assistant' : 'user'; - return { - type: 'message', - role, - content: [{ - type: role === 'assistant' ? 'output_text' : 'input_text', - text: stringifyContent(message.content) - }] - }; -} - -export async function parseCodexResponsesSse(sseText) { - const toolCalls = []; - const textDeltas = []; - const messageTexts = []; - const thinkingDeltas = []; - const reasoningItems = []; - const outputItems = []; - let responseId = null; - let usage = null; - const events = sseText.split(/\n\n+/); - for (const eventBlock of events) { - const dataLines = eventBlock - .split(/\r?\n/) - .filter(line => line.startsWith('data:')) - .map(line => line.slice(5).trimStart()); - if (dataLines.length === 0) continue; - const data = dataLines.join('\n'); - if (data === '[DONE]') continue; - let event; - try { - event = JSON.parse(data); - } catch { - continue; - } - if (event.type === 'response.output_text.delta' && typeof event.delta === 'string') { - textDeltas.push(event.delta); - } - if (typeof event.type === 'string' && event.type.includes('reasoning') && typeof event.delta === 'string') { - thinkingDeltas.push(event.delta); - } - const item = event.item; - if (event.type === 'response.output_item.done' && item) { - outputItems.push(item); - } - if (event.type === 'response.output_item.done' && item?.type === 'function_call') { - toolCalls.push({ - id: item.call_id, - type: 'function', - function: { - name: item.name, - arguments: item.arguments || '{}' - } - }); - } - if (event.type === 'response.output_item.done' && item?.type === 'message') { - messageTexts.push(extractMessageText(item)); - } - if (event.type === 'response.output_item.done' && item?.type === 'reasoning') { - reasoningItems.push(extractReasoningText(item)); - } - if (event.response?.usage) { - usage = event.response.usage; - } else if (event.usage) { - usage = event.usage; - } - if (event.response?.id) { - responseId = event.response.id; - } - if (event.response_id) { - responseId = event.response_id; - } - if (event.type === 'response.failed') { - const message = event.response?.error?.message || 'Codex Responses stream failed'; - throw new Error(message); - } - } - const text = textDeltas.length > 0 ? textDeltas.join('') : messageTexts.join(''); - const thinking = thinkingDeltas.length > 0 - ? normalizeThinkingText(thinkingDeltas.join('')) - : normalizeThinkingText(reasoningItems); - return { text, toolCalls, usage, thinking, responseId, outputItems }; -} - -async function requestDeviceCode(baseUrl, clientId) { - const response = await codexFetch(`${baseUrl}/api/accounts/deviceauth/usercode`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ client_id: clientId }) - }); - if (!response.ok) { - throw await codexHttpError(response); - } - const body = await response.json(); - return { - verification_url: `${baseUrl}/codex/device`, - user_code: body.user_code || body.usercode, - device_auth_id: body.device_auth_id, - interval: Number.parseInt(body.interval || '5', 10) - }; -} - -async function pollDeviceAuthorization(baseUrl, device) { - const started = Date.now(); - while (Date.now() - started < LOGIN_TIMEOUT_MS) { - const response = await codexFetch(`${baseUrl}/api/accounts/deviceauth/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - device_auth_id: device.device_auth_id, - user_code: device.user_code - }) - }); - if (response.ok) { - return await response.json(); - } - if (![403, 404].includes(response.status)) { - throw await codexHttpError(response); - } - await sleep(Math.max(1, device.interval) * 1000); - } - throw new Error('Codex device login timed out after 15 minutes.'); -} - -async function exchangeAuthorizationCodeForTokens(baseUrl, clientId, redirectUri, pkce, authorizationCode) { - const body = new URLSearchParams({ - grant_type: 'authorization_code', - code: authorizationCode, - redirect_uri: redirectUri, - client_id: clientId, - code_verifier: pkce.code_verifier - }); - const response = await codexFetch(`${baseUrl}/oauth/token`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body - }); - if (!response.ok) { - throw await codexHttpError(response); - } - return await response.json(); -} - -export function buildAuthorizeUrl({ - issuer = CODEX_ISSUER, - clientId = CODEX_OAUTH_CLIENT_ID, - redirectUri, - pkce, - state, - forcedChatgptWorkspaceId = null, - originator = DEFAULT_ORIGINATOR -}) { - const query = new URLSearchParams({ - response_type: 'code', - client_id: clientId, - redirect_uri: redirectUri, - scope: 'openid profile email offline_access api.connectors.read api.connectors.invoke', - code_challenge: pkce.code_challenge, - code_challenge_method: 'S256', - id_token_add_organizations: 'true', - codex_cli_simplified_flow: 'true', - state, - originator - }); - if (forcedChatgptWorkspaceId) { - query.set('allowed_workspace_id', forcedChatgptWorkspaceId); - } - return `${trimTrailingSlash(issuer)}/oauth/authorize?${query.toString()}`; -} - -function generatePkce() { - const codeVerifier = base64Url(randomBytes(64)); - const codeChallenge = base64Url(createHash('sha256').update(codeVerifier).digest()); - return { code_verifier: codeVerifier, code_challenge: codeChallenge }; -} - -function generateState() { - return base64Url(randomBytes(32)); -} - -function base64Url(buffer) { - return Buffer.from(buffer).toString('base64url'); -} - -async function startCallbackServer(preferredPort) { - return await new Promise((resolve, reject) => { - const server = createServer(); - let settled = false; - const finish = (err, result) => { - if (settled) return; - settled = true; - err ? reject(err) : resolve(result); - }; - server.once('error', async err => { - if (err?.code === 'EADDRINUSE' && preferredPort !== 0) { - try { - await sendCancelRequest(preferredPort); - setTimeout(() => { - startCallbackServer(preferredPort).then(resolve, reject); - }, 200); - } catch { - finish(err); - } - return; - } - finish(err); - }); - server.listen(preferredPort || 0, '127.0.0.1', () => { - const address = server.address(); - finish(null, { server, port: address.port }); - }); - }); -} - -function sendCancelRequest(port) { - return new Promise((resolve, reject) => { - const req = globalThis.fetch(`http://127.0.0.1:${port}/cancel`, { signal: AbortSignal.timeout(2000) }); - req.then(() => resolve(), reject); - }); -} - -function waitForOAuthCallback(serverInfo, expectedState) { - const { server } = serverInfo; - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - cleanup(); - reject(new Error('Codex browser login timed out after 15 minutes.')); - }, LOGIN_TIMEOUT_MS); - - const cleanup = () => { - clearTimeout(timeout); - server.removeListener('request', onRequest); - }; - - const finish = (res, status, body, done, headers = {}) => { - res.writeHead(status, { - 'Content-Type': 'text/html; charset=utf-8', - 'Connection': 'close', - ...headers - }); - res.end(body); - cleanup(); - done(); - }; - - const onRequest = (req, res) => { - const parsed = new URL(req.url || '/', 'http://localhost'); - if (parsed.pathname === '/cancel') { - finish(res, 200, 'Login cancelled', () => reject(new Error('Codex browser login cancelled.'))); - return; - } - if (parsed.pathname !== '/auth/callback') { - res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); - res.end('Not Found'); - return; - } - const state = parsed.searchParams.get('state'); - if (state !== expectedState) { - res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8', 'Connection': 'close' }); - res.end('

Codex login ignored

State mismatch. Return to the newest login tab or retry the latest URL.

'); - console.log('Ignored Codex browser login callback with mismatched state; still waiting for the current login.'); - return; - } - const error = parsed.searchParams.get('error'); - if (error) { - const description = parsed.searchParams.get('error_description') || error; - finish(res, 400, `

Codex login failed

${escapeHtml(description)}

`, () => reject(new Error(`Codex browser login failed: ${description}`))); - return; - } - const code = parsed.searchParams.get('code'); - if (!code) { - finish(res, 400, '

Codex login failed

Missing authorization code.

', () => reject(new Error('Codex browser login callback did not include an authorization code.'))); - return; - } - finish(res, 200, codexLoginClosePage(), () => resolve(code)); - }; - - server.on('request', onRequest); - }); -} - -function closeServer(server) { - return new Promise(resolve => { - server.close(() => resolve()); - }); -} - -function canReadCodexChatGPTAuth(authPath) { - try { - readCodexChatGPTAuth(authPath); - return true; - } catch { - return false; - } -} - -function normalizeCodexAuth(authJson, keysPath) { - const tokens = authJson.tokens || {}; - const accessToken = tokens.access_token; - if (typeof accessToken !== 'string' || accessToken.length === 0) { - throw new Error(`Codex ChatGPT auth is missing tokens.access_token in ${keysPath}.`); - } - return { - authPath: keysPath, - raw: authJson, - accessToken, - refreshToken: tokens.refresh_token, - accountId: tokens.account_id || parseJwtPayload(tokens.id_token)?.chatgpt_account_id - }; -} - -function toCodexAuthJson(input) { - const tokens = input.tokens || input; - const idToken = tokens.id_token; - const payload = parseJwtPayload(idToken) || {}; - return { - auth_mode: 'chatgpt', - OPENAI_API_KEY: null, - tokens: { - id_token: idToken, - access_token: tokens.access_token, - refresh_token: tokens.refresh_token, - account_id: tokens.account_id || payload.chatgpt_account_id - }, - last_refresh: input.last_refresh || new Date().toISOString() - }; -} - -function parseJwtPayload(jwt) { - if (typeof jwt !== 'string') return null; - const part = jwt.split('.')[1]; - if (!part) return null; - try { - return JSON.parse(Buffer.from(base64UrlToBase64(part), 'base64').toString('utf8')); - } catch { - return null; - } -} - -function base64UrlToBase64(value) { - const padded = value.replace(/-/g, '+').replace(/_/g, '/'); - return padded + '='.repeat((4 - padded.length % 4) % 4); -} - -function expandHomePath(filePath) { - if (!filePath || typeof filePath !== 'string') { - return filePath; - } - return filePath === '~' || filePath.startsWith('~/') - ? path.join(process.env.HOME || '', filePath.slice(2)) - : filePath; -} - -function readJsonFile(filePath) { - return JSON.parse(readFileSync(expandHomePath(filePath), 'utf8')); -} - -function printBrowserLoginPrompt(authUrl) { - console.log(` -Login to ChatGPT is required to enable Codex native account capabilities. - -Please open this login link in your browser: -${authUrl} - -You will be redirected back to Mindcraft after login; waiting for login to complete... -`); -} - - -function codexLoginClosePage() { - return '' - + '' - + 'Codex login complete' - + '' - + '

Codex login complete

Mindcraft is connected to ChatGPT/Codex.

You can close this page and return to the terminal.

' - + ''; -} - -function escapeHtml(value) { - return String(value) - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); -} - -function printDeviceCodePrompt(verificationUrl, code) { - console.log(`\nCodex ChatGPT login required for this project.\nOpen this URL and sign in:\n\n ${verificationUrl}\n\nEnter this one-time code:\n\n ${code}\n\nWaiting for login to complete...\n`); -} - - -async function codexFetch(url, init = {}) { - if (shouldUseFetch(url)) { - return await fetch(url, init); - } - try { - return await curlFetch(url, init); - } catch (curlError) { - console.warn(`System curl transport failed for Codex HTTP request; retrying with Node fetch: ${sanitizeCodexError(curlError)}`); - return await fetch(url, init); - } -} - -function shouldUseFetch(url) { - if (globalThis.fetch !== DEFAULT_FETCH) { - return true; - } - try { - const { hostname } = new URL(String(url)); - return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'; - } catch { - return false; - } -} - -function isFetchTransportError(error) { - return error?.message === 'fetch failed' || error?.cause?.code || Array.isArray(error?.cause?.errors); -} - -async function curlFetch(url, init = {}) { - const tempDir = mkdtempSync(path.join(tmpdir(), 'mindcraft-codex-curl-')); - const headersPath = path.join(tempDir, 'headers.txt'); - const bodyPath = path.join(tempDir, 'body.bin'); - const requestBodyPath = path.join(tempDir, 'request-body.bin'); - const configPath = path.join(tempDir, 'curl.conf'); - try { - const method = init.method || (init.body ? 'POST' : 'GET'); - const config = [ - `url = ${curlQuote(String(url))}`, - `request = ${curlQuote(method)}`, - `dump-header = ${curlQuote(headersPath)}`, - `output = ${curlQuote(bodyPath)}`, - 'silent', - 'show-error', - 'location', - 'max-time = 300' - ]; - - for (const [name, value] of headerEntries(init.headers)) { - config.push(`header = ${curlQuote(`${name}: ${value}`)}`); - } - - if (init.body !== undefined && init.body !== null) { - writeFileSync(requestBodyPath, bodyToString(init.body)); - config.push(`data-binary = ${curlQuote(`@${requestBodyPath}`)}`); - } - - writeFileSync(configPath, `${config.join('\n')}\n`, { mode: 0o600 }); - await runCurl(configPath); - const headersText = readFileSync(headersPath, 'utf8'); - const body = readFileSync(bodyPath); - const { status, headers } = parseCurlHeaders(headersText); - return new Response(body, { status, headers }); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } -} - -function runCurl(configPath) { - return new Promise((resolve, reject) => { - const child = spawn('curl', ['--config', configPath], { stdio: ['ignore', 'ignore', 'pipe'] }); - let stderr = ''; - child.stderr.on('data', chunk => { - stderr += chunk.toString(); - }); - child.on('error', reject); - child.on('close', code => { - if (code === 0) { - resolve(); - } else { - reject(new Error(`curl exited with code ${code}: ${stderr.trim()}`)); - } - }); - }); -} - -function headerEntries(headers = {}) { - if (headers instanceof Headers) { - return Array.from(headers.entries()); - } - if (Array.isArray(headers)) { - return headers; - } - return Object.entries(headers || {}); -} - -function bodyToString(body) { - if (body instanceof URLSearchParams) { - return body.toString(); - } - if (Buffer.isBuffer(body)) { - return body; - } - return String(body); -} - -function curlQuote(value) { - return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; -} - -function parseCurlHeaders(headersText) { - const blocks = headersText.trim().split(/\r?\n\r?\n/).filter(Boolean); - const block = blocks[blocks.length - 1] || ''; - const lines = block.split(/\r?\n/); - const statusMatch = lines.shift()?.match(/^HTTP\/\S+\s+(\d+)/); - const status = statusMatch ? Number.parseInt(statusMatch[1], 10) : 0; - const headers = new Headers(); - for (const line of lines) { - const idx = line.indexOf(':'); - if (idx > 0) { - headers.append(line.slice(0, idx).trim(), line.slice(idx + 1).trim()); - } - } - return { status, headers }; -} - -function formatFetchError(error) { - const cause = error?.cause; - const nestedCodes = Array.isArray(cause?.errors) - ? cause.errors.map(item => item.code).filter(Boolean).join(',') - : ''; - return [error?.message || String(error), cause?.code, nestedCodes, cause?.message] - .filter(Boolean) - .join(' | '); -} - -function isInteractiveTerminal() { - return Boolean(process.stdin.isTTY && process.stdout.isTTY); -} - -function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -} - -function extractMessageText(item) { - return (item.content || []) - .filter(content => content?.type === 'output_text' || content?.type === 'text') - .map(content => content.text || '') - .join(''); -} - -function extractReasoningText(item) { - const chunks = []; - chunks.push(item?.text, item?.reasoning, item?.reasoning_content, item?.thinking); - if (Array.isArray(item?.summary)) { - chunks.push(...item.summary.map(part => part?.text || part?.summary_text || part?.content || '')); - } - if (Array.isArray(item?.content)) { - chunks.push(...item.content.map(part => part?.text || part?.content || part?.reasoning || part?.thinking || '')); - } - return normalizeThinkingText(chunks); -} - -function stringifyContent(content) { - if (typeof content === 'string') return content; - if (Array.isArray(content)) { - return content.map(part => part?.text || part?.content || JSON.stringify(part)).join('\n'); - } - return String(content ?? ''); -} - -async function codexHttpError(response) { - const body = await response.text().catch(() => ''); - const message = extractErrorMessage(body) || response.statusText || 'Codex ChatGPT request failed'; - const error = new Error(`status=${response.status} ${message}`); - error.status = response.status; - return error; -} - -function extractErrorMessage(body) { - try { - const parsed = JSON.parse(body); - return parsed?.error?.message || parsed?.message || body; - } catch { - return body.slice(0, 300); - } -} - -function buildCodexReasoning(reasoning) { - if (reasoning === false || reasoning === null) return null; - if (typeof reasoning === 'string') { - return { effort: reasoning, summary: 'auto' }; - } - if (!reasoning || typeof reasoning !== 'object') return null; - const result = { ...reasoning }; - if (result.effort && result.summary === undefined) { - result.summary = 'auto'; - } - return Object.keys(result).length > 0 ? result : null; -} - -function buildCodexInclude(include, reasoning) { - const values = Array.isArray(include) ? [...include] : []; - if (reasoning && !values.includes('reasoning.encrypted_content')) { - values.push('reasoning.encrypted_content'); - } - return values; -} - -function isAbortError(err) { - return err?.name === 'AbortError' || String(err?.message || err || '').includes('aborted'); -} - -function sanitizeCodexError(error) { - return formatFetchError(error) - .replace(/Bearer\s+[A-Za-z0-9._-]+/g, 'Bearer [REDACTED_TOKEN]') - .replace(/(access_token|refresh_token|id_token)":"[^"]+"/g, '$1":"[REDACTED_TOKEN]"') - .slice(0, 500); -} - -function trimTrailingSlash(value) { - return String(value).replace(/\/+$/, ''); -} +export { + CodexChatGPT, + buildAuthorizeUrl, + buildScopedPromptCacheKey, + ensureCodexChatGPTAuth, + hasCodexChatGPTAuth, + parseCodexResponsesSse, + readCodexChatGPTAuth, + refreshCodexChatGPTAuth, + runCodexBrowserLogin, + runCodexDeviceLogin, + toCodexResponseItem, + toCodexResponsesTools, + writeKeysCodexAuth +} from './codex_chatgpt/client.js'; diff --git a/src/models/codex_chatgpt/auth.js b/src/models/codex_chatgpt/auth.js new file mode 100644 index 000000000..5658983c5 --- /dev/null +++ b/src/models/codex_chatgpt/auth.js @@ -0,0 +1,500 @@ +import { existsSync, readFileSync, writeFileSync } from 'fs'; +import { createServer } from 'http'; +import { createHash, randomBytes } from 'crypto'; +import open from 'open'; +import { + CODEX_AUTH_KEY, + CODEX_ISSUER, + CODEX_OAUTH_CLIENT_ID, + CODEX_REFRESH_TOKEN_URL, + DEFAULT_KEYS_PATH, + DEFAULT_LOGIN_PORT, + DEFAULT_ORIGINATOR, + LOGIN_TIMEOUT_MS +} from './constants.js'; +import { codexFetch } from './transport.js'; +import { codexHttpError, expandHomePath, sanitizeCodexError, sleep, trimTrailingSlash } from './utils.js'; + +export function hasCodexChatGPTAuth(authPath = DEFAULT_KEYS_PATH) { + return canReadCodexChatGPTAuth(expandHomePath(authPath)); +} + +export async function ensureCodexChatGPTAuth({ + authPath, + keysPath, + allowLogin = true, + loginRunner = runCodexBrowserLogin, + issuer = CODEX_ISSUER, + clientId = CODEX_OAUTH_CLIENT_ID, + port = DEFAULT_LOGIN_PORT, + openBrowser = true, + forcedChatgptWorkspaceId = null, + originator = DEFAULT_ORIGINATOR +} = {}) { + const resolvedAuthPath = expandHomePath(authPath || keysPath || DEFAULT_KEYS_PATH); + if (canReadCodexChatGPTAuth(resolvedAuthPath)) { + return readCodexChatGPTAuth(resolvedAuthPath); + } + if (allowLogin && (isInteractiveTerminal() || loginRunner !== runCodexBrowserLogin)) { + const authJson = await loginRunner({ authPath: resolvedAuthPath, keysPath: resolvedAuthPath, issuer, clientId, port, openBrowser, forcedChatgptWorkspaceId, originator }); + if (authJson) { + writeKeysCodexAuth(resolvedAuthPath, authJson); + } + return readCodexChatGPTAuth(resolvedAuthPath); + } + throw new Error(`Codex ChatGPT auth is missing in ${resolvedAuthPath}. Start with an interactive terminal and choose the codex profile to login here.`); +} + +export function readCodexChatGPTAuth(authPath = DEFAULT_KEYS_PATH) { + const resolvedAuthPath = expandHomePath(authPath); + const config = readJsonFile(resolvedAuthPath); + const authJson = extractCodexAuth(config); + if (!authJson || typeof authJson !== 'object') { + throw new Error(`Missing Codex ChatGPT auth in ${resolvedAuthPath}.`); + } + return normalizeCodexAuth(authJson, resolvedAuthPath); +} + +export async function refreshCodexChatGPTAuth(auth, authPath = auth.authPath || DEFAULT_KEYS_PATH, originator = DEFAULT_ORIGINATOR) { + if (!auth.refreshToken) { + throw new Error('Codex ChatGPT auth has no refresh token. Login again from this project.'); + } + const response = await codexFetch(CODEX_REFRESH_TOKEN_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'originator': originator, + 'User-Agent': `${originator}/mindcraft-native-tool` + }, + body: JSON.stringify({ + client_id: CODEX_OAUTH_CLIENT_ID, + grant_type: 'refresh_token', + refresh_token: auth.refreshToken + }) + }); + + if (!response.ok) { + throw await codexHttpError(response); + } + const refreshed = await response.json(); + const next = structuredClone(auth.raw || {}); + next.tokens = next.tokens || {}; + if (refreshed.id_token) next.tokens.id_token = refreshed.id_token; + if (refreshed.access_token) next.tokens.access_token = refreshed.access_token; + if (refreshed.refresh_token) next.tokens.refresh_token = refreshed.refresh_token; + next.last_refresh = new Date().toISOString(); + writeKeysCodexAuth(authPath, next); + return readCodexChatGPTAuth(authPath); +} + +export async function runCodexBrowserLogin({ + authPath, + keysPath, + issuer = CODEX_ISSUER, + clientId = CODEX_OAUTH_CLIENT_ID, + port = DEFAULT_LOGIN_PORT, + openBrowser = true, + forcedChatgptWorkspaceId = null, + originator = DEFAULT_ORIGINATOR +} = {}) { + const resolvedAuthPath = expandHomePath(authPath || keysPath || DEFAULT_KEYS_PATH); + const baseUrl = trimTrailingSlash(issuer); + const pkce = generatePkce(); + const state = generateState(); + const requestedPort = port === 0 ? 0 : Number.parseInt(port || DEFAULT_LOGIN_PORT, 10); + const server = await startCallbackServer(requestedPort); + const redirectUri = `http://localhost:${server.port}/auth/callback`; + const authUrl = buildAuthorizeUrl({ + issuer: baseUrl, + clientId, + redirectUri, + pkce, + state, + forcedChatgptWorkspaceId, + originator + }); + + try { + printBrowserLoginPrompt(authUrl, redirectUri); + if (openBrowser) { + open(authUrl).catch(err => { + console.log(`Could not open browser automatically: ${sanitizeCodexError(err)}`); + }); + } + const code = await waitForOAuthCallback(server, state); + const tokens = await exchangeAuthorizationCodeForTokens(baseUrl, clientId, redirectUri, pkce, code); + const authJson = toCodexAuthJson(tokens); + writeKeysCodexAuth(resolvedAuthPath, authJson); + return authJson; + } finally { + await closeServer(server.server); + } +} + +// Compatibility helper for explicit tests/dev flows. The default login path intentionally +// uses the local browser callback flow above, matching `codex login` rather than +// `codex login --device-auth`. +export async function runCodexDeviceLogin({ authPath, keysPath, issuer = CODEX_ISSUER, clientId = CODEX_OAUTH_CLIENT_ID } = {}) { + const resolvedAuthPath = expandHomePath(authPath || keysPath || DEFAULT_KEYS_PATH); + const baseUrl = trimTrailingSlash(issuer); + const device = await requestDeviceCode(baseUrl, clientId); + printDeviceCodePrompt(device.verification_url, device.user_code); + const code = await pollDeviceAuthorization(baseUrl, device); + const tokens = await exchangeAuthorizationCodeForTokens(baseUrl, clientId, `${baseUrl}/deviceauth/callback`, { code_verifier: code.code_verifier }, code.authorization_code); + const authJson = toCodexAuthJson(tokens); + writeKeysCodexAuth(resolvedAuthPath, authJson); + return authJson; +} + +export function writeKeysCodexAuth(authPath, authJson) { + const resolvedAuthPath = expandHomePath(authPath); + const normalized = toCodexAuthJson(authJson); + const existing = existsSync(resolvedAuthPath) ? readJsonFile(resolvedAuthPath) : {}; + if (looksLikeUnifiedKeysConfig(existing) || Object.prototype.hasOwnProperty.call(existing, CODEX_AUTH_KEY)) { + const section = getKeysSection(existing, true); + section[CODEX_AUTH_KEY] = normalized; + writeFileSync(resolvedAuthPath, `${JSON.stringify(existing, null, 4)}\n`, { mode: 0o600 }); + console.log(`Saved Codex ChatGPT auth to ${resolvedAuthPath} at keys.${CODEX_AUTH_KEY}`); + return; + } + writeFileSync(resolvedAuthPath, `${JSON.stringify(normalized, null, 4)}\n`, { mode: 0o600 }); + console.log(`Saved Codex ChatGPT auth to ${resolvedAuthPath}`); +} + +function extractCodexAuth(config) { + if (config?.tokens?.access_token) { + return config; + } + const section = getKeysSection(config); + return section?.[CODEX_AUTH_KEY]; +} + +function looksLikeUnifiedKeysConfig(config) { + return Boolean(config?.keys || config?.models || config?.embeddings); +} + +function getKeysSection(config, create = false) { + if (config?.keys && typeof config.keys === 'object') { + return config.keys; + } + if (create && (config.models || config.embeddings)) { + config.keys = {}; + return config.keys; + } + return config; +} + +async function requestDeviceCode(baseUrl, clientId) { + const response = await codexFetch(`${baseUrl}/api/accounts/deviceauth/usercode`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ client_id: clientId }) + }); + if (!response.ok) { + throw await codexHttpError(response); + } + const body = await response.json(); + return { + verification_url: `${baseUrl}/codex/device`, + user_code: body.user_code || body.usercode, + device_auth_id: body.device_auth_id, + interval: Number.parseInt(body.interval || '5', 10) + }; +} + +async function pollDeviceAuthorization(baseUrl, device) { + const started = Date.now(); + while (Date.now() - started < LOGIN_TIMEOUT_MS) { + const response = await codexFetch(`${baseUrl}/api/accounts/deviceauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + device_auth_id: device.device_auth_id, + user_code: device.user_code + }) + }); + if (response.ok) { + return await response.json(); + } + if (![403, 404].includes(response.status)) { + throw await codexHttpError(response); + } + await sleep(Math.max(1, device.interval) * 1000); + } + throw new Error('Codex device login timed out after 15 minutes.'); +} + +async function exchangeAuthorizationCodeForTokens(baseUrl, clientId, redirectUri, pkce, authorizationCode) { + const body = new URLSearchParams({ + grant_type: 'authorization_code', + code: authorizationCode, + redirect_uri: redirectUri, + client_id: clientId, + code_verifier: pkce.code_verifier + }); + const response = await codexFetch(`${baseUrl}/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body + }); + if (!response.ok) { + throw await codexHttpError(response); + } + return await response.json(); +} + +export function buildAuthorizeUrl({ + issuer = CODEX_ISSUER, + clientId = CODEX_OAUTH_CLIENT_ID, + redirectUri, + pkce, + state, + forcedChatgptWorkspaceId = null, + originator = DEFAULT_ORIGINATOR +}) { + const query = new URLSearchParams({ + response_type: 'code', + client_id: clientId, + redirect_uri: redirectUri, + scope: 'openid profile email offline_access api.connectors.read api.connectors.invoke', + code_challenge: pkce.code_challenge, + code_challenge_method: 'S256', + id_token_add_organizations: 'true', + codex_cli_simplified_flow: 'true', + state, + originator + }); + if (forcedChatgptWorkspaceId) { + query.set('allowed_workspace_id', forcedChatgptWorkspaceId); + } + return `${trimTrailingSlash(issuer)}/oauth/authorize?${query.toString()}`; +} + +function generatePkce() { + const codeVerifier = base64Url(randomBytes(64)); + const codeChallenge = base64Url(createHash('sha256').update(codeVerifier).digest()); + return { code_verifier: codeVerifier, code_challenge: codeChallenge }; +} + +function generateState() { + return base64Url(randomBytes(32)); +} + +function base64Url(buffer) { + return Buffer.from(buffer).toString('base64url'); +} + +async function startCallbackServer(preferredPort) { + return await new Promise((resolve, reject) => { + const server = createServer(); + let settled = false; + const finish = (err, result) => { + if (settled) return; + settled = true; + err ? reject(err) : resolve(result); + }; + server.once('error', async err => { + if (err?.code === 'EADDRINUSE' && preferredPort !== 0) { + try { + await sendCancelRequest(preferredPort); + setTimeout(() => { + startCallbackServer(preferredPort).then(resolve, reject); + }, 200); + } catch { + finish(err); + } + return; + } + finish(err); + }); + server.listen(preferredPort || 0, '127.0.0.1', () => { + const address = server.address(); + finish(null, { server, port: address.port }); + }); + }); +} + +function sendCancelRequest(port) { + return new Promise((resolve, reject) => { + const req = globalThis.fetch(`http://127.0.0.1:${port}/cancel`, { signal: AbortSignal.timeout(2000) }); + req.then(() => resolve(), reject); + }); +} + +function waitForOAuthCallback(serverInfo, expectedState) { + const { server } = serverInfo; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + cleanup(); + reject(new Error('Codex browser login timed out after 15 minutes.')); + }, LOGIN_TIMEOUT_MS); + + const cleanup = () => { + clearTimeout(timeout); + server.removeListener('request', onRequest); + }; + + const finish = (res, status, body, done, headers = {}) => { + res.writeHead(status, { + 'Content-Type': 'text/html; charset=utf-8', + 'Connection': 'close', + ...headers + }); + res.end(body); + cleanup(); + done(); + }; + + const onRequest = (req, res) => { + const parsed = new URL(req.url || '/', 'http://localhost'); + if (parsed.pathname === '/cancel') { + finish(res, 200, 'Login cancelled', () => reject(new Error('Codex browser login cancelled.'))); + return; + } + if (parsed.pathname !== '/auth/callback') { + res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' }); + res.end('Not Found'); + return; + } + const state = parsed.searchParams.get('state'); + if (state !== expectedState) { + res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8', 'Connection': 'close' }); + res.end('

Codex login ignored

State mismatch. Return to the newest login tab or retry the latest URL.

'); + console.log('Ignored Codex browser login callback with mismatched state; still waiting for the current login.'); + return; + } + const error = parsed.searchParams.get('error'); + if (error) { + const description = parsed.searchParams.get('error_description') || error; + finish(res, 400, `

Codex login failed

${escapeHtml(description)}

`, () => reject(new Error(`Codex browser login failed: ${description}`))); + return; + } + const code = parsed.searchParams.get('code'); + if (!code) { + finish(res, 400, '

Codex login failed

Missing authorization code.

', () => reject(new Error('Codex browser login callback did not include an authorization code.'))); + return; + } + finish(res, 200, codexLoginClosePage(), () => resolve(code)); + }; + + server.on('request', onRequest); + }); +} + +function closeServer(server) { + return new Promise(resolve => { + server.close(() => resolve()); + }); +} + +function canReadCodexChatGPTAuth(authPath) { + try { + readCodexChatGPTAuth(authPath); + return true; + } catch { + return false; + } +} + +function normalizeCodexAuth(authJson, keysPath) { + const tokens = authJson.tokens || {}; + const accessToken = tokens.access_token; + if (typeof accessToken !== 'string' || accessToken.length === 0) { + throw new Error(`Codex ChatGPT auth is missing tokens.access_token in ${keysPath}.`); + } + return { + authPath: keysPath, + raw: authJson, + accessToken, + refreshToken: tokens.refresh_token, + accountId: tokens.account_id || parseJwtPayload(tokens.id_token)?.chatgpt_account_id + }; +} + +function toCodexAuthJson(input) { + const tokens = input.tokens || input; + const idToken = tokens.id_token; + const payload = parseJwtPayload(idToken) || {}; + return { + auth_mode: 'chatgpt', + OPENAI_API_KEY: null, + tokens: { + id_token: idToken, + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + account_id: tokens.account_id || payload.chatgpt_account_id + }, + last_refresh: input.last_refresh || new Date().toISOString() + }; +} + +function parseJwtPayload(jwt) { + if (typeof jwt !== 'string') return null; + const part = jwt.split('.')[1]; + if (!part) return null; + try { + return JSON.parse(Buffer.from(base64UrlToBase64(part), 'base64').toString('utf8')); + } catch { + return null; + } +} + +function base64UrlToBase64(value) { + const padded = value.replace(/-/g, '+').replace(/_/g, '/'); + return padded + '='.repeat((4 - padded.length % 4) % 4); +} + +function readJsonFile(filePath) { + return JSON.parse(readFileSync(expandHomePath(filePath), 'utf8')); +} + +function printBrowserLoginPrompt(authUrl) { + console.log(` +Login to ChatGPT is required to enable Codex native account capabilities. + +Please open this login link in your browser: +${authUrl} + +You will be redirected back to Mindcraft after login; waiting for login to complete... +`); +} + +function codexLoginClosePage() { + return '' + + '' + + 'Codex login complete' + + '' + + '

Codex login complete

Mindcraft is connected to ChatGPT/Codex.

You can close this page and return to the terminal.

' + + ''; +} + +function escapeHtml(value) { + return String(value) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function printDeviceCodePrompt(verificationUrl, code) { + console.log(`\nCodex ChatGPT login required for this project.\nOpen this URL and sign in:\n\n ${verificationUrl}\n\nEnter this one-time code:\n\n ${code}\n\nWaiting for login to complete...\n`); +} + +function isInteractiveTerminal() { + return Boolean(process.stdin.isTTY && process.stdout.isTTY); +} diff --git a/src/models/codex_chatgpt/client.js b/src/models/codex_chatgpt/client.js new file mode 100644 index 000000000..f0d02340f --- /dev/null +++ b/src/models/codex_chatgpt/client.js @@ -0,0 +1,524 @@ +import { randomUUID } from 'crypto'; +import { createNativeToolResponse, toResponsesInputItems } from '../native_tools.js'; +import { setLastTokenUsage } from '../token_usage.js'; +import { + CONTINUITY_BASELINE_INPUT, + DEFAULT_CODEX_BASE_URL, + DEFAULT_KEYS_PATH, + DEFAULT_LOGIN_PORT, + DEFAULT_ORIGINATOR, + DEFAULT_WEBSOCKET_IDLE_TIMEOUT_MS, + RESPONSES_WEBSOCKET_BETA_HEADER_VALUE +} from './constants.js'; +import { ensureCodexChatGPTAuth, refreshCodexChatGPTAuth } from './auth.js'; +import { + buildCodexInclude, + buildCodexReasoning, + buildScopedPromptCacheKey, + codexRequestSignature, + expandContinuityRequestBody, + getIncrementalResponsesInput, + isChatGptCodexUrl, + normalizeResponsesItemsForContinuity, + parseCodexResponsesSse, + synthesizeCodexOutputItems, + toCodexResponsesTools, + toResponseCreateWebSocketRequest +} from './protocol.js'; +import { + codexFetch, + connectCodexResponsesWebSocket, + isResponsesWebSocketOpen, + streamCodexResponsesWebSocket, + toWebSocketUrl +} from './transport.js'; +import { + codexHttpError, + expandHomePath, + isAbortError, + sanitizeCodexError, + structuredCloneSafe, + trimTrailingSlash +} from './utils.js'; + +export class CodexChatGPT { + static prefix = 'codex'; + + constructor(model_name, url, params = {}) { + this.model_name = model_name; + this.url = trimTrailingSlash(url || params?.baseUrl || params?.base_url || DEFAULT_CODEX_BASE_URL); + this.params = { ...(params || {}) }; + delete this.params.baseUrl; + delete this.params.base_url; + this.provider = 'codex-chatgpt'; + this.default_model = 'gpt-5.5'; + this.supportsNativeToolCalls = true; + this.authPath = expandHomePath( + this.params.authPath || + this.params.auth_path || + this.params.codexAuthPath || + this.params.codex_auth_path || + this.params.keysPath || + this.params.keys_path || + DEFAULT_KEYS_PATH + ); + this.keysPath = this.authPath; + this.allowLogin = this.params.allowLogin ?? this.params.allow_login ?? true; + this.loginRunner = this.params.loginRunner; + this.issuer = this.params.issuer; + this.clientId = this.params.clientId || this.params.client_id; + this.loginPort = Number.parseInt(this.params.loginPort || this.params.login_port || DEFAULT_LOGIN_PORT, 10); + this.openBrowser = this.params.openBrowser ?? this.params.open_browser ?? true; + this.forcedChatgptWorkspaceId = this.params.forcedChatgptWorkspaceId || this.params.forced_chatgpt_workspace_id; + delete this.params.keysPath; + delete this.params.keys_path; + delete this.params.authPath; + delete this.params.auth_path; + delete this.params.codexAuthPath; + delete this.params.codex_auth_path; + delete this.params.allowLogin; + delete this.params.allow_login; + delete this.params.loginRunner; + delete this.params.issuer; + delete this.params.clientId; + delete this.params.client_id; + delete this.params.loginPort; + delete this.params.login_port; + delete this.params.openBrowser; + delete this.params.open_browser; + delete this.params.forcedChatgptWorkspaceId; + delete this.params.forced_chatgpt_workspace_id; + this.sessionIdWasExplicit = Boolean(this.params.sessionId || this.params.session_id); + this.sessionId = this.params.sessionId || this.params.session_id || randomUUID(); + delete this.params.sessionId; + delete this.params.session_id; + this.originator = this.params.originator || process.env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE || DEFAULT_ORIGINATOR; + delete this.params.originator; + const transport = String(this.params.transport || this.params.codexTransport || this.params.codex_transport || '').toLowerCase(); + const webSocketParam = this.params.responsesWebSocket ?? this.params.responses_websocket ?? this.params.useResponsesWebSocket ?? this.params.use_responses_websocket; + this.useResponsesWebSocket = webSocketParam ?? (transport ? transport === 'websocket' || transport === 'ws' : isChatGptCodexUrl(this.url)); + this.responsesWebSocketDisabled = transport === 'http' || transport === 'https'; + this.responsesWebSocket = null; + this.responsesWebSocketHeaders = null; + this.responsesWebSocketIdleTimeoutMs = Number.parseInt(this.params.responsesWebSocketIdleTimeoutMs || this.params.responses_websocket_idle_timeout_ms || DEFAULT_WEBSOCKET_IDLE_TIMEOUT_MS, 10); + delete this.params.transport; + delete this.params.codexTransport; + delete this.params.codex_transport; + delete this.params.responsesWebSocket; + delete this.params.responses_websocket; + delete this.params.useResponsesWebSocket; + delete this.params.use_responses_websocket; + delete this.params.responsesWebSocketIdleTimeoutMs; + delete this.params.responses_websocket_idle_timeout_ms; + this.enablePreviousResponseId = Boolean(this.params.enablePreviousResponseId || this.params.enable_previous_response_id); + delete this.params.enablePreviousResponseId; + delete this.params.enable_previous_response_id; + this.turnStateByKey = new Map(); + this.responseContinuityByKey = new Map(); + this.lastRequestCacheTrace = null; + } + + setSessionIdentity(identity) { + if (this.sessionIdWasExplicit) return; + const value = String(identity || '').trim(); + if (value) this.sessionId = value; + } + + async sendRequest(turns, systemMessage, stop_seq='***', tools=null, options = {}) { + this.lastTokenUsage = null; + this.lastThinking = ''; + const model = this.model_name || this.default_model; + const hasTools = Array.isArray(tools) && tools.length > 0; + const body = this.buildRequestBody(model, turns, systemMessage, tools, options); + const endpoint = `${this.url}/responses`; + + console.log(hasTools + ? `Awaiting Codex ChatGPT native-login response with tool calling (${tools.length} tools) from model ${model}` + : `Awaiting Codex ChatGPT native-login response from model ${model}`); + + try { + let auth = await ensureCodexChatGPTAuth({ + authPath: this.authPath, + allowLogin: this.allowLogin, + loginRunner: this.loginRunner, + issuer: this.issuer, + clientId: this.clientId, + port: this.loginPort, + openBrowser: this.openBrowser, + forcedChatgptWorkspaceId: this.forcedChatgptWorkspaceId, + originator: this.originator + }); + let response = await this.fetchResponses(endpoint, body, auth, options); + if (response.status === 401 && auth.refreshToken) { + auth = await refreshCodexChatGPTAuth(auth, this.authPath, this.originator); + response = await this.fetchResponses(endpoint, body, auth, options); + } + if (!response.ok) { + throw await codexHttpError(response); + } + this.rememberTurnState(options, response); + + const parsed = await parseCodexResponsesSse(await response.text()); + this.rememberResponseContinuity(this.lastSentResponsesOptions || options, this.lastSentResponsesBody || body, parsed); + console.log('Received.'); + setLastTokenUsage(this, parsed.usage); + this.lastThinking = parsed.thinking || ''; + if (parsed.toolCalls.length > 0) { + return createNativeToolResponse(parsed.toolCalls, this.provider, { thinking: this.lastThinking }); + } + let text = parsed.text; + if (stop_seq && text.includes(stop_seq)) { + text = text.slice(0, text.indexOf(stop_seq)); + } + return text || 'No response received.'; + } catch (err) { + if (isAbortError(err)) { + console.log('Codex ChatGPT request aborted.'); + throw err; + } + console.log(sanitizeCodexError(err)); + return 'My brain disconnected, try again.'; + } + } + + async sendVisionRequest(turns, systemMessage, imageBuffer, options = {}) { + const imageMessages = [...(turns || [])]; + imageMessages.push({ + role: 'user', + content: [ + { type: 'input_text', text: '' }, + { type: 'input_image', image_url: `data:image/jpeg;base64,${imageBuffer.toString('base64')}` }, + { type: 'input_text', text: `\n${systemMessage || 'Describe the image.'}` } + ] + }); + return this.sendRequest(imageMessages, systemMessage, '***', null, options); + } + + buildRequestBody(model, turns, systemMessage, tools=null, options = {}) { + const promptCacheKey = buildScopedPromptCacheKey(this.sessionId, options?.cacheScope); + const reasoning = buildCodexReasoning(this.params.reasoning); + const include = buildCodexInclude(this.params.include, reasoning); + const body = { + model, + instructions: systemMessage || '', + input: toResponsesInputItems(turns || []), + tools: toCodexResponsesTools(tools || []), + parallel_tool_calls: this.params.parallel_tool_calls ?? true, + reasoning, + store: false, + stream: true, + include, + prompt_cache_key: promptCacheKey + }; + + for (const [key, value] of Object.entries(this.params)) { + if (!['tool_choice', 'toolChoice', 'apiKeyName', 'api_key_name', 'defaultModel', 'default_model', 'parallel_tool_calls', 'reasoning', 'include'].includes(key)) { + body[key] = value; + } + } + this.applyPreviousResponseContinuity(options, body); + return body; + } + + getCacheTraceMetadata(options = {}) { + const scopedSessionId = buildScopedPromptCacheKey(this.sessionId, options?.cacheScope); + const responseContinuityKey = this.getResponseContinuityKey(options); + const responseContinuityEntries = responseContinuityKey + ? this.responseContinuityByKey.get(responseContinuityKey) + : null; + return { + cache_scope: options?.cacheScope || null, + turn_state_key: options?.turnStateKey || null, + transport_cache: { + protocol: 'openai-codex-responses', + prompt_cache_key: scopedSessionId, + session_id: scopedSessionId, + turn_state_present: Boolean(this.getTurnState(options)), + previous_response_id_available: Boolean(responseContinuityEntries?.some(entry => entry.responseId)) + } + }; + } + + consumeLastRequestCacheTrace() { + const value = this.lastRequestCacheTrace; + this.lastRequestCacheTrace = null; + return value || null; + } + + async fetchResponses(endpoint, body, auth, options = {}) { + this.lastSentResponsesBody = body; + this.lastSentResponsesOptions = options; + if (this.useResponsesWebSocket && !this.responsesWebSocketDisabled) { + try { + return await this.fetchResponsesWebSocket(endpoint, body, auth, options); + } catch (err) { + if (isAbortError(err)) throw err; + this.closeResponsesWebSocket(); + this.responsesWebSocketDisabled = true; + console.log(`Codex Responses WebSocket failed; falling back to HTTP. ${sanitizeCodexError(err)}`); + } + } + const httpBody = expandContinuityRequestBody(body); + if (httpBody !== body && this.lastRequestCacheTrace?.incremental_reuse) { + this.lastRequestCacheTrace = { + ...this.lastRequestCacheTrace, + previous_response_id: null, + incremental_input_items: null, + full_input_items: Array.isArray(httpBody.input) ? httpBody.input.length : this.lastRequestCacheTrace.full_input_items, + incremental_reuse: false, + incremental_reuse_reason: 'http_previous_response_unsupported' + }; + } + this.lastSentResponsesBody = httpBody; + this.lastSentResponsesOptions = options; + return await codexFetch(endpoint, { + method: 'POST', + headers: this.buildHeaders(auth, options), + body: JSON.stringify(httpBody), + signal: options?.signal + }); + } + + async fetchResponsesWebSocket(endpoint, body, auth, options = {}) { + const wsOptions = { + ...(options || {}), + transportSupportsPreviousResponseId: true, + responseContinuityLatestOnly: true + }; + const wsBody = structuredCloneSafe(expandContinuityRequestBody(body)); + this.applyPreviousResponseContinuity(wsOptions, wsBody); + this.lastSentResponsesBody = wsBody; + this.lastSentResponsesOptions = wsOptions; + const ws = await this.ensureResponsesWebSocket(endpoint, auth, options); + const responseText = await streamCodexResponsesWebSocket(ws, toResponseCreateWebSocketRequest(wsBody), { + signal: options?.signal, + idleTimeoutMs: this.responsesWebSocketIdleTimeoutMs, + onClosed: () => { + this.responsesWebSocket = null; + this.responsesWebSocketHeaders = null; + } + }); + return new Response(responseText, { + status: 200, + headers: this.responsesWebSocketHeaders || {} + }); + } + + async ensureResponsesWebSocket(endpoint, auth, options = {}) { + if (isResponsesWebSocketOpen(this.responsesWebSocket)) { + return this.responsesWebSocket; + } + this.closeResponsesWebSocket(); + const headers = this.buildWebSocketHeaders(auth, options); + const { ws, headers: responseHeaders } = await connectCodexResponsesWebSocket(toWebSocketUrl(endpoint), headers, { + signal: options?.signal, + timeoutMs: this.responsesWebSocketIdleTimeoutMs + }); + this.responsesWebSocket = ws; + this.responsesWebSocketHeaders = responseHeaders; + ws.once('close', () => { + if (this.responsesWebSocket === ws) { + this.responsesWebSocket = null; + this.responsesWebSocketHeaders = null; + } + }); + return ws; + } + + closeResponsesWebSocket() { + if (this.responsesWebSocket) { + try { + this.responsesWebSocket.close(); + } catch { + // Best-effort cleanup. + } + } + this.responsesWebSocket = null; + this.responsesWebSocketHeaders = null; + } + + buildWebSocketHeaders(auth, options = {}) { + const headers = { ...this.buildHeaders(auth, options) }; + delete headers['Content-Type']; + headers['OpenAI-Beta'] = RESPONSES_WEBSOCKET_BETA_HEADER_VALUE; + return headers; + } + + buildHeaders(auth, options = {}) { + const scopedSessionId = buildScopedPromptCacheKey(this.sessionId, options?.cacheScope); + const headers = { + 'Authorization': `Bearer ${auth.accessToken}`, + 'ChatGPT-Account-ID': auth.accountId, + 'Content-Type': 'application/json', + 'Accept': 'text/event-stream', + 'originator': this.originator, + 'session_id': scopedSessionId, + 'x-client-request-id': scopedSessionId, + 'User-Agent': `${this.originator}/mindcraft-native-tool` + }; + if (!auth.accountId) { + delete headers['ChatGPT-Account-ID']; + } + const turnState = this.getTurnState(options); + if (turnState) { + headers['x-codex-turn-state'] = turnState; + } + return headers; + } + + getTurnState(options = {}) { + const key = this.getTurnStateKey(options); + return key ? this.turnStateByKey.get(key) : null; + } + + rememberTurnState(options = {}, response) { + const key = this.getTurnStateKey(options); + const value = response?.headers?.get?.('x-codex-turn-state'); + if (!key || !value) return; + this.turnStateByKey.set(key, value); + if (this.turnStateByKey.size > 64) { + const oldestKey = this.turnStateByKey.keys().next().value; + this.turnStateByKey.delete(oldestKey); + } + } + + getTurnStateKey(options = {}) { + // Match Codex CLI's turn-scoped sticky-routing contract. The backend may + // return x-codex-turn-state during a ReAct turn; replay it only for + // follow-up requests in that same turn. Leaking it into the next inbound + // bot/user message can route an otherwise cacheable prompt to the wrong + // backend state and cause full prompt-cache misses. + return this.getTurnScopedContinuityKey(options); + } + + getResponseContinuityKey(options = {}) { + // The ChatGPT Codex HTTP endpoint rejects previous_response_id; Codex + // CLI uses that field only on its websocket transport. Keep the + // branch-aware continuity machinery behind an explicit transport opt-in + // so the default HTTP path relies on prompt_cache_key and never 400s. + if (!this.enablePreviousResponseId && !options?.transportSupportsPreviousResponseId) return null; + return buildScopedPromptCacheKey(this.sessionId, options?.cacheScope); + } + + getTurnScopedContinuityKey(options = {}) { + const turnStateKey = String(options?.turnStateKey || '').trim(); + if (!turnStateKey) return null; + const scopedSessionId = buildScopedPromptCacheKey(this.sessionId, options?.cacheScope); + return buildScopedPromptCacheKey(scopedSessionId, `turn:${turnStateKey}`); + } + + applyPreviousResponseContinuity(options = {}, body) { + const key = this.getResponseContinuityKey(options); + const previousEntries = key ? this.responseContinuityByKey.get(key) : null; + const baseTrace = { + protocol: 'openai-codex-responses', + prompt_cache_key: body.prompt_cache_key, + session_id: buildScopedPromptCacheKey(this.sessionId, options?.cacheScope), + turn_state_present: Boolean(this.getTurnState(options)), + previous_response_id: null, + incremental_input_items: null, + full_input_items: Array.isArray(body.input) ? body.input.length : 0, + incremental_reuse: false, + incremental_reuse_reason: 'no_previous_response' + }; + + if (!previousEntries?.length) { + this.lastRequestCacheTrace = baseTrace; + return; + } + + const requestSignature = codexRequestSignature(body); + const candidateEntries = options?.responseContinuityLatestOnly + ? previousEntries.slice(0, 1) + : previousEntries; + let sawMatchingSignature = false; + let bestMatch = null; + for (const entry of candidateEntries) { + if (requestSignature !== entry.requestSignature) continue; + sawMatchingSignature = true; + const delta = getIncrementalResponsesInput(body.input, entry.baselineInput); + if (!delta) continue; + if (!bestMatch || entry.baselineInput.length > bestMatch.entry.baselineInput.length) { + bestMatch = { entry, delta }; + } + } + + if (!sawMatchingSignature) { + this.lastRequestCacheTrace = { + ...baseTrace, + incremental_reuse_reason: 'non_input_fields_changed' + }; + return; + } + + if (!bestMatch) { + this.lastRequestCacheTrace = { + ...baseTrace, + incremental_reuse_reason: 'input_not_previous_prefix' + }; + return; + } + + body.previous_response_id = bestMatch.entry.responseId; + body.input = bestMatch.delta; + body[CONTINUITY_BASELINE_INPUT] = bestMatch.entry.baselineInput; + this.lastRequestCacheTrace = { + ...baseTrace, + previous_response_id: bestMatch.entry.responseId, + incremental_input_items: bestMatch.delta.length, + incremental_reuse: true, + incremental_reuse_reason: 'prefix_reused' + }; + } + + rememberResponseContinuity(options = {}, body, parsed = {}) { + const responseId = parsed.responseId; + if (!responseId) return; + const key = this.getResponseContinuityKey(options); + if (!key) return; + const sentInput = body.previous_response_id + ? [ + ...(body[CONTINUITY_BASELINE_INPUT] || []), + ...(body.input || []) + ] + : (body.input || []); + const outputItems = synthesizeCodexOutputItems(parsed); + const entries = this.responseContinuityByKey.get(key) || []; + entries.unshift({ + responseId, + requestSignature: codexRequestSignature(body), + baselineInput: normalizeResponsesItemsForContinuity([ + ...sentInput, + ...outputItems + ]) + }); + entries.length = Math.min(entries.length, 32); + this.responseContinuityByKey.set(key, entries); + if (this.responseContinuityByKey.size > 64) { + const oldestKey = this.responseContinuityByKey.keys().next().value; + this.responseContinuityByKey.delete(oldestKey); + } + } + + async embed() { + throw new Error('Codex ChatGPT native-login adapter does not support embeddings. Configure an embedding provider separately.'); + } +} + +export { + buildAuthorizeUrl, + ensureCodexChatGPTAuth, + hasCodexChatGPTAuth, + readCodexChatGPTAuth, + refreshCodexChatGPTAuth, + runCodexBrowserLogin, + runCodexDeviceLogin, + writeKeysCodexAuth +} from './auth.js'; + +export { + buildScopedPromptCacheKey, + parseCodexResponsesSse, + toCodexResponseItem, + toCodexResponsesTools +} from './protocol.js'; diff --git a/src/models/codex_chatgpt/constants.js b/src/models/codex_chatgpt/constants.js new file mode 100644 index 000000000..8e69f4f23 --- /dev/null +++ b/src/models/codex_chatgpt/constants.js @@ -0,0 +1,12 @@ +export const DEFAULT_CODEX_BASE_URL = 'https://chatgpt.com/backend-api/codex'; +export const DEFAULT_KEYS_PATH = 'settings_llm_providers.json'; +export const CODEX_REFRESH_TOKEN_URL = 'https://auth.openai.com/oauth/token'; +export const CODEX_ISSUER = 'https://auth.openai.com'; +export const CODEX_OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'; +export const CODEX_AUTH_KEY = 'CODEX_CHATGPT_AUTH'; +export const DEFAULT_ORIGINATOR = 'codex_cli_rs'; +export const LOGIN_TIMEOUT_MS = 15 * 60 * 1000; +export const DEFAULT_LOGIN_PORT = 1455; +export const RESPONSES_WEBSOCKET_BETA_HEADER_VALUE = 'responses_websockets=2026-02-06'; +export const DEFAULT_WEBSOCKET_IDLE_TIMEOUT_MS = 5 * 60 * 1000; +export const CONTINUITY_BASELINE_INPUT = Symbol('codexContinuityBaselineInput'); diff --git a/src/models/codex_chatgpt/protocol.js b/src/models/codex_chatgpt/protocol.js new file mode 100644 index 000000000..852db5f7a --- /dev/null +++ b/src/models/codex_chatgpt/protocol.js @@ -0,0 +1,249 @@ +import { normalizeThinkingText } from '../native_tools.js'; +import { CONTINUITY_BASELINE_INPUT } from './constants.js'; +import { stableJson, structuredCloneSafe } from './utils.js'; + +export function toCodexResponsesTools(tools = []) { + return tools.map(tool => { + const fn = tool.function || tool; + return { + type: 'function', + name: fn.name, + description: fn.description || '', + strict: Boolean(fn.strict), + parameters: fn.parameters || { type: 'object', properties: {} } + }; + }).filter(tool => tool.name); +} + +export function buildScopedPromptCacheKey(baseKey, cacheScope) { + const base = String(baseKey || '').trim(); + const scope = String(cacheScope || '').trim(); + if (!scope) return base; + return `${base}:${scope}`; +} + +export function toResponseCreateWebSocketRequest(body = {}) { + return { + type: 'response.create', + ...body, + tool_choice: body.tool_choice || 'auto' + }; +} + +export function expandContinuityRequestBody(body = {}) { + if (!body?.previous_response_id) return body; + const expanded = { + ...body, + input: [ + ...(body[CONTINUITY_BASELINE_INPUT] || []), + ...(body.input || []) + ] + }; + delete expanded.previous_response_id; + return expanded; +} + +export function isChatGptCodexUrl(url) { + try { + const parsed = new URL(url); + return parsed.hostname === 'chatgpt.com' && parsed.pathname.includes('/backend-api/codex'); + } catch { + return false; + } +} + +export function codexRequestSignature(body = {}) { + const copy = { ...(body || {}) }; + delete copy.input; + delete copy.previous_response_id; + return stableJson(copy); +} + +export function getIncrementalResponsesInput(input = [], previousBaseline = []) { + const normalizedInput = normalizeResponsesItemsForContinuity(input); + const normalizedBaseline = normalizeResponsesItemsForContinuity(previousBaseline); + if (normalizedBaseline.length > normalizedInput.length) return null; + for (let i = 0; i < normalizedBaseline.length; i++) { + if (stableJson(normalizedInput[i]) !== stableJson(normalizedBaseline[i])) { + return null; + } + } + return input.slice(normalizedBaseline.length); +} + +export function normalizeResponsesItemsForContinuity(items = []) { + return (items || []).map(normalizeResponsesItemForContinuity); +} + +function normalizeResponsesItemForContinuity(item) { + if (!item || typeof item !== 'object') return item; + const clone = structuredCloneSafe(item); + stripVolatileResponsesFields(clone); + return clone; +} + +function stripVolatileResponsesFields(value) { + if (!value || typeof value !== 'object') return; + if (Array.isArray(value)) { + for (const item of value) stripVolatileResponsesFields(item); + return; + } + delete value.id; + delete value.status; + delete value.object; + for (const item of Object.values(value)) { + stripVolatileResponsesFields(item); + } +} + +export function synthesizeCodexOutputItems(parsed = {}) { + if (parsed.toolCalls?.length) { + return parsed.toolCalls.map(call => ({ + type: 'function_call', + call_id: call.id, + name: call.function?.name || call.name, + arguments: call.function?.arguments || call.arguments || '{}' + })).filter(item => item.call_id && item.name); + } + if (parsed.text) { + return [{ + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: parsed.text }] + }]; + } + return []; +} + +export function toCodexResponseItem(message) { + const role = message.role === 'assistant' ? 'assistant' : 'user'; + return { + type: 'message', + role, + content: [{ + type: role === 'assistant' ? 'output_text' : 'input_text', + text: stringifyContent(message.content) + }] + }; +} + +export async function parseCodexResponsesSse(sseText) { + const toolCalls = []; + const textDeltas = []; + const messageTexts = []; + const thinkingDeltas = []; + const reasoningItems = []; + const outputItems = []; + let responseId = null; + let usage = null; + const events = sseText.split(/\n\n+/); + for (const eventBlock of events) { + const dataLines = eventBlock + .split(/\r?\n/) + .filter(line => line.startsWith('data:')) + .map(line => line.slice(5).trimStart()); + if (dataLines.length === 0) continue; + const data = dataLines.join('\n'); + if (data === '[DONE]') continue; + let event; + try { + event = JSON.parse(data); + } catch { + continue; + } + if (event.type === 'response.output_text.delta' && typeof event.delta === 'string') { + textDeltas.push(event.delta); + } + if (typeof event.type === 'string' && event.type.includes('reasoning') && typeof event.delta === 'string') { + thinkingDeltas.push(event.delta); + } + const item = event.item; + if (event.type === 'response.output_item.done' && item) { + outputItems.push(item); + } + if (event.type === 'response.output_item.done' && item?.type === 'function_call') { + toolCalls.push({ + id: item.call_id, + type: 'function', + function: { + name: item.name, + arguments: item.arguments || '{}' + } + }); + } + if (event.type === 'response.output_item.done' && item?.type === 'message') { + messageTexts.push(extractMessageText(item)); + } + if (event.type === 'response.output_item.done' && item?.type === 'reasoning') { + reasoningItems.push(extractReasoningText(item)); + } + if (event.response?.usage) { + usage = event.response.usage; + } else if (event.usage) { + usage = event.usage; + } + if (event.response?.id) { + responseId = event.response.id; + } + if (event.response_id) { + responseId = event.response_id; + } + if (event.type === 'response.failed') { + const message = event.response?.error?.message || 'Codex Responses stream failed'; + throw new Error(message); + } + } + const text = textDeltas.length > 0 ? textDeltas.join('') : messageTexts.join(''); + const thinking = thinkingDeltas.length > 0 + ? normalizeThinkingText(thinkingDeltas.join('')) + : normalizeThinkingText(reasoningItems); + return { text, toolCalls, usage, thinking, responseId, outputItems }; +} + +function extractMessageText(item) { + return (item.content || []) + .filter(content => content?.type === 'output_text' || content?.type === 'text') + .map(content => content.text || '') + .join(''); +} + +function extractReasoningText(item) { + const chunks = []; + chunks.push(item?.text, item?.reasoning, item?.reasoning_content, item?.thinking); + if (Array.isArray(item?.summary)) { + chunks.push(...item.summary.map(part => part?.text || part?.summary_text || part?.content || '')); + } + if (Array.isArray(item?.content)) { + chunks.push(...item.content.map(part => part?.text || part?.content || part?.reasoning || part?.thinking || '')); + } + return normalizeThinkingText(chunks); +} + +function stringifyContent(content) { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content.map(part => part?.text || part?.content || JSON.stringify(part)).join('\n'); + } + return String(content ?? ''); +} + +export function buildCodexReasoning(reasoning) { + if (reasoning === false || reasoning === null) return null; + if (typeof reasoning === 'string') { + return { effort: reasoning, summary: 'auto' }; + } + if (!reasoning || typeof reasoning !== 'object') return null; + const result = { ...reasoning }; + if (result.effort && result.summary === undefined) { + result.summary = 'auto'; + } + return Object.keys(result).length > 0 ? result : null; +} + +export function buildCodexInclude(include, reasoning) { + const values = Array.isArray(include) ? [...include] : []; + if (reasoning && !values.includes('reasoning.encrypted_content')) { + values.push('reasoning.encrypted_content'); + } + return values; +} diff --git a/src/models/codex_chatgpt/transport.js b/src/models/codex_chatgpt/transport.js new file mode 100644 index 000000000..cdc41ac26 --- /dev/null +++ b/src/models/codex_chatgpt/transport.js @@ -0,0 +1,288 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'; +import path from 'path'; +import { tmpdir } from 'os'; +import { spawn } from 'child_process'; +import WebSocket from 'ws'; +import { DEFAULT_WEBSOCKET_IDLE_TIMEOUT_MS } from './constants.js'; +import { abortError, sanitizeCodexError } from './utils.js'; + +const DEFAULT_FETCH = globalThis.fetch; + +export function isResponsesWebSocketOpen(ws) { + return ws?.readyState === WebSocket.OPEN; +} + +export function toWebSocketUrl(endpoint) { + const url = new URL(endpoint); + if (url.protocol === 'https:') url.protocol = 'wss:'; + if (url.protocol === 'http:') url.protocol = 'ws:'; + return url.toString(); +} + +export async function connectCodexResponsesWebSocket(url, headers, { signal, timeoutMs = DEFAULT_WEBSOCKET_IDLE_TIMEOUT_MS } = {}) { + if (signal?.aborted) throw abortError(); + return await new Promise((resolve, reject) => { + let settled = false; + let responseHeaders = {}; + const ws = new WebSocket(url, { + headers, + perMessageDeflate: true, + family: 4, + handshakeTimeout: Math.min(Math.max(timeoutMs, 1000), 30000) + }); + + const cleanup = () => { + ws.off('open', onOpen); + ws.off('upgrade', onUpgrade); + ws.off('unexpected-response', onUnexpectedResponse); + ws.off('error', onError); + signal?.removeEventListener?.('abort', onAbort); + }; + const fail = (error) => { + if (settled) return; + settled = true; + cleanup(); + try { + ws.close(); + } catch { + // Best-effort cleanup. + } + reject(error); + }; + const onAbort = () => fail(abortError()); + const onUpgrade = response => { + responseHeaders = normalizeNodeHeaders(response?.headers || {}); + }; + const onOpen = () => { + if (settled) return; + settled = true; + cleanup(); + resolve({ ws, headers: responseHeaders }); + }; + const onUnexpectedResponse = (_request, response) => { + const chunks = []; + response.on('data', chunk => chunks.push(Buffer.from(chunk))); + response.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8'); + const error = new Error(`WebSocket upgrade failed with status=${response.statusCode} ${body.slice(0, 300)}`); + error.status = response.statusCode; + fail(error); + }); + response.on('error', fail); + }; + const onError = error => fail(error); + + ws.once('open', onOpen); + ws.once('upgrade', onUpgrade); + ws.once('unexpected-response', onUnexpectedResponse); + ws.once('error', onError); + signal?.addEventListener?.('abort', onAbort, { once: true }); + }); +} + +export async function streamCodexResponsesWebSocket(ws, payload, { signal, idleTimeoutMs = DEFAULT_WEBSOCKET_IDLE_TIMEOUT_MS, onClosed } = {}) { + if (signal?.aborted) throw abortError(); + return await new Promise((resolve, reject) => { + let settled = false; + const chunks = []; + const timeout = setTimeout(() => { + fail(new Error('idle timeout waiting for Codex Responses WebSocket')); + }, Math.max(1000, idleTimeoutMs)); + + const cleanup = () => { + clearTimeout(timeout); + ws.off('message', onMessage); + ws.off('error', onError); + ws.off('close', onClose); + signal?.removeEventListener?.('abort', onAbort); + }; + const fail = (error) => { + if (settled) return; + settled = true; + cleanup(); + reject(error); + }; + const finish = () => { + if (settled) return; + settled = true; + cleanup(); + resolve(chunks.join('')); + }; + const onAbort = () => { + try { + ws.close(); + } catch { + // Best-effort cleanup. + } + fail(abortError()); + }; + const onError = error => fail(error); + const onClose = () => { + onClosed?.(); + fail(new Error('websocket closed before response.completed')); + }; + const onMessage = data => { + const text = Buffer.isBuffer(data) ? data.toString('utf8') : String(data); + let event = null; + try { + event = JSON.parse(text); + } catch { + return; + } + if (event?.type === 'error') { + const message = event.error?.message || event.message || text; + const error = new Error(message); + error.status = event.status || event.status_code; + fail(error); + return; + } + chunks.push(`data: ${text}\n\n`); + if (event?.type === 'response.failed') { + const error = new Error(event.response?.error?.message || 'Codex Responses WebSocket failed'); + error.status = event.response?.status; + fail(error); + return; + } + if (event?.type === 'response.completed') { + finish(); + } + }; + + ws.on('message', onMessage); + ws.once('error', onError); + ws.once('close', onClose); + signal?.addEventListener?.('abort', onAbort, { once: true }); + ws.send(JSON.stringify(payload), error => { + if (error) fail(error); + }); + }); +} + +function normalizeNodeHeaders(headers = {}) { + const normalized = {}; + for (const [key, value] of Object.entries(headers)) { + normalized[key] = Array.isArray(value) ? value.join(', ') : String(value); + } + return normalized; +} + +export async function codexFetch(url, init = {}) { + if (shouldUseFetch(url)) { + return await fetch(url, init); + } + try { + return await curlFetch(url, init); + } catch (curlError) { + console.warn(`System curl transport failed for Codex HTTP request; retrying with Node fetch: ${sanitizeCodexError(curlError)}`); + return await fetch(url, init); + } +} + +function shouldUseFetch(url) { + if (globalThis.fetch !== DEFAULT_FETCH) { + return true; + } + try { + const { hostname } = new URL(String(url)); + return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'; + } catch { + return false; + } +} + +async function curlFetch(url, init = {}) { + const tempDir = mkdtempSync(path.join(tmpdir(), 'mindcraft-codex-curl-')); + const headersPath = path.join(tempDir, 'headers.txt'); + const bodyPath = path.join(tempDir, 'body.bin'); + const requestBodyPath = path.join(tempDir, 'request-body.bin'); + const configPath = path.join(tempDir, 'curl.conf'); + try { + const method = init.method || (init.body ? 'POST' : 'GET'); + const config = [ + `url = ${curlQuote(String(url))}`, + `request = ${curlQuote(method)}`, + `dump-header = ${curlQuote(headersPath)}`, + `output = ${curlQuote(bodyPath)}`, + 'silent', + 'show-error', + 'location', + 'max-time = 300' + ]; + + for (const [name, value] of headerEntries(init.headers)) { + config.push(`header = ${curlQuote(`${name}: ${value}`)}`); + } + + if (init.body !== undefined && init.body !== null) { + writeFileSync(requestBodyPath, bodyToString(init.body)); + config.push(`data-binary = ${curlQuote(`@${requestBodyPath}`)}`); + } + + writeFileSync(configPath, `${config.join('\n')}\n`, { mode: 0o600 }); + await runCurl(configPath); + const headersText = readFileSync(headersPath, 'utf8'); + const body = readFileSync(bodyPath); + const { status, headers } = parseCurlHeaders(headersText); + return new Response(body, { status, headers }); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } +} + +function runCurl(configPath) { + return new Promise((resolve, reject) => { + const child = spawn('curl', ['--config', configPath], { stdio: ['ignore', 'ignore', 'pipe'] }); + let stderr = ''; + child.stderr.on('data', chunk => { + stderr += chunk.toString(); + }); + child.on('error', reject); + child.on('close', code => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`curl exited with code ${code}: ${stderr.trim()}`)); + } + }); + }); +} + +function headerEntries(headers = {}) { + if (headers instanceof Headers) { + return Array.from(headers.entries()); + } + if (Array.isArray(headers)) { + return headers; + } + return Object.entries(headers || {}); +} + +function bodyToString(body) { + if (body instanceof URLSearchParams) { + return body.toString(); + } + if (Buffer.isBuffer(body)) { + return body; + } + return String(body); +} + +function curlQuote(value) { + return `"${String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + +function parseCurlHeaders(headersText) { + const blocks = headersText.trim().split(/\r?\n\r?\n/).filter(Boolean); + const block = blocks[blocks.length - 1] || ''; + const lines = block.split(/\r?\n/); + const statusMatch = lines.shift()?.match(/^HTTP\/\S+\s+(\d+)/); + const status = statusMatch ? Number.parseInt(statusMatch[1], 10) : 0; + const headers = new Headers(); + for (const line of lines) { + const idx = line.indexOf(':'); + if (idx > 0) { + headers.append(line.slice(0, idx).trim(), line.slice(idx + 1).trim()); + } + } + return { status, headers }; +} diff --git a/src/models/codex_chatgpt/utils.js b/src/models/codex_chatgpt/utils.js new file mode 100644 index 000000000..fd8538699 --- /dev/null +++ b/src/models/codex_chatgpt/utils.js @@ -0,0 +1,86 @@ +import path from 'path'; + +export function expandHomePath(filePath) { + if (!filePath || typeof filePath !== 'string') { + return filePath; + } + return filePath === '~' || filePath.startsWith('~/') + ? path.join(process.env.HOME || '', filePath.slice(2)) + : filePath; +} + +export function trimTrailingSlash(value) { + return String(value).replace(/\/+$/, ''); +} + +export function structuredCloneSafe(value) { + if (typeof structuredClone === 'function') { + try { + return structuredClone(value); + } catch { + // Fall through to JSON clone. + } + } + return JSON.parse(JSON.stringify(value)); +} + +export function stableJson(value) { + return JSON.stringify(sortJsonKeys(value)); +} + +function sortJsonKeys(value) { + if (Array.isArray(value)) return value.map(sortJsonKeys); + if (!value || typeof value !== 'object') return value; + return Object.keys(value).sort().reduce((out, key) => { + out[key] = sortJsonKeys(value[key]); + return out; + }, {}); +} + +export function abortError() { + const error = new Error('aborted'); + error.name = 'AbortError'; + return error; +} + +export function isAbortError(err) { + return err?.name === 'AbortError' || String(err?.message || err || '').includes('aborted'); +} + +export function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +export async function codexHttpError(response) { + const body = await response.text().catch(() => ''); + const message = extractErrorMessage(body) || response.statusText || 'Codex ChatGPT request failed'; + const error = new Error(`status=${response.status} ${message}`); + error.status = response.status; + return error; +} + +function extractErrorMessage(body) { + try { + const parsed = JSON.parse(body); + return parsed?.error?.message || parsed?.message || body; + } catch { + return body.slice(0, 300); + } +} + +export function formatFetchError(error) { + const cause = error?.cause; + const nestedCodes = Array.isArray(cause?.errors) + ? cause.errors.map(item => item.code).filter(Boolean).join(',') + : ''; + return [error?.message || String(error), cause?.code, nestedCodes, cause?.message] + .filter(Boolean) + .join(' | '); +} + +export function sanitizeCodexError(error) { + return formatFetchError(error) + .replace(/Bearer\s+[A-Za-z0-9._-]+/g, 'Bearer [REDACTED_TOKEN]') + .replace(/(access_token|refresh_token|id_token)":"[^"]+"/g, '$1":"[REDACTED_TOKEN]"') + .slice(0, 500); +} From 4fa94df17442f761119d93bf20ebe6f228b5e02f Mon Sep 17 00:00:00 2001 From: NinotQuyi Date: Sun, 3 May 2026 14:29:47 +0800 Subject: [PATCH 41/56] Keep bot action telemetry out of ReAct turns Kimi action notices such as *used equip* followed by a State update were being merged into the main conversation while Codex was still running newAction. That forced a full 51-tool conversation request and showed up as a cold prompt-cache miss. The conversation scheduler now classifies the merged queue, treats currentActionLabel as busy even if isIdle is momentarily true, and drops pure bot action/status telemetry instead of forking the main ReAct line. Constraint: Normal bot messages while busy must still be allowed through the branch-decision path Rejected: Rely only on agent.isIdle() | UI-visible actions can have currentActionLabel set while isIdle is not a sufficient guard for queued bot telemetry Confidence: high Scope-risk: narrow Directive: Do not let other-bot action/status telemetry mutate main ReAct history unless it contains substantive conversation text Tested: node --test tests/conversation_queue.test.js Tested: node --test tests/codex_chatgpt.test.js Tested: npx eslint src/agent/conversation.js tests/conversation_queue.test.js src/models/codex_chatgpt.js src/models/codex_chatgpt/*.js tests/codex_chatgpt.test.js Tested: npm test Co-authored-by: OmX --- src/agent/conversation.js | 10 +++++----- tests/conversation_queue.test.js | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/agent/conversation.js b/src/agent/conversation.js index 264c7af72..b2ae43bfb 100644 --- a/src/agent/conversation.js +++ b/src/agent/conversation.js @@ -285,7 +285,9 @@ async function _scheduleProcessInMessage(sender, received, convo) { clearTimeout(convo.inMessageTimer); convo.inMessageGeneration++; } - const otherAgentBusy = isOtherBotActionNotice(received.message); + const pending = compileQueuedBotMessages(convo.in_queue); + const decisionMessage = pending?.message || received.message || ''; + const otherAgentBusy = isOtherBotActionNotice(decisionMessage); const scheduleResponse = (delay) => { const generation = convo.inMessageGeneration; @@ -295,7 +297,7 @@ async function _scheduleProcessInMessage(sender, received, convo) { const currentAction = agent.actions?.currentActionLabel || ''; const canTalkOver = talkOverActions.some(a => currentAction.includes(a)); - const agentBusy = !agent.isIdle() || (agent.active_message_handlers || 0) > 0; + const agentBusy = Boolean(currentAction) || !agent.isIdle() || (agent.active_message_handlers || 0) > 0; if (agentBusy && otherAgentBusy && !canTalkOver) { convo.in_queue = []; @@ -308,8 +310,6 @@ async function _scheduleProcessInMessage(sender, received, convo) { } else if (agentBusy && !canTalkOver) { const decisionGeneration = convo.inMessageGeneration; - const pending = compileQueuedBotMessages(convo.in_queue); - const decisionMessage = pending?.message || received.message || ''; const shouldRespond = await agent.prompter.promptShouldRespondToBot( `${sender}: ${_tagMessage(decisionMessage)}`, { cacheScope: 'botResponder' } @@ -332,7 +332,7 @@ async function _scheduleProcessInMessage(sender, received, convo) { function isOtherBotActionNotice(message) { const text = String(message || '').trim(); - return Boolean(containsCommand(text) || /^\*used\s+\w+\*/.test(text)); + return Boolean(containsCommand(text) || /^\*used\s+\w+\*/.test(text) || /^State update:/i.test(text)); } function _processInMessageQueue(name, expectedConvo=null, expectedGeneration=null, expectedTimer=null) { diff --git a/tests/conversation_queue.test.js b/tests/conversation_queue.test.js index 4ddbe8d45..07bf8a4d4 100644 --- a/tests/conversation_queue.test.js +++ b/tests/conversation_queue.test.js @@ -148,6 +148,30 @@ test('busy normal bot messages branch-decide before serialized ReAct delivery', assert.deepEqual(agent.calls, [{ source: 'buddy', message: '(FROM OTHER BOT)\nhello while busy' }]); }); +test('busy bot action notices with state updates do not enter the main ReAct history', async () => { + const agent = resetConversationManager(); + agent.isIdle = () => true; + agent.active_message_handlers = 0; + agent.actions.currentActionLabel = 'action:newAction'; + + await convoManager.receiveFromBot('buddy', { message: '*used equip*', start: true, end: false }); + await convoManager.receiveFromBot('buddy', { + message: [ + 'State update:', + '* action: Idle', + '* inventory: water_bucket:1, diamond_pickaxe:1' + ].join('\n'), + start: false, + end: true + }); + await delay(260); + + assert.deepEqual(agent.promptShouldRespondCalls, []); + assert.deepEqual(agent.calls, []); + assert.equal(convoManager.responseScheduledFor('buddy'), false); + assert.deepEqual(convoManager._getConvo('buddy').in_queue, []); +}); + test('busy normal bot messages can be ignored without stale queued replay', async () => { const agent = resetConversationManager(makeAgent({ shouldRespondToBot: false })); agent.isIdle = () => false; From 67b70b8dddf0e719aae975a9307bc451da93b2cd Mon Sep 17 00:00:00 2001 From: NinotQuyi Date: Sun, 3 May 2026 15:15:10 +0800 Subject: [PATCH 42/56] Prioritize human turns and clarify runtime traces Human and admin messages now preempt active native tool execution, close in-flight tool calls with explicit synthetic results, and drain queued priority turns before the next model request. Runtime traces were flattened and compacted so branch decisions, late user turns, running tools, token metadata, and lazily expanded payloads render without duplicate or jumpy UI noise. Codex WebSocket requests are serialized to prevent concurrent branch/main response cross-talk, and botResponder rejects unexpected native tool calls instead of displaying ambiguous decisions. Constraint: User turns must preempt agent/action work while keeping provider tool-call transcripts closed. Rejected: Treat botResponder non-text/tool-call outputs as generic decided states | hides cross-talk and produces ambiguous UI. Confidence: high Scope-risk: moderate Directive: Do not reuse a single Codex WebSocket stream concurrently without request demux or serialization. Tested: npm test (168/168) Tested: npx eslint changed JS/test files Tested: git diff --check Not-tested: Manual live Minecraft bot interaction after hard-refreshing the browser UI. Co-authored-by: OmX --- src/agent/agent.js | 47 +- src/mindcraft/public/chat_trace_projector.js | 27 +- src/mindcraft/public/index.html | 576 ++++++++++++++++--- src/models/codex_chatgpt/client.js | 30 +- src/models/prompter.js | 14 +- tests/agent_native_text_policy.test.js | 293 +++++++++- tests/codex_chatgpt.test.js | 60 ++ 7 files changed, 939 insertions(+), 108 deletions(-) diff --git a/src/agent/agent.js b/src/agent/agent.js index 79e05726d..79822a309 100644 --- a/src/agent/agent.js +++ b/src/agent/agent.js @@ -295,11 +295,19 @@ export class Agent { _shouldBatchHumanMessage(source, message, options={}) { if (options?.transient) return false; - const self_prompt = source === 'system' || source === this.name; - if (self_prompt || convoManager.isOtherAgent(source)) return false; + if (!this._isPriorityHumanSource(source)) return false; return !containsCommand(message); } + _isPriorityHumanSource(source) { + const self_prompt = source === 'system' || source === this.name; + return !self_prompt && !convoManager.isOtherAgent(source); + } + + _hasQueuedPriorityHumanMessage() { + return (this.human_message_queue || []).some(item => this._isPriorityHumanSource(item.source)); + } + _enqueueHumanMessage(source, message, max_responses=null, options={}) { let resolveQueued; let rejectQueued; @@ -309,12 +317,8 @@ export class Agent { }); this.message_interrupt_epoch = (this.message_interrupt_epoch || 0) + 1; this.human_message_queue.push({ source, message, max_responses, options, resolveQueued, rejectQueued }); - if ((this.active_message_handlers || 0) > 0) { - const previousInterrupt = this.human_message_interrupt_promise || Promise.resolve(); - this.human_message_interrupt_promise = previousInterrupt - .catch(error => console.warn('Failed to interrupt active turn for new user message:', error)) - .then(() => this._interruptActiveTurnForNewHumanMessage()) - .catch(error => console.warn('Failed to interrupt active turn for new user message:', error)); + if (this._hasQueuedPriorityHumanMessage()) { + this._schedulePriorityHumanMessageInterrupt(); } if (!this.human_message_flush_timer) { this.human_message_flush_timer = setTimeout(() => { @@ -326,6 +330,14 @@ export class Agent { return queuedPromise; } + _schedulePriorityHumanMessageInterrupt() { + const previousInterrupt = this.human_message_interrupt_promise || Promise.resolve(); + this.human_message_interrupt_promise = previousInterrupt + .catch(error => console.warn('Failed to interrupt active turn for new user/admin message:', error)) + .then(() => this._interruptActiveTurnForNewHumanMessage()) + .catch(error => console.warn('Failed to interrupt active turn for new user/admin message:', error)); + } + async _flushHumanMessageQueue() { await (this.human_message_interrupt_promise || Promise.resolve()); const batch = this.human_message_queue.splice(0); @@ -358,9 +370,18 @@ export class Agent { } async _interruptActiveTurnForNewHumanMessage() { - this.abortActiveLLMRequest('Interrupted by newer user message.'); - const closed = await this.finishInterruptedNativeToolCalls('Tool interrupted by newer user message.'); - if (closed > 0) { + this.abortActiveLLMRequest('Interrupted by newer user/admin message.'); + const actionWasExecuting = Boolean(this.actions?.executing); + if (actionWasExecuting) { + if (typeof this.actions.stop === 'function') { + await this.actions.stop(); + } + else if (this.bot) { + this.requestInterrupt(); + } + } + const closed = await this.finishInterruptedNativeToolCalls('Tool interrupted by newer user/admin message.'); + if (closed > 0 && !actionWasExecuting && this.bot && typeof this.requestInterrupt === 'function') { this.requestInterrupt(); } } @@ -639,6 +660,10 @@ export class Agent { await this.history.addNativeToolCall(toolCall, undefined, metadata); this._trackActiveNativeToolCall(toolCall); this.routeResponse(source, display); + if (shouldAbort()) { + await this._completeActiveNativeToolCall(toolCall, 'Tool call interrupted before execution by a newer message, stop command, or shutdown.'); + break; + } console.log(`[native-tool] calling ${commandName} args=${formatToolArgsForLog(toolCall.arguments)}`); const execute_res = await executeCommandToolCall(this, toolCall); diff --git a/src/mindcraft/public/chat_trace_projector.js b/src/mindcraft/public/chat_trace_projector.js index 2586a6da7..5973b21b6 100644 --- a/src/mindcraft/public/chat_trace_projector.js +++ b/src/mindcraft/public/chat_trace_projector.js @@ -178,6 +178,12 @@ class ChatTraceProjector { } addHistoryTurn(event) { + if (shouldAppendAsPendingHistoryTurn(this.current, event)) { + const pending = this.ensurePendingHistoryTurn(); + pending.historyMessages.push(event); + this.updateTurnProjection(pending); + return; + } const turn = this.ensureTurn(); turn.historyMessages.push(event); this.updateTurnProjection(turn); @@ -225,10 +231,20 @@ class ChatTraceProjector { } } + ensurePendingHistoryTurn() { + const last = this.thread.turns[this.thread.turns.length - 1]; + if (isHistoryOnlyProjectionTurn(last)) return last; + const pending = this.createTurn(); + this.thread.turns.push(pending); + return pending; + } + takePendingHistoryOnlyTurn() { - if (!isHistoryOnlyProjectionTurn(this.current)) return []; - const pendingHistoryMessages = this.current.historyMessages; + const pending = this.thread.turns[this.thread.turns.length - 1]; + if (!isHistoryOnlyProjectionTurn(pending)) return []; + const pendingHistoryMessages = pending.historyMessages; this.thread.turns.pop(); + if (this.current === pending) this.current = null; return pendingHistoryMessages; } @@ -301,6 +317,13 @@ function isHistoryOnlyProjectionTurn(turn) { && turn.historyMessages.length > 0; } +function shouldAppendAsPendingHistoryTurn(currentTurn, event) { + const role = event?.turn?.role; + if (role !== 'user') return false; + if (!currentTurn || isHistoryOnlyProjectionTurn(currentTurn)) return false; + return Boolean(currentTurn.response || currentTurn.errors?.length || currentTurn.toolRuns?.length); +} + function shouldRenderInlineHistoryEvent(event, requestMessages, hasToolRuns) { const role = event?.turn?.role; if (callHelper('isHistoryTurnIncludedInRequest', event?.turn, requestMessages)) return false; diff --git a/src/mindcraft/public/index.html b/src/mindcraft/public/index.html index 16b55c7ac..4eb8e64a2 100644 --- a/src/mindcraft/public/index.html +++ b/src/mindcraft/public/index.html @@ -369,8 +369,14 @@ background: #161616; } .chat-card.error { - background: #1a1212; - border-color: #5d2626; + background: transparent; + border-color: rgba(255,255,255,0.08); + border-left: none; + border-right: none; + border-bottom: none; + border-radius: 0; + box-shadow: none; + padding: 8px 0 9px; } .chat-card.request::before { background: #7f7f7f; } .chat-card.turn-card::before, @@ -970,8 +976,8 @@ font-style: italic; } .agent-bubble.error { - background: #1a1212; - border-color: #5d2626; + background: transparent; + border-color: transparent; } /* Chat polish: keep the agent trace useful without looking like a generated demo UI. */ @@ -1255,10 +1261,10 @@ padding: 2px 0 0; } .agent-message.assistant-message .agent-bubble.error { - background: #1a1212; - border: 1px solid #5d2626; - border-radius: 10px; - padding: 10px 12px; + background: transparent; + border: none; + border-radius: 0; + padding: 0; } .agent-message.assistant-message .agent-bubble-label { color: #b8b8b8; @@ -1278,6 +1284,19 @@ .agent-message.assistant-message .agent-bubble-meta { margin-bottom: 4px; } + .agent-bubble-meta { + color: #626262; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 10px; + font-style: italic; + font-weight: 300; + } + .chat-token-usage { + color: #626262; + font-size: 1em; + font-style: inherit; + font-weight: inherit; + } .agent-message.assistant-message .agent-text { background: transparent; border: none; @@ -1540,6 +1559,9 @@ cursor: pointer; color: #777; } + .chat-tool-output-detail.chat-tool-result { + white-space: normal; + } .chat-tool-output-detail > summary { list-style: none; } @@ -1553,7 +1575,15 @@ cursor: text; } .chat-tool-output-extra { + display: flex; + flex-direction: column; + gap: 4px; margin: 4px 0 0 18px; + white-space: normal; + } + .chat-tool-output-extra > .chat-details, + .chat-tool-output-extra > .chat-internal-tool-events { + margin-top: 0; } .chat-tool-result::before { content: none; } .agent-step.tool-running .agent-step-name, @@ -1568,6 +1598,72 @@ 50% { opacity: 1; } } + .agent-error-line { + display: grid; + grid-template-columns: 16px max-content minmax(0, 1fr) auto; + column-gap: 6px; + align-items: baseline; + min-height: 22px; + color: #9a9a9a; + } + .agent-error-dot { + grid-column: 1; + justify-self: center; + color: #9d6a6a; + font-size: 11px; + line-height: 1; + } + .agent-error-label { + grid-column: 2; + color: #e3e3e3; + font: 650 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + white-space: nowrap; + } + .agent-error-status { + grid-column: 4; + color: #a87f7f; + font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + white-space: nowrap; + } + .agent-error-body { + display: grid; + grid-template-columns: 18px minmax(0, 1fr); + padding: 2px 0 0 20px; + color: #8f8f8f; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + line-height: 1.45; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + .agent-error-glyph { + color: #5f5f5f; + white-space: pre; + } + .agent-error-text { + min-width: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + .agent-error-detail { + margin: 2px 0 0 38px; + } + .agent-error-detail .chat-details { + margin: 0; + padding: 0; + border-top: none; + } + .agent-error-detail .chat-details summary { + color: #6f6f6f; + font-size: 12px; + } + .agent-error-detail .chat-details[open] { + margin-top: 6px; + } + .agent-error-detail .chat-details[open] .chat-json { + margin: 6px 0 0 0; + max-height: 220px; + } + /* Neutralize context and user surfaces. */ @@ -2033,53 +2129,173 @@ } - /* Ephemeral branch decisions are observability-only: rendered outside the main conversation branch. */ + /* Ephemeral branch decisions are observability-only; render them as a flat router trace, not as normal ReAct work. */ .chat-branch-event { - margin: 10px 0 14px 34px; - max-width: min(860px, 92%); - color: #9a9a9a; + position: relative; + width: 100%; + max-width: none; + margin: 0; + padding: 8px 0 9px; + border-top: 1px solid rgba(190, 190, 190, 0.12); + color: #8f8f8f; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 11px; + font-style: italic; + font-weight: 300; + line-height: 1.5; } - .chat-branch-card { - border-left: 2px solid #5b5b5b; - padding: 7px 0 8px 12px; - background: linear-gradient(90deg, rgba(255,255,255,0.035), rgba(255,255,255,0)); + .chat-branch-event::before { + content: '↳'; + position: absolute; + top: 8px; + left: 0; + color: #6a6a6a; + font-weight: 300; } - .chat-branch-title { + .chat-branch-top { display: flex; align-items: baseline; + justify-content: space-between; + gap: 12px; flex-wrap: wrap; + } + .chat-branch-top::before { + content: '↳'; + color: #6a6a6a; + font-weight: 300; + } + .chat-branch-kind { + color: #8f8f8f; + font-weight: 300; + } + .chat-branch-route { + color: #777; + font-weight: 300; + } + .chat-branch-status { + color: #a8a8a8; + font-size: 11px; + font-weight: 300; + } + .chat-branch-status--respond, + .chat-branch-status--ignore, + .chat-branch-status--pending, + .chat-branch-status--error, + .chat-branch-status--invalid { color: #a8a8a8; } + .chat-branch-result-detail { + color: #777; + font-size: 11px; + font-weight: 300; + } + .chat-branch-meta { + color: #737373; + font-weight: 300; + margin-left: auto; + } + .chat-branch-request-meta { + display: flex; + align-items: baseline; + flex-wrap: nowrap; + justify-content: flex-end; gap: 6px; - color: #858585; - font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + margin-left: auto; + padding-left: 0; + color: #626262; + font-size: 10px; + font-style: italic; + font-weight: 300; } - .chat-branch-title strong { - color: #cfcfcf; - font: 600 12px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + .chat-branch-request-meta span { + white-space: nowrap; } - .chat-branch-question { - margin-top: 7px; - color: #bdbdbd; - white-space: pre-wrap; + .chat-branch-request-meta span + span::before { + content: '· '; + color: #626262; + } + .chat-branch-say { + margin-top: 5px; + padding-left: 20px; + color: #a8a8a8; + font-size: 11px; + font-weight: 300; + min-width: 0; overflow-wrap: anywhere; - line-height: 1.45; } - .chat-branch-decision { - margin-top: 6px; - color: #d7d7d7; - font-weight: 600; + .chat-branch-speaker { + color: #aaa; + font-size: 11px; + font-weight: 300; } - .chat-branch-decision .chat-muted { - font-weight: 400; - margin-left: 6px; + .chat-branch-bottom { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 16px; + margin-top: 5px; + padding-left: 20px; + white-space: nowrap; } - .chat-branch-event .chat-details { - margin-top: 7px; - padding-top: 6px; - border-top-color: rgba(255,255,255,0.05); + .chat-branch-decision { + display: flex; + align-items: baseline; + gap: 8px; + flex: 0 0 auto; } .chat-branch-error { - margin-top: 6px; - color: #d79a9a; + margin-top: 3px; + padding-left: 20px; + color: #a8a8a8; + font-size: 11px; + font-weight: 300; + } + .chat-branch-error::before { + content: 'error: '; + color: #747474; + text-transform: lowercase; + } + .chat-branch-payloads { + margin-top: 5px; + padding-left: 0; + padding-top: 0; + border-top: 0; + } + .chat-branch-payloads > summary { + display: block; + cursor: pointer; + list-style: none; + } + .chat-branch-payloads > summary::-webkit-details-marker { + display: none; + } + .chat-branch-payload-list { + min-width: 0; + margin-top: 5px; + padding-left: 20px; + } + .chat-branch-payload-entry { + margin-top: 5px; + font-size: 11px; + } + .chat-branch-payload-entry-title { + color: #777; + font-size: 11px; + font-style: italic; + font-weight: 300; + } + .chat-branch-payload-entry .chat-json { + font-size: 11px; + font-style: normal; + font-weight: 300; + max-height: 260px; + } + @media (max-width: 820px) { + .chat-branch-bottom { + gap: 8px; + overflow-x: auto; + } + .chat-branch-request-meta { + margin-left: 0; + } } @@ -2161,10 +2377,15 @@

Agent Settings

const agentSettings = {}; const agentLastMessage = {}; const agentChatEvents = {}; + const agentChatEventKeys = {}; + const agentUiRenderCache = {}; + const chatDetailPayloads = new Map(); + const pendingChatPanelRenders = {}; const chatHistoryLoadState = {}; const inventoryOpen = {}; const chatOpen = {}; let currentAgents = []; + let chatPanelRenderFrame = null; const MAX_CHAT_EVENTS = 5000; // Item texture helpers (use PrismarineJS minecraft-assets CDN with graceful fallbacks) @@ -2212,6 +2433,16 @@

Agent Settings

return String(name || '').replace(/_/g, ' '); } + function setTextIfChanged(element, text) { + if (!element) return; + if (element.textContent !== text) element.textContent = text; + } + + function getAgentUiRenderCache(name) { + if (!agentUiRenderCache[name]) agentUiRenderCache[name] = {}; + return agentUiRenderCache[name]; + } + function iconHTMLForItem(itemName, title, agentName) { const cands = buildIconUrlCandidates(itemName, agentName); const icon = cands[0]; @@ -2382,7 +2613,7 @@

Agent Settings

socket.on('agent-trace', (agentName, event) => { appendChatEvents(agentName, [event]); - renderChatPanel(agentName); + scheduleChatPanelRender(agentName); }); // Subscribe to aggregated state updates (re-sent on each connect) @@ -2393,9 +2624,10 @@

Agent Settings

const healthEl = document.getElementById(`health-${name}`); if (st && !st.error) { const gp = st.gameplay || {}; + const renderCache = getAgentUiRenderCache(name); if (healthEl && typeof gp.health === 'number') { const hMax = typeof gp.healthMax === 'number' ? gp.healthMax : 20; - healthEl.textContent = `health: ${gp.health}/${hMax}`; + setTextIfChanged(healthEl, `health: ${gp.health}/${hMax}`); } const posEl = document.getElementById(`pos-${name}`); const hunEl = document.getElementById(`hunger-${name}`); @@ -2407,48 +2639,53 @@

Agent Settings

const actionEl = document.getElementById(`action-${name}`); if (posEl && gp.position) { const p = gp.position; - posEl.textContent = `x ${p.x}, y ${p.y}, z ${p.z}`; + setTextIfChanged(posEl, `x ${p.x}, y ${p.y}, z ${p.z}`); } if (hunEl && typeof gp.hunger === 'number') { const fMax = typeof gp.hungerMax === 'number' ? gp.hungerMax : 20; - hunEl.textContent = `hunger: ${gp.hunger}/${fMax}`; + setTextIfChanged(hunEl, `hunger: ${gp.hunger}/${fMax}`); } - if (bioEl && gp.biome) bioEl.textContent = `biome: ${gp.biome}`; - if (modeEl && gp.gamemode) modeEl.textContent = `gamemode: ${gp.gamemode}`; + if (bioEl && gp.biome) setTextIfChanged(bioEl, `biome: ${gp.biome}`); + if (modeEl && gp.gamemode) setTextIfChanged(modeEl, `gamemode: ${gp.gamemode}`); if (itemsEl && st.inventory) { const used = st.inventory.stacksUsed ?? 0; const total = st.inventory.totalSlots ?? 0; - itemsEl.textContent = `inventory slots: ${used}/${total}`; + setTextIfChanged(itemsEl, `inventory slots: ${used}/${total}`); } if (equippedEl && st.inventory?.equipment) { const e = st.inventory.equipment; - equippedEl.textContent = `equipped: ${e.mainHand || 'none'}`; + setTextIfChanged(equippedEl, `equipped: ${e.mainHand || 'none'}`); } const armorEl = document.getElementById(`armor-${name}`); if (armorEl && st.inventory?.equipment) { const e = st.inventory.equipment; - const parts = []; - parts.push(e.helmet ? iconHTMLForItem(e.helmet, `head: ${prettyItemName(e.helmet)}`, name) : emptySlotHTML('head')); - parts.push(e.chestplate ? iconHTMLForItem(e.chestplate, `chest: ${prettyItemName(e.chestplate)}`, name) : emptySlotHTML('chest')); - parts.push(e.leggings ? iconHTMLForItem(e.leggings, `legs: ${prettyItemName(e.leggings)}`, name) : emptySlotHTML('legs')); - parts.push(e.boots ? iconHTMLForItem(e.boots, `feet: ${prettyItemName(e.boots)}`, name) : emptySlotHTML('feet')); - // Main hand for quick glance - parts.push(e.mainHand ? iconHTMLForItem(e.mainHand, `main hand: ${prettyItemName(e.mainHand)}`, name) : emptySlotHTML('main hand')); - armorEl.innerHTML = `
${parts.join('')}
`; + const armorKey = [e.helmet, e.chestplate, e.leggings, e.boots, e.mainHand].map(value => value || '').join('|'); + if (renderCache.armorKey !== armorKey) { + const parts = []; + parts.push(e.helmet ? iconHTMLForItem(e.helmet, `head: ${prettyItemName(e.helmet)}`, name) : emptySlotHTML('head')); + parts.push(e.chestplate ? iconHTMLForItem(e.chestplate, `chest: ${prettyItemName(e.chestplate)}`, name) : emptySlotHTML('chest')); + parts.push(e.leggings ? iconHTMLForItem(e.leggings, `legs: ${prettyItemName(e.leggings)}`, name) : emptySlotHTML('legs')); + parts.push(e.boots ? iconHTMLForItem(e.boots, `feet: ${prettyItemName(e.boots)}`, name) : emptySlotHTML('feet')); + // Main hand for quick glance + parts.push(e.mainHand ? iconHTMLForItem(e.mainHand, `main hand: ${prettyItemName(e.mainHand)}`, name) : emptySlotHTML('main hand')); + armorEl.innerHTML = `
${parts.join('')}
`; + renderCache.armorKey = armorKey; + } } if (actionEl && st.action) { - actionEl.textContent = `${st.action.current || 'Idle'}`; + setTextIfChanged(actionEl, `${st.action.current || 'Idle'}`); } if (invGrid && st.inventory?.counts) { const counts = st.inventory.counts; - const entries = Object.entries(counts); + const entries = Object.entries(counts).sort((a, b) => a[0].localeCompare(b[0])); + const inventoryKey = entries.map(([k, v]) => `${k}:${v}`).join('|'); + if (renderCache.inventoryKey === inventoryKey) return; + renderCache.inventoryKey = inventoryKey; if (entries.length) { invGrid.classList.remove('empty'); invGrid.innerHTML = entries - .sort((a, b) => a[0].localeCompare(b[0])) .map(([k, v]) => { const title = prettyItemName(k); - const safeAttr = k.replace(/'/g, "\\'"); const iconCandidates = buildIconUrlCandidates(k, name); const icon = iconCandidates[0]; return ` @@ -2484,7 +2721,7 @@

Agent Settings

function appendChatEvents(name, events, { prepend = false } = {}) { if (!Array.isArray(events) || events.length === 0) return; if (!agentChatEvents[name]) agentChatEvents[name] = []; - const existingKeys = new Set(agentChatEvents[name].map(getChatEventKey)); + const existingKeys = getAgentChatEventKeys(name); const uniqueEvents = events.filter(event => { const key = getChatEventKey(event); if (existingKeys.has(key)) return false; @@ -2494,11 +2731,23 @@

Agent Settings

if (!uniqueEvents.length) return; if (prepend) agentChatEvents[name].unshift(...uniqueEvents); else agentChatEvents[name].push(...uniqueEvents); - if (agentChatEvents[name].length > MAX_CHAT_EVENTS) { - const overflow = agentChatEvents[name].length - MAX_CHAT_EVENTS; - if (prepend) agentChatEvents[name].splice(MAX_CHAT_EVENTS); - else agentChatEvents[name].splice(0, overflow); + trimChatEvents(name); + } + + function getAgentChatEventKeys(name) { + if (!agentChatEventKeys[name]) { + agentChatEventKeys[name] = new Set((agentChatEvents[name] || []).map(getChatEventKey)); } + return agentChatEventKeys[name]; + } + + function trimChatEvents(name) { + const events = agentChatEvents[name] || []; + if (events.length <= MAX_CHAT_EVENTS) return; + const overflow = events.length - MAX_CHAT_EVENTS; + const removed = events.splice(0, overflow); + const keys = getAgentChatEventKeys(name); + removed.forEach(event => keys.delete(getChatEventKey(event))); } function getChatEventKey(event) { @@ -2756,6 +3005,24 @@

Inventory

} }; + function scheduleChatPanelRender(name, options = {}) { + if (!name) return; + const previous = pendingChatPanelRenders[name] || {}; + pendingChatPanelRenders[name] = { + forceScroll: Boolean(previous.forceScroll || options.forceScroll) + }; + if (chatPanelRenderFrame !== null) return; + const scheduleFrame = window.requestAnimationFrame || (callback => window.setTimeout(callback, 16)); + chatPanelRenderFrame = scheduleFrame(flushChatPanelRenders); + } + + function flushChatPanelRenders() { + chatPanelRenderFrame = null; + const queued = { ...pendingChatPanelRenders }; + Object.keys(pendingChatPanelRenders).forEach(name => delete pendingChatPanelRenders[name]); + Object.entries(queued).forEach(([name, options]) => renderChatPanel(name, options)); + } + function renderChatPanel(name, options = {}) { const panel = document.getElementById(`chatPanel-${name}`); if (!panel || chatOpen[name] !== true) return; @@ -2769,6 +3036,7 @@

Inventory

const openDetailIds = getOpenChatDetailIds(panel); panel.innerHTML = renderChatThread(buildChatThread(events)); restoreOpenChatDetails(panel, openDetailIds); + enableChatLazyDetails(panel); enableChatDetailBodyToggle(panel); enableChatCopyButtons(panel); if (shouldAutoScroll) panel.scrollTop = panel.scrollHeight; @@ -2806,6 +3074,28 @@

Inventory

}); } + function enableChatLazyDetails(panel) { + hydrateOpenChatJsonContainers(panel); + panel.querySelectorAll('details').forEach(details => { + details.addEventListener('toggle', () => { + if (!details.open) return; + hydrateChatJsonContainers(details); + enableChatCopyButtons(details); + }); + }); + } + + function hydrateOpenChatJsonContainers(root) { + root.querySelectorAll('details[open]').forEach(details => hydrateChatJsonContainers(details)); + } + + function hydrateChatJsonContainers(root) { + root.querySelectorAll('.chat-json-wrap[data-chat-json-id]').forEach(container => { + if (!chatDetailPayloads.has(container.dataset.chatJsonId)) return; + const json = JSON.stringify(chatDetailPayloads.get(container.dataset.chatJsonId), null, 2); + container.outerHTML = renderChatJsonPayloadHTML(json); + }); + } function enableChatDetailBodyToggle(panel) { panel.querySelectorAll('details.chat-tool-output-detail[data-chat-detail-id]').forEach(details => { @@ -2824,6 +3114,8 @@

Inventory

function enableChatCopyButtons(panel) { panel.querySelectorAll('.chat-copy-btn').forEach(button => { + if (button.dataset.copyBound === 'true') return; + button.dataset.copyBound = 'true'; button.addEventListener('click', async event => { event.preventDefault(); event.stopPropagation(); @@ -2946,11 +3238,15 @@

Inventory

if (!hasRunContent && turn.historyMessages.length) { return `
${renderRestoredHistoryMessages(turn.historyMessages, index)}
`; } + const inlineHistory = renderInlineHistoryMessages(turn); + const inlineHistoryBeforeAssistant = turn.toolRuns.length ? '' : inlineHistory; + const inlineHistoryAfterAssistant = turn.toolRuns.length ? inlineHistory : ''; return `
${renderUserInputMessage(turn, index)} - ${renderInlineHistoryMessages(turn)} + ${inlineHistoryBeforeAssistant} ${renderAssistantWorkMessage(turn, index)} + ${inlineHistoryAfterAssistant} ${turn.errors.map(renderInlineError).join('')} ${renderCompactEvent(turn.compacted)}
`; @@ -2971,34 +3267,113 @@

Inventory

const scope = request?.cache_scope || request?.cacheScope || response?.cache_scope || response?.cacheScope || request?.tag || response?.tag || 'branch'; const timestamp = response?.timestamp || request?.timestamp; const question = getBranchQuestionText(request); - const decision = getBranchDecisionText(response); - const tokenUsage = renderTokenUsage(response?.token_usage); + const branchMessage = parseBranchQuestion(question); + const branchMessageText = normalizeBranchInlineMessage(branchMessage.message); + const decision = getBranchDecisionInfo(response, errors); + const requestMeta = renderBranchRequestMeta(model, response?.token_usage, timestamp); const errorText = errors.map(error => error.error?.message || stringifyChatText(error.error)).filter(Boolean).join('\n'); return `
-
-
Branch decision${escapeHTML(scope)}Run ${index + 1}${escapeHTML(model)}${escapeHTML(formatChatTime(timestamp))}${tokenUsage}
- ${question ? `
${escapeHTML(question)}
` : '
No branch question recorded yet.
'} - ${decision ? `
decision: ${escapeHTML(decision)}
` : '
waiting for branch response…
'} - ${errorText ? `
${escapeHTML(limitText(errorText, 300))}
` : ''} - ${renderDetails('Branch request payload', request)} - ${renderDetails('Branch response payload', response)} - ${errors.map(error => renderDetails('Branch error payload', error)).join('')} -
+
${escapeHTML(branchMessage.sender || 'unknown')} say: ${branchMessageText ? escapeHTML(branchMessageText) : 'No branch question recorded yet.'}
+ ${errorText ? `
${escapeHTML(limitText(errorText, 300))}
` : ''} + ${renderBranchPayloadDetails(request, response, errors, ` +
+
+ result + ${escapeHTML(decision.label)} + ${decision.detail ? `${escapeHTML(decision.detail)}` : ''} +
+ ${requestMeta} +
+ `)}
`; } + function renderBranchRequestMeta(model, usage, timestamp) { + return ` +
+ model ${escapeHTML(model || 'model')} + input uncached ${formatTokenCount(usage?.input_uncached)} + input cached ${formatTokenCount(usage?.input_cached)} + output ${formatTokenCount(usage?.output)} + ${escapeHTML(formatChatTime(timestamp))} +
`; + } + + function renderBranchPayloadDetails(request, response, errors = [], summary = '') { + const details = [ + renderBranchPayloadBlock('Branch request payload', request), + renderBranchPayloadBlock('Branch response payload', response), + ...errors.map(error => renderBranchPayloadBlock('Branch error payload', error)) + ].filter(Boolean).join(''); + if (!details) return summary; + const detailId = getChatDetailId('Branch payloads', response || request || errors[0]); + return ` +
+ ${summary} +
${details}
+
`; + } + + function renderBranchPayloadBlock(label, value) { + if (!value) return ''; + const payloadId = registerChatJsonPayload(label, value); + return ` +
+
${escapeHTML(label)}
+ ${renderLazyChatJsonContainer(payloadId)} +
`; + } + + function getBranchDecisionInfo(response, errors = []) { + if (Array.isArray(errors) && errors.length > 0) return { state: 'error', label: 'error', detail: '' }; + if (!response) return { state: 'pending', label: 'pending', detail: '' }; + const toolCalls = getBranchResponseToolCalls(response); + if (toolCalls.length) { + const names = toolCalls.map(getToolName).filter(Boolean).join(', ') || 'tool'; + return { state: 'invalid', label: 'invalid', detail: `unexpected tool call: ${names}` }; + } + const decision = getBranchDecisionText(response); + const normalized = String(decision || '').trim().toLowerCase(); + if (normalized.startsWith('respond')) return { state: 'respond', label: 'respond', detail: '' }; + if (normalized.startsWith('ignore')) return { state: 'ignore', label: 'ignore', detail: '' }; + if (!normalized) return { state: 'pending', label: 'pending', detail: '' }; + return { state: 'invalid', label: 'invalid', detail: limitText(decision, 120) }; + } + function getBranchQuestionText(request) { const messages = Array.isArray(request?.messages) ? request.messages : []; const lastUser = messages.slice().reverse().find(message => message?.role === 'user'); return lastUser ? formatChatMessagePreview(lastUser) : ''; } + function parseBranchQuestion(text) { + const normalized = String(text || '').trim(); + if (!normalized) return { sender: '', message: '' }; + const match = normalized.match(/^([^:\n]+):\s*(?:\(FROM OTHER BOT\)\s*)?(?:\n)?([\s\S]*)$/); + if (!match) return { sender: 'unknown', message: normalized.replace(/\(FROM OTHER BOT\)/g, '').trim() }; + return { + sender: match[1].trim(), + message: match[2].replace(/^\(FROM OTHER BOT\)\s*/i, '').trim() + }; + } + + function normalizeBranchInlineMessage(text) { + return String(text || '').replace(/\s*\n\s*/g, ' ').replace(/\s+/g, ' ').trim(); + } + function getBranchDecisionText(response) { const text = extractResponseText(response?.response) || stringifyChatText(response?.response); return limitText(String(text || '').trim(), 300); } + function getBranchResponseToolCalls(response) { + const value = response?.response; + if (Array.isArray(value?.tool_calls)) return value.tool_calls; + if (Array.isArray(value?.native_tool_calls)) return value.native_tool_calls; + return []; + } + function renderCompactEvent(event) { if (!event) return ''; const detailId = getChatDetailId('Compact boundary', event); @@ -3338,13 +3713,21 @@

Inventory

} function renderInlineError(event) { + const message = limitText(event.error?.message || stringifyChatText(event.error), 1200); return ` -
+
!
-
Error
-
${escapeHTML(limitText(event.error?.message || stringifyChatText(event.error), 1200))}
- ${renderDetails('Error payload', event)} +
+ + Error + failed +
+
+ + ${escapeHTML(message)} +
+
${renderDetails('Error payload', event)}
`; } @@ -3361,17 +3744,28 @@

Inventory

function renderDetails(label, value) { if (!value) return ''; const detailId = getChatDetailId(label, value); - const json = JSON.stringify(value, null, 2); + const payloadId = registerChatJsonPayload(label, value, detailId); return `
${escapeHTML(label)} -
- -
${escapeHTML(json)}
-
+ ${renderLazyChatJsonContainer(payloadId)}
`; } + function registerChatJsonPayload(label, value, preferredId = '') { + const payloadId = preferredId || getChatDetailId(label, value); + chatDetailPayloads.set(payloadId, value); + return payloadId; + } + + function renderLazyChatJsonContainer(payloadId) { + return `
Open to render payload.
`; + } + + function renderChatJsonPayloadHTML(json) { + return `
${escapeHTML(json)}
`; + } + function getChatDetailId(label, value) { const type = value?.type || label || 'detail'; const timestamp = value?.timestamp || ''; @@ -3556,6 +3950,7 @@

Inventory

// If agentsDiv is empty, do a full render if (!agentsDiv.children.length) { + agents.forEach(agent => delete agentUiRenderCache[agent.name]); agentsDiv.innerHTML = agents.map(agent => renderAgentCard(agent)).join(''); // Update all viewers after initial render setTimeout(() => { @@ -3574,6 +3969,7 @@

Inventory

// Update only changed agents changedAgents.forEach(agent => { const el = document.getElementById(`agent-${agent.name}`); + delete agentUiRenderCache[agent.name]; if (el) { // Update existing card el.outerHTML = renderAgentCard(agent); @@ -3593,6 +3989,9 @@

Inventory

delete inventoryOpen[name]; delete chatHistoryLoadState[name]; delete agentChatEvents[name]; + delete agentChatEventKeys[name]; + delete agentUiRenderCache[name]; + delete pendingChatPanelRenders[name]; delete chatOpen[name]; } }); @@ -3622,6 +4021,7 @@

Inventory

// If agentsDiv is empty, do a full render if (!agentsDiv.children.length) { + agents.forEach(agent => delete agentUiRenderCache[agent.name]); agentsDiv.innerHTML = agents.map(agent => renderAgentCard(agent)).join(''); // Update all viewers after initial render setTimeout(() => { @@ -3633,6 +4033,7 @@

Inventory

// Update only changed agents changedAgents.forEach(agent => { const el = document.getElementById(`agent-${agent.name}`); + delete agentUiRenderCache[agent.name]; if (el) { // Update existing card el.outerHTML = renderAgentCard(agent); @@ -3652,6 +4053,9 @@

Inventory

delete inventoryOpen[name]; delete chatHistoryLoadState[name]; delete agentChatEvents[name]; + delete agentChatEventKeys[name]; + delete agentUiRenderCache[name]; + delete pendingChatPanelRenders[name]; delete chatOpen[name]; } }); diff --git a/src/models/codex_chatgpt/client.js b/src/models/codex_chatgpt/client.js index f0d02340f..5aa0f72fd 100644 --- a/src/models/codex_chatgpt/client.js +++ b/src/models/codex_chatgpt/client.js @@ -100,6 +100,7 @@ export class CodexChatGPT { this.responsesWebSocketDisabled = transport === 'http' || transport === 'https'; this.responsesWebSocket = null; this.responsesWebSocketHeaders = null; + this.responsesWebSocketQueue = Promise.resolve(); this.responsesWebSocketIdleTimeoutMs = Number.parseInt(this.params.responsesWebSocketIdleTimeoutMs || this.params.responses_websocket_idle_timeout_ms || DEFAULT_WEBSOCKET_IDLE_TIMEOUT_MS, 10); delete this.params.transport; delete this.params.codexTransport; @@ -159,7 +160,7 @@ export class CodexChatGPT { this.rememberTurnState(options, response); const parsed = await parseCodexResponsesSse(await response.text()); - this.rememberResponseContinuity(this.lastSentResponsesOptions || options, this.lastSentResponsesBody || body, parsed); + this.rememberResponseContinuity(response.codexSentOptions || options, response.codexSentBody || body, parsed); console.log('Received.'); setLastTokenUsage(this, parsed.usage); this.lastThinking = parsed.thinking || ''; @@ -250,7 +251,7 @@ export class CodexChatGPT { this.lastSentResponsesOptions = options; if (this.useResponsesWebSocket && !this.responsesWebSocketDisabled) { try { - return await this.fetchResponsesWebSocket(endpoint, body, auth, options); + return await this.enqueueResponsesWebSocketRequest(() => this.fetchResponsesWebSocket(endpoint, body, auth, options)); } catch (err) { if (isAbortError(err)) throw err; this.closeResponsesWebSocket(); @@ -271,12 +272,25 @@ export class CodexChatGPT { } this.lastSentResponsesBody = httpBody; this.lastSentResponsesOptions = options; - return await codexFetch(endpoint, { + const response = await codexFetch(endpoint, { method: 'POST', headers: this.buildHeaders(auth, options), body: JSON.stringify(httpBody), signal: options?.signal }); + return attachSentResponsesMetadata(response, httpBody, options); + } + + async enqueueResponsesWebSocketRequest(task) { + const previous = this.responsesWebSocketQueue.catch(() => {}); + let release; + this.responsesWebSocketQueue = new Promise(resolve => { release = resolve; }); + await previous; + try { + return await task(); + } finally { + release?.(); + } } async fetchResponsesWebSocket(endpoint, body, auth, options = {}) { @@ -298,10 +312,10 @@ export class CodexChatGPT { this.responsesWebSocketHeaders = null; } }); - return new Response(responseText, { + return attachSentResponsesMetadata(new Response(responseText, { status: 200, headers: this.responsesWebSocketHeaders || {} - }); + }), wsBody, wsOptions); } async ensureResponsesWebSocket(endpoint, auth, options = {}) { @@ -522,3 +536,9 @@ export { toCodexResponseItem, toCodexResponsesTools } from './protocol.js'; + +function attachSentResponsesMetadata(response, body, options = {}) { + response.codexSentBody = body; + response.codexSentOptions = options; + return response; +} diff --git a/src/models/prompter.js b/src/models/prompter.js index fc4f23629..5917eae58 100644 --- a/src/models/prompter.js +++ b/src/models/prompter.js @@ -403,7 +403,11 @@ export class Prompter { this.agent.history.traceLLMRequest('botResponder', this.chat_model, prompt, messages, null, traceMetadata); let res = await this.chat_model.sendRequest([], prompt, '***', null, { cacheScope: options.cacheScope || 'botResponder' }); this.agent.history.traceLLMResponse('botResponder', this.chat_model, res, traceMetadata); - return res.trim().toLowerCase() === 'respond'; + const decision = normalizeBotResponderDecision(res); + if (decision !== 'respond' && decision !== 'ignore') { + console.warn(`Invalid botResponder decision for ${this.agent.name}: ${decision}`); + } + return decision === 'respond'; } async promptVision(messages, imageBuffer) { @@ -534,6 +538,14 @@ function stableModelSessionIdentity(parts) { return `mindcraft-${createHash('sha256').update(text).digest('hex').slice(0, 24)}`; } +export function normalizeBotResponderDecision(response) { + if (isNativeToolResponse(response)) return 'invalid_tool_call'; + const normalized = String(response ?? '').trim().toLowerCase(); + if (normalized.startsWith('respond')) return 'respond'; + if (normalized.startsWith('ignore')) return 'ignore'; + return normalized ? 'invalid' : 'invalid_empty'; +} + function isAbortError(error) { return error?.name === 'AbortError' || String(error?.message || error || '').includes('aborted'); } diff --git a/tests/agent_native_text_policy.test.js b/tests/agent_native_text_policy.test.js index d1b898aac..afc33884f 100644 --- a/tests/agent_native_text_policy.test.js +++ b/tests/agent_native_text_policy.test.js @@ -224,6 +224,125 @@ test('new human message skips stale queued self prompt before consuming a ReAct assert.deepEqual(turns.map(turn => turn.content), ['Steve: new request', 'reply']); }); +test('queued human message stops active action before prompting even without native tool calls', async () => { + const { Agent } = await import('../src/agent/agent.js'); + const events = []; + const turns = []; + const requests = []; + const agent = Object.create(Agent.prototype); + agent.name = 'bot'; + agent.shut_up = false; + agent.last_sender = null; + agent.active_message_handlers = 1; + agent.active_native_tool_calls = new Map(); + agent.message_interrupt_epoch = 0; + agent.message_handler_queue = Promise.resolve(); + agent.human_message_queue = []; + agent.human_message_interrupt_promise = Promise.resolve(); + agent.checkTaskDone = async () => {}; + agent.self_prompter = { + shouldInterrupt: () => false, + isActive: () => false, + handleUserPromptedCmd: () => {} + }; + agent.actions = { + executing: true, + stop: async () => { + events.push('stop-action'); + agent.actions.executing = false; + } + }; + agent.bot = { modes: { flushBehaviorLog: () => '' } }; + agent.history = { + addUserContext: async content => { + events.push(`user:${content}`); + turns.push({ role: 'user', content }); + }, + add: async (name, content) => turns.push(name === agent.name ? { role: 'assistant', content } : { role: 'user', content: `${name}: ${content}` }), + save: () => {}, + getHistory: () => turns.map(turn => ({ ...turn })) + }; + agent.prompter = { + promptConvo: async messages => { + events.push('prompt'); + requests.push(messages.map(turn => ({ ...turn }))); + return ''; + } + }; + agent.routeResponse = () => {}; + + await agent.handleMessage('Steve', 'urgent correction', 1); + + assert.ok(events.indexOf('stop-action') !== -1); + assert.ok(events.indexOf('stop-action') < events.findIndex(event => event.startsWith('user:'))); + assert.ok(events.findIndex(event => event.startsWith('user:')) < events.indexOf('prompt')); + assert.deepEqual(requests[0].map(turn => turn.content), ['Steve: urgent correction']); +}); + +test('queued admin message stops action then closes active native tool before prompting', async () => { + const { Agent } = await import('../src/agent/agent.js'); + const toolCall = { id: 'call_running', type: 'function', name: 'collectBlocks', arguments: '{"type":"oak_log","num":1}' }; + const events = []; + const turns = [{ role: 'assistant', content: '', native_tool_calls: [toolCall] }]; + const requests = []; + const agent = Object.create(Agent.prototype); + agent.name = 'bot'; + agent.shut_up = false; + agent.last_sender = null; + agent.active_message_handlers = 0; + agent.active_native_tool_calls = new Map([[toolCall.id, { toolCall, completed: false }]]); + agent.message_interrupt_epoch = 0; + agent.message_handler_queue = Promise.resolve(); + agent.human_message_queue = []; + agent.human_message_interrupt_promise = Promise.resolve(); + agent.checkTaskDone = async () => {}; + agent.self_prompter = { + shouldInterrupt: () => false, + isActive: () => false, + handleUserPromptedCmd: () => {} + }; + agent.actions = { + executing: true, + stop: async () => { + events.push('stop-action'); + agent.actions.executing = false; + } + }; + agent.bot = { modes: { flushBehaviorLog: () => '' } }; + agent.history = { + addUserContext: async content => { + events.push(`user:${content}`); + turns.push({ role: 'user', content }); + }, + addNativeToolResult: async (call, result) => { + events.push(`tool-result:${result}`); + turns.push({ role: 'tool', tool_call_id: call.id, name: call.name, content: result }); + }, + add: async (name, content) => turns.push(name === agent.name ? { role: 'assistant', content } : { role: 'user', content: `${name}: ${content}` }), + save: () => events.push('save'), + getHistory: () => turns.map(turn => ({ ...turn })) + }; + agent.prompter = { + promptConvo: async messages => { + events.push('prompt'); + requests.push(messages.map(turn => ({ ...turn }))); + return ''; + } + }; + agent.routeResponse = () => {}; + + await agent.handleMessage('ADMIN', 'stop and listen', 1); + + const toolResultIndex = events.findIndex(event => event.startsWith('tool-result:')); + const userIndex = events.findIndex(event => event.startsWith('user:')); + assert.ok(events.indexOf('stop-action') < toolResultIndex); + assert.ok(toolResultIndex < userIndex); + assert.ok(userIndex < events.indexOf('prompt')); + assert.match(events[toolResultIndex], /newer user\/admin message/); + assert.deepEqual(requests[0].map(turn => turn.role), ['assistant', 'tool', 'user']); + assert.equal(requests[0][2].content, 'ADMIN: stop and listen'); +}); + test('user stop closes an executing native tool exactly once', async () => { @@ -437,6 +556,18 @@ test('newAction code generation uses an isolated tool-internal prompt', async () assert.ok(prompterSource.includes("msg.content.startsWith('Code generation task:')")); }); +test('botResponder decisions reject native tool calls instead of treating them as responses', async () => { + const { normalizeBotResponderDecision } = await import('../src/models/prompter.js'); + + assert.equal(normalizeBotResponderDecision('respond'), 'respond'); + assert.equal(normalizeBotResponderDecision('ignore because busy'), 'ignore'); + assert.equal(normalizeBotResponderDecision({ + type: 'tool_calls', + tool_calls: [{ id: 'call_1', name: 'newAction', arguments: '{}' }] + }), 'invalid_tool_call'); + assert.equal(normalizeBotResponderDecision(''), 'invalid_empty'); +}); + test('chat UI projects instruction context trace events', () => { @@ -500,14 +631,36 @@ test('chat trace projection renders ephemeral branch decisions outside the main assert.ok(projector.includes('if (event.ephemeral) return;')); assert.equal(projector.includes('if (event.ephemeral) return;\n switch'), true); assert.ok(html.includes('function renderBranchDecision')); + assert.ok(html.includes('function renderBranchRequestMeta')); + assert.ok(html.includes('function renderBranchPayloadDetails')); + assert.ok(html.includes('function renderBranchPayloadBlock')); + assert.ok(html.includes('function parseBranchQuestion')); + assert.ok(html.includes('function normalizeBranchInlineMessage')); assert.ok(html.includes('class="chat-branch-event"')); - assert.ok(html.includes('Branch decision')); - assert.ok(html.includes('renderTokenUsage(response?.token_usage)')); + assert.ok(html.includes('chat-branch-say')); + assert.ok(html.includes(' say:')); + assert.ok(html.includes('chat-branch-status')); + assert.ok(html.includes('chat-branch-bottom')); + assert.ok(html.includes('renderBranchRequestMeta(model, response?.token_usage, timestamp)')); + assert.ok(html.includes('input uncached')); + assert.ok(html.includes('input cached')); + assert.ok(html.includes('output')); + assert.ok(html.includes('chat-branch-payload-list')); assert.ok(prompterSource.includes('ephemeral: true')); assert.ok(prompterSource.includes('branch: true')); assert.ok(prompterSource.includes("cache_scope: options.cacheScope || 'botResponder'")); }); +test('chat UI labels non respond-ignore branch outputs as invalid instead of decided', () => { + const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); + + assert.ok(html.includes('function getBranchDecisionInfo(response, errors = [])')); + assert.ok(html.includes('unexpected tool call:')); + assert.ok(html.includes("return { state: 'invalid', label: 'invalid'")); + assert.equal(html.includes("default: return 'decided'"), false); + assert.equal(html.includes('formatBranchDecisionState'), false); +}); + test('ephemeral branch decisions do not update the main request delta baseline', () => { const projectorSource = readFileSync('src/mindcraft/public/chat_trace_projector.js', 'utf8'); const window = { @@ -536,6 +689,52 @@ test('ephemeral branch decisions do not update the main request delta baseline', assert.deepEqual(thread.turns[2].visibleRequestMessages, [branchQuestion]); }); +test('late user history stays at the end while an active tool turn awaits the next request', () => { + const projectorSource = readFileSync('src/mindcraft/public/chat_trace_projector.js', 'utf8'); + const window = { + selectVisibleRequestMessages: (messages, previousMessages = []) => messages.slice(previousMessages.length), + extractResponseText: response => typeof response === 'string' ? response : '', + extractResponseThinking: () => '', + extractResponseToolCalls: response => response?.tool_calls || [], + getToolCallId: call => call?.id || null, + getToolName: call => call?.name || call?.function?.name || 'tool', + isHistoryTurnIncludedInRequest: (turn, requestMessages = []) => requestMessages.some(message => message.role === turn?.role && message.content === turn?.content) + }; + vm.runInNewContext(projectorSource, { window }); + + const first = { role: 'user', content: 'Steve: mine iron' }; + const late = { role: 'user', content: 'Steve: stop and listen' }; + const toolCall = { id: 'call_collect', type: 'function', name: 'collectBlocks', arguments: '{"type":"iron_ore","num":16}' }; + const branchQuestion = { role: 'user', content: 'buddy: (FROM OTHER BOT)\nhello while busy' }; + const beforeRequest = window.buildChatThread([ + { type: 'llm_request', tag: 'conversation', messages: [first], model: { model: 'gpt-5.5' }, timestamp: 't1' }, + { type: 'llm_response', tag: 'conversation', response: { tool_calls: [toolCall] }, model: { model: 'gpt-5.5' }, timestamp: 't2' }, + { type: 'tool_call', tag: 'conversation', tool_call: toolCall, timestamp: 't3' }, + { type: 'llm_request', tag: 'botResponder', branch: true, ephemeral: true, cache_scope: 'botResponder', messages: [first, branchQuestion], model: { model: 'gpt-5.5' }, timestamp: 't4' }, + { type: 'llm_response', tag: 'botResponder', branch: true, ephemeral: true, cache_scope: 'botResponder', response: 'ignore', model: { model: 'gpt-5.5' }, timestamp: 't5' }, + { type: 'history_turn_added', turn: late, timestamp: 't6' } + ]); + + assert.equal(beforeRequest.turns.length, 3); + assert.equal(beforeRequest.turns[2].historyMessages[0].turn.content, late.content); + assert.equal(beforeRequest.turns[0].inlineHistoryMessages.length, 0); + + const afterRequest = window.buildChatThread([ + { type: 'llm_request', tag: 'conversation', messages: [first], model: { model: 'gpt-5.5' }, timestamp: 't1' }, + { type: 'llm_response', tag: 'conversation', response: { tool_calls: [toolCall] }, model: { model: 'gpt-5.5' }, timestamp: 't2' }, + { type: 'tool_call', tag: 'conversation', tool_call: toolCall, timestamp: 't3' }, + { type: 'llm_request', tag: 'botResponder', branch: true, ephemeral: true, cache_scope: 'botResponder', messages: [first, branchQuestion], model: { model: 'gpt-5.5' }, timestamp: 't4' }, + { type: 'llm_response', tag: 'botResponder', branch: true, ephemeral: true, cache_scope: 'botResponder', response: 'ignore', model: { model: 'gpt-5.5' }, timestamp: 't5' }, + { type: 'history_turn_added', turn: late, timestamp: 't6' }, + { type: 'llm_request', tag: 'conversation', messages: [first, late], model: { model: 'gpt-5.5' }, timestamp: 't7' } + ]); + + assert.equal(afterRequest.turns.length, 3); + assert.deepEqual(afterRequest.turns[2].visibleRequestMessages, [late]); + assert.equal(afterRequest.turns[2].historyMessages[0].turn.content, late.content); + assert.equal(afterRequest.turns[2].inlineHistoryMessages.length, 0); +}); + test('chat trace projection can show reasoning effort in the model label', () => { const projector = readFileSync('src/mindcraft/public/chat_trace_projector.js', 'utf8'); @@ -610,6 +809,93 @@ test('chat UI does not render redundant tool argument expanders', () => { assert.equal(html.includes('Tool arguments'), false); }); +test('chat UI renders inline failures as flat ReAct-style rows', () => { + const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); + const renderInlineError = html.slice(html.indexOf('function renderInlineError'), html.indexOf('function renderErrorCard')); + const assistantErrorRule = html.match(/\.agent-message\.assistant-message \.agent-bubble\.error \{[\s\S]*?\}/)?.[0] || ''; + const chatErrorRule = html.match(/\.chat-card\.error \{[\s\S]*?\}/)?.[0] || ''; + + assert.ok(renderInlineError.includes('agent-error-line')); + assert.ok(renderInlineError.includes('agent-error-body')); + assert.ok(renderInlineError.includes('agent-error-glyph')); + assert.ok(renderInlineError.includes('agent-error-detail')); + assert.equal(renderInlineError.includes('agent-bubble-label">Error'), false); + assert.equal(renderInlineError.includes('class="agent-text"'), false); + assert.ok(assistantErrorRule.includes('background: transparent;')); + assert.ok(assistantErrorRule.includes('border: none;')); + assert.ok(assistantErrorRule.includes('border-radius: 0;')); + assert.ok(chatErrorRule.includes('background: transparent;')); + assert.ok(chatErrorRule.includes('border-radius: 0;')); + assert.ok(chatErrorRule.includes('box-shadow: none;')); +}); + +test('chat UI keeps late user messages below active tool output while waiting for next request', () => { + const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); + const renderChatTurn = html.slice(html.indexOf('function renderChatTurn'), html.indexOf('function renderBranchDecision')); + + assert.ok(renderChatTurn.includes('const inlineHistory = renderInlineHistoryMessages(turn);')); + assert.ok(renderChatTurn.includes("const inlineHistoryBeforeAssistant = turn.toolRuns.length ? '' : inlineHistory;")); + assert.ok(renderChatTurn.includes("const inlineHistoryAfterAssistant = turn.toolRuns.length ? inlineHistory : '';")); + assert.ok(renderChatTurn.indexOf('${renderAssistantWorkMessage(turn, index)}') < renderChatTurn.indexOf('${inlineHistoryAfterAssistant}')); +}); + +test('runtime chat UI batches live trace renders and keeps a cached event-key index', () => { + const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); + const traceHandler = html.slice(html.indexOf("socket.on('agent-trace'"), html.indexOf('// Subscribe to aggregated state updates')); + const appendChatEvents = html.slice(html.indexOf('function appendChatEvents'), html.indexOf('function getChatEventKey')); + + assert.ok(html.includes('const agentChatEventKeys = {};')); + assert.ok(html.includes('const pendingChatPanelRenders = {};')); + assert.ok(html.includes('function scheduleChatPanelRender(name, options = {})')); + assert.ok(html.includes('window.requestAnimationFrame || (callback => window.setTimeout(callback, 16))')); + assert.ok(traceHandler.includes('scheduleChatPanelRender(agentName);')); + assert.equal(traceHandler.includes('renderChatPanel(agentName);'), false); + assert.ok(appendChatEvents.includes('const existingKeys = getAgentChatEventKeys(name);')); + assert.equal(appendChatEvents.includes('new Set(agentChatEvents[name].map(getChatEventKey))'), false); +}); + +test('runtime chat UI lazily hydrates JSON payload DOM only when details are open', () => { + const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); + const renderDetails = html.slice(html.indexOf('function renderDetails'), html.indexOf('function registerChatJsonPayload')); + + assert.ok(html.includes('const chatDetailPayloads = new Map();')); + assert.ok(html.includes('function enableChatLazyDetails(panel)')); + assert.ok(html.includes('function hydrateChatJsonContainers(root)')); + assert.ok(html.includes('function renderLazyChatJsonContainer(payloadId)')); + assert.ok(renderDetails.includes('registerChatJsonPayload(label, value, detailId)')); + assert.ok(renderDetails.includes('renderLazyChatJsonContainer(payloadId)')); + assert.equal(renderDetails.includes('
${escapeHTML(json)}
'), false); +}); + +test('runtime chat JSON hydration does not insert whitespace-only line boxes before payloads', () => { + const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); + const renderJson = html.slice(html.indexOf('function renderChatJsonPayloadHTML'), html.indexOf('function getChatDetailId')); + + assert.ok(renderJson.includes('return `
${escapeHTML(json)}
`;')); + assert.equal(renderJson.includes('return `\\n'), false); +}); + +test('running tool details do not preserve template whitespace as large blank rows', () => { + const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); + + assert.ok(html.includes('.chat-tool-output-detail.chat-tool-result')); + assert.ok(html.includes('white-space: normal;')); + assert.ok(html.includes('.chat-tool-output-extra {')); + assert.ok(html.includes('flex-direction: column;')); + assert.ok(html.includes('.chat-tool-output-extra > .chat-details')); +}); + +test('runtime status UI avoids rebuilding inventory and armor DOM when state is unchanged', () => { + const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); + + assert.ok(html.includes('const agentUiRenderCache = {};')); + assert.ok(html.includes('function setTextIfChanged(element, text)')); + assert.ok(html.includes('function getAgentUiRenderCache(name)')); + assert.ok(html.includes('if (renderCache.armorKey !== armorKey)')); + assert.ok(html.includes('if (renderCache.inventoryKey === inventoryKey) return;')); +}); + test('New Agent forms hide hidden settings and keep profile upload separate', () => { const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); @@ -668,7 +954,8 @@ test('chat UI projection suppresses history turns duplicated by the following re assert.ok(projector.includes('class ChatTraceProjector')); assert.ok(projector.includes('takePendingHistoryOnlyTurn()')); - assert.ok(projector.includes('isHistoryOnlyProjectionTurn(this.current)')); + assert.ok(projector.includes('const pending = this.thread.turns[this.thread.turns.length - 1];')); + assert.ok(projector.includes('isHistoryOnlyProjectionTurn(pending)')); assert.ok(projector.includes('this.thread.turns.pop()')); assert.ok(projector.includes('removeRequestIncludedHistory(turn, requestMessages)')); assert.ok(projector.includes("callHelper('isHistoryTurnIncludedInRequest', historyEvent.turn, requestMessages)")); diff --git a/tests/codex_chatgpt.test.js b/tests/codex_chatgpt.test.js index 3e9c4d25d..65e84f078 100644 --- a/tests/codex_chatgpt.test.js +++ b/tests/codex_chatgpt.test.js @@ -741,6 +741,66 @@ test('Codex adapter keeps forked WebSocket branches on the shared prompt cache k } }); +test('Codex WebSocket transport serializes concurrent requests to avoid response cross-talk', async () => { + const { keysPath, cleanup } = writeTempKeys(); + const wss = new WebSocketServer({ port: 0 }); + const payloads = []; + const handshakes = []; + wss.on('connection', (ws, request) => { + handshakes.push(request.headers); + ws.on('message', data => { + const payload = JSON.parse(data.toString('utf8')); + payloads.push(payload); + const text = payload.input?.[0]?.content?.[0]?.text || 'unknown'; + const delay = text === 'slow' ? 40 : 0; + setTimeout(() => { + ws.send(JSON.stringify({ + type: 'response.output_text.delta', + delta: `${text} ok` + })); + ws.send(JSON.stringify({ + type: 'response.completed', + response: { + id: `resp_${text}`, + usage: { + input_tokens: 10, + input_tokens_details: { cached_tokens: 0 }, + output_tokens: 2 + } + } + })); + }, delay); + }); + }); + await new Promise(resolve => wss.once('listening', resolve)); + + try { + const { port } = wss.address(); + const model = new CodexChatGPT('gpt-5.5', `http://127.0.0.1:${port}/backend-api/codex`, { + keysPath, + sessionId: 'session-test', + useResponsesWebSocket: true, + responsesWebSocketIdleTimeoutMs: 5000 + }); + + const [slow, fast] = await Promise.all([ + model.sendRequest([{ role: 'user', content: 'slow' }], 'Decide.', '***', null, { cacheScope: 'conversation' }), + model.sendRequest([{ role: 'user', content: 'fast' }], 'Decide.', '***', null, { cacheScope: 'botResponder' }) + ]); + + assert.equal(slow, 'slow ok'); + assert.equal(fast, 'fast ok'); + assert.equal(handshakes.length, 1); + assert.equal(payloads.length, 2); + assert.equal(payloads[0].input[0].content[0].text, 'slow'); + assert.equal(payloads[1].input[0].content[0].text, 'fast'); + model.closeResponsesWebSocket(); + } finally { + await new Promise(resolve => wss.close(resolve)); + cleanup(); + } +}); + test('Codex adapter does not reuse previous_response_id when non-input fields change', async () => { const { keysPath, cleanup } = writeTempKeys(); const originalFetch = globalThis.fetch; From 4ffd9f9166ae9abb0419a312c5d190c61593f2b7 Mon Sep 17 00:00:00 2001 From: NinotQuyi Date: Sun, 3 May 2026 15:40:39 +0800 Subject: [PATCH 43/56] Reduce runtime trace review noise The runtime trace UI accumulated generated demo fixtures, repeated CSS override passes, and decorative badges that made the branch diff harder to review. This removes the committed trace fixture, keeps local traces ignored, and collapses the UI transcript styling to the current intended surface while preserving the existing runtime behavior. Constraint: User-visible runtime trace layout should remain flat and without AI/U badge noise Rejected: Keep trace-demo.json as a public fixture | it contains local prompt/history data and is a high-noise one-line JSON blob Rejected: Hide avatars only with CSS | removing the DOM avoids future cascade regressions Confidence: high Scope-risk: narrow Directive: Do not commit generated runtime traces; use ignored local fixtures for UI demos Tested: npm test Tested: npx eslint src/agent/agent.js src/mindcraft/public/chat_trace_projector.js src/models/codex_chatgpt/client.js src/models/prompter.js tests/agent_native_text_policy.test.js tests/chat_history_trace.test.js tests/codex_chatgpt.test.js Tested: git diff --check Not-tested: Manual browser screenshot after final badge removal Co-authored-by: OmX --- .dockerignore | 1 + .gitignore | 2 + src/agent/agent.js | 8 +- src/mindcraft/public/index.html | 606 ++++--------------------- src/mindcraft/public/trace-demo.json | 1 - tests/agent_native_text_policy.test.js | 16 + 6 files changed, 119 insertions(+), 515 deletions(-) delete mode 100644 src/mindcraft/public/trace-demo.json diff --git a/.dockerignore b/.dockerignore index 66e756220..1e7c48b39 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,7 @@ node_modules bots/*/ !bots/* +keys.json settings_llm_providers.json results tests/results diff --git a/.gitignore b/.gitignore index 7760d046e..d14204456 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ docs/ scratch.js bots/**/action-code/** bots/**/ +keys.json settings_llm_providers.json services/viaproxy/jars/** services/viaproxy/logs/** @@ -22,6 +23,7 @@ jill_*.json src/models/logs/* results/ tests/results/ +src/mindcraft/public/trace-demo.json server_data/* tasks/construction_tasks/test_multiagent_construction_tasks.json tasks/construction_tasks/train_multiagent_construction_tasks.json diff --git a/src/agent/agent.js b/src/agent/agent.js index 79822a309..6aa09dd05 100644 --- a/src/agent/agent.js +++ b/src/agent/agent.js @@ -304,10 +304,6 @@ export class Agent { return !self_prompt && !convoManager.isOtherAgent(source); } - _hasQueuedPriorityHumanMessage() { - return (this.human_message_queue || []).some(item => this._isPriorityHumanSource(item.source)); - } - _enqueueHumanMessage(source, message, max_responses=null, options={}) { let resolveQueued; let rejectQueued; @@ -317,9 +313,7 @@ export class Agent { }); this.message_interrupt_epoch = (this.message_interrupt_epoch || 0) + 1; this.human_message_queue.push({ source, message, max_responses, options, resolveQueued, rejectQueued }); - if (this._hasQueuedPriorityHumanMessage()) { - this._schedulePriorityHumanMessageInterrupt(); - } + this._schedulePriorityHumanMessageInterrupt(); if (!this.human_message_flush_timer) { this.human_message_flush_timer = setTimeout(() => { this.human_message_flush_timer = null; diff --git a/src/mindcraft/public/index.html b/src/mindcraft/public/index.html index 4eb8e64a2..b1ebb89c7 100644 --- a/src/mindcraft/public/index.html +++ b/src/mindcraft/public/index.html @@ -419,44 +419,6 @@ align-items: center; min-height: 20px; } - .chat-card.request .chat-card-title span:first-child::before, - .chat-card.turn-card .chat-card-title span:first-child::before, - .chat-card.response .chat-card-title span:first-child::before, - .chat-card.tool-status .chat-card-title span:first-child::before, - .chat-system-card .chat-card-title span:first-child::before, - .chat-tools-card .chat-card-title span:first-child::before { - display: inline-flex; - align-items: center; - justify-content: center; - height: 18px; - padding: 0 6px; - margin-right: 8px; - border-radius: 5px; - border: 1px solid #3a3a3a; - color: #bdbdbd; - background: #202020; - font-size: 10px; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - font-weight: 600; - letter-spacing: 0.02em; - text-transform: uppercase; - } - .chat-card.request .chat-card-title span:first-child::before { content: 'REQ'; } - .chat-card.turn-card .chat-card-title span:first-child::before, - .chat-card.response .chat-card-title span:first-child::before { content: 'AI'; } - .chat-card.tool-status .chat-card-title span:first-child::before { content: 'TOOL'; } - .chat-system-card .chat-card-title span:first-child::before { - content: 'SYS'; - background: #2a2113; - border-color: #5a421f; - color: #f0c978; - } - .chat-tools-card .chat-card-title span:first-child::before { - content: 'TOOLS'; - background: #132435; - border-color: #2b587c; - color: #9fd0f4; - } .chat-system-card .chat-card-title { color: #f5dfb2; } .chat-tools-card .chat-card-title { color: #c8e4f7; } .chat-tools-card .chat-tool-def { @@ -842,24 +804,6 @@ .agent-message.user-message { grid-template-columns: minmax(0, 1fr) 28px; } - .agent-avatar { - width: 28px; - height: 28px; - border-radius: 50%; - display: inline-flex; - align-items: center; - justify-content: center; - background: #262626; - border: 1px solid #3a3a3a; - color: #cfcfcf; - font: 700 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - } - .agent-message.user-message .agent-avatar { - grid-column: 2; - background: #1d2a20; - border-color: #33563b; - color: #bfe6c6; - } .agent-message.user-message .agent-bubble { grid-column: 1; grid-row: 1; @@ -980,10 +924,10 @@ border-color: transparent; } - /* Chat polish: keep the agent trace useful without looking like a generated demo UI. */ + /* Chat transcript layout: full-width agent work, right-aligned user turns, inline tool activity. */ .chat-thread.agent-workspace { - background: #0f0f0f; - gap: 12px; + background: #0e0e0e; + gap: 10px; } .agent-context-card { box-shadow: none; @@ -1002,239 +946,18 @@ font-weight: 560; } .agent-run-flat { - gap: 8px; - padding: 2px 0 14px; - } - .agent-run-flat + .agent-run-flat { - border-top: 1px solid rgba(255,255,255,0.045); - padding-top: 14px; - } - .agent-message, - .agent-message.user-message { - display: block; - } - .agent-avatar { - display: none; - } - .agent-message.user-message .agent-bubble, - .agent-bubble { - max-width: none; - width: auto; - justify-self: auto; - grid-column: auto; - grid-row: auto; - border-radius: 9px; - padding: 11px 12px; - box-shadow: none; - } - .agent-message.user-message .agent-bubble { - margin-left: 8%; - background: #151914; - border: 1px solid #293127; - } - .agent-message.assistant-message .agent-bubble { - margin-right: 8%; - background: #141414; - border: 1px solid #292929; - } - .agent-bubble-meta { - color: #858585; - font: 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - margin-bottom: 7px; - } - .agent-bubble-label, - .tool-run-label { - color: #a5a5a5; - font: 12px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - text-transform: none; - letter-spacing: 0; - margin: 0 0 7px; - } - .chat-message-row .chat-muted:first-child { - background: transparent; - border-radius: 0; - padding: 0; - margin-bottom: 4px; - color: #858585; - font-size: 11px; - text-transform: none; - } - .agent-steps { - gap: 6px; - margin-top: 8px; - } - .agent-steps .agent-step, - .agent-step { - background: #151515; - border: 1px solid #2a2a2a; - border-radius: 8px; - } - .agent-steps .agent-step-header, - .agent-step-header { - background: transparent; - padding: 8px 10px; - border-bottom: 1px solid rgba(255,255,255,0.045); - } - .agent-step-name { - color: #d8d8d8; - font: 500 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - } - .agent-step-status { - color: #868686; - font-size: 11px; - } - .agent-step-status.running { - color: #b9a86a; - } - .agent-step-status.done { - color: #8fb893; - } - .agent-step-body { - padding: 8px 10px; - gap: 7px; - } - .chat-tool-args, - .chat-tool-result, - .chat-tool-params { - background: #101010; - border-color: #272727; - border-radius: 6px; - color: #cdcdcd; - } - .chat-tools-card .chat-tool-def { - background: rgba(255,255,255,0.025); - border-color: rgba(255,255,255,0.08); - } - - /* Chat de-template pass: flatter, quieter, closer to practical agent logs. */ - .chat-thread.agent-workspace { - background: #0e0e0e; - gap: 10px; - } - .agent-run-flat { - padding: 10px 0 12px; + gap: 12px; + padding: 12px 0 16px; } .agent-run-flat + .agent-run-flat { border-top: 1px solid rgba(255,255,255,0.055); } - .agent-message, - .agent-message.user-message { + .agent-message { display: block; } .agent-message + .agent-message { margin-top: 8px; } - .agent-message.user-message .agent-bubble, - .agent-message.assistant-message .agent-bubble, - .agent-bubble { - margin: 0; - max-width: none; - width: auto; - background: #131313; - border: 1px solid #252525; - border-radius: 8px; - padding: 10px 12px; - box-shadow: none; - } - .agent-message.user-message .agent-bubble { - background: #121512; - border-color: #252d25; - } - .agent-message.assistant-message .agent-bubble { - background: #121212; - border-color: #272727; - } - .agent-bubble-meta { - color: #767676; - font: 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - margin-bottom: 8px; - } - .agent-bubble-label, - .tool-run-label { - color: #888; - font: 12px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - text-transform: none; - letter-spacing: 0; - margin-bottom: 6px; - } - .chat-message-row { - display: block; - border-top: 1px solid rgba(255,255,255,0.055); - padding-top: 8px; - margin-top: 8px; - } - .chat-message-row:first-child { - border-top: none; - padding-top: 0; - margin-top: 0; - } - .chat-message-text { - white-space: pre-wrap; - overflow-wrap: anywhere; - line-height: 1.45; - } - .agent-steps { - margin-top: 8px; - gap: 0; - border: 1px solid #272727; - border-radius: 8px; - overflow: hidden; - background: #111; - } - .agent-steps .agent-step, - .agent-step { - border: none; - border-radius: 0; - background: transparent; - } - .agent-step + .agent-step { - border-top: 1px solid rgba(255,255,255,0.06); - } - .agent-steps .agent-step-header, - .agent-step-header { - background: transparent; - border-bottom: none; - padding: 8px 10px; - } - .agent-step-name { - color: #d6d6d6; - font: 500 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - } - .agent-step-status { - color: #777; - font-size: 11px; - } - .agent-step-status.running { color: #b5a76d; } - .agent-step-status.done { color: #8aaa8d; } - .agent-step-body { - padding: 0 10px 9px; - } - .chat-tool-args, - .chat-tool-result, - .chat-tool-params { - background: #0d0d0d; - border-color: #232323; - color: #c9c9c9; - border-radius: 6px; - font-size: 0.84em; - } - .agent-message.user-message .agent-request-detail { - border-top: 1px solid rgba(255,255,255,0.055); - margin-top: 8px; - padding-top: 7px; - } - .agent-request-detail .chat-details summary, - .chat-details summary { - color: #777; - font-size: 12px; - } - - - /* Chat transcript direction: right user bubbles, full-width agent work, inline tool activity. */ - .agent-run-flat { - gap: 12px; - padding: 12px 0 16px; - } .agent-message.user-message { display: flex; justify-content: flex-end; @@ -1247,6 +970,7 @@ border: 1px solid #294229; border-radius: 16px 16px 5px 16px; padding: 11px 13px; + box-shadow: none; } .agent-message.assistant-message { display: block; @@ -1259,6 +983,7 @@ border: none; border-radius: 0; padding: 2px 0 0; + box-shadow: none; } .agent-message.assistant-message .agent-bubble.error { background: transparent; @@ -1266,23 +991,17 @@ border-radius: 0; padding: 0; } - .agent-message.assistant-message .agent-bubble-label { - color: #b8b8b8; - font-size: 12px; - margin-bottom: 6px; + .agent-message.assistant-message .agent-bubble-label, + .agent-message.user-message .agent-bubble-label, + .tool-run-label { + color: #8f8f8f; + font: 12px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + text-transform: none; + letter-spacing: 0; + margin: 0 0 6px; } .agent-message.user-message .agent-bubble-label { color: #b9d3ba; - font-size: 12px; - margin-bottom: 6px; - } - .agent-bubble-meta { - color: #7b7b7b; - font-size: 11px; - margin-bottom: 6px; - } - .agent-message.assistant-message .agent-bubble-meta { - margin-bottom: 4px; } .agent-bubble-meta { color: #626262; @@ -1290,6 +1009,10 @@ font-size: 10px; font-style: italic; font-weight: 300; + margin-bottom: 6px; + } + .agent-message.assistant-message .agent-bubble-meta { + margin-bottom: 4px; } .chat-token-usage { color: #626262; @@ -1297,6 +1020,35 @@ font-style: inherit; font-weight: inherit; } + .chat-message-row { + display: block; + border-top: 1px solid rgba(255,255,255,0.055); + padding-top: 8px; + margin-top: 8px; + } + .chat-message-row:first-child { + border-top: none; + padding-top: 0; + margin-top: 0; + } + .chat-message-row .chat-muted:first-child { + background: transparent; + border-radius: 0; + padding: 0; + margin-bottom: 4px; + color: #858585; + font-size: 11px; + text-transform: none; + } + .chat-message-text { + white-space: pre-wrap; + overflow-wrap: anywhere; + line-height: 1.45; + } + .agent-empty { + color: #8f8f8f; + font-style: italic; + } .agent-message.assistant-message .agent-text { background: transparent; border: none; @@ -1307,38 +1059,18 @@ } .tool-run-label { margin: 12px 0 6px; - color: #8f8f8f; - } - .agent-steps { - margin: 6px 0 10px; - border: 1px solid #252525; - border-radius: 10px; - background: #101010; - overflow: hidden; - } - .agent-step-header { - padding: 9px 11px 5px; - } - .agent-step-name::before { - content: '↳'; - color: #777; - margin-right: 8px; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } - .agent-step-name { - color: #dfdfdf; - font: 500 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - } - .agent-step-status.requested, - .agent-step-status.running { color: #a69763; } - .agent-step-status.done { color: #7f9c82; } - .agent-step-body { - padding: 0 11px 10px 31px; - gap: 8px; + .chat-tool-args, + .chat-tool-params { + background: #0d0d0d; + border: 1px solid #232323; + color: #c9c9c9; + border-radius: 6px; + font-size: 0.84em; } .chat-tool-args { background: #0c0c0c; - border: 1px solid #252525; + border-color: #252525; color: #d8d8d8; border-radius: 7px; } @@ -1370,8 +1102,14 @@ } .agent-message.user-message .agent-request-detail { border-top: 1px solid rgba(185, 211, 186, 0.14); + margin-top: 8px; + padding-top: 7px; + } + .agent-request-detail .chat-details summary, + .chat-details summary { + color: #777; + font-size: 12px; } - /* Claude Code-style tool transcript: one line for the call, indented result/progress below. */ @@ -1430,9 +1168,13 @@ color: #a2a2a2; font: 13px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + max-width: 100%; + overflow: visible; + overflow-wrap: anywhere; + text-overflow: clip; + white-space: normal; + word-break: break-word; + line-height: 1.35; } .agent-step-status { margin-left: auto; @@ -1523,11 +1265,14 @@ } .agent-step-params { grid-column: 3; - display: inline-block; + display: block; + align-self: start; } .agent-step-status { grid-column: 4; margin-left: 0; + align-self: start; + justify-self: end; } @@ -1774,7 +1519,7 @@ .footer-left { color: #cccccc; font-style: italic; } - /* Final neutral chat color override. */ + /* Neutral compact context/tools panel. */ .agent-context-card.chat-system-card, .agent-context-card.chat-tools-card, .chat-system-card, @@ -1793,172 +1538,56 @@ .agent-message.user-message .agent-bubble-label { color: #d0d0d0 !important; } - - - - /* Tools context is one card; individual tools are plain rows, not nested cards. */ - .chat-tools-card .chat-tools-list { - display: block; - } - .chat-tools-card .chat-tool-def { - background: transparent !important; - border: none !important; - border-radius: 0 !important; - padding: 9px 0 !important; - margin: 0 !important; - border-top: 1px solid rgba(255,255,255,0.075) !important; - } - .chat-tools-card .chat-tool-def:first-child { - border-top: none !important; - padding-top: 0 !important; - } - .chat-tools-card .chat-tool-name { - color: #e1e1e1; - font: 650 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - } - .chat-tools-card .chat-tool-description { - color: #a5a5a5; - margin-top: 3px; - line-height: 1.4; - } - .chat-tools-card .chat-tool-params { - display: block; - background: transparent !important; - border: none !important; - border-radius: 0 !important; - padding: 0 !important; - margin-top: 4px; - color: #8f8f8f; - font: 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - white-space: normal; - } - - - - /* No internal divider lines inside the tools context card. */ - .chat-tools-card .chat-tool-def, - .chat-tools-card .chat-tool-def:first-child { - border-top: none !important; - } - - - - /* Compact tools context list. */ .chat-tools-card .chat-tools-list { display: grid; - grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); - column-gap: 18px; - row-gap: 4px; + grid-template-columns: 1fr !important; + row-gap: 7px !important; align-items: start; } .chat-tools-card .chat-tool-def, .chat-tools-card .chat-tool-def:first-child { + background: transparent !important; + border: none !important; + border-radius: 0 !important; + margin: 0 !important; padding: 3px 0 !important; line-height: 1.25; } .chat-tools-card .chat-tool-name { - display: inline; + display: block !important; color: #dedede; font: 600 12px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } - .chat-tools-card .chat-tool-description { - display: inline; - color: #969696; - margin: 0 0 0 6px; - font-size: 12px; - line-height: 1.25; - } - .chat-tools-card .chat-tool-description::before { - content: '— '; - color: #666; - } - .chat-tools-card .chat-tool-params { - margin-top: 1px; - color: #777; - font-size: 11px; - line-height: 1.2; - } - - - - /* Tools list: no three-column layout and no disclosure triangle. */ - .chat-tools-card .chat-tools-list { - grid-template-columns: repeat(2, minmax(0, 1fr)); - column-gap: 22px; - } - @media (max-width: 1100px) { - .chat-tools-card .chat-tools-list { - grid-template-columns: 1fr; - } - } - .chat-tools-card > summary.chat-card-title::after { + .chat-tools-card .chat-tool-name::before { content: none !important; } - .chat-tools-card > summary.chat-card-title, - .chat-tools-card > summary.chat-card-title::marker, - .chat-tools-card > summary.chat-card-title::-webkit-details-marker { - list-style: none; - } - - - - /* Use a single compact tools column to keep spacing consistent. */ - .chat-tools-card .chat-tools-list { - grid-template-columns: 1fr !important; - row-gap: 2px; - } - .chat-tools-card .chat-tool-def, - .chat-tools-card .chat-tool-def:first-child { - padding: 2px 0 !important; - } - - - - /* Add consistent breathing room between tools without dividers. */ - .chat-tools-card .chat-tools-list { - row-gap: 7px !important; - } - .chat-tools-card .chat-tool-def, - .chat-tools-card .chat-tool-def:first-child { - padding: 3px 0 !important; - } - - - - /* Stack tool name, description, and parameters for clearer compact reading. */ - .chat-tools-card .chat-tool-name { - display: block !important; - } .chat-tools-card .chat-tool-description { display: block !important; + color: #969696; margin: 2px 0 0 0 !important; + font-size: 12px; line-height: 1.3; } - .chat-tools-card .chat-tool-description::before { - content: none !important; - } - .chat-tools-card .chat-tool-params { - margin-top: 2px !important; - } - - - - /* Add a subtle prefix before each tool description. */ .chat-tools-card .chat-tool-description::before { content: 'desc: ' !important; color: #666; font-size: 11px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } - - - - /* Prefix tool parameters and suppress any disclosure marker in the tools context. */ + .chat-tools-card .chat-tool-params { + display: block; + background: transparent !important; + border: none !important; + border-radius: 0 !important; + padding: 0 !important; + margin-top: 2px !important; + color: #777; + font: 11px/1.2 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + white-space: normal; + } .chat-tools-card .chat-tool-params::before { content: 'params · ' !important; color: #666; - font-size: 11px; - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } .chat-tools-card summary, .chat-tools-card summary::marker, @@ -1966,20 +1595,10 @@ list-style: none !important; display: block; } - .chat-tools-card summary::after { - content: none !important; - } - - - - /* No marker before tool names in the tools context list. */ - .chat-tools-card .chat-tool-name::before { + .chat-tools-card summary::after, + .chat-tools-card > summary.chat-card-title::after { content: none !important; } - - - - /* Separate the Tools title from the count. */ .chat-tools-card > summary.chat-card-title { display: flex !important; justify-content: flex-start !important; @@ -1992,7 +1611,6 @@ } - /* Unified context panel: system prompt and tools share one status surface. */ .agent-context-row { display: block !important; @@ -2152,22 +1770,6 @@ color: #6a6a6a; font-weight: 300; } - .chat-branch-top { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: 12px; - flex-wrap: wrap; - } - .chat-branch-top::before { - content: '↳'; - color: #6a6a6a; - font-weight: 300; - } - .chat-branch-kind { - color: #8f8f8f; - font-weight: 300; - } .chat-branch-route { color: #777; font-weight: 300; @@ -2187,11 +1789,6 @@ font-size: 11px; font-weight: 300; } - .chat-branch-meta { - color: #737373; - font-weight: 300; - margin-left: auto; - } .chat-branch-request-meta { display: flex; align-items: baseline; @@ -3419,7 +3016,6 @@

Inventory

if (isTool) { return `
-
T
${escapeHTML(label)} · tool result
${renderToolOutput(turn.content, event)} @@ -3429,7 +3025,6 @@

Inventory

} return `
-
${isUser ? 'U' : 'AI'}
${escapeHTML(label)} · ${escapeHTML(role)}${event.restored_from_memory ? ' · memory' : ''}
${renderThinking(turn.thinking)} @@ -3444,7 +3039,6 @@

Inventory

if (!request || !turn.visibleRequestMessages?.length) return ''; return `
-
U
Run ${index + 1} · ${escapeHTML(summarizeRequest(request, turn.modelLabel))} · ${escapeHTML(formatChatTime(request.timestamp))}
User
@@ -3459,7 +3053,6 @@

Inventory

const hasAssistantDisplay = Boolean(turn.assistantText || turn.assistantThinking || turn.assistantToolCalls.length); return `
-
AI
Run ${index + 1} · ${escapeHTML(turn.modelLabel || 'model')} · ${escapeHTML(formatChatTime(response?.timestamp))}${renderTokenUsage(response?.token_usage)}
${hasAssistantDisplay ? '
Assistant
' : ''} @@ -3716,7 +3309,6 @@

Inventory

const message = limitText(event.error?.message || stringifyChatText(event.error), 1200); return `
-
!
diff --git a/src/mindcraft/public/trace-demo.json b/src/mindcraft/public/trace-demo.json deleted file mode 100644 index 4c6f86a02..000000000 --- a/src/mindcraft/public/trace-demo.json +++ /dev/null @@ -1 +0,0 @@ -[{"timestamp": "2026-05-02T02:48:29.169Z", "agent": "codex", "type": "llm_request", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "system_prompt": "You are an AI Minecraft bot named codex that can converse with players, see, move, mine, build, and interact with the world by using native tools/function calls.\nYOUR CURRENT ASSIGNED GOAL: \"通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。\"\n 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; when you need to act, call the appropriate native tool/function instead of writing command text. Do NOT write legacy text commands, function-looking text, or fake tool-use log text in your message; call the native tool and optionally respond briefly after the tool result. Respond only as codex, 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:'中文交流;MC bot codex。行动用native工具,勿聊天输出命令;人类!可。重启/被要求先native查状态简短确认。newAction勿带```js。速通:木石→食物床火把→铁装盾桶→岩浆门→下界棒+珍珠/交易→眼找要塞→打龙,自保优先。地标:营地床(-197,67,-19);煤洞(-101,58,15);矿区(-198~-207,50~59,-1~-19);深矿炉(-192,35,-25)。深矿已清空间,工作台可放会回收;近(-178,32,-26)有矿道迹象(栅栏/蛛网)与铁/煤,铁装进度到胸裤鞋+盾;下一步做头盔桶、熔铁、继续采铁煤。'\n\nSTATS\n- Position: x: -179.59, y: 31.00, z: -28.43\n- Gamemode: survival\n- Health: 20 / 20\n- Hunger: 20 / 20\n- Biome: plains\n- Weather: Clear\n- Time: Night- Current Action: Idle\n- Nearby Human Players: None.\n- Nearby Bot Players: None.\nAgent Modes:\n- self_preservation(ON)\n- unstuck(ON)\n- cowardice(OFF)\n- self_defense(ON)\n- hunting(OFF)\n- item_collecting(ON)\n- torch_placing(ON)\n- elbow_room(ON)\n- idle_staring(ON)\n- cheat(OFF)\n\n\n\nNEARBY_ENTITIES: none\n\n\nNEARBY_BLOCKS\n- cave_air\n- stone\n- cobblestone\n- cobweb\n- oak_fence\n- oak_planks\n- furnace\n- copper_ore\n- wall_torch\n- iron_ore\n- diorite\n- dirt\n- andesite\n- torch\n- granite\n- glow_lichen\n- rail\n- Block Below: stone\n- Block at Legs: cave_air\n- Block at Head: cave_air\n- First Solid Block Above Head: stone (1 blocks up)\n\n\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n\n\n*NATIVE TOOL DOCS\nYou can use native function/tool calls to act in the Minecraft world and query state. Human users may type !commands, but AI responses must not output !command text. Use the provided tools instead.\nstats: Get your bot's location, health, hunger, and time of day.\ninventory: Get your bot's inventory.\nnearbyBlocks: Get the blocks near the bot.\ncraftable: Get the craftable items with the bot's inventory.\nentities: Get the nearby players and entities.\nmodes: Get all available modes and their docs and see which are on/off.\nsavedPlaces: List all saved locations.\ngetCraftingPlan: Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.\nParams:\ntargetItem: (string) The item that we are trying to craft\nquantity: (integer) The quantity of the item that we are trying to craft\nsearchWiki: Search the Minecraft Wiki for the given query.\nParams:\nquery: (string) The query to search for.\nhelp: Lists all available commands and their descriptions.\nnewAction: Perform new and unknown custom behaviors that are not available as a command.\nParams:\nprompt: (string) A natural language prompt to guide code generation. Make a detailed step-by-step plan.\nstop: Force stop all actions and commands that are currently executing.\nstfu: Stop all chatting and self prompting, but continue current action.\nrestart: Restart the agent process.\nclearChat: Clear the chat history.\ngoToPlayer: Go to the given player.\nParams:\nplayer_name: (string) The name of the player to go to.\ncloseness: (number) How close to get to the player.\nfollowPlayer: Endlessly follow the given player.\nParams:\nplayer_name: (string) name of the player to follow.\nfollow_dist: (number) The distance to follow from.\ngoToCoordinates: Go to the given x, y, z location.\nParams:\nx: (number) The x coordinate.\ny: (number) The y coordinate.\nz: (number) The z coordinate.\ncloseness: (number) How close to get to the location.\nsearchForBlock: Find and go to the nearest block of a given type in a given range.\nParams:\ntype: (string) The block type to go to.\nsearch_range: (number) The range to search for the block. Minimum 32.\nsearchForEntity: Find and go to the nearest entity of a given type in a given range.\nParams:\ntype: (string) The type of entity to go to.\nsearch_range: (number) The range to search for the entity.\nmoveAway: Move away from the current location in any direction by a given distance.\nParams:\ndistance: (number) The distance to move away.\nrememberHere: Save the current location with a given name.\nParams:\nname: (string) The name to remember the location as.\ngoToRememberedPlace: Go to a saved location.\nParams:\nname: (string) The name of the location to go to.\ngivePlayer: Give the specified item to the given player.\nParams:\nplayer_name: (string) The name of the player to give the item to.\nitem_name: (string) The name of the item to give.\nnum: (integer) The number of items to give.\nconsume: Eat/drink the given item.\nParams:\nitem_name: (string) The name of the item to consume.\nequip: Equip the given item.\nParams:\nitem_name: (string) The name of the item to equip.\nputInChest: Put the given item in the nearest chest.\nParams:\nitem_name: (string) The name of the item to put in the chest.\nnum: (integer) The number of items to put in the chest.\ntakeFromChest: Take the given items from the nearest chest.\nParams:\nitem_name: (string) The name of the item to take.\nnum: (integer) The number of items to take.\nviewChest: View the items/counts of the nearest chest.\nParams:\ndiscard: Discard the given item from the inventory.\nParams:\nitem_name: (string) The name of the item to discard.\nnum: (integer) The number of items to discard.\ncollectBlocks: Collect the nearest blocks of a given type.\nParams:\ntype: (string) The block type to collect.\nnum: (integer) The number of blocks to collect.\ncraftRecipe: Craft the given recipe a given number of times.\nParams:\nrecipe_name: (string) The name of the output item to craft.\nnum: (integer) The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.\nsmeltItem: Smelt the given item the given number of times.\nParams:\nitem_name: (string) The name of the input item to smelt.\nnum: (integer) The number of times to smelt the item.\nclearFurnace: Take all items out of the nearest furnace.\nParams:\nplaceHere: Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.\nParams:\ntype: (string) The block type to place.\nattack: Attack and kill the nearest entity of a given type.\nParams:\ntype: (string) The type of entity to attack.\nattackPlayer: Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.\nParams:\nplayer_name: (string) The name of the player to attack.\ngoToBed: Go to the nearest bed and sleep.\nstay: Stay in the current location no matter what. Pauses all modes.\nParams:\ntype: (integer) The number of seconds to stay. -1 for forever.\nsetMode: Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.\nParams:\nmode_name: (string) The name of the mode to enable.\non: (boolean) Whether to enable or disable the mode.\ngoal: Set a goal prompt to endlessly work towards with continuous self-prompting.\nParams:\nselfPrompt: (string) The goal prompt.\nendGoal: Call when you have accomplished your goal. It will stop self-prompting and the current action. \nshowVillagerTrades: Show trades of a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\ntradeWithVillager: Trade with a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\nindex: (integer) The index of the trade you want executed (1-indexed).\ncount: (integer) How many times that trade should be executed.\nstartConversation: Start a conversation with a bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to send the message to.\nmessage: (string) The message to send.\nendConversation: End the conversation with the given bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to end the conversation with.\nlookAtPlayer: Look at a player or look in the same direction as the player.\nParams:\nplayer_name: (string) Name of the target player\ndirection: (string) How to look (\"at\": look at the player, \"with\": look in the same direction as the player)\nlookAtPosition: Look at specified coordinates.\nParams:\nx: (integer) x coordinate\ny: (integer) y coordinate\nz: (integer) z coordinate\ndigDown: Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.\nParams:\ndistance: (integer) Distance to dig down\ngoToSurface: Moves the bot to the highest block above it (usually the surface).\nParams:\nuseOn: Use (right click) the given tool on the nearest target of the given type.\nParams:\ntool_name: (string) Name of the tool to use, or \"hand\" for no tool.\ntarget: (string) The target as an entity type, block type, or \"nothing\" for no target.\n*\n\nExamples of how to respond:\nExample 1:\nUser input: miner_32: Hey! What are you up to?\nYour output:\nNothing much miner_32, what do you need?\n\nExample 2:\nUser input: rupert: Let us work together on a small house.\nYour output:\nSure, I can coordinate with you and handle my part.\nUser input: rupert: I will build the walls if you prepare materials.\nYour output:\nSounds good. I will prepare materials first.\n\n\nConversation Begin:\n", "messages": [{"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}], "tools": [{"type": "function", "function": {"name": "stats", "description": "Get your bot's location, health, hunger, and time of day.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "inventory", "description": "Get your bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "nearbyBlocks", "description": "Get the blocks near the bot.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftable", "description": "Get the craftable items with the bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "entities", "description": "Get the nearby players and entities.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "modes", "description": "Get all available modes and their docs and see which are on/off.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "savedPlaces", "description": "List all saved locations.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "getCraftingPlan", "description": "Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.", "parameters": {"type": "object", "properties": {"targetItem": {"type": "string", "description": "The item that we are trying to craft"}, "quantity": {"type": "integer", "description": "The quantity of the item that we are trying to craft", "minimum": 1}}, "required": ["targetItem"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchWiki", "description": "Search the Minecraft Wiki for the given query.", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "The query to search for."}}, "required": ["query"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "help", "description": "Lists all available commands and their descriptions.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "newAction", "description": "Perform new and unknown custom behaviors that are not available as a command.", "parameters": {"type": "object", "properties": {"prompt": {"type": "string", "description": "A natural language prompt to guide code generation. Make a detailed step-by-step plan."}}, "required": ["prompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stop", "description": "Force stop all actions and commands that are currently executing.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stfu", "description": "Stop all chatting and self prompting, but continue current action.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "restart", "description": "Restart the agent process.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearChat", "description": "Clear the chat history.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToPlayer", "description": "Go to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to go to."}, "closeness": {"type": "number", "description": "How close to get to the player.", "minimum": 0}}, "required": ["player_name", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "followPlayer", "description": "Endlessly follow the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "name of the player to follow."}, "follow_dist": {"type": "number", "description": "The distance to follow from.", "minimum": 0}}, "required": ["player_name", "follow_dist"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToCoordinates", "description": "Go to the given x, y, z location.", "parameters": {"type": "object", "properties": {"x": {"type": "number", "description": "The x coordinate."}, "y": {"type": "number", "description": "The y coordinate.", "minimum": -64, "maximum": 320}, "z": {"type": "number", "description": "The z coordinate."}, "closeness": {"type": "number", "description": "How close to get to the location.", "minimum": 0}}, "required": ["x", "y", "z", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForBlock", "description": "Find and go to the nearest block of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to go to."}, "search_range": {"type": "number", "description": "The range to search for the block. Minimum 32.", "minimum": 10, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForEntity", "description": "Find and go to the nearest entity of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to go to."}, "search_range": {"type": "number", "description": "The range to search for the entity.", "minimum": 32, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "moveAway", "description": "Move away from the current location in any direction by a given distance.", "parameters": {"type": "object", "properties": {"distance": {"type": "number", "description": "The distance to move away.", "minimum": 0}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "rememberHere", "description": "Save the current location with a given name.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name to remember the location as."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToRememberedPlace", "description": "Go to a saved location.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name of the location to go to."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "givePlayer", "description": "Give the specified item to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to give the item to."}, "item_name": {"type": "string", "description": "The name of the item to give."}, "num": {"type": "integer", "description": "The number of items to give.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["player_name", "item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "consume", "description": "Eat/drink the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to consume."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "equip", "description": "Equip the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to equip."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "putInChest", "description": "Put the given item in the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to put in the chest."}, "num": {"type": "integer", "description": "The number of items to put in the chest.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "takeFromChest", "description": "Take the given items from the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to take."}, "num": {"type": "integer", "description": "The number of items to take.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "viewChest", "description": "View the items/counts of the nearest chest.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "discard", "description": "Discard the given item from the inventory.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to discard."}, "num": {"type": "integer", "description": "The number of items to discard.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "collectBlocks", "description": "Collect the nearest blocks of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to collect."}, "num": {"type": "integer", "description": "The number of blocks to collect.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["type", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftRecipe", "description": "Craft the given recipe a given number of times.", "parameters": {"type": "object", "properties": {"recipe_name": {"type": "string", "description": "The name of the output item to craft."}, "num": {"type": "integer", "description": "The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["recipe_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "smeltItem", "description": "Smelt the given item the given number of times.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the input item to smelt."}, "num": {"type": "integer", "description": "The number of times to smelt the item.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearFurnace", "description": "Take all items out of the nearest furnace.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "placeHere", "description": "Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to place."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attack", "description": "Attack and kill the nearest entity of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to attack."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attackPlayer", "description": "Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to attack."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToBed", "description": "Go to the nearest bed and sleep.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stay", "description": "Stay in the current location no matter what. Pauses all modes.", "parameters": {"type": "object", "properties": {"type": {"type": "integer", "description": "The number of seconds to stay. -1 for forever.", "minimum": -1, "maximum": 9007199254740991}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "setMode", "description": "Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.", "parameters": {"type": "object", "properties": {"mode_name": {"type": "string", "description": "The name of the mode to enable."}, "on": {"type": "boolean", "description": "Whether to enable or disable the mode."}}, "required": ["mode_name", "on"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goal", "description": "Set a goal prompt to endlessly work towards with continuous self-prompting.", "parameters": {"type": "object", "properties": {"selfPrompt": {"type": "string", "description": "The goal prompt."}}, "required": ["selfPrompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endGoal", "description": "Call when you have accomplished your goal. It will stop self-prompting and the current action. ", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "showVillagerTrades", "description": "Show trades of a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}}, "required": ["id"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "tradeWithVillager", "description": "Trade with a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}, "index": {"type": "integer", "description": "The index of the trade you want executed (1-indexed).", "minimum": 1, "maximum": 9007199254740991}, "count": {"type": "integer", "description": "How many times that trade should be executed.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["id", "index", "count"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "startConversation", "description": "Start a conversation with a bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to send the message to."}, "message": {"type": "string", "description": "The message to send."}}, "required": ["player_name", "message"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endConversation", "description": "End the conversation with the given bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to end the conversation with."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPlayer", "description": "Look at a player or look in the same direction as the player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "Name of the target player"}, "direction": {"type": "string", "description": "How to look (\"at\": look at the player, \"with\": look in the same direction as the player)"}}, "required": ["player_name", "direction"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPosition", "description": "Look at specified coordinates.", "parameters": {"type": "object", "properties": {"x": {"type": "integer", "description": "x coordinate"}, "y": {"type": "integer", "description": "y coordinate"}, "z": {"type": "integer", "description": "z coordinate"}}, "required": ["x", "y", "z"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "digDown", "description": "Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.", "parameters": {"type": "object", "properties": {"distance": {"type": "integer", "description": "Distance to dig down", "minimum": 1, "maximum": 9007199254740991}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToSurface", "description": "Moves the bot to the highest block above it (usually the surface).", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "useOn", "description": "Use (right click) the given tool on the nearest target of the given type.", "parameters": {"type": "object", "properties": {"tool_name": {"type": "string", "description": "Name of the tool to use, or \"hand\" for no tool."}, "target": {"type": "string", "description": "The target as an entity type, block type, or \"nothing\" for no target."}}, "required": ["tool_name", "target"], "additionalProperties": false}}}], "tool_count": 51}, {"timestamp": "2026-05-02T02:48:32.164Z", "agent": "codex", "type": "llm_request", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "system_prompt": "You are an AI Minecraft bot named codex that can converse with players, see, move, mine, build, and interact with the world by using native tools/function calls.\nYOUR CURRENT ASSIGNED GOAL: \"通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。\"\n 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; when you need to act, call the appropriate native tool/function instead of writing command text. Do NOT write legacy text commands, function-looking text, or fake tool-use log text in your message; call the native tool and optionally respond briefly after the tool result. Respond only as codex, 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:'中文交流;MC bot codex。行动用native工具,勿聊天输出命令;人类!可。重启/被要求先native查状态简短确认。newAction勿带```js。速通:木石→食物床火把→铁装盾桶→岩浆门→下界棒+珍珠/交易→眼找要塞→打龙,自保优先。地标:营地床(-197,67,-19);煤洞(-101,58,15);矿区(-198~-207,50~59,-1~-19);深矿炉(-192,35,-25)。深矿已清空间,工作台可放会回收;近(-178,32,-26)有矿道迹象(栅栏/蛛网)与铁/煤,铁装进度到胸裤鞋+盾;下一步做头盔桶、熔铁、继续采铁煤。'\n\nSTATS\n- Position: x: -179.59, y: 31.00, z: -28.43\n- Gamemode: survival\n- Health: 20 / 20\n- Hunger: 20 / 20\n- Biome: plains\n- Weather: Clear\n- Time: Night- Current Action: Idle\n- Nearby Human Players: None.\n- Nearby Bot Players: None.\nAgent Modes:\n- self_preservation(ON)\n- unstuck(ON)\n- cowardice(OFF)\n- self_defense(ON)\n- hunting(OFF)\n- item_collecting(ON)\n- torch_placing(ON)\n- elbow_room(ON)\n- idle_staring(ON)\n- cheat(OFF)\n\n\n\nNEARBY_ENTITIES\n- entities: 2 bat(s)\n\n\nNEARBY_BLOCKS\n- cave_air\n- stone\n- cobblestone\n- cobweb\n- oak_fence\n- oak_planks\n- furnace\n- copper_ore\n- wall_torch\n- iron_ore\n- diorite\n- dirt\n- andesite\n- torch\n- granite\n- glow_lichen\n- rail\n- Block Below: stone\n- Block at Legs: cave_air\n- Block at Head: cave_air\n- First Solid Block Above Head: stone (1 blocks up)\n\n\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n\n\n*NATIVE TOOL DOCS\nYou can use native function/tool calls to act in the Minecraft world and query state. Human users may type !commands, but AI responses must not output !command text. Use the provided tools instead.\nstats: Get your bot's location, health, hunger, and time of day.\ninventory: Get your bot's inventory.\nnearbyBlocks: Get the blocks near the bot.\ncraftable: Get the craftable items with the bot's inventory.\nentities: Get the nearby players and entities.\nmodes: Get all available modes and their docs and see which are on/off.\nsavedPlaces: List all saved locations.\ngetCraftingPlan: Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.\nParams:\ntargetItem: (string) The item that we are trying to craft\nquantity: (integer) The quantity of the item that we are trying to craft\nsearchWiki: Search the Minecraft Wiki for the given query.\nParams:\nquery: (string) The query to search for.\nhelp: Lists all available commands and their descriptions.\nnewAction: Perform new and unknown custom behaviors that are not available as a command.\nParams:\nprompt: (string) A natural language prompt to guide code generation. Make a detailed step-by-step plan.\nstop: Force stop all actions and commands that are currently executing.\nstfu: Stop all chatting and self prompting, but continue current action.\nrestart: Restart the agent process.\nclearChat: Clear the chat history.\ngoToPlayer: Go to the given player.\nParams:\nplayer_name: (string) The name of the player to go to.\ncloseness: (number) How close to get to the player.\nfollowPlayer: Endlessly follow the given player.\nParams:\nplayer_name: (string) name of the player to follow.\nfollow_dist: (number) The distance to follow from.\ngoToCoordinates: Go to the given x, y, z location.\nParams:\nx: (number) The x coordinate.\ny: (number) The y coordinate.\nz: (number) The z coordinate.\ncloseness: (number) How close to get to the location.\nsearchForBlock: Find and go to the nearest block of a given type in a given range.\nParams:\ntype: (string) The block type to go to.\nsearch_range: (number) The range to search for the block. Minimum 32.\nsearchForEntity: Find and go to the nearest entity of a given type in a given range.\nParams:\ntype: (string) The type of entity to go to.\nsearch_range: (number) The range to search for the entity.\nmoveAway: Move away from the current location in any direction by a given distance.\nParams:\ndistance: (number) The distance to move away.\nrememberHere: Save the current location with a given name.\nParams:\nname: (string) The name to remember the location as.\ngoToRememberedPlace: Go to a saved location.\nParams:\nname: (string) The name of the location to go to.\ngivePlayer: Give the specified item to the given player.\nParams:\nplayer_name: (string) The name of the player to give the item to.\nitem_name: (string) The name of the item to give.\nnum: (integer) The number of items to give.\nconsume: Eat/drink the given item.\nParams:\nitem_name: (string) The name of the item to consume.\nequip: Equip the given item.\nParams:\nitem_name: (string) The name of the item to equip.\nputInChest: Put the given item in the nearest chest.\nParams:\nitem_name: (string) The name of the item to put in the chest.\nnum: (integer) The number of items to put in the chest.\ntakeFromChest: Take the given items from the nearest chest.\nParams:\nitem_name: (string) The name of the item to take.\nnum: (integer) The number of items to take.\nviewChest: View the items/counts of the nearest chest.\nParams:\ndiscard: Discard the given item from the inventory.\nParams:\nitem_name: (string) The name of the item to discard.\nnum: (integer) The number of items to discard.\ncollectBlocks: Collect the nearest blocks of a given type.\nParams:\ntype: (string) The block type to collect.\nnum: (integer) The number of blocks to collect.\ncraftRecipe: Craft the given recipe a given number of times.\nParams:\nrecipe_name: (string) The name of the output item to craft.\nnum: (integer) The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.\nsmeltItem: Smelt the given item the given number of times.\nParams:\nitem_name: (string) The name of the input item to smelt.\nnum: (integer) The number of times to smelt the item.\nclearFurnace: Take all items out of the nearest furnace.\nParams:\nplaceHere: Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.\nParams:\ntype: (string) The block type to place.\nattack: Attack and kill the nearest entity of a given type.\nParams:\ntype: (string) The type of entity to attack.\nattackPlayer: Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.\nParams:\nplayer_name: (string) The name of the player to attack.\ngoToBed: Go to the nearest bed and sleep.\nstay: Stay in the current location no matter what. Pauses all modes.\nParams:\ntype: (integer) The number of seconds to stay. -1 for forever.\nsetMode: Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.\nParams:\nmode_name: (string) The name of the mode to enable.\non: (boolean) Whether to enable or disable the mode.\ngoal: Set a goal prompt to endlessly work towards with continuous self-prompting.\nParams:\nselfPrompt: (string) The goal prompt.\nendGoal: Call when you have accomplished your goal. It will stop self-prompting and the current action. \nshowVillagerTrades: Show trades of a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\ntradeWithVillager: Trade with a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\nindex: (integer) The index of the trade you want executed (1-indexed).\ncount: (integer) How many times that trade should be executed.\nstartConversation: Start a conversation with a bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to send the message to.\nmessage: (string) The message to send.\nendConversation: End the conversation with the given bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to end the conversation with.\nlookAtPlayer: Look at a player or look in the same direction as the player.\nParams:\nplayer_name: (string) Name of the target player\ndirection: (string) How to look (\"at\": look at the player, \"with\": look in the same direction as the player)\nlookAtPosition: Look at specified coordinates.\nParams:\nx: (integer) x coordinate\ny: (integer) y coordinate\nz: (integer) z coordinate\ndigDown: Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.\nParams:\ndistance: (integer) Distance to dig down\ngoToSurface: Moves the bot to the highest block above it (usually the surface).\nParams:\nuseOn: Use (right click) the given tool on the nearest target of the given type.\nParams:\ntool_name: (string) Name of the tool to use, or \"hand\" for no tool.\ntarget: (string) The target as an entity type, block type, or \"nothing\" for no target.\n*\n\nExamples of how to respond:\nExample 1:\nUser input: miner_32: Hey! What are you up to?\nYour output:\nNothing much miner_32, what do you need?\n\nExample 2:\nUser input: rupert: Let us work together on a small house.\nYour output:\nSure, I can coordinate with you and handle my part.\nUser input: rupert: I will build the walls if you prepare materials.\nYour output:\nSounds good. I will prepare materials first.\n\n\nConversation Begin:\n", "messages": [{"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}, {"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}], "tools": [{"type": "function", "function": {"name": "stats", "description": "Get your bot's location, health, hunger, and time of day.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "inventory", "description": "Get your bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "nearbyBlocks", "description": "Get the blocks near the bot.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftable", "description": "Get the craftable items with the bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "entities", "description": "Get the nearby players and entities.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "modes", "description": "Get all available modes and their docs and see which are on/off.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "savedPlaces", "description": "List all saved locations.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "getCraftingPlan", "description": "Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.", "parameters": {"type": "object", "properties": {"targetItem": {"type": "string", "description": "The item that we are trying to craft"}, "quantity": {"type": "integer", "description": "The quantity of the item that we are trying to craft", "minimum": 1}}, "required": ["targetItem"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchWiki", "description": "Search the Minecraft Wiki for the given query.", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "The query to search for."}}, "required": ["query"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "help", "description": "Lists all available commands and their descriptions.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "newAction", "description": "Perform new and unknown custom behaviors that are not available as a command.", "parameters": {"type": "object", "properties": {"prompt": {"type": "string", "description": "A natural language prompt to guide code generation. Make a detailed step-by-step plan."}}, "required": ["prompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stop", "description": "Force stop all actions and commands that are currently executing.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stfu", "description": "Stop all chatting and self prompting, but continue current action.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "restart", "description": "Restart the agent process.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearChat", "description": "Clear the chat history.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToPlayer", "description": "Go to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to go to."}, "closeness": {"type": "number", "description": "How close to get to the player.", "minimum": 0}}, "required": ["player_name", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "followPlayer", "description": "Endlessly follow the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "name of the player to follow."}, "follow_dist": {"type": "number", "description": "The distance to follow from.", "minimum": 0}}, "required": ["player_name", "follow_dist"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToCoordinates", "description": "Go to the given x, y, z location.", "parameters": {"type": "object", "properties": {"x": {"type": "number", "description": "The x coordinate."}, "y": {"type": "number", "description": "The y coordinate.", "minimum": -64, "maximum": 320}, "z": {"type": "number", "description": "The z coordinate."}, "closeness": {"type": "number", "description": "How close to get to the location.", "minimum": 0}}, "required": ["x", "y", "z", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForBlock", "description": "Find and go to the nearest block of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to go to."}, "search_range": {"type": "number", "description": "The range to search for the block. Minimum 32.", "minimum": 10, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForEntity", "description": "Find and go to the nearest entity of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to go to."}, "search_range": {"type": "number", "description": "The range to search for the entity.", "minimum": 32, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "moveAway", "description": "Move away from the current location in any direction by a given distance.", "parameters": {"type": "object", "properties": {"distance": {"type": "number", "description": "The distance to move away.", "minimum": 0}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "rememberHere", "description": "Save the current location with a given name.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name to remember the location as."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToRememberedPlace", "description": "Go to a saved location.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name of the location to go to."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "givePlayer", "description": "Give the specified item to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to give the item to."}, "item_name": {"type": "string", "description": "The name of the item to give."}, "num": {"type": "integer", "description": "The number of items to give.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["player_name", "item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "consume", "description": "Eat/drink the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to consume."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "equip", "description": "Equip the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to equip."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "putInChest", "description": "Put the given item in the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to put in the chest."}, "num": {"type": "integer", "description": "The number of items to put in the chest.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "takeFromChest", "description": "Take the given items from the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to take."}, "num": {"type": "integer", "description": "The number of items to take.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "viewChest", "description": "View the items/counts of the nearest chest.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "discard", "description": "Discard the given item from the inventory.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to discard."}, "num": {"type": "integer", "description": "The number of items to discard.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "collectBlocks", "description": "Collect the nearest blocks of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to collect."}, "num": {"type": "integer", "description": "The number of blocks to collect.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["type", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftRecipe", "description": "Craft the given recipe a given number of times.", "parameters": {"type": "object", "properties": {"recipe_name": {"type": "string", "description": "The name of the output item to craft."}, "num": {"type": "integer", "description": "The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["recipe_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "smeltItem", "description": "Smelt the given item the given number of times.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the input item to smelt."}, "num": {"type": "integer", "description": "The number of times to smelt the item.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearFurnace", "description": "Take all items out of the nearest furnace.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "placeHere", "description": "Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to place."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attack", "description": "Attack and kill the nearest entity of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to attack."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attackPlayer", "description": "Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to attack."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToBed", "description": "Go to the nearest bed and sleep.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stay", "description": "Stay in the current location no matter what. Pauses all modes.", "parameters": {"type": "object", "properties": {"type": {"type": "integer", "description": "The number of seconds to stay. -1 for forever.", "minimum": -1, "maximum": 9007199254740991}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "setMode", "description": "Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.", "parameters": {"type": "object", "properties": {"mode_name": {"type": "string", "description": "The name of the mode to enable."}, "on": {"type": "boolean", "description": "Whether to enable or disable the mode."}}, "required": ["mode_name", "on"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goal", "description": "Set a goal prompt to endlessly work towards with continuous self-prompting.", "parameters": {"type": "object", "properties": {"selfPrompt": {"type": "string", "description": "The goal prompt."}}, "required": ["selfPrompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endGoal", "description": "Call when you have accomplished your goal. It will stop self-prompting and the current action. ", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "showVillagerTrades", "description": "Show trades of a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}}, "required": ["id"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "tradeWithVillager", "description": "Trade with a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}, "index": {"type": "integer", "description": "The index of the trade you want executed (1-indexed).", "minimum": 1, "maximum": 9007199254740991}, "count": {"type": "integer", "description": "How many times that trade should be executed.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["id", "index", "count"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "startConversation", "description": "Start a conversation with a bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to send the message to."}, "message": {"type": "string", "description": "The message to send."}}, "required": ["player_name", "message"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endConversation", "description": "End the conversation with the given bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to end the conversation with."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPlayer", "description": "Look at a player or look in the same direction as the player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "Name of the target player"}, "direction": {"type": "string", "description": "How to look (\"at\": look at the player, \"with\": look in the same direction as the player)"}}, "required": ["player_name", "direction"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPosition", "description": "Look at specified coordinates.", "parameters": {"type": "object", "properties": {"x": {"type": "integer", "description": "x coordinate"}, "y": {"type": "integer", "description": "y coordinate"}, "z": {"type": "integer", "description": "z coordinate"}}, "required": ["x", "y", "z"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "digDown", "description": "Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.", "parameters": {"type": "object", "properties": {"distance": {"type": "integer", "description": "Distance to dig down", "minimum": 1, "maximum": 9007199254740991}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToSurface", "description": "Moves the bot to the highest block above it (usually the surface).", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "useOn", "description": "Use (right click) the given tool on the nearest target of the given type.", "parameters": {"type": "object", "properties": {"tool_name": {"type": "string", "description": "Name of the tool to use, or \"hand\" for no tool."}, "target": {"type": "string", "description": "The target as an entity type, block type, or \"nothing\" for no target."}}, "required": ["tool_name", "target"], "additionalProperties": false}}}], "tool_count": 51}, {"timestamp": "2026-05-02T02:48:34.484Z", "agent": "codex", "type": "llm_response", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "response": {"type": "tool_calls", "provider": "codex-chatgpt", "tool_calls": [{"id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "type": "function", "name": "inventory", "arguments": "{}"}]}}, {"timestamp": "2026-05-02T02:48:34.490Z", "agent": "codex", "type": "tool_call", "tool_call": {"id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "type": "function", "name": "inventory", "arguments": "{}"}, "content": ""}, {"timestamp": "2026-05-02T02:48:34.498Z", "agent": "codex", "type": "tool_result", "tool_call": {"id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "type": "function", "name": "inventory", "arguments": "{}"}, "result": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n"}, {"timestamp": "2026-05-02T02:48:35.194Z", "agent": "codex", "type": "llm_request", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "system_prompt": "You are an AI Minecraft bot named codex that can converse with players, see, move, mine, build, and interact with the world by using native tools/function calls.\nYOUR CURRENT ASSIGNED GOAL: \"通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。\"\n 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; when you need to act, call the appropriate native tool/function instead of writing command text. Do NOT write legacy text commands, function-looking text, or fake tool-use log text in your message; call the native tool and optionally respond briefly after the tool result. Respond only as codex, 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:'中文交流;MC bot codex。行动用native工具,勿聊天输出命令;人类!可。重启/被要求先native查状态简短确认。newAction勿带```js。速通:木石→食物床火把→铁装盾桶→岩浆门→下界棒+珍珠/交易→眼找要塞→打龙,自保优先。地标:营地床(-197,67,-19);煤洞(-101,58,15);矿区(-198~-207,50~59,-1~-19);深矿炉(-192,35,-25)。深矿已清空间,工作台可放会回收;近(-178,32,-26)有矿道迹象(栅栏/蛛网)与铁/煤,铁装进度到胸裤鞋+盾;下一步做头盔桶、熔铁、继续采铁煤。'\n\nSTATS\n- Position: x: -179.59, y: 31.00, z: -28.43\n- Gamemode: survival\n- Health: 20 / 20\n- Hunger: 20 / 20\n- Biome: plains\n- Weather: Clear\n- Time: Night- Current Action: Idle\n- Nearby Human Players: None.\n- Nearby Bot Players: None.\nAgent Modes:\n- self_preservation(ON)\n- unstuck(ON)\n- cowardice(OFF)\n- self_defense(ON)\n- hunting(OFF)\n- item_collecting(ON)\n- torch_placing(ON)\n- elbow_room(ON)\n- idle_staring(ON)\n- cheat(OFF)\n\n\n\nNEARBY_ENTITIES\n- entities: 2 bat(s)\n\n\nNEARBY_BLOCKS\n- cave_air\n- stone\n- cobblestone\n- cobweb\n- oak_fence\n- oak_planks\n- furnace\n- copper_ore\n- wall_torch\n- iron_ore\n- diorite\n- dirt\n- andesite\n- torch\n- granite\n- glow_lichen\n- rail\n- Block Below: stone\n- Block at Legs: cave_air\n- Block at Head: cave_air\n- First Solid Block Above Head: stone (1 blocks up)\n\n\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n\n\n*NATIVE TOOL DOCS\nYou can use native function/tool calls to act in the Minecraft world and query state. Human users may type !commands, but AI responses must not output !command text. Use the provided tools instead.\nstats: Get your bot's location, health, hunger, and time of day.\ninventory: Get your bot's inventory.\nnearbyBlocks: Get the blocks near the bot.\ncraftable: Get the craftable items with the bot's inventory.\nentities: Get the nearby players and entities.\nmodes: Get all available modes and their docs and see which are on/off.\nsavedPlaces: List all saved locations.\ngetCraftingPlan: Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.\nParams:\ntargetItem: (string) The item that we are trying to craft\nquantity: (integer) The quantity of the item that we are trying to craft\nsearchWiki: Search the Minecraft Wiki for the given query.\nParams:\nquery: (string) The query to search for.\nhelp: Lists all available commands and their descriptions.\nnewAction: Perform new and unknown custom behaviors that are not available as a command.\nParams:\nprompt: (string) A natural language prompt to guide code generation. Make a detailed step-by-step plan.\nstop: Force stop all actions and commands that are currently executing.\nstfu: Stop all chatting and self prompting, but continue current action.\nrestart: Restart the agent process.\nclearChat: Clear the chat history.\ngoToPlayer: Go to the given player.\nParams:\nplayer_name: (string) The name of the player to go to.\ncloseness: (number) How close to get to the player.\nfollowPlayer: Endlessly follow the given player.\nParams:\nplayer_name: (string) name of the player to follow.\nfollow_dist: (number) The distance to follow from.\ngoToCoordinates: Go to the given x, y, z location.\nParams:\nx: (number) The x coordinate.\ny: (number) The y coordinate.\nz: (number) The z coordinate.\ncloseness: (number) How close to get to the location.\nsearchForBlock: Find and go to the nearest block of a given type in a given range.\nParams:\ntype: (string) The block type to go to.\nsearch_range: (number) The range to search for the block. Minimum 32.\nsearchForEntity: Find and go to the nearest entity of a given type in a given range.\nParams:\ntype: (string) The type of entity to go to.\nsearch_range: (number) The range to search for the entity.\nmoveAway: Move away from the current location in any direction by a given distance.\nParams:\ndistance: (number) The distance to move away.\nrememberHere: Save the current location with a given name.\nParams:\nname: (string) The name to remember the location as.\ngoToRememberedPlace: Go to a saved location.\nParams:\nname: (string) The name of the location to go to.\ngivePlayer: Give the specified item to the given player.\nParams:\nplayer_name: (string) The name of the player to give the item to.\nitem_name: (string) The name of the item to give.\nnum: (integer) The number of items to give.\nconsume: Eat/drink the given item.\nParams:\nitem_name: (string) The name of the item to consume.\nequip: Equip the given item.\nParams:\nitem_name: (string) The name of the item to equip.\nputInChest: Put the given item in the nearest chest.\nParams:\nitem_name: (string) The name of the item to put in the chest.\nnum: (integer) The number of items to put in the chest.\ntakeFromChest: Take the given items from the nearest chest.\nParams:\nitem_name: (string) The name of the item to take.\nnum: (integer) The number of items to take.\nviewChest: View the items/counts of the nearest chest.\nParams:\ndiscard: Discard the given item from the inventory.\nParams:\nitem_name: (string) The name of the item to discard.\nnum: (integer) The number of items to discard.\ncollectBlocks: Collect the nearest blocks of a given type.\nParams:\ntype: (string) The block type to collect.\nnum: (integer) The number of blocks to collect.\ncraftRecipe: Craft the given recipe a given number of times.\nParams:\nrecipe_name: (string) The name of the output item to craft.\nnum: (integer) The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.\nsmeltItem: Smelt the given item the given number of times.\nParams:\nitem_name: (string) The name of the input item to smelt.\nnum: (integer) The number of times to smelt the item.\nclearFurnace: Take all items out of the nearest furnace.\nParams:\nplaceHere: Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.\nParams:\ntype: (string) The block type to place.\nattack: Attack and kill the nearest entity of a given type.\nParams:\ntype: (string) The type of entity to attack.\nattackPlayer: Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.\nParams:\nplayer_name: (string) The name of the player to attack.\ngoToBed: Go to the nearest bed and sleep.\nstay: Stay in the current location no matter what. Pauses all modes.\nParams:\ntype: (integer) The number of seconds to stay. -1 for forever.\nsetMode: Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.\nParams:\nmode_name: (string) The name of the mode to enable.\non: (boolean) Whether to enable or disable the mode.\ngoal: Set a goal prompt to endlessly work towards with continuous self-prompting.\nParams:\nselfPrompt: (string) The goal prompt.\nendGoal: Call when you have accomplished your goal. It will stop self-prompting and the current action. \nshowVillagerTrades: Show trades of a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\ntradeWithVillager: Trade with a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\nindex: (integer) The index of the trade you want executed (1-indexed).\ncount: (integer) How many times that trade should be executed.\nstartConversation: Start a conversation with a bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to send the message to.\nmessage: (string) The message to send.\nendConversation: End the conversation with the given bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to end the conversation with.\nlookAtPlayer: Look at a player or look in the same direction as the player.\nParams:\nplayer_name: (string) Name of the target player\ndirection: (string) How to look (\"at\": look at the player, \"with\": look in the same direction as the player)\nlookAtPosition: Look at specified coordinates.\nParams:\nx: (integer) x coordinate\ny: (integer) y coordinate\nz: (integer) z coordinate\ndigDown: Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.\nParams:\ndistance: (integer) Distance to dig down\ngoToSurface: Moves the bot to the highest block above it (usually the surface).\nParams:\nuseOn: Use (right click) the given tool on the nearest target of the given type.\nParams:\ntool_name: (string) Name of the tool to use, or \"hand\" for no tool.\ntarget: (string) The target as an entity type, block type, or \"nothing\" for no target.\n*\n\nExamples of how to respond:\nExample 1:\nUser input: miner_32: Hey! What are you up to?\nYour output:\nNothing much miner_32, what do you need?\n\nExample 2:\nUser input: rupert: Let us work together on a small house.\nYour output:\nSure, I can coordinate with you and handle my part.\nUser input: rupert: I will build the walls if you prepare materials.\nYour output:\nSounds good. I will prepare materials first.\n\n\nConversation Begin:\n", "messages": [{"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}, {"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "type": "function", "name": "inventory", "arguments": "{}"}]}, {"role": "tool", "content": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n", "tool_call_id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "name": "inventory"}], "tools": [{"type": "function", "function": {"name": "stats", "description": "Get your bot's location, health, hunger, and time of day.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "inventory", "description": "Get your bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "nearbyBlocks", "description": "Get the blocks near the bot.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftable", "description": "Get the craftable items with the bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "entities", "description": "Get the nearby players and entities.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "modes", "description": "Get all available modes and their docs and see which are on/off.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "savedPlaces", "description": "List all saved locations.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "getCraftingPlan", "description": "Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.", "parameters": {"type": "object", "properties": {"targetItem": {"type": "string", "description": "The item that we are trying to craft"}, "quantity": {"type": "integer", "description": "The quantity of the item that we are trying to craft", "minimum": 1}}, "required": ["targetItem"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchWiki", "description": "Search the Minecraft Wiki for the given query.", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "The query to search for."}}, "required": ["query"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "help", "description": "Lists all available commands and their descriptions.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "newAction", "description": "Perform new and unknown custom behaviors that are not available as a command.", "parameters": {"type": "object", "properties": {"prompt": {"type": "string", "description": "A natural language prompt to guide code generation. Make a detailed step-by-step plan."}}, "required": ["prompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stop", "description": "Force stop all actions and commands that are currently executing.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stfu", "description": "Stop all chatting and self prompting, but continue current action.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "restart", "description": "Restart the agent process.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearChat", "description": "Clear the chat history.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToPlayer", "description": "Go to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to go to."}, "closeness": {"type": "number", "description": "How close to get to the player.", "minimum": 0}}, "required": ["player_name", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "followPlayer", "description": "Endlessly follow the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "name of the player to follow."}, "follow_dist": {"type": "number", "description": "The distance to follow from.", "minimum": 0}}, "required": ["player_name", "follow_dist"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToCoordinates", "description": "Go to the given x, y, z location.", "parameters": {"type": "object", "properties": {"x": {"type": "number", "description": "The x coordinate."}, "y": {"type": "number", "description": "The y coordinate.", "minimum": -64, "maximum": 320}, "z": {"type": "number", "description": "The z coordinate."}, "closeness": {"type": "number", "description": "How close to get to the location.", "minimum": 0}}, "required": ["x", "y", "z", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForBlock", "description": "Find and go to the nearest block of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to go to."}, "search_range": {"type": "number", "description": "The range to search for the block. Minimum 32.", "minimum": 10, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForEntity", "description": "Find and go to the nearest entity of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to go to."}, "search_range": {"type": "number", "description": "The range to search for the entity.", "minimum": 32, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "moveAway", "description": "Move away from the current location in any direction by a given distance.", "parameters": {"type": "object", "properties": {"distance": {"type": "number", "description": "The distance to move away.", "minimum": 0}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "rememberHere", "description": "Save the current location with a given name.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name to remember the location as."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToRememberedPlace", "description": "Go to a saved location.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name of the location to go to."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "givePlayer", "description": "Give the specified item to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to give the item to."}, "item_name": {"type": "string", "description": "The name of the item to give."}, "num": {"type": "integer", "description": "The number of items to give.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["player_name", "item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "consume", "description": "Eat/drink the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to consume."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "equip", "description": "Equip the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to equip."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "putInChest", "description": "Put the given item in the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to put in the chest."}, "num": {"type": "integer", "description": "The number of items to put in the chest.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "takeFromChest", "description": "Take the given items from the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to take."}, "num": {"type": "integer", "description": "The number of items to take.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "viewChest", "description": "View the items/counts of the nearest chest.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "discard", "description": "Discard the given item from the inventory.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to discard."}, "num": {"type": "integer", "description": "The number of items to discard.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "collectBlocks", "description": "Collect the nearest blocks of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to collect."}, "num": {"type": "integer", "description": "The number of blocks to collect.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["type", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftRecipe", "description": "Craft the given recipe a given number of times.", "parameters": {"type": "object", "properties": {"recipe_name": {"type": "string", "description": "The name of the output item to craft."}, "num": {"type": "integer", "description": "The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["recipe_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "smeltItem", "description": "Smelt the given item the given number of times.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the input item to smelt."}, "num": {"type": "integer", "description": "The number of times to smelt the item.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearFurnace", "description": "Take all items out of the nearest furnace.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "placeHere", "description": "Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to place."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attack", "description": "Attack and kill the nearest entity of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to attack."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attackPlayer", "description": "Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to attack."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToBed", "description": "Go to the nearest bed and sleep.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stay", "description": "Stay in the current location no matter what. Pauses all modes.", "parameters": {"type": "object", "properties": {"type": {"type": "integer", "description": "The number of seconds to stay. -1 for forever.", "minimum": -1, "maximum": 9007199254740991}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "setMode", "description": "Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.", "parameters": {"type": "object", "properties": {"mode_name": {"type": "string", "description": "The name of the mode to enable."}, "on": {"type": "boolean", "description": "Whether to enable or disable the mode."}}, "required": ["mode_name", "on"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goal", "description": "Set a goal prompt to endlessly work towards with continuous self-prompting.", "parameters": {"type": "object", "properties": {"selfPrompt": {"type": "string", "description": "The goal prompt."}}, "required": ["selfPrompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endGoal", "description": "Call when you have accomplished your goal. It will stop self-prompting and the current action. ", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "showVillagerTrades", "description": "Show trades of a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}}, "required": ["id"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "tradeWithVillager", "description": "Trade with a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}, "index": {"type": "integer", "description": "The index of the trade you want executed (1-indexed).", "minimum": 1, "maximum": 9007199254740991}, "count": {"type": "integer", "description": "How many times that trade should be executed.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["id", "index", "count"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "startConversation", "description": "Start a conversation with a bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to send the message to."}, "message": {"type": "string", "description": "The message to send."}}, "required": ["player_name", "message"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endConversation", "description": "End the conversation with the given bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to end the conversation with."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPlayer", "description": "Look at a player or look in the same direction as the player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "Name of the target player"}, "direction": {"type": "string", "description": "How to look (\"at\": look at the player, \"with\": look in the same direction as the player)"}}, "required": ["player_name", "direction"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPosition", "description": "Look at specified coordinates.", "parameters": {"type": "object", "properties": {"x": {"type": "integer", "description": "x coordinate"}, "y": {"type": "integer", "description": "y coordinate"}, "z": {"type": "integer", "description": "z coordinate"}}, "required": ["x", "y", "z"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "digDown", "description": "Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.", "parameters": {"type": "object", "properties": {"distance": {"type": "integer", "description": "Distance to dig down", "minimum": 1, "maximum": 9007199254740991}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToSurface", "description": "Moves the bot to the highest block above it (usually the surface).", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "useOn", "description": "Use (right click) the given tool on the nearest target of the given type.", "parameters": {"type": "object", "properties": {"tool_name": {"type": "string", "description": "Name of the tool to use, or \"hand\" for no tool."}, "target": {"type": "string", "description": "The target as an entity type, block type, or \"nothing\" for no target."}}, "required": ["tool_name", "target"], "additionalProperties": false}}}], "tool_count": 51}, {"timestamp": "2026-05-02T02:48:36.324Z", "agent": "codex", "type": "llm_response", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "response": {"type": "tool_calls", "provider": "codex-chatgpt", "tool_calls": [{"id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "type": "function", "name": "inventory", "arguments": "{}"}]}}, {"timestamp": "2026-05-02T02:48:36.327Z", "agent": "codex", "type": "tool_call", "tool_call": {"id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "type": "function", "name": "inventory", "arguments": "{}"}, "content": ""}, {"timestamp": "2026-05-02T02:48:36.334Z", "agent": "codex", "type": "tool_result", "tool_call": {"id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "type": "function", "name": "inventory", "arguments": "{}"}, "result": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n"}, {"timestamp": "2026-05-02T02:48:38.171Z", "agent": "codex", "type": "llm_request", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "system_prompt": "You are an AI Minecraft bot named codex that can converse with players, see, move, mine, build, and interact with the world by using native tools/function calls.\nYOUR CURRENT ASSIGNED GOAL: \"通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。\"\n 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; when you need to act, call the appropriate native tool/function instead of writing command text. Do NOT write legacy text commands, function-looking text, or fake tool-use log text in your message; call the native tool and optionally respond briefly after the tool result. Respond only as codex, 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:'中文交流;MC bot codex。行动用native工具,勿聊天输出命令;人类!可。重启/被要求先native查状态简短确认。newAction勿带```js。速通:木石→食物床火把→铁装盾桶→岩浆门→下界棒+珍珠/交易→眼找要塞→打龙,自保优先。地标:营地床(-197,67,-19);煤洞(-101,58,15);矿区(-198~-207,50~59,-1~-19);深矿炉(-192,35,-25)。深矿已清空间,工作台可放会回收;近(-178,32,-26)有矿道迹象(栅栏/蛛网)与铁/煤,铁装进度到胸裤鞋+盾;下一步做头盔桶、熔铁、继续采铁煤。'\n\nSTATS\n- Position: x: -179.59, y: 31.00, z: -28.43\n- Gamemode: survival\n- Health: 20 / 20\n- Hunger: 20 / 20\n- Biome: plains\n- Weather: Clear\n- Time: Night- Current Action: Idle\n- Nearby Human Players: None.\n- Nearby Bot Players: None.\nAgent Modes:\n- self_preservation(ON)\n- unstuck(ON)\n- cowardice(OFF)\n- self_defense(ON)\n- hunting(OFF)\n- item_collecting(ON)\n- torch_placing(ON)\n- elbow_room(ON)\n- idle_staring(ON)\n- cheat(OFF)\n\n\n\nNEARBY_ENTITIES\n- entities: 2 bat(s)\n\n\nNEARBY_BLOCKS\n- cave_air\n- stone\n- cobblestone\n- cobweb\n- oak_fence\n- oak_planks\n- furnace\n- copper_ore\n- wall_torch\n- iron_ore\n- diorite\n- dirt\n- andesite\n- torch\n- granite\n- glow_lichen\n- rail\n- Block Below: stone\n- Block at Legs: cave_air\n- Block at Head: cave_air\n- First Solid Block Above Head: stone (1 blocks up)\n\n\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n\n\n*NATIVE TOOL DOCS\nYou can use native function/tool calls to act in the Minecraft world and query state. Human users may type !commands, but AI responses must not output !command text. Use the provided tools instead.\nstats: Get your bot's location, health, hunger, and time of day.\ninventory: Get your bot's inventory.\nnearbyBlocks: Get the blocks near the bot.\ncraftable: Get the craftable items with the bot's inventory.\nentities: Get the nearby players and entities.\nmodes: Get all available modes and their docs and see which are on/off.\nsavedPlaces: List all saved locations.\ngetCraftingPlan: Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.\nParams:\ntargetItem: (string) The item that we are trying to craft\nquantity: (integer) The quantity of the item that we are trying to craft\nsearchWiki: Search the Minecraft Wiki for the given query.\nParams:\nquery: (string) The query to search for.\nhelp: Lists all available commands and their descriptions.\nnewAction: Perform new and unknown custom behaviors that are not available as a command.\nParams:\nprompt: (string) A natural language prompt to guide code generation. Make a detailed step-by-step plan.\nstop: Force stop all actions and commands that are currently executing.\nstfu: Stop all chatting and self prompting, but continue current action.\nrestart: Restart the agent process.\nclearChat: Clear the chat history.\ngoToPlayer: Go to the given player.\nParams:\nplayer_name: (string) The name of the player to go to.\ncloseness: (number) How close to get to the player.\nfollowPlayer: Endlessly follow the given player.\nParams:\nplayer_name: (string) name of the player to follow.\nfollow_dist: (number) The distance to follow from.\ngoToCoordinates: Go to the given x, y, z location.\nParams:\nx: (number) The x coordinate.\ny: (number) The y coordinate.\nz: (number) The z coordinate.\ncloseness: (number) How close to get to the location.\nsearchForBlock: Find and go to the nearest block of a given type in a given range.\nParams:\ntype: (string) The block type to go to.\nsearch_range: (number) The range to search for the block. Minimum 32.\nsearchForEntity: Find and go to the nearest entity of a given type in a given range.\nParams:\ntype: (string) The type of entity to go to.\nsearch_range: (number) The range to search for the entity.\nmoveAway: Move away from the current location in any direction by a given distance.\nParams:\ndistance: (number) The distance to move away.\nrememberHere: Save the current location with a given name.\nParams:\nname: (string) The name to remember the location as.\ngoToRememberedPlace: Go to a saved location.\nParams:\nname: (string) The name of the location to go to.\ngivePlayer: Give the specified item to the given player.\nParams:\nplayer_name: (string) The name of the player to give the item to.\nitem_name: (string) The name of the item to give.\nnum: (integer) The number of items to give.\nconsume: Eat/drink the given item.\nParams:\nitem_name: (string) The name of the item to consume.\nequip: Equip the given item.\nParams:\nitem_name: (string) The name of the item to equip.\nputInChest: Put the given item in the nearest chest.\nParams:\nitem_name: (string) The name of the item to put in the chest.\nnum: (integer) The number of items to put in the chest.\ntakeFromChest: Take the given items from the nearest chest.\nParams:\nitem_name: (string) The name of the item to take.\nnum: (integer) The number of items to take.\nviewChest: View the items/counts of the nearest chest.\nParams:\ndiscard: Discard the given item from the inventory.\nParams:\nitem_name: (string) The name of the item to discard.\nnum: (integer) The number of items to discard.\ncollectBlocks: Collect the nearest blocks of a given type.\nParams:\ntype: (string) The block type to collect.\nnum: (integer) The number of blocks to collect.\ncraftRecipe: Craft the given recipe a given number of times.\nParams:\nrecipe_name: (string) The name of the output item to craft.\nnum: (integer) The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.\nsmeltItem: Smelt the given item the given number of times.\nParams:\nitem_name: (string) The name of the input item to smelt.\nnum: (integer) The number of times to smelt the item.\nclearFurnace: Take all items out of the nearest furnace.\nParams:\nplaceHere: Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.\nParams:\ntype: (string) The block type to place.\nattack: Attack and kill the nearest entity of a given type.\nParams:\ntype: (string) The type of entity to attack.\nattackPlayer: Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.\nParams:\nplayer_name: (string) The name of the player to attack.\ngoToBed: Go to the nearest bed and sleep.\nstay: Stay in the current location no matter what. Pauses all modes.\nParams:\ntype: (integer) The number of seconds to stay. -1 for forever.\nsetMode: Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.\nParams:\nmode_name: (string) The name of the mode to enable.\non: (boolean) Whether to enable or disable the mode.\ngoal: Set a goal prompt to endlessly work towards with continuous self-prompting.\nParams:\nselfPrompt: (string) The goal prompt.\nendGoal: Call when you have accomplished your goal. It will stop self-prompting and the current action. \nshowVillagerTrades: Show trades of a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\ntradeWithVillager: Trade with a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\nindex: (integer) The index of the trade you want executed (1-indexed).\ncount: (integer) How many times that trade should be executed.\nstartConversation: Start a conversation with a bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to send the message to.\nmessage: (string) The message to send.\nendConversation: End the conversation with the given bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to end the conversation with.\nlookAtPlayer: Look at a player or look in the same direction as the player.\nParams:\nplayer_name: (string) Name of the target player\ndirection: (string) How to look (\"at\": look at the player, \"with\": look in the same direction as the player)\nlookAtPosition: Look at specified coordinates.\nParams:\nx: (integer) x coordinate\ny: (integer) y coordinate\nz: (integer) z coordinate\ndigDown: Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.\nParams:\ndistance: (integer) Distance to dig down\ngoToSurface: Moves the bot to the highest block above it (usually the surface).\nParams:\nuseOn: Use (right click) the given tool on the nearest target of the given type.\nParams:\ntool_name: (string) Name of the tool to use, or \"hand\" for no tool.\ntarget: (string) The target as an entity type, block type, or \"nothing\" for no target.\n*\n\nExamples of how to respond:\nExample 1:\nUser input: miner_32: Hey! What are you up to?\nYour output:\nNothing much miner_32, what do you need?\n\nExample 2:\nUser input: rupert: Let us work together on a small house.\nYour output:\nSure, I can coordinate with you and handle my part.\nUser input: rupert: I will build the walls if you prepare materials.\nYour output:\nSounds good. I will prepare materials first.\n\n\nConversation Begin:\n", "messages": [{"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}, {"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "type": "function", "name": "inventory", "arguments": "{}"}]}, {"role": "tool", "content": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n", "tool_call_id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "name": "inventory"}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "type": "function", "name": "inventory", "arguments": "{}"}]}, {"role": "tool", "content": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n", "tool_call_id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "name": "inventory"}], "tools": [{"type": "function", "function": {"name": "stats", "description": "Get your bot's location, health, hunger, and time of day.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "inventory", "description": "Get your bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "nearbyBlocks", "description": "Get the blocks near the bot.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftable", "description": "Get the craftable items with the bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "entities", "description": "Get the nearby players and entities.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "modes", "description": "Get all available modes and their docs and see which are on/off.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "savedPlaces", "description": "List all saved locations.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "getCraftingPlan", "description": "Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.", "parameters": {"type": "object", "properties": {"targetItem": {"type": "string", "description": "The item that we are trying to craft"}, "quantity": {"type": "integer", "description": "The quantity of the item that we are trying to craft", "minimum": 1}}, "required": ["targetItem"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchWiki", "description": "Search the Minecraft Wiki for the given query.", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "The query to search for."}}, "required": ["query"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "help", "description": "Lists all available commands and their descriptions.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "newAction", "description": "Perform new and unknown custom behaviors that are not available as a command.", "parameters": {"type": "object", "properties": {"prompt": {"type": "string", "description": "A natural language prompt to guide code generation. Make a detailed step-by-step plan."}}, "required": ["prompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stop", "description": "Force stop all actions and commands that are currently executing.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stfu", "description": "Stop all chatting and self prompting, but continue current action.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "restart", "description": "Restart the agent process.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearChat", "description": "Clear the chat history.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToPlayer", "description": "Go to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to go to."}, "closeness": {"type": "number", "description": "How close to get to the player.", "minimum": 0}}, "required": ["player_name", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "followPlayer", "description": "Endlessly follow the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "name of the player to follow."}, "follow_dist": {"type": "number", "description": "The distance to follow from.", "minimum": 0}}, "required": ["player_name", "follow_dist"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToCoordinates", "description": "Go to the given x, y, z location.", "parameters": {"type": "object", "properties": {"x": {"type": "number", "description": "The x coordinate."}, "y": {"type": "number", "description": "The y coordinate.", "minimum": -64, "maximum": 320}, "z": {"type": "number", "description": "The z coordinate."}, "closeness": {"type": "number", "description": "How close to get to the location.", "minimum": 0}}, "required": ["x", "y", "z", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForBlock", "description": "Find and go to the nearest block of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to go to."}, "search_range": {"type": "number", "description": "The range to search for the block. Minimum 32.", "minimum": 10, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForEntity", "description": "Find and go to the nearest entity of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to go to."}, "search_range": {"type": "number", "description": "The range to search for the entity.", "minimum": 32, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "moveAway", "description": "Move away from the current location in any direction by a given distance.", "parameters": {"type": "object", "properties": {"distance": {"type": "number", "description": "The distance to move away.", "minimum": 0}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "rememberHere", "description": "Save the current location with a given name.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name to remember the location as."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToRememberedPlace", "description": "Go to a saved location.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name of the location to go to."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "givePlayer", "description": "Give the specified item to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to give the item to."}, "item_name": {"type": "string", "description": "The name of the item to give."}, "num": {"type": "integer", "description": "The number of items to give.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["player_name", "item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "consume", "description": "Eat/drink the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to consume."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "equip", "description": "Equip the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to equip."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "putInChest", "description": "Put the given item in the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to put in the chest."}, "num": {"type": "integer", "description": "The number of items to put in the chest.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "takeFromChest", "description": "Take the given items from the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to take."}, "num": {"type": "integer", "description": "The number of items to take.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "viewChest", "description": "View the items/counts of the nearest chest.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "discard", "description": "Discard the given item from the inventory.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to discard."}, "num": {"type": "integer", "description": "The number of items to discard.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "collectBlocks", "description": "Collect the nearest blocks of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to collect."}, "num": {"type": "integer", "description": "The number of blocks to collect.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["type", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftRecipe", "description": "Craft the given recipe a given number of times.", "parameters": {"type": "object", "properties": {"recipe_name": {"type": "string", "description": "The name of the output item to craft."}, "num": {"type": "integer", "description": "The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["recipe_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "smeltItem", "description": "Smelt the given item the given number of times.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the input item to smelt."}, "num": {"type": "integer", "description": "The number of times to smelt the item.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearFurnace", "description": "Take all items out of the nearest furnace.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "placeHere", "description": "Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to place."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attack", "description": "Attack and kill the nearest entity of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to attack."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attackPlayer", "description": "Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to attack."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToBed", "description": "Go to the nearest bed and sleep.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stay", "description": "Stay in the current location no matter what. Pauses all modes.", "parameters": {"type": "object", "properties": {"type": {"type": "integer", "description": "The number of seconds to stay. -1 for forever.", "minimum": -1, "maximum": 9007199254740991}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "setMode", "description": "Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.", "parameters": {"type": "object", "properties": {"mode_name": {"type": "string", "description": "The name of the mode to enable."}, "on": {"type": "boolean", "description": "Whether to enable or disable the mode."}}, "required": ["mode_name", "on"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goal", "description": "Set a goal prompt to endlessly work towards with continuous self-prompting.", "parameters": {"type": "object", "properties": {"selfPrompt": {"type": "string", "description": "The goal prompt."}}, "required": ["selfPrompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endGoal", "description": "Call when you have accomplished your goal. It will stop self-prompting and the current action. ", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "showVillagerTrades", "description": "Show trades of a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}}, "required": ["id"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "tradeWithVillager", "description": "Trade with a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}, "index": {"type": "integer", "description": "The index of the trade you want executed (1-indexed).", "minimum": 1, "maximum": 9007199254740991}, "count": {"type": "integer", "description": "How many times that trade should be executed.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["id", "index", "count"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "startConversation", "description": "Start a conversation with a bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to send the message to."}, "message": {"type": "string", "description": "The message to send."}}, "required": ["player_name", "message"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endConversation", "description": "End the conversation with the given bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to end the conversation with."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPlayer", "description": "Look at a player or look in the same direction as the player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "Name of the target player"}, "direction": {"type": "string", "description": "How to look (\"at\": look at the player, \"with\": look in the same direction as the player)"}}, "required": ["player_name", "direction"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPosition", "description": "Look at specified coordinates.", "parameters": {"type": "object", "properties": {"x": {"type": "integer", "description": "x coordinate"}, "y": {"type": "integer", "description": "y coordinate"}, "z": {"type": "integer", "description": "z coordinate"}}, "required": ["x", "y", "z"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "digDown", "description": "Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.", "parameters": {"type": "object", "properties": {"distance": {"type": "integer", "description": "Distance to dig down", "minimum": 1, "maximum": 9007199254740991}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToSurface", "description": "Moves the bot to the highest block above it (usually the surface).", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "useOn", "description": "Use (right click) the given tool on the nearest target of the given type.", "parameters": {"type": "object", "properties": {"tool_name": {"type": "string", "description": "Name of the tool to use, or \"hand\" for no tool."}, "target": {"type": "string", "description": "The target as an entity type, block type, or \"nothing\" for no target."}}, "required": ["tool_name", "target"], "additionalProperties": false}}}], "tool_count": 51}, {"timestamp": "2026-05-02T02:48:38.376Z", "agent": "codex", "type": "llm_response", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "response": "native tool check complete"}, {"timestamp": "2026-05-02T02:48:40.958Z", "agent": "codex", "type": "llm_response", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "response": "native tool check complete"}, {"timestamp": "2026-05-02T02:48:41.181Z", "agent": "codex", "type": "llm_request", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "system_prompt": "You are an AI Minecraft bot named codex that can converse with players, see, move, mine, build, and interact with the world by using native tools/function calls.\nYOUR CURRENT ASSIGNED GOAL: \"通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。\"\n 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; when you need to act, call the appropriate native tool/function instead of writing command text. Do NOT write legacy text commands, function-looking text, or fake tool-use log text in your message; call the native tool and optionally respond briefly after the tool result. Respond only as codex, 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:'中文交流;MC bot codex。行动用native工具,勿聊天输出命令;人类!可。重启/被要求先native查状态简短确认。newAction勿带```js。速通:木石→食物床火把→铁装盾桶→岩浆门→下界棒+珍珠/交易→眼找要塞→打龙,自保优先。地标:营地床(-197,67,-19);煤洞(-101,58,15);矿区(-198~-207,50~59,-1~-19);深矿炉(-192,35,-25)。深矿已清空间,工作台可放会回收;近(-178,32,-26)有矿道迹象(栅栏/蛛网)与铁/煤,铁装进度到胸裤鞋+盾;下一步做头盔桶、熔铁、继续采铁煤。'\n\nSTATS\n- Position: x: -179.59, y: 31.00, z: -28.43\n- Gamemode: survival\n- Health: 20 / 20\n- Hunger: 20 / 20\n- Biome: plains\n- Weather: Clear\n- Time: Night- Current Action: Idle\n- Nearby Human Players: None.\n- Nearby Bot Players: None.\nAgent Modes:\n- self_preservation(ON)\n- unstuck(ON)\n- cowardice(OFF)\n- self_defense(ON)\n- hunting(OFF)\n- item_collecting(ON)\n- torch_placing(ON)\n- elbow_room(ON)\n- idle_staring(ON)\n- cheat(OFF)\n\n\n\nNEARBY_ENTITIES\n- entities: 2 bat(s)\n\n\nNEARBY_BLOCKS\n- cave_air\n- stone\n- cobblestone\n- cobweb\n- oak_fence\n- oak_planks\n- furnace\n- copper_ore\n- wall_torch\n- iron_ore\n- diorite\n- dirt\n- andesite\n- torch\n- granite\n- glow_lichen\n- rail\n- Block Below: stone\n- Block at Legs: cave_air\n- Block at Head: cave_air\n- First Solid Block Above Head: stone (1 blocks up)\n\n\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n\n\n*NATIVE TOOL DOCS\nYou can use native function/tool calls to act in the Minecraft world and query state. Human users may type !commands, but AI responses must not output !command text. Use the provided tools instead.\nstats: Get your bot's location, health, hunger, and time of day.\ninventory: Get your bot's inventory.\nnearbyBlocks: Get the blocks near the bot.\ncraftable: Get the craftable items with the bot's inventory.\nentities: Get the nearby players and entities.\nmodes: Get all available modes and their docs and see which are on/off.\nsavedPlaces: List all saved locations.\ngetCraftingPlan: Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.\nParams:\ntargetItem: (string) The item that we are trying to craft\nquantity: (integer) The quantity of the item that we are trying to craft\nsearchWiki: Search the Minecraft Wiki for the given query.\nParams:\nquery: (string) The query to search for.\nhelp: Lists all available commands and their descriptions.\nnewAction: Perform new and unknown custom behaviors that are not available as a command.\nParams:\nprompt: (string) A natural language prompt to guide code generation. Make a detailed step-by-step plan.\nstop: Force stop all actions and commands that are currently executing.\nstfu: Stop all chatting and self prompting, but continue current action.\nrestart: Restart the agent process.\nclearChat: Clear the chat history.\ngoToPlayer: Go to the given player.\nParams:\nplayer_name: (string) The name of the player to go to.\ncloseness: (number) How close to get to the player.\nfollowPlayer: Endlessly follow the given player.\nParams:\nplayer_name: (string) name of the player to follow.\nfollow_dist: (number) The distance to follow from.\ngoToCoordinates: Go to the given x, y, z location.\nParams:\nx: (number) The x coordinate.\ny: (number) The y coordinate.\nz: (number) The z coordinate.\ncloseness: (number) How close to get to the location.\nsearchForBlock: Find and go to the nearest block of a given type in a given range.\nParams:\ntype: (string) The block type to go to.\nsearch_range: (number) The range to search for the block. Minimum 32.\nsearchForEntity: Find and go to the nearest entity of a given type in a given range.\nParams:\ntype: (string) The type of entity to go to.\nsearch_range: (number) The range to search for the entity.\nmoveAway: Move away from the current location in any direction by a given distance.\nParams:\ndistance: (number) The distance to move away.\nrememberHere: Save the current location with a given name.\nParams:\nname: (string) The name to remember the location as.\ngoToRememberedPlace: Go to a saved location.\nParams:\nname: (string) The name of the location to go to.\ngivePlayer: Give the specified item to the given player.\nParams:\nplayer_name: (string) The name of the player to give the item to.\nitem_name: (string) The name of the item to give.\nnum: (integer) The number of items to give.\nconsume: Eat/drink the given item.\nParams:\nitem_name: (string) The name of the item to consume.\nequip: Equip the given item.\nParams:\nitem_name: (string) The name of the item to equip.\nputInChest: Put the given item in the nearest chest.\nParams:\nitem_name: (string) The name of the item to put in the chest.\nnum: (integer) The number of items to put in the chest.\ntakeFromChest: Take the given items from the nearest chest.\nParams:\nitem_name: (string) The name of the item to take.\nnum: (integer) The number of items to take.\nviewChest: View the items/counts of the nearest chest.\nParams:\ndiscard: Discard the given item from the inventory.\nParams:\nitem_name: (string) The name of the item to discard.\nnum: (integer) The number of items to discard.\ncollectBlocks: Collect the nearest blocks of a given type.\nParams:\ntype: (string) The block type to collect.\nnum: (integer) The number of blocks to collect.\ncraftRecipe: Craft the given recipe a given number of times.\nParams:\nrecipe_name: (string) The name of the output item to craft.\nnum: (integer) The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.\nsmeltItem: Smelt the given item the given number of times.\nParams:\nitem_name: (string) The name of the input item to smelt.\nnum: (integer) The number of times to smelt the item.\nclearFurnace: Take all items out of the nearest furnace.\nParams:\nplaceHere: Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.\nParams:\ntype: (string) The block type to place.\nattack: Attack and kill the nearest entity of a given type.\nParams:\ntype: (string) The type of entity to attack.\nattackPlayer: Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.\nParams:\nplayer_name: (string) The name of the player to attack.\ngoToBed: Go to the nearest bed and sleep.\nstay: Stay in the current location no matter what. Pauses all modes.\nParams:\ntype: (integer) The number of seconds to stay. -1 for forever.\nsetMode: Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.\nParams:\nmode_name: (string) The name of the mode to enable.\non: (boolean) Whether to enable or disable the mode.\ngoal: Set a goal prompt to endlessly work towards with continuous self-prompting.\nParams:\nselfPrompt: (string) The goal prompt.\nendGoal: Call when you have accomplished your goal. It will stop self-prompting and the current action. \nshowVillagerTrades: Show trades of a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\ntradeWithVillager: Trade with a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\nindex: (integer) The index of the trade you want executed (1-indexed).\ncount: (integer) How many times that trade should be executed.\nstartConversation: Start a conversation with a bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to send the message to.\nmessage: (string) The message to send.\nendConversation: End the conversation with the given bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to end the conversation with.\nlookAtPlayer: Look at a player or look in the same direction as the player.\nParams:\nplayer_name: (string) Name of the target player\ndirection: (string) How to look (\"at\": look at the player, \"with\": look in the same direction as the player)\nlookAtPosition: Look at specified coordinates.\nParams:\nx: (integer) x coordinate\ny: (integer) y coordinate\nz: (integer) z coordinate\ndigDown: Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.\nParams:\ndistance: (integer) Distance to dig down\ngoToSurface: Moves the bot to the highest block above it (usually the surface).\nParams:\nuseOn: Use (right click) the given tool on the nearest target of the given type.\nParams:\ntool_name: (string) Name of the tool to use, or \"hand\" for no tool.\ntarget: (string) The target as an entity type, block type, or \"nothing\" for no target.\n*\n\nExamples of how to respond:\nExample 1:\nUser input: miner_32: Hey! What are you up to?\nYour output:\nNothing much miner_32, what do you need?\n\nExample 2:\nUser input: rupert: Let us work together on a small house.\nYour output:\nSure, I can coordinate with you and handle my part.\nUser input: rupert: I will build the walls if you prepare materials.\nYour output:\nSounds good. I will prepare materials first.\n\n\nConversation Begin:\n", "messages": [{"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}, {"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "type": "function", "name": "inventory", "arguments": "{}"}]}, {"role": "tool", "content": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n", "tool_call_id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "name": "inventory"}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "type": "function", "name": "inventory", "arguments": "{}"}]}, {"role": "tool", "content": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n", "tool_call_id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "name": "inventory"}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}], "tools": [{"type": "function", "function": {"name": "stats", "description": "Get your bot's location, health, hunger, and time of day.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "inventory", "description": "Get your bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "nearbyBlocks", "description": "Get the blocks near the bot.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftable", "description": "Get the craftable items with the bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "entities", "description": "Get the nearby players and entities.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "modes", "description": "Get all available modes and their docs and see which are on/off.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "savedPlaces", "description": "List all saved locations.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "getCraftingPlan", "description": "Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.", "parameters": {"type": "object", "properties": {"targetItem": {"type": "string", "description": "The item that we are trying to craft"}, "quantity": {"type": "integer", "description": "The quantity of the item that we are trying to craft", "minimum": 1}}, "required": ["targetItem"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchWiki", "description": "Search the Minecraft Wiki for the given query.", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "The query to search for."}}, "required": ["query"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "help", "description": "Lists all available commands and their descriptions.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "newAction", "description": "Perform new and unknown custom behaviors that are not available as a command.", "parameters": {"type": "object", "properties": {"prompt": {"type": "string", "description": "A natural language prompt to guide code generation. Make a detailed step-by-step plan."}}, "required": ["prompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stop", "description": "Force stop all actions and commands that are currently executing.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stfu", "description": "Stop all chatting and self prompting, but continue current action.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "restart", "description": "Restart the agent process.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearChat", "description": "Clear the chat history.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToPlayer", "description": "Go to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to go to."}, "closeness": {"type": "number", "description": "How close to get to the player.", "minimum": 0}}, "required": ["player_name", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "followPlayer", "description": "Endlessly follow the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "name of the player to follow."}, "follow_dist": {"type": "number", "description": "The distance to follow from.", "minimum": 0}}, "required": ["player_name", "follow_dist"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToCoordinates", "description": "Go to the given x, y, z location.", "parameters": {"type": "object", "properties": {"x": {"type": "number", "description": "The x coordinate."}, "y": {"type": "number", "description": "The y coordinate.", "minimum": -64, "maximum": 320}, "z": {"type": "number", "description": "The z coordinate."}, "closeness": {"type": "number", "description": "How close to get to the location.", "minimum": 0}}, "required": ["x", "y", "z", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForBlock", "description": "Find and go to the nearest block of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to go to."}, "search_range": {"type": "number", "description": "The range to search for the block. Minimum 32.", "minimum": 10, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForEntity", "description": "Find and go to the nearest entity of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to go to."}, "search_range": {"type": "number", "description": "The range to search for the entity.", "minimum": 32, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "moveAway", "description": "Move away from the current location in any direction by a given distance.", "parameters": {"type": "object", "properties": {"distance": {"type": "number", "description": "The distance to move away.", "minimum": 0}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "rememberHere", "description": "Save the current location with a given name.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name to remember the location as."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToRememberedPlace", "description": "Go to a saved location.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name of the location to go to."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "givePlayer", "description": "Give the specified item to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to give the item to."}, "item_name": {"type": "string", "description": "The name of the item to give."}, "num": {"type": "integer", "description": "The number of items to give.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["player_name", "item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "consume", "description": "Eat/drink the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to consume."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "equip", "description": "Equip the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to equip."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "putInChest", "description": "Put the given item in the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to put in the chest."}, "num": {"type": "integer", "description": "The number of items to put in the chest.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "takeFromChest", "description": "Take the given items from the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to take."}, "num": {"type": "integer", "description": "The number of items to take.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "viewChest", "description": "View the items/counts of the nearest chest.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "discard", "description": "Discard the given item from the inventory.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to discard."}, "num": {"type": "integer", "description": "The number of items to discard.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "collectBlocks", "description": "Collect the nearest blocks of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to collect."}, "num": {"type": "integer", "description": "The number of blocks to collect.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["type", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftRecipe", "description": "Craft the given recipe a given number of times.", "parameters": {"type": "object", "properties": {"recipe_name": {"type": "string", "description": "The name of the output item to craft."}, "num": {"type": "integer", "description": "The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["recipe_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "smeltItem", "description": "Smelt the given item the given number of times.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the input item to smelt."}, "num": {"type": "integer", "description": "The number of times to smelt the item.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearFurnace", "description": "Take all items out of the nearest furnace.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "placeHere", "description": "Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to place."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attack", "description": "Attack and kill the nearest entity of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to attack."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attackPlayer", "description": "Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to attack."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToBed", "description": "Go to the nearest bed and sleep.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stay", "description": "Stay in the current location no matter what. Pauses all modes.", "parameters": {"type": "object", "properties": {"type": {"type": "integer", "description": "The number of seconds to stay. -1 for forever.", "minimum": -1, "maximum": 9007199254740991}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "setMode", "description": "Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.", "parameters": {"type": "object", "properties": {"mode_name": {"type": "string", "description": "The name of the mode to enable."}, "on": {"type": "boolean", "description": "Whether to enable or disable the mode."}}, "required": ["mode_name", "on"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goal", "description": "Set a goal prompt to endlessly work towards with continuous self-prompting.", "parameters": {"type": "object", "properties": {"selfPrompt": {"type": "string", "description": "The goal prompt."}}, "required": ["selfPrompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endGoal", "description": "Call when you have accomplished your goal. It will stop self-prompting and the current action. ", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "showVillagerTrades", "description": "Show trades of a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}}, "required": ["id"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "tradeWithVillager", "description": "Trade with a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}, "index": {"type": "integer", "description": "The index of the trade you want executed (1-indexed).", "minimum": 1, "maximum": 9007199254740991}, "count": {"type": "integer", "description": "How many times that trade should be executed.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["id", "index", "count"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "startConversation", "description": "Start a conversation with a bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to send the message to."}, "message": {"type": "string", "description": "The message to send."}}, "required": ["player_name", "message"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endConversation", "description": "End the conversation with the given bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to end the conversation with."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPlayer", "description": "Look at a player or look in the same direction as the player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "Name of the target player"}, "direction": {"type": "string", "description": "How to look (\"at\": look at the player, \"with\": look in the same direction as the player)"}}, "required": ["player_name", "direction"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPosition", "description": "Look at specified coordinates.", "parameters": {"type": "object", "properties": {"x": {"type": "integer", "description": "x coordinate"}, "y": {"type": "integer", "description": "y coordinate"}, "z": {"type": "integer", "description": "z coordinate"}}, "required": ["x", "y", "z"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "digDown", "description": "Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.", "parameters": {"type": "object", "properties": {"distance": {"type": "integer", "description": "Distance to dig down", "minimum": 1, "maximum": 9007199254740991}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToSurface", "description": "Moves the bot to the highest block above it (usually the surface).", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "useOn", "description": "Use (right click) the given tool on the nearest target of the given type.", "parameters": {"type": "object", "properties": {"tool_name": {"type": "string", "description": "Name of the tool to use, or \"hand\" for no tool."}, "target": {"type": "string", "description": "The target as an entity type, block type, or \"nothing\" for no target."}}, "required": ["tool_name", "target"], "additionalProperties": false}}}], "tool_count": 51}, {"timestamp": "2026-05-02T02:48:44.288Z", "agent": "codex", "type": "llm_response", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "response": "native tool check complete"}, {"timestamp": "2026-05-02T02:48:44.349Z", "agent": "codex", "type": "llm_request", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "system_prompt": "You are an AI Minecraft bot named codex that can converse with players, see, move, mine, build, and interact with the world by using native tools/function calls.\nYOUR CURRENT ASSIGNED GOAL: \"通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。\"\n 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; when you need to act, call the appropriate native tool/function instead of writing command text. Do NOT write legacy text commands, function-looking text, or fake tool-use log text in your message; call the native tool and optionally respond briefly after the tool result. Respond only as codex, 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:'中文交流;MC bot codex。行动用native工具,勿聊天输出命令;人类!可。重启/被要求先native查状态简短确认。newAction勿带```js。速通:木石→食物床火把→铁装盾桶→岩浆门→下界棒+珍珠/交易→眼找要塞→打龙,自保优先。地标:营地床(-197,67,-19);煤洞(-101,58,15);矿区(-198~-207,50~59,-1~-19);深矿炉(-192,35,-25)。深矿已清空间,工作台可放会回收;近(-178,32,-26)有矿道迹象(栅栏/蛛网)与铁/煤,铁装进度到胸裤鞋+盾;下一步做头盔桶、熔铁、继续采铁煤。'\n\nSTATS\n- Position: x: -179.59, y: 31.00, z: -28.43\n- Gamemode: survival\n- Health: 20 / 20\n- Hunger: 20 / 20\n- Biome: plains\n- Weather: Clear\n- Time: Night- Current Action: Idle\n- Nearby Human Players: None.\n- Nearby Bot Players: None.\nAgent Modes:\n- self_preservation(ON)\n- unstuck(ON)\n- cowardice(OFF)\n- self_defense(ON)\n- hunting(OFF)\n- item_collecting(ON)\n- torch_placing(ON)\n- elbow_room(ON)\n- idle_staring(ON)\n- cheat(OFF)\n\n\n\nNEARBY_ENTITIES\n- entities: 2 bat(s)\n\n\nNEARBY_BLOCKS\n- cave_air\n- stone\n- cobblestone\n- cobweb\n- oak_fence\n- oak_planks\n- furnace\n- copper_ore\n- wall_torch\n- iron_ore\n- diorite\n- dirt\n- andesite\n- torch\n- granite\n- glow_lichen\n- rail\n- Block Below: stone\n- Block at Legs: cave_air\n- Block at Head: cave_air\n- First Solid Block Above Head: stone (1 blocks up)\n\n\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n\n\n*NATIVE TOOL DOCS\nYou can use native function/tool calls to act in the Minecraft world and query state. Human users may type !commands, but AI responses must not output !command text. Use the provided tools instead.\nstats: Get your bot's location, health, hunger, and time of day.\ninventory: Get your bot's inventory.\nnearbyBlocks: Get the blocks near the bot.\ncraftable: Get the craftable items with the bot's inventory.\nentities: Get the nearby players and entities.\nmodes: Get all available modes and their docs and see which are on/off.\nsavedPlaces: List all saved locations.\ngetCraftingPlan: Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.\nParams:\ntargetItem: (string) The item that we are trying to craft\nquantity: (integer) The quantity of the item that we are trying to craft\nsearchWiki: Search the Minecraft Wiki for the given query.\nParams:\nquery: (string) The query to search for.\nhelp: Lists all available commands and their descriptions.\nnewAction: Perform new and unknown custom behaviors that are not available as a command.\nParams:\nprompt: (string) A natural language prompt to guide code generation. Make a detailed step-by-step plan.\nstop: Force stop all actions and commands that are currently executing.\nstfu: Stop all chatting and self prompting, but continue current action.\nrestart: Restart the agent process.\nclearChat: Clear the chat history.\ngoToPlayer: Go to the given player.\nParams:\nplayer_name: (string) The name of the player to go to.\ncloseness: (number) How close to get to the player.\nfollowPlayer: Endlessly follow the given player.\nParams:\nplayer_name: (string) name of the player to follow.\nfollow_dist: (number) The distance to follow from.\ngoToCoordinates: Go to the given x, y, z location.\nParams:\nx: (number) The x coordinate.\ny: (number) The y coordinate.\nz: (number) The z coordinate.\ncloseness: (number) How close to get to the location.\nsearchForBlock: Find and go to the nearest block of a given type in a given range.\nParams:\ntype: (string) The block type to go to.\nsearch_range: (number) The range to search for the block. Minimum 32.\nsearchForEntity: Find and go to the nearest entity of a given type in a given range.\nParams:\ntype: (string) The type of entity to go to.\nsearch_range: (number) The range to search for the entity.\nmoveAway: Move away from the current location in any direction by a given distance.\nParams:\ndistance: (number) The distance to move away.\nrememberHere: Save the current location with a given name.\nParams:\nname: (string) The name to remember the location as.\ngoToRememberedPlace: Go to a saved location.\nParams:\nname: (string) The name of the location to go to.\ngivePlayer: Give the specified item to the given player.\nParams:\nplayer_name: (string) The name of the player to give the item to.\nitem_name: (string) The name of the item to give.\nnum: (integer) The number of items to give.\nconsume: Eat/drink the given item.\nParams:\nitem_name: (string) The name of the item to consume.\nequip: Equip the given item.\nParams:\nitem_name: (string) The name of the item to equip.\nputInChest: Put the given item in the nearest chest.\nParams:\nitem_name: (string) The name of the item to put in the chest.\nnum: (integer) The number of items to put in the chest.\ntakeFromChest: Take the given items from the nearest chest.\nParams:\nitem_name: (string) The name of the item to take.\nnum: (integer) The number of items to take.\nviewChest: View the items/counts of the nearest chest.\nParams:\ndiscard: Discard the given item from the inventory.\nParams:\nitem_name: (string) The name of the item to discard.\nnum: (integer) The number of items to discard.\ncollectBlocks: Collect the nearest blocks of a given type.\nParams:\ntype: (string) The block type to collect.\nnum: (integer) The number of blocks to collect.\ncraftRecipe: Craft the given recipe a given number of times.\nParams:\nrecipe_name: (string) The name of the output item to craft.\nnum: (integer) The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.\nsmeltItem: Smelt the given item the given number of times.\nParams:\nitem_name: (string) The name of the input item to smelt.\nnum: (integer) The number of times to smelt the item.\nclearFurnace: Take all items out of the nearest furnace.\nParams:\nplaceHere: Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.\nParams:\ntype: (string) The block type to place.\nattack: Attack and kill the nearest entity of a given type.\nParams:\ntype: (string) The type of entity to attack.\nattackPlayer: Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.\nParams:\nplayer_name: (string) The name of the player to attack.\ngoToBed: Go to the nearest bed and sleep.\nstay: Stay in the current location no matter what. Pauses all modes.\nParams:\ntype: (integer) The number of seconds to stay. -1 for forever.\nsetMode: Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.\nParams:\nmode_name: (string) The name of the mode to enable.\non: (boolean) Whether to enable or disable the mode.\ngoal: Set a goal prompt to endlessly work towards with continuous self-prompting.\nParams:\nselfPrompt: (string) The goal prompt.\nendGoal: Call when you have accomplished your goal. It will stop self-prompting and the current action. \nshowVillagerTrades: Show trades of a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\ntradeWithVillager: Trade with a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\nindex: (integer) The index of the trade you want executed (1-indexed).\ncount: (integer) How many times that trade should be executed.\nstartConversation: Start a conversation with a bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to send the message to.\nmessage: (string) The message to send.\nendConversation: End the conversation with the given bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to end the conversation with.\nlookAtPlayer: Look at a player or look in the same direction as the player.\nParams:\nplayer_name: (string) Name of the target player\ndirection: (string) How to look (\"at\": look at the player, \"with\": look in the same direction as the player)\nlookAtPosition: Look at specified coordinates.\nParams:\nx: (integer) x coordinate\ny: (integer) y coordinate\nz: (integer) z coordinate\ndigDown: Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.\nParams:\ndistance: (integer) Distance to dig down\ngoToSurface: Moves the bot to the highest block above it (usually the surface).\nParams:\nuseOn: Use (right click) the given tool on the nearest target of the given type.\nParams:\ntool_name: (string) Name of the tool to use, or \"hand\" for no tool.\ntarget: (string) The target as an entity type, block type, or \"nothing\" for no target.\n*\n\nExamples of how to respond:\nExample 1:\nUser input: rupert: Let us work together on a small house.\nYour output:\nSure, I can coordinate with you and handle my part.\nUser input: rupert: I will build the walls if you prepare materials.\nYour output:\nSounds good. I will prepare materials first.\n\nExample 2:\nUser input: miner_32: Hey! What are you up to?\nYour output:\nNothing much miner_32, what do you need?\n\n\nConversation Begin:\n", "messages": [{"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}, {"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "type": "function", "name": "inventory", "arguments": "{}"}]}, {"role": "tool", "content": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n", "tool_call_id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "name": "inventory"}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "type": "function", "name": "inventory", "arguments": "{}"}]}, {"role": "tool", "content": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n", "tool_call_id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "name": "inventory"}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}, {"role": "assistant", "content": "native tool check complete"}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}], "tools": [{"type": "function", "function": {"name": "stats", "description": "Get your bot's location, health, hunger, and time of day.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "inventory", "description": "Get your bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "nearbyBlocks", "description": "Get the blocks near the bot.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftable", "description": "Get the craftable items with the bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "entities", "description": "Get the nearby players and entities.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "modes", "description": "Get all available modes and their docs and see which are on/off.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "savedPlaces", "description": "List all saved locations.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "getCraftingPlan", "description": "Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.", "parameters": {"type": "object", "properties": {"targetItem": {"type": "string", "description": "The item that we are trying to craft"}, "quantity": {"type": "integer", "description": "The quantity of the item that we are trying to craft", "minimum": 1}}, "required": ["targetItem"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchWiki", "description": "Search the Minecraft Wiki for the given query.", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "The query to search for."}}, "required": ["query"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "help", "description": "Lists all available commands and their descriptions.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "newAction", "description": "Perform new and unknown custom behaviors that are not available as a command.", "parameters": {"type": "object", "properties": {"prompt": {"type": "string", "description": "A natural language prompt to guide code generation. Make a detailed step-by-step plan."}}, "required": ["prompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stop", "description": "Force stop all actions and commands that are currently executing.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stfu", "description": "Stop all chatting and self prompting, but continue current action.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "restart", "description": "Restart the agent process.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearChat", "description": "Clear the chat history.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToPlayer", "description": "Go to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to go to."}, "closeness": {"type": "number", "description": "How close to get to the player.", "minimum": 0}}, "required": ["player_name", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "followPlayer", "description": "Endlessly follow the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "name of the player to follow."}, "follow_dist": {"type": "number", "description": "The distance to follow from.", "minimum": 0}}, "required": ["player_name", "follow_dist"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToCoordinates", "description": "Go to the given x, y, z location.", "parameters": {"type": "object", "properties": {"x": {"type": "number", "description": "The x coordinate."}, "y": {"type": "number", "description": "The y coordinate.", "minimum": -64, "maximum": 320}, "z": {"type": "number", "description": "The z coordinate."}, "closeness": {"type": "number", "description": "How close to get to the location.", "minimum": 0}}, "required": ["x", "y", "z", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForBlock", "description": "Find and go to the nearest block of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to go to."}, "search_range": {"type": "number", "description": "The range to search for the block. Minimum 32.", "minimum": 10, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForEntity", "description": "Find and go to the nearest entity of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to go to."}, "search_range": {"type": "number", "description": "The range to search for the entity.", "minimum": 32, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "moveAway", "description": "Move away from the current location in any direction by a given distance.", "parameters": {"type": "object", "properties": {"distance": {"type": "number", "description": "The distance to move away.", "minimum": 0}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "rememberHere", "description": "Save the current location with a given name.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name to remember the location as."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToRememberedPlace", "description": "Go to a saved location.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name of the location to go to."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "givePlayer", "description": "Give the specified item to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to give the item to."}, "item_name": {"type": "string", "description": "The name of the item to give."}, "num": {"type": "integer", "description": "The number of items to give.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["player_name", "item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "consume", "description": "Eat/drink the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to consume."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "equip", "description": "Equip the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to equip."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "putInChest", "description": "Put the given item in the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to put in the chest."}, "num": {"type": "integer", "description": "The number of items to put in the chest.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "takeFromChest", "description": "Take the given items from the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to take."}, "num": {"type": "integer", "description": "The number of items to take.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "viewChest", "description": "View the items/counts of the nearest chest.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "discard", "description": "Discard the given item from the inventory.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to discard."}, "num": {"type": "integer", "description": "The number of items to discard.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "collectBlocks", "description": "Collect the nearest blocks of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to collect."}, "num": {"type": "integer", "description": "The number of blocks to collect.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["type", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftRecipe", "description": "Craft the given recipe a given number of times.", "parameters": {"type": "object", "properties": {"recipe_name": {"type": "string", "description": "The name of the output item to craft."}, "num": {"type": "integer", "description": "The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["recipe_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "smeltItem", "description": "Smelt the given item the given number of times.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the input item to smelt."}, "num": {"type": "integer", "description": "The number of times to smelt the item.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearFurnace", "description": "Take all items out of the nearest furnace.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "placeHere", "description": "Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to place."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attack", "description": "Attack and kill the nearest entity of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to attack."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attackPlayer", "description": "Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to attack."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToBed", "description": "Go to the nearest bed and sleep.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stay", "description": "Stay in the current location no matter what. Pauses all modes.", "parameters": {"type": "object", "properties": {"type": {"type": "integer", "description": "The number of seconds to stay. -1 for forever.", "minimum": -1, "maximum": 9007199254740991}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "setMode", "description": "Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.", "parameters": {"type": "object", "properties": {"mode_name": {"type": "string", "description": "The name of the mode to enable."}, "on": {"type": "boolean", "description": "Whether to enable or disable the mode."}}, "required": ["mode_name", "on"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goal", "description": "Set a goal prompt to endlessly work towards with continuous self-prompting.", "parameters": {"type": "object", "properties": {"selfPrompt": {"type": "string", "description": "The goal prompt."}}, "required": ["selfPrompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endGoal", "description": "Call when you have accomplished your goal. It will stop self-prompting and the current action. ", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "showVillagerTrades", "description": "Show trades of a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}}, "required": ["id"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "tradeWithVillager", "description": "Trade with a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}, "index": {"type": "integer", "description": "The index of the trade you want executed (1-indexed).", "minimum": 1, "maximum": 9007199254740991}, "count": {"type": "integer", "description": "How many times that trade should be executed.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["id", "index", "count"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "startConversation", "description": "Start a conversation with a bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to send the message to."}, "message": {"type": "string", "description": "The message to send."}}, "required": ["player_name", "message"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endConversation", "description": "End the conversation with the given bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to end the conversation with."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPlayer", "description": "Look at a player or look in the same direction as the player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "Name of the target player"}, "direction": {"type": "string", "description": "How to look (\"at\": look at the player, \"with\": look in the same direction as the player)"}}, "required": ["player_name", "direction"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPosition", "description": "Look at specified coordinates.", "parameters": {"type": "object", "properties": {"x": {"type": "integer", "description": "x coordinate"}, "y": {"type": "integer", "description": "y coordinate"}, "z": {"type": "integer", "description": "z coordinate"}}, "required": ["x", "y", "z"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "digDown", "description": "Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.", "parameters": {"type": "object", "properties": {"distance": {"type": "integer", "description": "Distance to dig down", "minimum": 1, "maximum": 9007199254740991}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToSurface", "description": "Moves the bot to the highest block above it (usually the surface).", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "useOn", "description": "Use (right click) the given tool on the nearest target of the given type.", "parameters": {"type": "object", "properties": {"tool_name": {"type": "string", "description": "Name of the tool to use, or \"hand\" for no tool."}, "target": {"type": "string", "description": "The target as an entity type, block type, or \"nothing\" for no target."}}, "required": ["tool_name", "target"], "additionalProperties": false}}}], "tool_count": 51}, {"timestamp": "2026-05-02T02:48:52.100Z", "agent": "codex", "type": "llm_response", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "response": {"type": "tool_calls", "provider": "codex-chatgpt", "tool_calls": [{"id": "call_yMfjPKQLlKPEWOEVsF1wCAz5", "type": "function", "name": "craftRecipe", "arguments": "{\"recipe_name\":\"iron_helmet\",\"num\":1}"}]}}, {"timestamp": "2026-05-02T02:48:52.106Z", "agent": "codex", "type": "tool_call", "tool_call": {"id": "call_yMfjPKQLlKPEWOEVsF1wCAz5", "type": "function", "name": "craftRecipe", "arguments": "{\"recipe_name\":\"iron_helmet\",\"num\":1}"}, "content": ""}, {"timestamp": "2026-05-02T02:48:57.969Z", "agent": "codex", "type": "tool_result", "tool_call": {"id": "call_yMfjPKQLlKPEWOEVsF1wCAz5", "type": "function", "name": "craftRecipe", "arguments": "{\"recipe_name\":\"iron_helmet\",\"num\":1}"}, "result": "Action output:\nPlaced crafting_table at (-179, 34, -27).\nSuccessfully crafted iron_helmet, you now have 1 iron_helmet.\nFailed to place torch at (-179, 34, -27): Error: Event blockUpdate:(-179, 34, -27) did not fire within timeout of 500ms.\nCollected 1 crafting_table.\n"}, {"timestamp": "2026-05-02T02:48:57.997Z", "agent": "codex", "type": "llm_request", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "system_prompt": "You are an AI Minecraft bot named codex that can converse with players, see, move, mine, build, and interact with the world by using native tools/function calls.\nYOUR CURRENT ASSIGNED GOAL: \"通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。\"\n 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; when you need to act, call the appropriate native tool/function instead of writing command text. Do NOT write legacy text commands, function-looking text, or fake tool-use log text in your message; call the native tool and optionally respond briefly after the tool result. Respond only as codex, 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:'中文交流;MC bot codex。行动用native工具,勿聊天输出命令;人类!可。重启/被要求先native查状态简短确认。newAction勿带```js。速通:木石→食物床火把→铁装盾桶→岩浆门→下界棒+珍珠/交易→眼找要塞→打龙,自保优先。地标:营地床(-197,67,-19);煤洞(-101,58,15);矿区(-198~-207,50~59,-1~-19);深矿炉(-192,35,-25)。深矿已清空间,工作台可放会回收;近(-178,32,-26)有矿道迹象(栅栏/蛛网)与铁/煤,铁装进度到胸裤鞋+盾;下一步做头盔桶、熔铁、继续采铁煤。'\n\nSTATS\n- Position: x: -178.48, y: 33.00, z: -26.49\n- Gamemode: survival\n- Health: 20 / 20\n- Hunger: 20 / 20\n- Biome: plains\n- Weather: Clear\n- Time: Night- Current Action: Idle\n- Nearby Human Players: None.\n- Nearby Bot Players: None.\nAgent Modes:\n- self_preservation(ON)\n- unstuck(ON)\n- cowardice(OFF)\n- self_defense(ON)\n- hunting(OFF)\n- item_collecting(ON)\n- torch_placing(ON)\n- elbow_room(ON)\n- idle_staring(ON)\n- cheat(OFF)\n\n\n\nNEARBY_ENTITIES\n- entities: 2 bat(s)\n- entities: 1 chest_minecart(s)\n\n\nNEARBY_BLOCKS\n- stone\n- furnace\n- cave_air\n- iron_ore\n- oak_planks\n- oak_fence\n- cobblestone\n- cobweb\n- copper_ore\n- gravel\n- diorite\n- granite\n- dirt\n- torch\n- glow_lichen\n- andesite\n- Block Below: furnace\n- Block at Legs: air\n- Block at Head: air\n- First Solid Block Above Head: stone (1 blocks up)\n\n\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 3\n- torch: 4\n- iron_helmet: 1\n- crafting_table: 1\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- wooden_pickaxe: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n\n\n*NATIVE TOOL DOCS\nYou can use native function/tool calls to act in the Minecraft world and query state. Human users may type !commands, but AI responses must not output !command text. Use the provided tools instead.\nstats: Get your bot's location, health, hunger, and time of day.\ninventory: Get your bot's inventory.\nnearbyBlocks: Get the blocks near the bot.\ncraftable: Get the craftable items with the bot's inventory.\nentities: Get the nearby players and entities.\nmodes: Get all available modes and their docs and see which are on/off.\nsavedPlaces: List all saved locations.\ngetCraftingPlan: Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.\nParams:\ntargetItem: (string) The item that we are trying to craft\nquantity: (integer) The quantity of the item that we are trying to craft\nsearchWiki: Search the Minecraft Wiki for the given query.\nParams:\nquery: (string) The query to search for.\nhelp: Lists all available commands and their descriptions.\nnewAction: Perform new and unknown custom behaviors that are not available as a command.\nParams:\nprompt: (string) A natural language prompt to guide code generation. Make a detailed step-by-step plan.\nstop: Force stop all actions and commands that are currently executing.\nstfu: Stop all chatting and self prompting, but continue current action.\nrestart: Restart the agent process.\nclearChat: Clear the chat history.\ngoToPlayer: Go to the given player.\nParams:\nplayer_name: (string) The name of the player to go to.\ncloseness: (number) How close to get to the player.\nfollowPlayer: Endlessly follow the given player.\nParams:\nplayer_name: (string) name of the player to follow.\nfollow_dist: (number) The distance to follow from.\ngoToCoordinates: Go to the given x, y, z location.\nParams:\nx: (number) The x coordinate.\ny: (number) The y coordinate.\nz: (number) The z coordinate.\ncloseness: (number) How close to get to the location.\nsearchForBlock: Find and go to the nearest block of a given type in a given range.\nParams:\ntype: (string) The block type to go to.\nsearch_range: (number) The range to search for the block. Minimum 32.\nsearchForEntity: Find and go to the nearest entity of a given type in a given range.\nParams:\ntype: (string) The type of entity to go to.\nsearch_range: (number) The range to search for the entity.\nmoveAway: Move away from the current location in any direction by a given distance.\nParams:\ndistance: (number) The distance to move away.\nrememberHere: Save the current location with a given name.\nParams:\nname: (string) The name to remember the location as.\ngoToRememberedPlace: Go to a saved location.\nParams:\nname: (string) The name of the location to go to.\ngivePlayer: Give the specified item to the given player.\nParams:\nplayer_name: (string) The name of the player to give the item to.\nitem_name: (string) The name of the item to give.\nnum: (integer) The number of items to give.\nconsume: Eat/drink the given item.\nParams:\nitem_name: (string) The name of the item to consume.\nequip: Equip the given item.\nParams:\nitem_name: (string) The name of the item to equip.\nputInChest: Put the given item in the nearest chest.\nParams:\nitem_name: (string) The name of the item to put in the chest.\nnum: (integer) The number of items to put in the chest.\ntakeFromChest: Take the given items from the nearest chest.\nParams:\nitem_name: (string) The name of the item to take.\nnum: (integer) The number of items to take.\nviewChest: View the items/counts of the nearest chest.\nParams:\ndiscard: Discard the given item from the inventory.\nParams:\nitem_name: (string) The name of the item to discard.\nnum: (integer) The number of items to discard.\ncollectBlocks: Collect the nearest blocks of a given type.\nParams:\ntype: (string) The block type to collect.\nnum: (integer) The number of blocks to collect.\ncraftRecipe: Craft the given recipe a given number of times.\nParams:\nrecipe_name: (string) The name of the output item to craft.\nnum: (integer) The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.\nsmeltItem: Smelt the given item the given number of times.\nParams:\nitem_name: (string) The name of the input item to smelt.\nnum: (integer) The number of times to smelt the item.\nclearFurnace: Take all items out of the nearest furnace.\nParams:\nplaceHere: Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.\nParams:\ntype: (string) The block type to place.\nattack: Attack and kill the nearest entity of a given type.\nParams:\ntype: (string) The type of entity to attack.\nattackPlayer: Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.\nParams:\nplayer_name: (string) The name of the player to attack.\ngoToBed: Go to the nearest bed and sleep.\nstay: Stay in the current location no matter what. Pauses all modes.\nParams:\ntype: (integer) The number of seconds to stay. -1 for forever.\nsetMode: Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.\nParams:\nmode_name: (string) The name of the mode to enable.\non: (boolean) Whether to enable or disable the mode.\ngoal: Set a goal prompt to endlessly work towards with continuous self-prompting.\nParams:\nselfPrompt: (string) The goal prompt.\nendGoal: Call when you have accomplished your goal. It will stop self-prompting and the current action. \nshowVillagerTrades: Show trades of a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\ntradeWithVillager: Trade with a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\nindex: (integer) The index of the trade you want executed (1-indexed).\ncount: (integer) How many times that trade should be executed.\nstartConversation: Start a conversation with a bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to send the message to.\nmessage: (string) The message to send.\nendConversation: End the conversation with the given bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to end the conversation with.\nlookAtPlayer: Look at a player or look in the same direction as the player.\nParams:\nplayer_name: (string) Name of the target player\ndirection: (string) How to look (\"at\": look at the player, \"with\": look in the same direction as the player)\nlookAtPosition: Look at specified coordinates.\nParams:\nx: (integer) x coordinate\ny: (integer) y coordinate\nz: (integer) z coordinate\ndigDown: Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.\nParams:\ndistance: (integer) Distance to dig down\ngoToSurface: Moves the bot to the highest block above it (usually the surface).\nParams:\nuseOn: Use (right click) the given tool on the nearest target of the given type.\nParams:\ntool_name: (string) Name of the tool to use, or \"hand\" for no tool.\ntarget: (string) The target as an entity type, block type, or \"nothing\" for no target.\n*\n\nExamples of how to respond:\nExample 1:\nUser input: miner_32: Hey! What are you up to?\nYour output:\nNothing much miner_32, what do you need?\n\nExample 2:\nUser input: rupert: Let us work together on a small house.\nYour output:\nSure, I can coordinate with you and handle my part.\nUser input: rupert: I will build the walls if you prepare materials.\nYour output:\nSounds good. I will prepare materials first.\n\n\nConversation Begin:\n", "messages": [{"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}, {"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "type": "function", "name": "inventory", "arguments": "{}"}]}, {"role": "tool", "content": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n", "tool_call_id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "name": "inventory"}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "type": "function", "name": "inventory", "arguments": "{}"}]}, {"role": "tool", "content": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n", "tool_call_id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "name": "inventory"}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}, {"role": "assistant", "content": "native tool check complete"}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_yMfjPKQLlKPEWOEVsF1wCAz5", "type": "function", "name": "craftRecipe", "arguments": "{\"recipe_name\":\"iron_helmet\",\"num\":1}"}]}, {"role": "tool", "content": "Action output:\nPlaced crafting_table at (-179, 34, -27).\nSuccessfully crafted iron_helmet, you now have 1 iron_helmet.\nFailed to place torch at (-179, 34, -27): Error: Event blockUpdate:(-179, 34, -27) did not fire within timeout of 500ms.\nCollected 1 crafting_table.\n", "tool_call_id": "call_yMfjPKQLlKPEWOEVsF1wCAz5", "name": "craftRecipe"}], "tools": [{"type": "function", "function": {"name": "stats", "description": "Get your bot's location, health, hunger, and time of day.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "inventory", "description": "Get your bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "nearbyBlocks", "description": "Get the blocks near the bot.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftable", "description": "Get the craftable items with the bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "entities", "description": "Get the nearby players and entities.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "modes", "description": "Get all available modes and their docs and see which are on/off.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "savedPlaces", "description": "List all saved locations.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "getCraftingPlan", "description": "Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.", "parameters": {"type": "object", "properties": {"targetItem": {"type": "string", "description": "The item that we are trying to craft"}, "quantity": {"type": "integer", "description": "The quantity of the item that we are trying to craft", "minimum": 1}}, "required": ["targetItem"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchWiki", "description": "Search the Minecraft Wiki for the given query.", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "The query to search for."}}, "required": ["query"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "help", "description": "Lists all available commands and their descriptions.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "newAction", "description": "Perform new and unknown custom behaviors that are not available as a command.", "parameters": {"type": "object", "properties": {"prompt": {"type": "string", "description": "A natural language prompt to guide code generation. Make a detailed step-by-step plan."}}, "required": ["prompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stop", "description": "Force stop all actions and commands that are currently executing.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stfu", "description": "Stop all chatting and self prompting, but continue current action.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "restart", "description": "Restart the agent process.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearChat", "description": "Clear the chat history.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToPlayer", "description": "Go to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to go to."}, "closeness": {"type": "number", "description": "How close to get to the player.", "minimum": 0}}, "required": ["player_name", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "followPlayer", "description": "Endlessly follow the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "name of the player to follow."}, "follow_dist": {"type": "number", "description": "The distance to follow from.", "minimum": 0}}, "required": ["player_name", "follow_dist"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToCoordinates", "description": "Go to the given x, y, z location.", "parameters": {"type": "object", "properties": {"x": {"type": "number", "description": "The x coordinate."}, "y": {"type": "number", "description": "The y coordinate.", "minimum": -64, "maximum": 320}, "z": {"type": "number", "description": "The z coordinate."}, "closeness": {"type": "number", "description": "How close to get to the location.", "minimum": 0}}, "required": ["x", "y", "z", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForBlock", "description": "Find and go to the nearest block of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to go to."}, "search_range": {"type": "number", "description": "The range to search for the block. Minimum 32.", "minimum": 10, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForEntity", "description": "Find and go to the nearest entity of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to go to."}, "search_range": {"type": "number", "description": "The range to search for the entity.", "minimum": 32, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "moveAway", "description": "Move away from the current location in any direction by a given distance.", "parameters": {"type": "object", "properties": {"distance": {"type": "number", "description": "The distance to move away.", "minimum": 0}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "rememberHere", "description": "Save the current location with a given name.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name to remember the location as."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToRememberedPlace", "description": "Go to a saved location.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name of the location to go to."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "givePlayer", "description": "Give the specified item to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to give the item to."}, "item_name": {"type": "string", "description": "The name of the item to give."}, "num": {"type": "integer", "description": "The number of items to give.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["player_name", "item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "consume", "description": "Eat/drink the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to consume."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "equip", "description": "Equip the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to equip."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "putInChest", "description": "Put the given item in the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to put in the chest."}, "num": {"type": "integer", "description": "The number of items to put in the chest.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "takeFromChest", "description": "Take the given items from the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to take."}, "num": {"type": "integer", "description": "The number of items to take.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "viewChest", "description": "View the items/counts of the nearest chest.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "discard", "description": "Discard the given item from the inventory.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to discard."}, "num": {"type": "integer", "description": "The number of items to discard.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "collectBlocks", "description": "Collect the nearest blocks of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to collect."}, "num": {"type": "integer", "description": "The number of blocks to collect.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["type", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftRecipe", "description": "Craft the given recipe a given number of times.", "parameters": {"type": "object", "properties": {"recipe_name": {"type": "string", "description": "The name of the output item to craft."}, "num": {"type": "integer", "description": "The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["recipe_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "smeltItem", "description": "Smelt the given item the given number of times.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the input item to smelt."}, "num": {"type": "integer", "description": "The number of times to smelt the item.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearFurnace", "description": "Take all items out of the nearest furnace.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "placeHere", "description": "Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to place."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attack", "description": "Attack and kill the nearest entity of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to attack."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attackPlayer", "description": "Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to attack."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToBed", "description": "Go to the nearest bed and sleep.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stay", "description": "Stay in the current location no matter what. Pauses all modes.", "parameters": {"type": "object", "properties": {"type": {"type": "integer", "description": "The number of seconds to stay. -1 for forever.", "minimum": -1, "maximum": 9007199254740991}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "setMode", "description": "Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.", "parameters": {"type": "object", "properties": {"mode_name": {"type": "string", "description": "The name of the mode to enable."}, "on": {"type": "boolean", "description": "Whether to enable or disable the mode."}}, "required": ["mode_name", "on"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goal", "description": "Set a goal prompt to endlessly work towards with continuous self-prompting.", "parameters": {"type": "object", "properties": {"selfPrompt": {"type": "string", "description": "The goal prompt."}}, "required": ["selfPrompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endGoal", "description": "Call when you have accomplished your goal. It will stop self-prompting and the current action. ", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "showVillagerTrades", "description": "Show trades of a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}}, "required": ["id"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "tradeWithVillager", "description": "Trade with a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}, "index": {"type": "integer", "description": "The index of the trade you want executed (1-indexed).", "minimum": 1, "maximum": 9007199254740991}, "count": {"type": "integer", "description": "How many times that trade should be executed.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["id", "index", "count"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "startConversation", "description": "Start a conversation with a bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to send the message to."}, "message": {"type": "string", "description": "The message to send."}}, "required": ["player_name", "message"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endConversation", "description": "End the conversation with the given bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to end the conversation with."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPlayer", "description": "Look at a player or look in the same direction as the player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "Name of the target player"}, "direction": {"type": "string", "description": "How to look (\"at\": look at the player, \"with\": look in the same direction as the player)"}}, "required": ["player_name", "direction"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPosition", "description": "Look at specified coordinates.", "parameters": {"type": "object", "properties": {"x": {"type": "integer", "description": "x coordinate"}, "y": {"type": "integer", "description": "y coordinate"}, "z": {"type": "integer", "description": "z coordinate"}}, "required": ["x", "y", "z"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "digDown", "description": "Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.", "parameters": {"type": "object", "properties": {"distance": {"type": "integer", "description": "Distance to dig down", "minimum": 1, "maximum": 9007199254740991}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToSurface", "description": "Moves the bot to the highest block above it (usually the surface).", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "useOn", "description": "Use (right click) the given tool on the nearest target of the given type.", "parameters": {"type": "object", "properties": {"tool_name": {"type": "string", "description": "Name of the tool to use, or \"hand\" for no tool."}, "target": {"type": "string", "description": "The target as an entity type, block type, or \"nothing\" for no target."}}, "required": ["tool_name", "target"], "additionalProperties": false}}}], "tool_count": 51}, {"timestamp": "2026-05-02T02:49:02.695Z", "agent": "codex", "type": "llm_response", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "response": {"type": "tool_calls", "provider": "codex-chatgpt", "tool_calls": [{"id": "call_dMEgzMMJyhvj3Xq7tebtnKjw", "type": "function", "name": "craftRecipe", "arguments": "{\"recipe_name\":\"bucket\",\"num\":1}"}]}}, {"timestamp": "2026-05-02T02:49:02.700Z", "agent": "codex", "type": "tool_call", "tool_call": {"id": "call_dMEgzMMJyhvj3Xq7tebtnKjw", "type": "function", "name": "craftRecipe", "arguments": "{\"recipe_name\":\"bucket\",\"num\":1}"}, "content": ""}, {"timestamp": "2026-05-02T02:49:04.979Z", "agent": "codex", "type": "tool_result", "tool_call": {"id": "call_dMEgzMMJyhvj3Xq7tebtnKjw", "type": "function", "name": "craftRecipe", "arguments": "{\"recipe_name\":\"bucket\",\"num\":1}"}, "result": "Action output:\nFailed to place crafting_table at (-179, 33, -27): Error: Event blockUpdate:(-179, 33, -27) did not fire within timeout of 500ms.\n!!Code threw exception!!\nError: Error: Recipe requires craftingTable, but one was not supplied: {\"result\":{\"id\":941,\"metadata\":null,\"count\":1},\"inShape\":[[{\"id\":842,\"metadata\":null,\"count\":1},{\"id\":-1,\"metadata\":null,\"count\":1},{\"id\":842,\"metadata\":null,\"count\":1}],[{\"id\":-1,\"metadata\":null,\"count\":1},{\"id\":842,\"metadata\":null,\"count\":1},{\"id\":-1,\"metadata\":null,\"count\":1}]],\"outShape\":null,\"ingredients\":null,\"delta\":[{\"id\":842,\"metadata\":null,\"count\":-3},{\"id\":941,\"metadata\":null,\"count\":1}],\"requiresTable\":true}\nStack trace:\nError: Recipe requires craftingTable, but one was not supplied: {\"result\":{\"id\":941,\"metadata\":null,\"count\":1},\"inShape\":[[{\"id\":842,\"metadata\":null,\"count\":1},{\"id\":-1,\"metadata\":null,\"count\":1},{\"id\":842,\"metadata\":null,\"count\":1}],[{\"id\":-1,\"metadata\":null,\"count\":1},{\"id\":842,\"metadata\":null,\"count\":1},{\"id\":-1,\"metadata\":null,\"count\":1}]],\"outShape\":null,\"ingredients\":null,\"delta\":[{\"id\":842,\"metadata\":null,\"count\":-3},{\"id\":941,\"metadata\":null,\"count\":1}],\"requiresTable\":true}\n at EventEmitter.craft (/Users/Ninot/NinotQuyi/mindcraft-native-tool/node_modules/mineflayer/lib/plugins/craft.js:15:13)\n at Module.craftRecipe (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/library/skills.js:151:15)\n at async file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/commands/actions.js:274:13\n at async actionFnWithAgent (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/commands/actions.js:17:13)\n at async ActionManager._executeAction (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/action_manager.js:113:13)\n at async Object.wrappedAction [as perform] (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/commands/actions.js:19:29)\n at async executeCommandToolCall (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/commands/tool_adapter.js:103:24)\n at async Agent._executeNativeToolCalls (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/agent.js:425:33)\n at async Agent.handleMessage (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/agent.js:332:37)\n at async SelfPrompter.startLoop (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/self_prompter.js:68:32)\n"}, {"timestamp": "2026-05-02T02:49:05.026Z", "agent": "codex", "type": "llm_request", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "system_prompt": "You are an AI Minecraft bot named codex that can converse with players, see, move, mine, build, and interact with the world by using native tools/function calls.\nYOUR CURRENT ASSIGNED GOAL: \"通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。\"\n 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; when you need to act, call the appropriate native tool/function instead of writing command text. Do NOT write legacy text commands, function-looking text, or fake tool-use log text in your message; call the native tool and optionally respond briefly after the tool result. Respond only as codex, 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:'中文交流;MC bot codex。行动用native工具,勿聊天输出命令;人类!可。重启/被要求先native查状态简短确认。newAction勿带```js。速通:木石→食物床火把→铁装盾桶→岩浆门→下界棒+珍珠/交易→眼找要塞→打龙,自保优先。地标:营地床(-197,67,-19);煤洞(-101,58,15);矿区(-198~-207,50~59,-1~-19);深矿炉(-192,35,-25)。深矿已清空间,工作台可放会回收;近(-178,32,-26)有矿道迹象(栅栏/蛛网)与铁/煤,铁装进度到胸裤鞋+盾;下一步做头盔桶、熔铁、继续采铁煤。'\n\nSTATS\n- Position: x: -179.58, y: 31.00, z: -28.45\n- Gamemode: survival\n- Health: 20 / 20\n- Hunger: 20 / 20\n- Biome: plains\n- Weather: Clear\n- Time: Night- Current Action: Idle\n- Nearby Human Players: None.\n- Nearby Bot Players: None.\nAgent Modes:\n- self_preservation(ON)\n- unstuck(ON)\n- cowardice(OFF)\n- self_defense(ON)\n- hunting(OFF)\n- item_collecting(ON)\n- torch_placing(ON)\n- elbow_room(ON)\n- idle_staring(ON)\n- cheat(OFF)\n\n\n\nNEARBY_ENTITIES\n- entities: 2 bat(s)\n\n\nNEARBY_BLOCKS\n- cave_air\n- stone\n- cobblestone\n- cobweb\n- oak_fence\n- oak_planks\n- furnace\n- copper_ore\n- iron_ore\n- diorite\n- dirt\n- andesite\n- torch\n- granite\n- glow_lichen\n- rail\n- Block Below: stone\n- Block at Legs: cave_air\n- Block at Head: cave_air\n- First Solid Block Above Head: stone (1 blocks up)\n\n\nINVENTORY\n- oak_planks: 8\n- iron_leggings: 1\n- iron_boots: 1\n- oak_sapling: 1\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 3\n- torch: 4\n- iron_helmet: 1\n- crafting_table: 1\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- wooden_pickaxe: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: oak_planks\nLegs: iron_leggings\nFeet: iron_boots\n\n\n*NATIVE TOOL DOCS\nYou can use native function/tool calls to act in the Minecraft world and query state. Human users may type !commands, but AI responses must not output !command text. Use the provided tools instead.\nstats: Get your bot's location, health, hunger, and time of day.\ninventory: Get your bot's inventory.\nnearbyBlocks: Get the blocks near the bot.\ncraftable: Get the craftable items with the bot's inventory.\nentities: Get the nearby players and entities.\nmodes: Get all available modes and their docs and see which are on/off.\nsavedPlaces: List all saved locations.\ngetCraftingPlan: Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.\nParams:\ntargetItem: (string) The item that we are trying to craft\nquantity: (integer) The quantity of the item that we are trying to craft\nsearchWiki: Search the Minecraft Wiki for the given query.\nParams:\nquery: (string) The query to search for.\nhelp: Lists all available commands and their descriptions.\nnewAction: Perform new and unknown custom behaviors that are not available as a command.\nParams:\nprompt: (string) A natural language prompt to guide code generation. Make a detailed step-by-step plan.\nstop: Force stop all actions and commands that are currently executing.\nstfu: Stop all chatting and self prompting, but continue current action.\nrestart: Restart the agent process.\nclearChat: Clear the chat history.\ngoToPlayer: Go to the given player.\nParams:\nplayer_name: (string) The name of the player to go to.\ncloseness: (number) How close to get to the player.\nfollowPlayer: Endlessly follow the given player.\nParams:\nplayer_name: (string) name of the player to follow.\nfollow_dist: (number) The distance to follow from.\ngoToCoordinates: Go to the given x, y, z location.\nParams:\nx: (number) The x coordinate.\ny: (number) The y coordinate.\nz: (number) The z coordinate.\ncloseness: (number) How close to get to the location.\nsearchForBlock: Find and go to the nearest block of a given type in a given range.\nParams:\ntype: (string) The block type to go to.\nsearch_range: (number) The range to search for the block. Minimum 32.\nsearchForEntity: Find and go to the nearest entity of a given type in a given range.\nParams:\ntype: (string) The type of entity to go to.\nsearch_range: (number) The range to search for the entity.\nmoveAway: Move away from the current location in any direction by a given distance.\nParams:\ndistance: (number) The distance to move away.\nrememberHere: Save the current location with a given name.\nParams:\nname: (string) The name to remember the location as.\ngoToRememberedPlace: Go to a saved location.\nParams:\nname: (string) The name of the location to go to.\ngivePlayer: Give the specified item to the given player.\nParams:\nplayer_name: (string) The name of the player to give the item to.\nitem_name: (string) The name of the item to give.\nnum: (integer) The number of items to give.\nconsume: Eat/drink the given item.\nParams:\nitem_name: (string) The name of the item to consume.\nequip: Equip the given item.\nParams:\nitem_name: (string) The name of the item to equip.\nputInChest: Put the given item in the nearest chest.\nParams:\nitem_name: (string) The name of the item to put in the chest.\nnum: (integer) The number of items to put in the chest.\ntakeFromChest: Take the given items from the nearest chest.\nParams:\nitem_name: (string) The name of the item to take.\nnum: (integer) The number of items to take.\nviewChest: View the items/counts of the nearest chest.\nParams:\ndiscard: Discard the given item from the inventory.\nParams:\nitem_name: (string) The name of the item to discard.\nnum: (integer) The number of items to discard.\ncollectBlocks: Collect the nearest blocks of a given type.\nParams:\ntype: (string) The block type to collect.\nnum: (integer) The number of blocks to collect.\ncraftRecipe: Craft the given recipe a given number of times.\nParams:\nrecipe_name: (string) The name of the output item to craft.\nnum: (integer) The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.\nsmeltItem: Smelt the given item the given number of times.\nParams:\nitem_name: (string) The name of the input item to smelt.\nnum: (integer) The number of times to smelt the item.\nclearFurnace: Take all items out of the nearest furnace.\nParams:\nplaceHere: Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.\nParams:\ntype: (string) The block type to place.\nattack: Attack and kill the nearest entity of a given type.\nParams:\ntype: (string) The type of entity to attack.\nattackPlayer: Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.\nParams:\nplayer_name: (string) The name of the player to attack.\ngoToBed: Go to the nearest bed and sleep.\nstay: Stay in the current location no matter what. Pauses all modes.\nParams:\ntype: (integer) The number of seconds to stay. -1 for forever.\nsetMode: Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.\nParams:\nmode_name: (string) The name of the mode to enable.\non: (boolean) Whether to enable or disable the mode.\ngoal: Set a goal prompt to endlessly work towards with continuous self-prompting.\nParams:\nselfPrompt: (string) The goal prompt.\nendGoal: Call when you have accomplished your goal. It will stop self-prompting and the current action. \nshowVillagerTrades: Show trades of a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\ntradeWithVillager: Trade with a specified villager.\nParams:\nid: (integer) The id number of the villager that you want to trade with.\nindex: (integer) The index of the trade you want executed (1-indexed).\ncount: (integer) How many times that trade should be executed.\nstartConversation: Start a conversation with a bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to send the message to.\nmessage: (string) The message to send.\nendConversation: End the conversation with the given bot. (FOR OTHER BOTS ONLY)\nParams:\nplayer_name: (string) The name of the player to end the conversation with.\nlookAtPlayer: Look at a player or look in the same direction as the player.\nParams:\nplayer_name: (string) Name of the target player\ndirection: (string) How to look (\"at\": look at the player, \"with\": look in the same direction as the player)\nlookAtPosition: Look at specified coordinates.\nParams:\nx: (integer) x coordinate\ny: (integer) y coordinate\nz: (integer) z coordinate\ndigDown: Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.\nParams:\ndistance: (integer) Distance to dig down\ngoToSurface: Moves the bot to the highest block above it (usually the surface).\nParams:\nuseOn: Use (right click) the given tool on the nearest target of the given type.\nParams:\ntool_name: (string) Name of the tool to use, or \"hand\" for no tool.\ntarget: (string) The target as an entity type, block type, or \"nothing\" for no target.\n*\n\nExamples of how to respond:\nExample 1:\nUser input: miner_32: Hey! What are you up to?\nYour output:\nNothing much miner_32, what do you need?\n\nExample 2:\nUser input: rupert: Let us work together on a small house.\nYour output:\nSure, I can coordinate with you and handle my part.\nUser input: rupert: I will build the walls if you prepare materials.\nYour output:\nSounds good. I will prepare materials first.\n\n\nConversation Begin:\n", "messages": [{"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}, {"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "type": "function", "name": "inventory", "arguments": "{}"}]}, {"role": "tool", "content": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n", "tool_call_id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "name": "inventory"}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "type": "function", "name": "inventory", "arguments": "{}"}]}, {"role": "tool", "content": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n", "tool_call_id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "name": "inventory"}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}, {"role": "assistant", "content": "native tool check complete"}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_yMfjPKQLlKPEWOEVsF1wCAz5", "type": "function", "name": "craftRecipe", "arguments": "{\"recipe_name\":\"iron_helmet\",\"num\":1}"}]}, {"role": "tool", "content": "Action output:\nPlaced crafting_table at (-179, 34, -27).\nSuccessfully crafted iron_helmet, you now have 1 iron_helmet.\nFailed to place torch at (-179, 34, -27): Error: Event blockUpdate:(-179, 34, -27) did not fire within timeout of 500ms.\nCollected 1 crafting_table.\n", "tool_call_id": "call_yMfjPKQLlKPEWOEVsF1wCAz5", "name": "craftRecipe"}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_dMEgzMMJyhvj3Xq7tebtnKjw", "type": "function", "name": "craftRecipe", "arguments": "{\"recipe_name\":\"bucket\",\"num\":1}"}]}, {"role": "tool", "content": "Action output:\nFailed to place crafting_table at (-179, 33, -27): Error: Event blockUpdate:(-179, 33, -27) did not fire within timeout of 500ms.\n!!Code threw exception!!\nError: Error: Recipe requires craftingTable, but one was not supplied: {\"result\":{\"id\":941,\"metadata\":null,\"count\":1},\"inShape\":[[{\"id\":842,\"metadata\":null,\"count\":1},{\"id\":-1,\"metadata\":null,\"count\":1},{\"id\":842,\"metadata\":null,\"count\":1}],[{\"id\":-1,\"metadata\":null,\"count\":1},{\"id\":842,\"metadata\":null,\"count\":1},{\"id\":-1,\"metadata\":null,\"count\":1}]],\"outShape\":null,\"ingredients\":null,\"delta\":[{\"id\":842,\"metadata\":null,\"count\":-3},{\"id\":941,\"metadata\":null,\"count\":1}],\"requiresTable\":true}\nStack trace:\nError: Recipe requires craftingTable, but one was not supplied: {\"result\":{\"id\":941,\"metadata\":null,\"count\":1},\"inShape\":[[{\"id\":842,\"metadata\":null,\"count\":1},{\"id\":-1,\"metadata\":null,\"count\":1},{\"id\":842,\"metadata\":null,\"count\":1}],[{\"id\":-1,\"metadata\":null,\"count\":1},{\"id\":842,\"metadata\":null,\"count\":1},{\"id\":-1,\"metadata\":null,\"count\":1}]],\"outShape\":null,\"ingredients\":null,\"delta\":[{\"id\":842,\"metadata\":null,\"count\":-3},{\"id\":941,\"metadata\":null,\"count\":1}],\"requiresTable\":true}\n at EventEmitter.craft (/Users/Ninot/NinotQuyi/mindcraft-native-tool/node_modules/mineflayer/lib/plugins/craft.js:15:13)\n at Module.craftRecipe (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/library/skills.js:151:15)\n at async file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/commands/actions.js:274:13\n at async actionFnWithAgent (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/commands/actions.js:17:13)\n at async ActionManager._executeAction (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/action_manager.js:113:13)\n at async Object.wrappedAction [as perform] (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/commands/actions.js:19:29)\n at async executeCommandToolCall (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/commands/tool_adapter.js:103:24)\n at async Agent._executeNativeToolCalls (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/agent.js:425:33)\n at async Agent.handleMessage (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/agent.js:332:37)\n at async SelfPrompter.startLoop (file:///Users/Ninot/NinotQuyi/mindcraft-native-tool/src/agent/self_prompter.js:68:32)\n", "tool_call_id": "call_dMEgzMMJyhvj3Xq7tebtnKjw", "name": "craftRecipe"}], "tools": [{"type": "function", "function": {"name": "stats", "description": "Get your bot's location, health, hunger, and time of day.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "inventory", "description": "Get your bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "nearbyBlocks", "description": "Get the blocks near the bot.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftable", "description": "Get the craftable items with the bot's inventory.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "entities", "description": "Get the nearby players and entities.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "modes", "description": "Get all available modes and their docs and see which are on/off.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "savedPlaces", "description": "List all saved locations.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "getCraftingPlan", "description": "Provides a comprehensive crafting plan for a specified item. This includes a breakdown of required ingredients, the exact quantities needed, and an analysis of missing ingredients or extra items needed based on the bot's current inventory.", "parameters": {"type": "object", "properties": {"targetItem": {"type": "string", "description": "The item that we are trying to craft"}, "quantity": {"type": "integer", "description": "The quantity of the item that we are trying to craft", "minimum": 1}}, "required": ["targetItem"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchWiki", "description": "Search the Minecraft Wiki for the given query.", "parameters": {"type": "object", "properties": {"query": {"type": "string", "description": "The query to search for."}}, "required": ["query"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "help", "description": "Lists all available commands and their descriptions.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "newAction", "description": "Perform new and unknown custom behaviors that are not available as a command.", "parameters": {"type": "object", "properties": {"prompt": {"type": "string", "description": "A natural language prompt to guide code generation. Make a detailed step-by-step plan."}}, "required": ["prompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stop", "description": "Force stop all actions and commands that are currently executing.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stfu", "description": "Stop all chatting and self prompting, but continue current action.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "restart", "description": "Restart the agent process.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearChat", "description": "Clear the chat history.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToPlayer", "description": "Go to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to go to."}, "closeness": {"type": "number", "description": "How close to get to the player.", "minimum": 0}}, "required": ["player_name", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "followPlayer", "description": "Endlessly follow the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "name of the player to follow."}, "follow_dist": {"type": "number", "description": "The distance to follow from.", "minimum": 0}}, "required": ["player_name", "follow_dist"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToCoordinates", "description": "Go to the given x, y, z location.", "parameters": {"type": "object", "properties": {"x": {"type": "number", "description": "The x coordinate."}, "y": {"type": "number", "description": "The y coordinate.", "minimum": -64, "maximum": 320}, "z": {"type": "number", "description": "The z coordinate."}, "closeness": {"type": "number", "description": "How close to get to the location.", "minimum": 0}}, "required": ["x", "y", "z", "closeness"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForBlock", "description": "Find and go to the nearest block of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to go to."}, "search_range": {"type": "number", "description": "The range to search for the block. Minimum 32.", "minimum": 10, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "searchForEntity", "description": "Find and go to the nearest entity of a given type in a given range.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to go to."}, "search_range": {"type": "number", "description": "The range to search for the entity.", "minimum": 32, "maximum": 512}}, "required": ["type", "search_range"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "moveAway", "description": "Move away from the current location in any direction by a given distance.", "parameters": {"type": "object", "properties": {"distance": {"type": "number", "description": "The distance to move away.", "minimum": 0}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "rememberHere", "description": "Save the current location with a given name.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name to remember the location as."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToRememberedPlace", "description": "Go to a saved location.", "parameters": {"type": "object", "properties": {"name": {"type": "string", "description": "The name of the location to go to."}}, "required": ["name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "givePlayer", "description": "Give the specified item to the given player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to give the item to."}, "item_name": {"type": "string", "description": "The name of the item to give."}, "num": {"type": "integer", "description": "The number of items to give.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["player_name", "item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "consume", "description": "Eat/drink the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to consume."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "equip", "description": "Equip the given item.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to equip."}}, "required": ["item_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "putInChest", "description": "Put the given item in the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to put in the chest."}, "num": {"type": "integer", "description": "The number of items to put in the chest.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "takeFromChest", "description": "Take the given items from the nearest chest.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to take."}, "num": {"type": "integer", "description": "The number of items to take.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "viewChest", "description": "View the items/counts of the nearest chest.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "discard", "description": "Discard the given item from the inventory.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the item to discard."}, "num": {"type": "integer", "description": "The number of items to discard.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "collectBlocks", "description": "Collect the nearest blocks of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to collect."}, "num": {"type": "integer", "description": "The number of blocks to collect.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["type", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "craftRecipe", "description": "Craft the given recipe a given number of times.", "parameters": {"type": "object", "properties": {"recipe_name": {"type": "string", "description": "The name of the output item to craft."}, "num": {"type": "integer", "description": "The number of times to craft the recipe. This is NOT the number of output items, as it may craft many more items depending on the recipe.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["recipe_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "smeltItem", "description": "Smelt the given item the given number of times.", "parameters": {"type": "object", "properties": {"item_name": {"type": "string", "description": "The name of the input item to smelt."}, "num": {"type": "integer", "description": "The number of times to smelt the item.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["item_name", "num"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "clearFurnace", "description": "Take all items out of the nearest furnace.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "placeHere", "description": "Place a given block in the current location. Do NOT use to build structures, only use for single blocks/torches.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The block type to place."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attack", "description": "Attack and kill the nearest entity of a given type.", "parameters": {"type": "object", "properties": {"type": {"type": "string", "description": "The type of entity to attack."}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "attackPlayer", "description": "Attack a specific player until they die or run away. Remember this is just a game and does not cause real life harm.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to attack."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToBed", "description": "Go to the nearest bed and sleep.", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "stay", "description": "Stay in the current location no matter what. Pauses all modes.", "parameters": {"type": "object", "properties": {"type": {"type": "integer", "description": "The number of seconds to stay. -1 for forever.", "minimum": -1, "maximum": 9007199254740991}}, "required": ["type"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "setMode", "description": "Set a mode to on or off. A mode is an automatic behavior that constantly checks and responds to the environment.", "parameters": {"type": "object", "properties": {"mode_name": {"type": "string", "description": "The name of the mode to enable."}, "on": {"type": "boolean", "description": "Whether to enable or disable the mode."}}, "required": ["mode_name", "on"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goal", "description": "Set a goal prompt to endlessly work towards with continuous self-prompting.", "parameters": {"type": "object", "properties": {"selfPrompt": {"type": "string", "description": "The goal prompt."}}, "required": ["selfPrompt"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endGoal", "description": "Call when you have accomplished your goal. It will stop self-prompting and the current action. ", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "showVillagerTrades", "description": "Show trades of a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}}, "required": ["id"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "tradeWithVillager", "description": "Trade with a specified villager.", "parameters": {"type": "object", "properties": {"id": {"type": "integer", "description": "The id number of the villager that you want to trade with."}, "index": {"type": "integer", "description": "The index of the trade you want executed (1-indexed).", "minimum": 1, "maximum": 9007199254740991}, "count": {"type": "integer", "description": "How many times that trade should be executed.", "minimum": 1, "maximum": 9007199254740991}}, "required": ["id", "index", "count"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "startConversation", "description": "Start a conversation with a bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to send the message to."}, "message": {"type": "string", "description": "The message to send."}}, "required": ["player_name", "message"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "endConversation", "description": "End the conversation with the given bot. (FOR OTHER BOTS ONLY)", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "The name of the player to end the conversation with."}}, "required": ["player_name"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPlayer", "description": "Look at a player or look in the same direction as the player.", "parameters": {"type": "object", "properties": {"player_name": {"type": "string", "description": "Name of the target player"}, "direction": {"type": "string", "description": "How to look (\"at\": look at the player, \"with\": look in the same direction as the player)"}}, "required": ["player_name", "direction"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "lookAtPosition", "description": "Look at specified coordinates.", "parameters": {"type": "object", "properties": {"x": {"type": "integer", "description": "x coordinate"}, "y": {"type": "integer", "description": "y coordinate"}, "z": {"type": "integer", "description": "z coordinate"}}, "required": ["x", "y", "z"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "digDown", "description": "Digs down a specified distance. Will stop if it reaches lava, water, or a fall of >=4 blocks below the bot.", "parameters": {"type": "object", "properties": {"distance": {"type": "integer", "description": "Distance to dig down", "minimum": 1, "maximum": 9007199254740991}}, "required": ["distance"], "additionalProperties": false}}}, {"type": "function", "function": {"name": "goToSurface", "description": "Moves the bot to the highest block above it (usually the surface).", "parameters": {"type": "object", "properties": {}, "required": [], "additionalProperties": false}}}, {"type": "function", "function": {"name": "useOn", "description": "Use (right click) the given tool on the nearest target of the given type.", "parameters": {"type": "object", "properties": {"tool_name": {"type": "string", "description": "Name of the tool to use, or \"hand\" for no tool."}, "target": {"type": "string", "description": "The target as an entity type, block type, or \"nothing\" for no target."}}, "required": ["tool_name", "target"], "additionalProperties": false}}}], "tool_count": 51}, {"timestamp": "2026-05-02T02:49:11.936Z", "agent": "codex", "type": "llm_response", "tag": "conversation", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "response": {"type": "tool_calls", "provider": "codex-chatgpt", "tool_calls": [{"id": "call_yfmSKsyHrOdCpn7TQyTrdJ0F", "type": "function", "name": "inventory", "arguments": "{}"}, {"id": "call_B3PqghWouGkJbuelal2hDT77", "type": "function", "name": "stats", "arguments": "{}"}, {"id": "call_sNHPGuzme6lBrVorqgV3IVbb", "type": "function", "name": "nearbyBlocks", "arguments": "{}"}]}}, {"timestamp": "2026-05-02T02:49:11.945Z", "agent": "codex", "type": "llm_request", "tag": "memSaving", "model": {"api": "codex", "provider": "codex-chatgpt", "model": "gpt-5.5", "supports_native_tool_calls": true}, "system_prompt": "You are a minecraft bot named codex 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: '中文交流;MC bot codex。行动用native工具,勿聊天输出命令;人类!可。重启/被要求先native查状态简短确认。newAction勿带```js。速通:木石→食物床火把→铁装盾桶→岩浆门→下界棒+珍珠/交易→眼找要塞→打龙,自保优先。地标:营地床(-197,67,-19);煤洞(-101,58,15);矿区(-198~-207,50~59,-1~-19);深矿炉(-192,35,-25)。深矿已清空间,工作台可放会回收;近(-178,32,-26)有矿道迹象(栅栏/蛛网)与铁/煤,铁装进度到胸裤鞋+盾;下一步做头盔桶、熔铁、继续采铁煤。'\nRecent conversation:\nUser input: System: Use native tool calling to check your inventory, then say: native tool check complete.\nUser input: System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:\nUser input: System: Use native tool calling to check your inventory, then say: native tool check complete.\nTool call (inventory): {}\nTool result (inventory): \nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n\nTool call (inventory): {}\nTool result (inventory): \nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\nSummarize your old memory and recent conversation into a new memory, and respond only with the unwrapped memory text:\n", "messages": [{"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "user", "content": "System: You are self-prompting with the goal: '通关Minecraft:从零开始高效生存,先收集木头并制作基础工具,再获取石制工具、食物、床和煤炭/火把;下矿获得铁装备和盾,找岩浆池造下界门,进入下界获取烈焰棒和末影珍珠/交易,合成末影之眼寻找要塞,准备弓箭/食物/方块,进入末地击败末影龙。每一步注意自保,缺什么就采集/制作什么,完成后调用endGoal。'. Your next response MUST contain a command with this syntax: !commandName. Respond:"}, {"role": "user", "content": "System: Use native tool calling to check your inventory, then say: native tool check complete."}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "type": "function", "name": "inventory", "arguments": "{}"}]}, {"role": "tool", "content": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n", "tool_call_id": "call_WWy3EY6hRuohBnX2MFVyZKjF", "name": "inventory"}, {"role": "assistant", "content": "", "native_tool_calls": [{"id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "type": "function", "name": "inventory", "arguments": "{}"}]}, {"role": "tool", "content": "\nINVENTORY\n- iron_chestplate: 1\n- iron_leggings: 1\n- iron_boots: 1\n- wooden_pickaxe: 1\n- oak_sapling: 1\n- oak_planks: 4\n- mutton: 4\n- stone_sword: 1\n- coal: 5\n- granite: 66\n- raw_copper: 5\n- diorite: 7\n- iron_ingot: 8\n- torch: 4\n- cobblestone: 197\n- iron_pickaxe: 1\n- stick: 12\n- crafting_table: 1\n- stone_axe: 1\n- shield: 1\nWEARING: \nTorso: iron_chestplate\nLegs: iron_leggings\nFeet: iron_boots\n", "tool_call_id": "call_JhkL0rnHC91T1ymcDVXGsbsQ", "name": "inventory"}], "tools": null, "tool_count": 0}] \ No newline at end of file diff --git a/tests/agent_native_text_policy.test.js b/tests/agent_native_text_policy.test.js index afc33884f..6f3c626ef 100644 --- a/tests/agent_native_text_policy.test.js +++ b/tests/agent_native_text_policy.test.js @@ -886,6 +886,22 @@ test('running tool details do not preserve template whitespace as large blank ro assert.ok(html.includes('.chat-tool-output-extra > .chat-details')); }); +test('tool call parameters wrap without displacing the running status column', () => { + const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); + const paramsRules = [...html.matchAll(/\.agent-step-params \{[\s\S]*?\}/g)].map(match => match[0]).join('\n'); + const headerRules = [...html.matchAll(/\.agent-step-header,\n\s*\.agent-steps \.agent-step-header \{[\s\S]*?\}/g)].map(match => match[0]).join('\n'); + const statusRules = [...html.matchAll(/\.agent-step-status \{[\s\S]*?\}/g)].map(match => match[0]).join('\n'); + + assert.ok(paramsRules.includes('white-space: normal;')); + assert.ok(paramsRules.includes('overflow-wrap: anywhere;')); + assert.ok(paramsRules.includes('word-break: break-word;')); + assert.ok(paramsRules.includes('display: block;')); + assert.ok(headerRules.includes('grid-template-columns: 16px max-content minmax(0, 1fr) auto;')); + assert.ok(statusRules.includes('grid-column: 4;')); + assert.ok(statusRules.includes('justify-self: end;')); + assert.ok(statusRules.includes('white-space: nowrap;')); +}); + test('runtime status UI avoids rebuilding inventory and armor DOM when state is unchanged', () => { const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); From d8b2dee6305a98d5cc914d1eaa8ae1e2d905a688 Mon Sep 17 00:00:00 2001 From: NinotQuyi Date: Sun, 3 May 2026 20:42:33 +0800 Subject: [PATCH 44/56] Stabilize native tool branching and document PR scope The branch now keeps botResponder decisions on the conversation cache prefix, moves newAction coding into an isolated request path, and records the PR review surface in markdown. The settings template is committed intentionally so the default local multi-agent preset includes codex, kimi, and minimax. Constraint: botResponder must not rewrite the system prompt or rebuild history Constraint: newAction must not share mutable adapter state with conversation turns Rejected: Reuse the chat model instance for coding | shared request state polluted cache and trace metadata Confidence: high Scope-risk: moderate Directive: Keep cacheScope as logical trace metadata; only transportCacheScope should change prompt_cache_key/session_id Tested: node --test tests/agent_native_text_policy.test.js tests/codex_chatgpt.test.js tests/native_prompt_hygiene.test.js Tested: npm test Tested: npx eslint src/models/codex_chatgpt/client.js src/models/prompter.js src/agent/coder.js tests/codex_chatgpt.test.js tests/agent_native_text_policy.test.js tests/native_prompt_hygiene.test.js Tested: git diff --check Not-tested: live Minecraft join after restarting MindServer Co-authored-by: OmX --- PR_DEVELOP_SUMMARY.md | 25 ++ develop-diff-summary.md | 261 +++++++++++++++++++++ profiles/defaults/prompts/bot_responder.md | 23 +- settings.js | 2 +- src/agent/coder.js | 20 +- src/mindcraft/public/index.html | 5 +- src/models/codex_chatgpt/client.js | 103 ++++++-- src/models/prompter.js | 128 ++++++---- tests/agent_native_text_policy.test.js | 111 ++++++++- tests/codex_chatgpt.test.js | 52 ++-- tests/native_prompt_hygiene.test.js | 3 + 11 files changed, 633 insertions(+), 100 deletions(-) create mode 100644 PR_DEVELOP_SUMMARY.md create mode 100644 develop-diff-summary.md diff --git a/PR_DEVELOP_SUMMARY.md b/PR_DEVELOP_SUMMARY.md new file mode 100644 index 000000000..1e771f972 --- /dev/null +++ b/PR_DEVELOP_SUMMARY.md @@ -0,0 +1,25 @@ +# PR Summary vs develop + +This PR updates Mindcraft from the legacy text-command/provider layout toward a native tool-calling runtime with richer multi-agent traceability and broader provider support. + +## Functional changes + +- Adds native function/tool calling for agent actions, including structured tool calls/results in conversation history. +- Adds native ChatGPT Codex login support, enabling the Codex/ChatGPT Responses transport through a ChatGPT account login flow. +- Adds provider-registry based LLM configuration via `settings_llm_providers.json`, including Kimi, MiniMax, OpenRouter, Gemini, Codex, Replicate, and other presets. +- Splits editable default prompts into markdown files under `profiles/defaults/prompts/` for easier review and prompt maintenance. +- Improves message priority handling so user/admin messages interrupt active actions before the next model turn. +- Makes `newAction`/coding requests independent from the main conversation request path so coding does not pollute conversation cache/state. +- Adds bot-to-bot response branching (`botResponder`) that forks the current conversation context instead of rebuilding or replacing the system prompt. +- Expands Runtime UI trace rendering for native tool calls, tool results, branch decisions, token/cache metadata, and active tool status. + +## Review notes + +- The largest review surface is in `src/agent/`, `src/models/`, and `src/mindcraft/public/index.html`. +- New test coverage was added for native tools, provider config, Codex ChatGPT transport, conversation queueing, trace projection, token usage, and prompt hygiene. +- `settings.js` is updated as the working template and enables the current multi-agent preset profiles. + +## Verification + +- Targeted native-tool/Codex/prompt tests have been run successfully. +- ESLint has been run on the recently changed implementation and test files. diff --git a/develop-diff-summary.md b/develop-diff-summary.md new file mode 100644 index 000000000..e904ea704 --- /dev/null +++ b/develop-diff-summary.md @@ -0,0 +1,261 @@ +# Diff Summary vs develop + +- Generated: 2026-05-03 20:30:51 +- Branch: `native-tool` (`4ffd9f9`) +- Compared against: `develop` (`8acbd90`) +- Diff command: `git diff develop` (includes current working tree / uncommitted changes) + +## Overall + +| Metric | Value | +|---|---:| +| Files changed | 133 | +| Added files | 58 | +| Modified files | 52 | +| Deleted files | 23 | +| Renamed files | 0 | +| Shortstat | `133 files changed, 15090 insertions(+), 2787 deletions(-)` | + +> Note: committed-only branch diff (`git diff develop...HEAD`) is: +> `133 files changed, 14822 insertions(+), 2766 deletions(-)` +> The difference comes from local uncommitted working-tree changes listed below. + +## Current uncommitted changes + +- ` M profiles/defaults/prompts/bot_responder.md` +- ` M settings.js` +- ` M src/agent/coder.js` +- ` M src/mindcraft/public/index.html` +- ` M src/models/codex_chatgpt/client.js` +- ` M src/models/prompter.js` +- ` M tests/agent_native_text_policy.test.js` +- ` M tests/codex_chatgpt.test.js` +- ` M tests/native_prompt_hygiene.test.js` + +## Directory / area churn + +| Area | Files | Additions | Deletions | Churn | +|---|---:|---:|---:|---:| +| `src/` | 63 | 9201 | 2266 | 11467 | +| `tests/` | 16 | 4247 | 0 | 4247 | +| `profiles/` | 39 | 749 | 453 | 1202 | +| `scripts/` | 3 | 496 | 0 | 496 | +| `settings_llm_providers.example.json` | 1 | 285 | 0 | 285 | +| `settings.js` | 1 | 62 | 27 | 89 | +| `andy.json` | 1 | 17 | 4 | 21 | +| `keys.example.json` | 1 | 0 | 19 | 19 | +| `package.json` | 1 | 7 | 4 | 11 | +| `eslint.config.js` | 1 | 6 | 3 | 9 | +| `tasks/` | 1 | 4 | 4 | 8 | +| `.gitignore` | 1 | 5 | 1 | 6 | +| `.dockerignore` | 1 | 4 | 1 | 5 | +| `README.md` | 1 | 2 | 3 | 5 | +| `main.js` | 1 | 4 | 1 | 5 | +| `FAQ.md` | 1 | 1 | 1 | 2 | + +## Top churn files + +| File | Additions | Deletions | Churn | Status | +|---|---:|---:|---:|---| +| `src/mindcraft/public/index.html` | 2649 | 41 | 2690 | M | +| `tests/agent_native_text_policy.test.js` | 1127 | 0 | 1127 | A | +| `tests/codex_chatgpt.test.js` | 1051 | 0 | 1051 | A | +| `src/models/codex_chatgpt/client.js` | 609 | 0 | 609 | A | +| `src/agent/history.js` | 574 | 29 | 603 | M | +| `src/models/native_tools.js` | 584 | 0 | 584 | A | +| `src/models/codex_chatgpt/auth.js` | 500 | 0 | 500 | A | +| `src/agent/agent.js` | 436 | 46 | 482 | M | +| `tests/chat_history_trace.test.js` | 439 | 0 | 439 | A | +| `src/models/prompter.js` | 310 | 79 | 389 | M | +| `tests/openai_compatible.test.js` | 389 | 0 | 389 | A | +| `tests/native_tools.test.js` | 370 | 0 | 370 | A | +| `src/mindcraft/public/chat_trace_projector.js` | 351 | 0 | 351 | A | +| `src/models/openai_compatible.js` | 329 | 0 | 329 | A | +| `src/models/codex_chatgpt/transport.js` | 288 | 0 | 288 | A | +| `settings_llm_providers.example.json` | 285 | 0 | 285 | A | +| `scripts/smoke/live_model_matrix.js` | 282 | 0 | 282 | A | +| `src/mindcraft/mindserver.js` | 263 | 7 | 270 | M | +| `src/models/google_generative_ai.js` | 266 | 0 | 266 | A | +| `src/models/replicate.js` | 206 | 56 | 262 | M | +| `profiles/defaults/_default.json` | 17 | 242 | 259 | M | +| `src/models/codex_chatgpt/protocol.js` | 249 | 0 | 249 | A | +| `tests/conversation_queue.test.js` | 188 | 0 | 188 | A | +| `src/models/gemini.js` | 0 | 176 | 176 | D | +| `src/models/_model_map.js` | 140 | 31 | 171 | M | +| `src/agent/commands/tool_adapter.js` | 170 | 0 | 170 | A | +| `scripts/smoke/live_function_call_smoke.js` | 169 | 0 | 169 | A | +| `src/agent/state_snapshot.js` | 152 | 0 | 152 | A | +| `src/models/gpt.js` | 0 | 147 | 147 | D | +| `tests/llm_providers_config.test.js` | 143 | 0 | 143 | A | +| `tests/mindserver_chat_history.test.js` | 138 | 0 | 138 | A | +| `src/agent/conversation.js` | 94 | 43 | 137 | M | +| `src/models/anthropic_messages.js` | 132 | 0 | 132 | A | +| `src/agent/library/skills.js` | 110 | 17 | 127 | M | +| `src/agent/react_message_manager.js` | 118 | 0 | 118 | A | +| `src/models/openai_responses.js` | 117 | 0 | 117 | A | +| `src/models/ollama.js` | 0 | 115 | 115 | D | +| `src/models/hyperbolic.js` | 0 | 114 | 114 | D | +| `profiles/defaults/prompts/coding.md` | 110 | 0 | 110 | A | +| `tests/native_prompt_hygiene.test.js` | 98 | 0 | 98 | A | + +## Added files + +- `profiles/cerebras.json` (+19/-0) +- `profiles/codex.json` (+25/-0) +- `profiles/defaults/prompts/bot_responder.md` (+11/-0) +- `profiles/defaults/prompts/coding.md` (+110/-0) +- `profiles/defaults/prompts/conversing.md` (+34/-0) +- `profiles/defaults/prompts/image_analysis.md` (+1/-0) +- `profiles/defaults/prompts/saving_memory.md` (+48/-0) +- `profiles/groq.json` (+19/-0) +- `profiles/kimi.json` (+19/-0) +- `profiles/minimax-cn.json` (+19/-0) +- `profiles/minimax-intl.json` (+19/-0) +- `profiles/novita.json` (+19/-0) +- `profiles/ollama.json` (+19/-0) +- `profiles/openrouter.json` (+19/-0) +- `profiles/qwen-cn.json` (+20/-0) +- `profiles/replicate.json` (+19/-0) +- `profiles/siliconflow.json` (+19/-0) +- `profiles/tasks/construction_prompt.md` (+14/-0) +- `profiles/tasks/cooking_prompt.md` (+19/-0) +- `profiles/tasks/crafting_prompt.md` (+14/-0) +- `scripts/smoke/dev_native_tool_loop.js` (+45/-0) +- `scripts/smoke/live_function_call_smoke.js` (+169/-0) +- `scripts/smoke/live_model_matrix.js` (+282/-0) +- `settings_llm_providers.example.json` (+285/-0) +- `src/agent/commands/tool_adapter.js` (+170/-0) +- `src/agent/react_message_manager.js` (+118/-0) +- `src/agent/state_snapshot.js` (+152/-0) +- `src/mindcraft/public/chat_trace_projector.js` (+351/-0) +- `src/models/anthropic_messages.js` (+132/-0) +- `src/models/azure_openai_responses.js` (+36/-0) +- `src/models/codex_chatgpt.js` (+15/-0) +- `src/models/codex_chatgpt/auth.js` (+500/-0) +- `src/models/codex_chatgpt/client.js` (+609/-0) +- `src/models/codex_chatgpt/constants.js` (+12/-0) +- `src/models/codex_chatgpt/protocol.js` (+249/-0) +- `src/models/codex_chatgpt/transport.js` (+288/-0) +- `src/models/codex_chatgpt/utils.js` (+86/-0) +- `src/models/google_generative_ai.js` (+266/-0) +- `src/models/native_tools.js` (+584/-0) +- `src/models/openai_compatible.js` (+329/-0) +- `src/models/openai_responses.js` (+117/-0) +- `src/models/token_usage.js` (+81/-0) +- `tests/agent_native_text_policy.test.js` (+1127/-0) +- `tests/chat_history_trace.test.js` (+439/-0) +- `tests/codex_chatgpt.test.js` (+1051/-0) +- `tests/conversation_queue.test.js` (+188/-0) +- `tests/llm_providers_config.test.js` (+143/-0) +- `tests/memory_summary_tool_history.test.js` (+25/-0) +- `tests/mindserver_chat_history.test.js` (+138/-0) +- `tests/mindserver_settings_spec.test.js` (+17/-0) +- `tests/native_prompt_hygiene.test.js` (+98/-0) +- `tests/native_tools.test.js` (+370/-0) +- `tests/openai_compatible.test.js` (+389/-0) +- `tests/profile_shape.test.js` (+97/-0) +- `tests/prompt_markdown_refs.test.js` (+18/-0) +- `tests/token_usage.test.js` (+50/-0) +- `tests/tool_result_policy.test.js` (+64/-0) +- `tests/vision_interpreter.test.js` (+33/-0) + +## Deleted files + +- `keys.example.json` (+0/-19) +- `profiles/qwen.json` (+0/-17) +- `src/models/azure.js` (+0/-32) +- `src/models/cerebras.js` (+0/-61) +- `src/models/claude.js` (+0/-87) +- `src/models/deepseek.js` (+0/-59) +- `src/models/gemini.js` (+0/-176) +- `src/models/glhf.js` (+0/-71) +- `src/models/gpt.js` (+0/-147) +- `src/models/grok.js` (+0/-82) +- `src/models/groq.js` (+0/-95) +- `src/models/huggingface.js` (+0/-86) +- `src/models/hyperbolic.js` (+0/-114) +- `src/models/lmstudio.js` (+0/-74) +- `src/models/mercury.js` (+0/-95) +- `src/models/mistral.js` (+0/-94) +- `src/models/novita.js` (+0/-71) +- `src/models/ollama.js` (+0/-115) +- `src/models/openrouter.js` (+0/-77) +- `src/models/qwen.js` (+0/-80) +- `src/models/vllm.js` (+0/-78) +- `src/utils/examples.js` (+0/-83) +- `src/utils/translator.js` (+0/-30) + +## Modified files + +### (root) + +- `.dockerignore` (+4/-1) +- `.gitignore` (+5/-1) +- `FAQ.md` (+1/-1) +- `README.md` (+2/-3) +- `andy.json` (+17/-4) +- `eslint.config.js` (+6/-3) +- `main.js` (+4/-1) +- `package.json` (+7/-4) +- `settings.js` (+62/-27) + +### profiles/ + +- `profiles/andy-4-reasoning.json` (+16/-11) +- `profiles/andy-4.json` (+16/-4) +- `profiles/azure.json` (+13/-13) +- `profiles/claude.json` (+17/-5) +- `profiles/claude_thinker.json` (+14/-4) +- `profiles/deepseek.json` (+17/-5) +- `profiles/defaults/_default.json` (+17/-242) +- `profiles/freeguy.json` (+18/-5) +- `profiles/gemini.json` (+17/-6) +- `profiles/gpt.json` (+16/-4) +- `profiles/grok.json` (+17/-5) +- `profiles/llama.json` (+17/-7) +- `profiles/mercury.json` (+17/-6) +- `profiles/mistral.json` (+17/-3) +- `profiles/tasks/construction_profile.json` (+5/-37) +- `profiles/tasks/cooking_profile.json` (+7/-6) +- `profiles/tasks/crafting_profile.json` (+7/-67) +- `profiles/vllm.json` (+15/-6) + +### src/ + +- `src/agent/action_manager.js` (+32/-16) +- `src/agent/agent.js` (+436/-46) +- `src/agent/coder.js` (+40/-16) +- `src/agent/commands/actions.js` (+14/-11) +- `src/agent/commands/index.js` (+3/-2) +- `src/agent/conversation.js` (+94/-43) +- `src/agent/history.js` (+574/-29) +- `src/agent/library/skill_library.js` (+5/-0) +- `src/agent/library/skills.js` (+110/-17) +- `src/agent/mindserver_proxy.js` (+13/-0) +- `src/agent/modes.js` (+1/-0) +- `src/agent/self_prompter.js` (+9/-3) +- `src/agent/speak.js` (+63/-32) +- `src/agent/vision/vision_interpreter.js` (+16/-4) +- `src/mindcraft/mindcraft.js` (+5/-2) +- `src/mindcraft/mindserver.js` (+263/-7) +- `src/mindcraft/public/index.html` (+2649/-41) +- `src/mindcraft/public/settings_spec.json` (+31/-12) +- `src/models/_model_map.js` (+140/-31) +- `src/models/prompter.js` (+310/-79) +- `src/models/replicate.js` (+206/-56) +- `src/process/agent_process.js` (+3/-3) +- `src/utils/keys.js` (+35/-5) +- `src/utils/text.js` (+54/-4) + +### tasks/ + +- `tasks/evaluation_script.py` (+4/-4) + +## Review-oriented summary + +- Biggest review surface is `src/`, especially runtime/chat UI, native tool plumbing, model adapters, and agent history/message flow. +- Test coverage was expanded substantially under `tests/` with new native-tool, provider, trace, queue, token-usage, and UI projection tests. +- Provider layer changed from many provider-specific files toward registry-driven adapters and shared protocol implementations. +- Prompt text moved into markdown files under `profiles/defaults/prompts/`, making prompt edits easier to review separately from code. +- Local uncommitted files are included in this report because the diff target is current worktree vs `develop`. + diff --git a/profiles/defaults/prompts/bot_responder.md b/profiles/defaults/prompts/bot_responder.md index d79dbd376..029c37be7 100644 --- a/profiles/defaults/prompts/bot_responder.md +++ b/profiles/defaults/prompts/bot_responder.md @@ -1,12 +1,11 @@ -You are a Minecraft bot named $NAME that is currently in conversation with another AI bot. Both of you may be taking actions through native tools/function calls, 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. Do not write command syntax or pretend to call tools in this decision. -Example 1: You: Building a house. -Other Bot: 'Come here!' -Your decision: ignore -Example 2: You: Collecting dirt. -Other Bot: 'No, collect some wood instead.' -Your decision: respond -Example 3: You: Coming to you now. -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: +Decide whether to respond now or ignore the incoming message while preserving your current action. + +Current bot: $NAME +Current action: $ACTION + +Incoming message: +$INCOMING_MESSAGE + +Respond immediately only if the message requires changing or stopping your current action, asks for necessary information, or needs urgent coordination. Otherwise ignore it and keep working. + +Do not call tools or write command syntax. Output ONLY one word: respond or ignore. diff --git a/settings.js b/settings.js index 0473f1819..47fad3ac0 100644 --- a/settings.js +++ b/settings.js @@ -54,7 +54,7 @@ const settings = { // "profiles/codex.json", // Codex ChatGPT login: codex:gpt-5.5 // "profiles/openrouter.json",// OpenRouter / OpenAI Chat Completions: moonshotai/kimi-k2.6 // "profiles/kimi.json", // Kimi Anthropic-compatible: kimi-k2.6 - // "profiles/gemini.json", // Gemini / google-generative-ai: gemini-3.1-pro-preview + // "profiles/gemini.json", // Gemini / google-generative-ai: gemini-2.5-flash // OpenAI / ChatGPT // "profiles/gpt.json", diff --git a/src/agent/coder.js b/src/agent/coder.js index 61f1a0253..2009b7315 100644 --- a/src/agent/coder.js +++ b/src/agent/coder.js @@ -46,7 +46,17 @@ export class Coder { if (this.agent.bot.interrupt_code) return null; const messages_copy = JSON.parse(JSON.stringify(messages)); - let res = await this.agent.prompter.promptCoding(messages_copy); + const llmAbortController = this.agent.beginActiveLLMRequest?.(); + let res; + try { + res = await this.agent.prompter.promptCoding(messages_copy, { signal: llmAbortController?.signal }); + } catch (error) { + if (this.agent.bot.interrupt_code || isAbortError(error)) + return null; + throw error; + } finally { + this.agent.endActiveLLMRequest?.(llmAbortController); + } if (this.agent.bot.interrupt_code) return null; let contains_code = res.indexOf('```') !== -1; @@ -130,7 +140,7 @@ export class Coder { if (missingSkills.length > 0) { result += 'These functions do not exist:\n'; result += missingSkills.join('\n'); - console.log(result) + console.log(result); return result; } @@ -205,7 +215,7 @@ export class Coder { _sanitizeCode(code) { code = code.trim(); - const remove_strs = ['Javascript', 'javascript', 'js'] + const remove_strs = ['Javascript', 'javascript', 'js']; for (let r of remove_strs) { if (code.startsWith(r)) { code = code.slice(r.length); @@ -236,3 +246,7 @@ export function createCodeGenerationMessages(prompt) { content: `Code generation task:\n${task}\n\nWrite the implementation as a JavaScript code block.` }]; } + +function isAbortError(error) { + return error?.name === 'AbortError' || String(error?.message || error || '').includes('aborted'); +} diff --git a/src/mindcraft/public/index.html b/src/mindcraft/public/index.html index b1ebb89c7..4e749acfd 100644 --- a/src/mindcraft/public/index.html +++ b/src/mindcraft/public/index.html @@ -2939,8 +2939,11 @@

Inventory

} function getBranchQuestionText(request) { + if (request?.incoming_message) return request.incoming_message; const messages = Array.isArray(request?.messages) ? request.messages : []; - const lastUser = messages.slice().reverse().find(message => message?.role === 'user'); + const reversedUsers = messages.slice().reverse().filter(message => message?.role === 'user'); + const incomingUser = reversedUsers.find(message => String(message?.content || '').includes('(FROM OTHER BOT)')); + const lastUser = incomingUser || reversedUsers[0]; return lastUser ? formatChatMessagePreview(lastUser) : ''; } diff --git a/src/models/codex_chatgpt/client.js b/src/models/codex_chatgpt/client.js index 5aa0f72fd..be1a70f08 100644 --- a/src/models/codex_chatgpt/client.js +++ b/src/models/codex_chatgpt/client.js @@ -116,6 +116,8 @@ export class CodexChatGPT { delete this.params.enable_previous_response_id; this.turnStateByKey = new Map(); this.responseContinuityByKey = new Map(); + this.requestStateByOptions = new WeakMap(); + this.lastCompletedRequestState = null; this.lastRequestCacheTrace = null; } @@ -126,6 +128,7 @@ export class CodexChatGPT { } async sendRequest(turns, systemMessage, stop_seq='***', tools=null, options = {}) { + const requestState = this.beginRequestState(options); this.lastTokenUsage = null; this.lastThinking = ''; const model = this.model_name || this.default_model; @@ -162,8 +165,10 @@ export class CodexChatGPT { const parsed = await parseCodexResponsesSse(await response.text()); this.rememberResponseContinuity(response.codexSentOptions || options, response.codexSentBody || body, parsed); console.log('Received.'); - setLastTokenUsage(this, parsed.usage); - this.lastThinking = parsed.thinking || ''; + requestState.tokenUsage = setLastTokenUsage(this, parsed.usage); + requestState.thinking = parsed.thinking || ''; + this.lastThinking = requestState.thinking; + this.lastCompletedRequestState = requestState; if (parsed.toolCalls.length > 0) { return createNativeToolResponse(parsed.toolCalls, this.provider, { thinking: this.lastThinking }); } @@ -196,7 +201,7 @@ export class CodexChatGPT { } buildRequestBody(model, turns, systemMessage, tools=null, options = {}) { - const promptCacheKey = buildScopedPromptCacheKey(this.sessionId, options?.cacheScope); + const promptCacheKey = this.getTransportSessionId(options); const reasoning = buildCodexReasoning(this.params.reasoning); const include = buildCodexInclude(this.params.include, reasoning); const body = { @@ -222,13 +227,14 @@ export class CodexChatGPT { } getCacheTraceMetadata(options = {}) { - const scopedSessionId = buildScopedPromptCacheKey(this.sessionId, options?.cacheScope); + const scopedSessionId = this.getTransportSessionId(options); const responseContinuityKey = this.getResponseContinuityKey(options); const responseContinuityEntries = responseContinuityKey ? this.responseContinuityByKey.get(responseContinuityKey) : null; return { cache_scope: options?.cacheScope || null, + transport_cache_scope: this.getTransportCacheScope(options) || null, turn_state_key: options?.turnStateKey || null, transport_cache: { protocol: 'openai-codex-responses', @@ -240,10 +246,17 @@ export class CodexChatGPT { }; } - consumeLastRequestCacheTrace() { - const value = this.lastRequestCacheTrace; - this.lastRequestCacheTrace = null; - return value || null; + consumeLastRequestTraceMetadata(options = {}) { + const state = this.consumeRequestState(options); + return { + transport_cache: state?.cacheTrace || null, + token_usage: state?.tokenUsage || null, + thinking: state?.thinking || '' + }; + } + + consumeLastRequestCacheTrace(options = {}) { + return this.consumeLastRequestTraceMetadata(options).transport_cache || null; } async fetchResponses(endpoint, body, auth, options = {}) { @@ -260,15 +273,17 @@ export class CodexChatGPT { } } const httpBody = expandContinuityRequestBody(body); - if (httpBody !== body && this.lastRequestCacheTrace?.incremental_reuse) { - this.lastRequestCacheTrace = { - ...this.lastRequestCacheTrace, + const requestState = this.getRequestState(options); + if (httpBody !== body && requestState.cacheTrace?.incremental_reuse) { + requestState.cacheTrace = { + ...requestState.cacheTrace, previous_response_id: null, incremental_input_items: null, - full_input_items: Array.isArray(httpBody.input) ? httpBody.input.length : this.lastRequestCacheTrace.full_input_items, + full_input_items: Array.isArray(httpBody.input) ? httpBody.input.length : requestState.cacheTrace.full_input_items, incremental_reuse: false, incremental_reuse_reason: 'http_previous_response_unsupported' }; + this.lastRequestCacheTrace = requestState.cacheTrace; } this.lastSentResponsesBody = httpBody; this.lastSentResponsesOptions = options; @@ -359,7 +374,7 @@ export class CodexChatGPT { } buildHeaders(auth, options = {}) { - const scopedSessionId = buildScopedPromptCacheKey(this.sessionId, options?.cacheScope); + const scopedSessionId = this.getTransportSessionId(options); const headers = { 'Authorization': `Bearer ${auth.accessToken}`, 'ChatGPT-Account-ID': auth.accountId, @@ -411,23 +426,24 @@ export class CodexChatGPT { // branch-aware continuity machinery behind an explicit transport opt-in // so the default HTTP path relies on prompt_cache_key and never 400s. if (!this.enablePreviousResponseId && !options?.transportSupportsPreviousResponseId) return null; - return buildScopedPromptCacheKey(this.sessionId, options?.cacheScope); + return this.getTransportSessionId(options); } getTurnScopedContinuityKey(options = {}) { const turnStateKey = String(options?.turnStateKey || '').trim(); if (!turnStateKey) return null; - const scopedSessionId = buildScopedPromptCacheKey(this.sessionId, options?.cacheScope); + const scopedSessionId = this.getTransportSessionId(options); return buildScopedPromptCacheKey(scopedSessionId, `turn:${turnStateKey}`); } applyPreviousResponseContinuity(options = {}, body) { const key = this.getResponseContinuityKey(options); const previousEntries = key ? this.responseContinuityByKey.get(key) : null; + const requestState = this.getRequestState(options); const baseTrace = { protocol: 'openai-codex-responses', prompt_cache_key: body.prompt_cache_key, - session_id: buildScopedPromptCacheKey(this.sessionId, options?.cacheScope), + session_id: this.getTransportSessionId(options), turn_state_present: Boolean(this.getTurnState(options)), previous_response_id: null, incremental_input_items: null, @@ -437,6 +453,7 @@ export class CodexChatGPT { }; if (!previousEntries?.length) { + requestState.cacheTrace = baseTrace; this.lastRequestCacheTrace = baseTrace; return; } @@ -458,31 +475,34 @@ export class CodexChatGPT { } if (!sawMatchingSignature) { - this.lastRequestCacheTrace = { + requestState.cacheTrace = { ...baseTrace, incremental_reuse_reason: 'non_input_fields_changed' }; + this.lastRequestCacheTrace = requestState.cacheTrace; return; } if (!bestMatch) { - this.lastRequestCacheTrace = { + requestState.cacheTrace = { ...baseTrace, incremental_reuse_reason: 'input_not_previous_prefix' }; + this.lastRequestCacheTrace = requestState.cacheTrace; return; } body.previous_response_id = bestMatch.entry.responseId; body.input = bestMatch.delta; body[CONTINUITY_BASELINE_INPUT] = bestMatch.entry.baselineInput; - this.lastRequestCacheTrace = { + requestState.cacheTrace = { ...baseTrace, previous_response_id: bestMatch.entry.responseId, incremental_input_items: bestMatch.delta.length, incremental_reuse: true, incremental_reuse_reason: 'prefix_reused' }; + this.lastRequestCacheTrace = requestState.cacheTrace; } rememberResponseContinuity(options = {}, body, parsed = {}) { @@ -514,6 +534,51 @@ export class CodexChatGPT { } } + getTransportCacheScope(options = {}) { + return String(options?.transportCacheScope ?? options?.transport_cache_scope ?? '').trim(); + } + + getTransportSessionId(options = {}) { + return buildScopedPromptCacheKey(this.sessionId, this.getTransportCacheScope(options)); + } + + beginRequestState(options = {}) { + const state = { cacheTrace: null, tokenUsage: null, thinking: '' }; + if (options && typeof options === 'object') { + this.requestStateByOptions.set(options, state); + } + return state; + } + + getRequestState(options = {}) { + if (options && typeof options === 'object') { + const existing = this.requestStateByOptions.get(options); + if (existing) return existing; + const state = { cacheTrace: null, tokenUsage: null, thinking: '' }; + this.requestStateByOptions.set(options, state); + return state; + } + if (!this.lastCompletedRequestState) { + this.lastCompletedRequestState = { cacheTrace: this.lastRequestCacheTrace, tokenUsage: this.lastTokenUsage, thinking: this.lastThinking || '' }; + } + return this.lastCompletedRequestState; + } + + consumeRequestState(options = {}) { + if (options && typeof options === 'object') { + const state = this.requestStateByOptions.get(options); + if (state) { + this.requestStateByOptions.delete(options); + if (this.lastCompletedRequestState === state) this.lastCompletedRequestState = null; + return state; + } + } + const state = this.lastCompletedRequestState || { cacheTrace: this.lastRequestCacheTrace, tokenUsage: this.lastTokenUsage, thinking: this.lastThinking || '' }; + this.lastCompletedRequestState = null; + this.lastRequestCacheTrace = null; + return state; + } + async embed() { throw new Error('Codex ChatGPT native-login adapter does not support embeddings. Configure an embedding provider separately.'); } diff --git a/src/models/prompter.js b/src/models/prompter.js index 5917eae58..e345baf9b 100644 --- a/src/models/prompter.js +++ b/src/models/prompter.js @@ -79,17 +79,14 @@ export class Prompter { max_tokens = this.profile.max_tokens; let chat_model_profile = selectAPI(this.profile.model); - this.chat_model = createModel(chat_model_profile); + this.chat_model = createModel(cloneModelProfile(chat_model_profile)); this.applyModelSessionIdentity(this.chat_model, chat_model_profile, 'conversation'); - if (hasModelSelection(this.profile.code_model)) { - let code_model_profile = selectAPI(this.profile.code_model); - this.code_model = createModel(code_model_profile); - this.applyModelSessionIdentity(this.code_model, code_model_profile, 'coding'); - } - else { - this.code_model = this.chat_model; - } + const code_model_profile = hasModelSelection(this.profile.code_model) + ? selectAPI(this.profile.code_model) + : cloneModelProfile(chat_model_profile); + this.code_model = createModel(cloneModelProfile(code_model_profile)); + this.applyModelSessionIdentity(this.code_model, code_model_profile, 'coding'); if (hasModelSelection(this.profile.vision_model)) { let vision_model_profile = selectAPI(this.profile.vision_model); @@ -265,10 +262,7 @@ export class Prompter { this.agent.history.traceLLMRequest('conversation', this.chat_model, prompt, requestMessages, tools, requestTraceMetadata); generation = await this.chat_model.sendRequest(requestMessages, prompt, '***', tools, requestOptions); this.captureConversationResponseMetadata(this.chat_model, generation); - const lastRequestCacheTrace = this.chat_model.consumeLastRequestCacheTrace?.(); - const responseTraceMetadata = lastRequestCacheTrace - ? { transport_cache: lastRequestCacheTrace } - : requestTraceMetadata; + const responseTraceMetadata = consumeModelRequestTraceMetadata(this.chat_model, requestOptions, requestTraceMetadata); this.agent.history.traceLLMResponse('conversation', this.chat_model, generation, responseTraceMetadata); if (isNativeToolResponse(generation)) { await this._saveLog(prompt, requestMessages, JSON.stringify(generation), 'conversation'); @@ -356,31 +350,44 @@ export class Prompter { return '\n*NATIVE TOOL FALLBACK WARNING\nThis model adapter does not advertise native tool calling support, so Mindcraft is temporarily falling back to text !command syntax for AI actions. Prefer a native-tool-capable provider when available. Human users may still type !commands.*\n' + docs; } - async promptCoding(messages) { + async promptCoding(messages, options = {}) { 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.agent.history.traceLLMRequest('coding', this.code_model, prompt, messages); - let resp = await this.code_model.sendRequest(messages, prompt, '***', null, { cacheScope: 'coding' }); - this.agent.history.traceLLMResponse('coding', this.code_model, resp); - this.awaiting_coding = false; - await this._saveLog(prompt, messages, resp, 'coding'); - return resp; + try { + await this.checkCooldown(); + let prompt = this.profile.coding; + prompt = await this.replaceStrings(prompt, messages); + + const requestOptions = { cacheScope: 'coding', transportCacheScope: 'coding', signal: options.signal }; + const requestTraceMetadata = this.code_model.getCacheTraceMetadata?.(requestOptions) || { + cache_scope: requestOptions.cacheScope, + transport_cache_scope: requestOptions.transportCacheScope + }; + this.agent.history.traceLLMRequest('coding', this.code_model, prompt, messages, null, requestTraceMetadata); + let resp = await this.code_model.sendRequest(messages, prompt, '***', null, requestOptions); + this.agent.history.traceLLMResponse('coding', this.code_model, resp, consumeModelRequestTraceMetadata(this.code_model, requestOptions, requestTraceMetadata)); + await this._saveLog(prompt, messages, resp, 'coding'); + return resp; + } finally { + this.awaiting_coding = false; + } } async promptCompactSummary(to_summarize) { await this.checkCooldown(); let prompt = this.profile.saving_memory; prompt = await this.replaceStrings(prompt, null, to_summarize); - this.agent.history.traceLLMRequest('compactSummary', this.chat_model, prompt, to_summarize); - let resp = await this.chat_model.sendRequest([], prompt, '***', null, { cacheScope: 'compactSummary' }); - this.agent.history.traceLLMResponse('compactSummary', this.chat_model, resp); + const requestOptions = { cacheScope: 'compactSummary', transportCacheScope: 'compactSummary' }; + const requestTraceMetadata = this.chat_model.getCacheTraceMetadata?.(requestOptions) || { + cache_scope: requestOptions.cacheScope, + transport_cache_scope: requestOptions.transportCacheScope + }; + this.agent.history.traceLLMRequest('compactSummary', this.chat_model, prompt, to_summarize, null, requestTraceMetadata); + let resp = await this.chat_model.sendRequest([], prompt, '***', null, requestOptions); + this.agent.history.traceLLMResponse('compactSummary', this.chat_model, resp, consumeModelRequestTraceMetadata(this.chat_model, requestOptions, requestTraceMetadata)); await this._saveLog(prompt, to_summarize, resp, 'compactSummary'); if (resp?.includes('')) { const [_, afterThink] = resp.split(''); @@ -395,14 +402,24 @@ export class Prompter { async promptShouldRespondToBot(new_message, options = {}) { await this.checkCooldown(); - let prompt = this.profile.bot_responder; - let messages = this.agent.history.getHistory(); - messages.push({role: 'user', content: new_message}); - prompt = await this.replaceStrings(prompt, null, messages); - const traceMetadata = { ephemeral: true, branch: true, cache_scope: options.cacheScope || 'botResponder' }; - this.agent.history.traceLLMRequest('botResponder', this.chat_model, prompt, messages, null, traceMetadata); - let res = await this.chat_model.sendRequest([], prompt, '***', null, { cacheScope: options.cacheScope || 'botResponder' }); - this.agent.history.traceLLMResponse('botResponder', this.chat_model, res, traceMetadata); + const historyMessages = this.agent.history.getHistory(); + const requestMessages = [ + ...(await this.buildConversationMessages(historyMessages)), + { role: 'user', content: await this.buildBotResponderUserPrompt(new_message) } + ]; + const prompt = await this.buildConversationSystemPrompt(historyMessages); + const requestOptions = { cacheScope: options.cacheScope || 'botResponder', signal: options.signal }; + const tools = this.isNativeToolMode() ? getCommandToolDefinitions(this.agent) : null; + const traceMetadata = { + ephemeral: true, + branch: true, + incoming_message: new_message, + ...(this.chat_model.getCacheTraceMetadata?.(requestOptions) || { cache_scope: options.cacheScope || 'botResponder' }) + }; + this.agent.history.traceLLMRequest('botResponder', this.chat_model, prompt, requestMessages, tools, traceMetadata); + let res = await this.chat_model.sendRequest(requestMessages, prompt, '***', tools, requestOptions); + const responseTraceMetadata = consumeModelRequestTraceMetadata(this.chat_model, requestOptions, traceMetadata); + this.agent.history.traceLLMResponse('botResponder', this.chat_model, res, responseTraceMetadata); const decision = normalizeBotResponderDecision(res); if (decision !== 'respond' && decision !== 'ignore') { console.warn(`Invalid botResponder decision for ${this.agent.name}: ${decision}`); @@ -410,13 +427,25 @@ export class Prompter { return decision === 'respond'; } + async buildBotResponderUserPrompt(newMessage) { + const incomingMessage = String(newMessage ?? ''); + const prompt = String(this.profile.bot_responder || '') + .replaceAll('$INCOMING_MESSAGE', incomingMessage); + return await this.replaceStrings(prompt, [], []); + } + async promptVision(messages, imageBuffer) { await this.checkCooldown(); let prompt = this.profile.image_analysis; prompt = await this.replaceStrings(prompt, messages); - this.agent.history.traceLLMRequest('vision', this.vision_model, prompt, messages); - const res = await this.vision_model.sendVisionRequest(messages, prompt, imageBuffer, { cacheScope: 'vision' }); - this.agent.history.traceLLMResponse('vision', this.vision_model, res); + const requestOptions = { cacheScope: 'vision', transportCacheScope: 'vision' }; + const requestTraceMetadata = this.vision_model.getCacheTraceMetadata?.(requestOptions) || { + cache_scope: requestOptions.cacheScope, + transport_cache_scope: requestOptions.transportCacheScope + }; + this.agent.history.traceLLMRequest('vision', this.vision_model, prompt, messages, null, requestTraceMetadata); + const res = await this.vision_model.sendVisionRequest(messages, prompt, imageBuffer, requestOptions); + this.agent.history.traceLLMResponse('vision', this.vision_model, res, consumeModelRequestTraceMetadata(this.vision_model, requestOptions, requestTraceMetadata)); return res; } @@ -430,9 +459,14 @@ export class Prompter { user_message = await this.replaceStrings(user_message, messages, null, last_goals); let user_messages = [{role: 'user', content: user_message}]; - this.agent.history.traceLLMRequest('goalSetting', this.chat_model, system_message, user_messages); - let res = await this.chat_model.sendRequest(user_messages, system_message, '***', null, { cacheScope: 'goalSetting' }); - this.agent.history.traceLLMResponse('goalSetting', this.chat_model, res); + const requestOptions = { cacheScope: 'goalSetting', transportCacheScope: 'goalSetting' }; + const requestTraceMetadata = this.chat_model.getCacheTraceMetadata?.(requestOptions) || { + cache_scope: requestOptions.cacheScope, + transport_cache_scope: requestOptions.transportCacheScope + }; + this.agent.history.traceLLMRequest('goalSetting', this.chat_model, system_message, user_messages, null, requestTraceMetadata); + let res = await this.chat_model.sendRequest(user_messages, system_message, '***', null, requestOptions); + this.agent.history.traceLLMResponse('goalSetting', this.chat_model, res, consumeModelRequestTraceMetadata(this.chat_model, requestOptions, requestTraceMetadata)); let goal = null; try { @@ -538,6 +572,18 @@ function stableModelSessionIdentity(parts) { return `mindcraft-${createHash('sha256').update(text).digest('hex').slice(0, 24)}`; } +function cloneModelProfile(profile) { + return JSON.parse(JSON.stringify(profile || {})); +} + +function consumeModelRequestTraceMetadata(model, requestOptions, fallbackMetadata = {}) { + const requestMetadata = model?.consumeLastRequestTraceMetadata?.(requestOptions); + const metadata = { ...(fallbackMetadata || {}) }; + if (requestMetadata?.transport_cache) metadata.transport_cache = requestMetadata.transport_cache; + if (requestMetadata?.token_usage) metadata.token_usage = requestMetadata.token_usage; + return metadata; +} + export function normalizeBotResponderDecision(response) { if (isNativeToolResponse(response)) return 'invalid_tool_call'; const normalized = String(response ?? '').trim().toLowerCase(); diff --git a/tests/agent_native_text_policy.test.js b/tests/agent_native_text_policy.test.js index 6f3c626ef..1ede6c1ea 100644 --- a/tests/agent_native_text_policy.test.js +++ b/tests/agent_native_text_policy.test.js @@ -447,8 +447,8 @@ test('minecraft command echoes are filtered without blocking normal human chat', assert.equal(isMinecraftCommandEchoMessage('Removed 10 item(s) from 2 players]'), true); assert.equal(isMinecraftCommandEchoMessage('Gave 64 oak_log to Ninot_Quyi'), true); assert.equal(isMinecraftCommandEchoMessage('/clear @a'), true); - assert.equal(isMinecraftCommandEchoMessage('Ninot_Quyi: 给我做个石头镐子'), false); - assert.equal(isMinecraftCommandEchoMessage('给我做个石头镐子'), false); + assert.equal(isMinecraftCommandEchoMessage('Ninot_Quyi: make me a stone pickaxe'), false); + assert.equal(isMinecraftCommandEchoMessage('make me a stone pickaxe'), false); assert.equal(isMinecraftCommandEchoMessage('I removed 10 items from a chest'), false); }); @@ -554,6 +554,48 @@ test('newAction code generation uses an isolated tool-internal prompt', async () assert.equal(messages.some(message => message.content.includes('Code generation started')), false); assert.ok(prompterSource.includes('extractCodeTaskContent(messages)')); assert.ok(prompterSource.includes("msg.content.startsWith('Code generation task:')")); + assert.ok(coderSource.includes('beginActiveLLMRequest')); + assert.ok(coderSource.includes('promptCoding(messages_copy, { signal: llmAbortController?.signal })')); + assert.equal(prompterSource.includes('this.code_model = this.chat_model'), false); + assert.ok(prompterSource.includes(': cloneModelProfile(chat_model_profile)')); + assert.ok(prompterSource.includes('createModel(cloneModelProfile(code_model_profile))')); +}); + +test('newAction coding prompt clears in-flight state when interrupted', async () => { + const { Prompter } = await import('../src/models/prompter.js'); + const prompter = Object.create(Prompter.prototype); + const signal = AbortSignal.abort(); + const captured = {}; + prompter.awaiting_coding = false; + prompter.profile = { coding: 'Write code for $NAME.' }; + prompter.agent = { + name: 'codex', + history: { + traceLLMRequest: () => {}, + traceLLMResponse: () => {} + } + }; + prompter.checkCooldown = async () => {}; + prompter.replaceStrings = async prompt => prompt.replaceAll('$NAME', 'codex'); + prompter.code_model = { + getCacheTraceMetadata: () => ({}), + sendRequest: async (_messages, _prompt, _stop, _tools, options) => { + captured.options = options; + const error = new Error('aborted'); + error.name = 'AbortError'; + throw error; + } + }; + + await assert.rejects( + () => prompter.promptCoding([{ role: 'user', content: 'Code generation task:\nstop' }], { signal }), + /aborted/ + ); + + assert.equal(prompter.awaiting_coding, false); + assert.equal(captured.options.cacheScope, 'coding'); + assert.equal(captured.options.transportCacheScope, 'coding'); + assert.equal(captured.options.signal, signal); }); test('botResponder decisions reject native tool calls instead of treating them as responses', async () => { @@ -568,6 +610,69 @@ test('botResponder decisions reject native tool calls instead of treating them a assert.equal(normalizeBotResponderDecision(''), 'invalid_empty'); }); +test('botResponder forks structured conversation history and appends only a decision user prompt', async () => { + const { Prompter } = await import('../src/models/prompter.js'); + const historyTurns = [{ role: 'user', content: 'Steve: mine iron first' }]; + const captured = {}; + const prompter = Object.create(Prompter.prototype); + prompter.agent = { + name: 'codex', + actions: { currentActionLabel: 'collectBlocks iron_ore' }, + history: { + getHistory: () => historyTurns.map(turn => ({ ...turn })), + traceLLMRequest: (tag, model, systemPrompt, messages, tools, metadata) => { + captured.request = { tag, model, systemPrompt, messages, tools, metadata }; + }, + traceLLMResponse: (tag, model, response, metadata) => { + captured.response = { tag, model, response, metadata }; + } + } + }; + prompter.profile = { + conversing: 'Stable system for $NAME.', + bot_responder: 'Current action: $ACTION\nIncoming message:\n$INCOMING_MESSAGE\nOnly respond or ignore.' + }; + prompter.checkCooldown = async () => {}; + prompter.chat_model = { + supportsNativeToolCalls: true, + getCacheTraceMetadata: options => ({ + cache_scope: options.cacheScope, + transport_cache_scope: options.transportCacheScope || null, + transport_cache: { prompt_cache_key: 'session' } + }), + consumeLastRequestTraceMetadata: () => ({ + transport_cache: { prompt_cache_key: 'session', incremental_reuse: false }, + token_usage: { input_uncached: 12, input_cached: 34, output: 1 } + }), + sendRequest: async (messages, systemPrompt, stop, tools, options) => { + captured.sent = { messages, systemPrompt, stop, tools, options }; + return 'ignore'; + } + }; + + const shouldRespond = await prompter.promptShouldRespondToBot('kimi: (FROM OTHER BOT)\nhelp now'); + + assert.equal(shouldRespond, false); + assert.equal(captured.sent.systemPrompt, 'Stable system for codex.'); + assert.equal(captured.sent.options.cacheScope, 'botResponder'); + assert.equal(captured.sent.options.transportCacheScope, undefined); + assert.equal(Array.isArray(captured.sent.tools), true); + assert.ok(captured.sent.tools.length > 0); + assert.deepEqual(captured.sent.messages[0], historyTurns[0]); + assert.equal(captured.sent.messages.length, 2); + assert.match(captured.sent.messages[1].content, /Current action: collectBlocks iron_ore/); + assert.match(captured.sent.messages[1].content, /Incoming message:\nkimi: \(FROM OTHER BOT\)\nhelp now/); + assert.doesNotMatch(captured.sent.systemPrompt, /kimi|mine iron|Incoming message|Actual Conversation/); + assert.deepEqual(captured.request.messages, captured.sent.messages); + assert.deepEqual(captured.request.tools, captured.sent.tools); + assert.equal(captured.request.metadata.incoming_message, 'kimi: (FROM OTHER BOT)\nhelp now'); + assert.equal(captured.request.metadata.cache_scope, 'botResponder'); + assert.equal(captured.request.metadata.transport_cache_scope, null); + assert.equal(captured.response.metadata.transport_cache.prompt_cache_key, 'session'); + assert.equal(captured.response.metadata.token_usage.input_cached, 34); + assert.equal(historyTurns.length, 1); +}); + test('chat UI projects instruction context trace events', () => { @@ -636,6 +741,7 @@ test('chat trace projection renders ephemeral branch decisions outside the main assert.ok(html.includes('function renderBranchPayloadBlock')); assert.ok(html.includes('function parseBranchQuestion')); assert.ok(html.includes('function normalizeBranchInlineMessage')); + assert.ok(html.includes('request?.incoming_message')); assert.ok(html.includes('class="chat-branch-event"')); assert.ok(html.includes('chat-branch-say')); assert.ok(html.includes(' say:')); @@ -1013,6 +1119,7 @@ test('conversation and coding requests use separate prompt cache scopes', () => assert.ok(prompterSource.includes("cacheScope: 'conversation'")); assert.ok(prompterSource.includes("cacheScope: 'coding'")); + assert.ok(prompterSource.includes("transportCacheScope: 'coding'")); assert.ok(prompterSource.includes("cacheScope: 'compactSummary'")); assert.ok(prompterSource.includes("cacheScope: options.cacheScope || 'botResponder'")); assert.ok(prompterSource.includes("cacheScope: 'vision'")); diff --git a/tests/codex_chatgpt.test.js b/tests/codex_chatgpt.test.js index 65e84f078..cf1d9f96c 100644 --- a/tests/codex_chatgpt.test.js +++ b/tests/codex_chatgpt.test.js @@ -427,12 +427,12 @@ test('Codex adapter expands response continuity when using HTTP transport', asyn assert.equal(requests[1].body.input.length, 3); assert.equal(requests[1].body.input[2].role, 'user'); assert.equal(requests[1].body.input[2].content[0].text, 'second'); - assert.equal(requests[1].body.prompt_cache_key, 'session-test:conversation'); - assert.equal(requests[1].init.headers.session_id, 'session-test:conversation'); + assert.equal(requests[1].body.prompt_cache_key, 'session-test'); + assert.equal(requests[1].init.headers.session_id, 'session-test'); assert.deepEqual(model.consumeLastRequestCacheTrace(), { protocol: 'openai-codex-responses', - prompt_cache_key: 'session-test:conversation', - session_id: 'session-test:conversation', + prompt_cache_key: 'session-test', + session_id: 'session-test', turn_state_present: false, previous_response_id: null, incremental_input_items: null, @@ -513,7 +513,7 @@ test('Codex adapter does not carry turn-state into a new bot-message branch', as assert.equal(requests[0].body.previous_response_id, undefined); assert.equal(requests[1].body.previous_response_id, undefined); assert.equal(requests[1].init.headers['x-codex-turn-state'], undefined); - assert.equal(requests[1].body.prompt_cache_key, 'session-test:conversation'); + assert.equal(requests[1].body.prompt_cache_key, 'session-test'); assert.equal(requests[1].body.input.length, 4); assert.equal(requests[1].body.input[1].type, 'function_call'); assert.equal(requests[1].body.input[2].type, 'function_call_output'); @@ -586,9 +586,9 @@ test('Codex adapter keeps forked branches on the shared prompt cache key without }); for (const request of requests) { - assert.equal(request.body.prompt_cache_key, 'session-test:conversation'); - assert.equal(request.init.headers.session_id, 'session-test:conversation'); - assert.equal(request.init.headers['x-client-request-id'], 'session-test:conversation'); + assert.equal(request.body.prompt_cache_key, 'session-test'); + assert.equal(request.init.headers.session_id, 'session-test'); + assert.equal(request.init.headers['x-client-request-id'], 'session-test'); } assert.equal(requests[1].body.previous_response_id, undefined); assert.equal(requests[1].body.input.length, 3); @@ -710,13 +710,13 @@ test('Codex adapter keeps forked WebSocket branches on the shared prompt cache k assert.equal(handshakes.length, 1); assert.equal(handshakes[0].authorization, 'Bearer access-token-test'); assert.equal(handshakes[0]['openai-beta'], 'responses_websockets=2026-02-06'); - assert.equal(handshakes[0].session_id, 'session-test:conversation'); + assert.equal(handshakes[0].session_id, 'session-test'); assert.equal(payloads[0].type, 'response.create'); assert.equal(payloads[0].previous_response_id, undefined); - assert.equal(payloads[0].prompt_cache_key, 'session-test:conversation'); + assert.equal(payloads[0].prompt_cache_key, 'session-test'); assert.equal(payloads[0].tool_choice, 'auto'); for (const payload of payloads) { - assert.equal(payload.prompt_cache_key, 'session-test:conversation'); + assert.equal(payload.prompt_cache_key, 'session-test'); } assert.equal(payloads[1].previous_response_id, 'resp_1'); assert.equal(payloads[1].input.length, 1); @@ -763,8 +763,8 @@ test('Codex WebSocket transport serializes concurrent requests to avoid response response: { id: `resp_${text}`, usage: { - input_tokens: 10, - input_tokens_details: { cached_tokens: 0 }, + input_tokens: text === 'slow' ? 111 : 222, + input_tokens_details: { cached_tokens: text === 'slow' ? 11 : 22 }, output_tokens: 2 } } @@ -783,9 +783,11 @@ test('Codex WebSocket transport serializes concurrent requests to avoid response responsesWebSocketIdleTimeoutMs: 5000 }); + const slowOptions = { cacheScope: 'conversation' }; + const fastOptions = { cacheScope: 'botResponder' }; const [slow, fast] = await Promise.all([ - model.sendRequest([{ role: 'user', content: 'slow' }], 'Decide.', '***', null, { cacheScope: 'conversation' }), - model.sendRequest([{ role: 'user', content: 'fast' }], 'Decide.', '***', null, { cacheScope: 'botResponder' }) + model.sendRequest([{ role: 'user', content: 'slow' }], 'Decide.', '***', null, slowOptions), + model.sendRequest([{ role: 'user', content: 'fast' }], 'Decide.', '***', null, fastOptions) ]); assert.equal(slow, 'slow ok'); @@ -794,6 +796,14 @@ test('Codex WebSocket transport serializes concurrent requests to avoid response assert.equal(payloads.length, 2); assert.equal(payloads[0].input[0].content[0].text, 'slow'); assert.equal(payloads[1].input[0].content[0].text, 'fast'); + assert.equal(payloads[0].prompt_cache_key, 'session-test'); + assert.equal(payloads[1].prompt_cache_key, 'session-test'); + const slowMetadata = model.consumeLastRequestTraceMetadata(slowOptions); + const fastMetadata = model.consumeLastRequestTraceMetadata(fastOptions); + assert.equal(slowMetadata.transport_cache.prompt_cache_key, 'session-test'); + assert.equal(fastMetadata.transport_cache.prompt_cache_key, 'session-test'); + assert.equal(slowMetadata.token_usage.input_uncached, 100); + assert.equal(fastMetadata.token_usage.input_uncached, 200); model.closeResponsesWebSocket(); } finally { await new Promise(resolve => wss.close(resolve)); @@ -916,15 +926,15 @@ test('Codex adapter keeps prompt cache key stable across multi-turn tool replay' const first = model.buildRequestBody('gpt-5.5', turns, 'Use tools.', [tool]); const second = model.buildRequestBody('gpt-5.5', turns, 'Use tools.', [tool]); const conversation = model.buildRequestBody('gpt-5.5', turns, 'Use tools.', [tool], { cacheScope: 'conversation' }); - const coding = model.buildRequestBody('gpt-5.5', [{ role: 'user', content: 'write code' }], 'Write code.', null, { cacheScope: 'coding' }); + const coding = model.buildRequestBody('gpt-5.5', [{ role: 'user', content: 'write code' }], 'Write code.', null, { cacheScope: 'coding', transportCacheScope: 'coding' }); assert.equal(first.prompt_cache_key, 'stable-cache-session'); assert.equal(second.prompt_cache_key, 'stable-cache-session'); - assert.equal(conversation.prompt_cache_key, 'stable-cache-session:conversation'); + assert.equal(conversation.prompt_cache_key, 'stable-cache-session'); assert.equal(coding.prompt_cache_key, 'stable-cache-session:coding'); assert.equal(model.buildHeaders({ accessToken: 'token', accountId: 'account' }).session_id, 'stable-cache-session'); - assert.equal(model.buildHeaders({ accessToken: 'token', accountId: 'account' }, { cacheScope: 'conversation' }).session_id, 'stable-cache-session:conversation'); - assert.equal(model.buildHeaders({ accessToken: 'token', accountId: 'account' }, { cacheScope: 'coding' })['x-client-request-id'], 'stable-cache-session:coding'); + assert.equal(model.buildHeaders({ accessToken: 'token', accountId: 'account' }, { cacheScope: 'conversation' }).session_id, 'stable-cache-session'); + assert.equal(model.buildHeaders({ accessToken: 'token', accountId: 'account' }, { cacheScope: 'coding', transportCacheScope: 'coding' })['x-client-request-id'], 'stable-cache-session:coding'); assert.deepEqual(second.input, first.input); assert.deepEqual( first.input.filter(item => item.type === 'function_call' || item.type === 'function_call_output').map(item => item.call_id), @@ -981,8 +991,8 @@ test('Codex adapter replays turn-state only inside one ReAct turn', async () => turnStateKey: 'react-2' }); - assert.equal(requests[0].init.headers.session_id, 'session-test:conversation'); - assert.equal(requests[0].init.headers['x-client-request-id'], 'session-test:conversation'); + assert.equal(requests[0].init.headers.session_id, 'session-test'); + assert.equal(requests[0].init.headers['x-client-request-id'], 'session-test'); assert.equal(requests[0].init.headers['x-codex-turn-state'], undefined); assert.equal(requests[1].init.headers['x-codex-turn-state'], 'sticky-route-1'); assert.equal(requests[2].init.headers['x-codex-turn-state'], undefined); diff --git a/tests/native_prompt_hygiene.test.js b/tests/native_prompt_hygiene.test.js index d126a0e76..e72225ea2 100644 --- a/tests/native_prompt_hygiene.test.js +++ b/tests/native_prompt_hygiene.test.js @@ -40,6 +40,9 @@ test('native prompt markdown rejects text-command and fake tool-call examples in assert.match(conversing, /Do NOT write legacy text commands/i); assert.doesNotMatch(conversing, legacyToolSyntax); assert.doesNotMatch(botResponder, legacyToolSyntax); + assert.match(botResponder, /\$INCOMING_MESSAGE/); + assert.doesNotMatch(botResponder, /\$TO_SUMMARIZE/); + assert.doesNotMatch(botResponder, /Actual Conversation/i); }); From 37e031670f0c794082633257ae269f4db7da0b3c Mon Sep 17 00:00:00 2001 From: NinotQuyi Date: Sun, 3 May 2026 20:55:03 +0800 Subject: [PATCH 45/56] Document verified PR behavior up front The PR summary now starts with the functional verification surface reviewers care about before listing implementation details. This keeps the review focused on user-visible behavior and provider/cache compatibility. Constraint: User asked for the verification note at the beginning of the markdown summary Confidence: high Scope-risk: narrow Tested: git diff --check Not-tested: Documentation-only change; no runtime tests rerun Co-authored-by: OmX --- PR_DEVELOP_SUMMARY.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/PR_DEVELOP_SUMMARY.md b/PR_DEVELOP_SUMMARY.md index 1e771f972..d3b499783 100644 --- a/PR_DEVELOP_SUMMARY.md +++ b/PR_DEVELOP_SUMMARY.md @@ -1,5 +1,13 @@ # PR Summary vs develop +## Verified functionality + +- Verified single-agent survival and construction flows. +- Verified voice broadcast / narration behavior. +- Verified multiple agents can run concurrently while cooperating with each other. +- Tested and passed mainstream provider protocols: OpenAI Chat, OpenAI Responses, Gemini, and Anthropic-compatible messages. +- Verified both user-sent messages and agent-to-agent messages still hit the conversation cache after branching/interruption. + This PR updates Mindcraft from the legacy text-command/provider layout toward a native tool-calling runtime with richer multi-agent traceability and broader provider support. ## Functional changes From f0f3f25cb533f2afb1b3f66c0b0271b471e23be4 Mon Sep 17 00:00:00 2001 From: NinotQuyi Date: Sun, 3 May 2026 20:56:53 +0800 Subject: [PATCH 46/56] Keep generated PR summary out of tracked code The PR summary was useful as a local review aid, but it should not be part of the repository diff. Removing it keeps the branch focused on runtime and test changes instead of temporary documentation artifacts. Constraint: User explicitly requested PR_DEVELOP_SUMMARY.md not be kept in git Confidence: high Scope-risk: narrow Tested: git status --short Not-tested: Documentation-only removal; no runtime tests rerun Co-authored-by: OmX --- PR_DEVELOP_SUMMARY.md | 33 --------------------------------- 1 file changed, 33 deletions(-) delete mode 100644 PR_DEVELOP_SUMMARY.md diff --git a/PR_DEVELOP_SUMMARY.md b/PR_DEVELOP_SUMMARY.md deleted file mode 100644 index d3b499783..000000000 --- a/PR_DEVELOP_SUMMARY.md +++ /dev/null @@ -1,33 +0,0 @@ -# PR Summary vs develop - -## Verified functionality - -- Verified single-agent survival and construction flows. -- Verified voice broadcast / narration behavior. -- Verified multiple agents can run concurrently while cooperating with each other. -- Tested and passed mainstream provider protocols: OpenAI Chat, OpenAI Responses, Gemini, and Anthropic-compatible messages. -- Verified both user-sent messages and agent-to-agent messages still hit the conversation cache after branching/interruption. - -This PR updates Mindcraft from the legacy text-command/provider layout toward a native tool-calling runtime with richer multi-agent traceability and broader provider support. - -## Functional changes - -- Adds native function/tool calling for agent actions, including structured tool calls/results in conversation history. -- Adds native ChatGPT Codex login support, enabling the Codex/ChatGPT Responses transport through a ChatGPT account login flow. -- Adds provider-registry based LLM configuration via `settings_llm_providers.json`, including Kimi, MiniMax, OpenRouter, Gemini, Codex, Replicate, and other presets. -- Splits editable default prompts into markdown files under `profiles/defaults/prompts/` for easier review and prompt maintenance. -- Improves message priority handling so user/admin messages interrupt active actions before the next model turn. -- Makes `newAction`/coding requests independent from the main conversation request path so coding does not pollute conversation cache/state. -- Adds bot-to-bot response branching (`botResponder`) that forks the current conversation context instead of rebuilding or replacing the system prompt. -- Expands Runtime UI trace rendering for native tool calls, tool results, branch decisions, token/cache metadata, and active tool status. - -## Review notes - -- The largest review surface is in `src/agent/`, `src/models/`, and `src/mindcraft/public/index.html`. -- New test coverage was added for native tools, provider config, Codex ChatGPT transport, conversation queueing, trace projection, token usage, and prompt hygiene. -- `settings.js` is updated as the working template and enables the current multi-agent preset profiles. - -## Verification - -- Targeted native-tool/Codex/prompt tests have been run successfully. -- ESLint has been run on the recently changed implementation and test files. From e129513812feeab93825f9b87a9c2c59ef7f9c04 Mon Sep 17 00:00:00 2001 From: NinotQuyi Date: Sun, 3 May 2026 21:06:23 +0800 Subject: [PATCH 47/56] Keep local test scaffolding out of the PR diff The native-tool branch used repository-local test files to validate the new runtime behavior, but those files should not be part of the submitted branch. They are removed from the git index while remaining available in the working tree for local verification. Constraint: User explicitly requested tests/ be removed from git submissions while preserving local files Confidence: high Scope-risk: narrow Tested: git rm -r --cached tests Tested: test -f tests/codex_chatgpt.test.js Not-tested: Runtime tests not rerun after index-only removal Co-authored-by: OmX --- tests/agent_native_text_policy.test.js | 1127 --------------------- tests/chat_history_trace.test.js | 439 -------- tests/codex_chatgpt.test.js | 1051 ------------------- tests/conversation_queue.test.js | 188 ---- tests/llm_providers_config.test.js | 143 --- tests/memory_summary_tool_history.test.js | 25 - tests/mindserver_chat_history.test.js | 138 --- tests/mindserver_settings_spec.test.js | 17 - tests/native_prompt_hygiene.test.js | 98 -- tests/native_tools.test.js | 370 ------- tests/openai_compatible.test.js | 389 ------- tests/profile_shape.test.js | 97 -- tests/prompt_markdown_refs.test.js | 18 - tests/token_usage.test.js | 50 - tests/tool_result_policy.test.js | 64 -- tests/vision_interpreter.test.js | 33 - 16 files changed, 4247 deletions(-) delete mode 100644 tests/agent_native_text_policy.test.js delete mode 100644 tests/chat_history_trace.test.js delete mode 100644 tests/codex_chatgpt.test.js delete mode 100644 tests/conversation_queue.test.js delete mode 100644 tests/llm_providers_config.test.js delete mode 100644 tests/memory_summary_tool_history.test.js delete mode 100644 tests/mindserver_chat_history.test.js delete mode 100644 tests/mindserver_settings_spec.test.js delete mode 100644 tests/native_prompt_hygiene.test.js delete mode 100644 tests/native_tools.test.js delete mode 100644 tests/openai_compatible.test.js delete mode 100644 tests/profile_shape.test.js delete mode 100644 tests/prompt_markdown_refs.test.js delete mode 100644 tests/token_usage.test.js delete mode 100644 tests/tool_result_policy.test.js delete mode 100644 tests/vision_interpreter.test.js diff --git a/tests/agent_native_text_policy.test.js b/tests/agent_native_text_policy.test.js deleted file mode 100644 index 1ede6c1ea..000000000 --- a/tests/agent_native_text_policy.test.js +++ /dev/null @@ -1,1127 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import vm from 'node:vm'; - -test('agent contains explicit AI text-command block in native tool mode', () => { - const agentSource = readFileSync('src/agent/agent.js', 'utf8'); - - assert.ok(agentSource.includes('if (this.prompter.isNativeToolMode())')); - assert.ok(agentSource.includes('Text command ${command_name} was not executed')); - assert.ok(agentSource.includes('AI actions must use native tool calls')); - assert.ok(agentSource.includes('continue;')); -}); - - - -test('interrupted native tool responses are closed with synthetic tool results before next request', async () => { - const { Agent } = await import('../src/agent/agent.js'); - const turns = []; - const toolEvents = []; - let interruptChecks = 0; - let executed = false; - const agent = Object.create(Agent.prototype); - agent.name = 'bot'; - agent.shut_up = false; - agent.last_sender = null; - agent.checkTaskDone = async () => {}; - agent.self_prompter = { - shouldInterrupt: () => ++interruptChecks > 1, - isActive: () => false, - handleUserPromptedCmd: () => { executed = true; } - }; - agent.bot = { modes: { flushBehaviorLog: () => '' } }; - agent.history = { - addUserContext: async content => turns.push({ role: 'user', content }), - addNativeToolCall: async toolCall => { - toolEvents.push({ type: 'call', toolCall }); - turns.push({ role: 'assistant', content: '', native_tool_calls: [toolCall] }); - }, - addNativeToolResult: async (toolCall, result) => { - toolEvents.push({ type: 'result', toolCall, result }); - turns.push({ role: 'tool', tool_call_id: toolCall.id, content: result }); - }, - save: () => {}, - getHistory: () => turns.map(turn => ({ ...turn })) - }; - agent.prompter = { - promptConvo: async () => ({ - type: 'tool_calls', - tool_calls: [{ id: 'call_interrupted', type: 'function', name: 'collectBlocks', arguments: '{"type":"oak_log","num":1}' }] - }) - }; - agent.routeResponse = () => {}; - - const usedCommand = await agent._handleMessageImpl('Steve', 'collect one oak log', 1); - - assert.equal(usedCommand, true); - assert.equal(executed, false); - assert.deepEqual(toolEvents.map(event => event.type), ['call', 'result']); - assert.equal(toolEvents[1].toolCall.id, 'call_interrupted'); - assert.match(toolEvents[1].result, /interrupted before execution/); - assert.deepEqual(turns.slice(-2).map(turn => turn.role), ['assistant', 'tool']); -}); - -test('human message queue drains all pending messages as one request', async () => { - const { Agent } = await import('../src/agent/agent.js'); - const turns = []; - const requests = []; - const agent = Object.create(Agent.prototype); - agent.name = 'bot'; - agent.shut_up = false; - agent.last_sender = null; - agent.active_message_handlers = 0; - agent.active_native_tool_calls = new Map(); - agent.message_interrupt_epoch = 0; - agent.human_message_queue = []; - agent.human_message_interrupt_promise = Promise.resolve(); - agent.checkTaskDone = async () => {}; - agent.self_prompter = { - shouldInterrupt: () => false, - isActive: () => false, - handleUserPromptedCmd: () => {} - }; - agent.bot = { modes: { flushBehaviorLog: () => '' } }; - agent.history = { - addUserContext: async content => turns.push({ role: 'user', content }), - add: async (name, content) => turns.push(name === agent.name ? { role: 'assistant', content } : { role: 'user', content: `${name}: ${content}` }), - save: () => {}, - getHistory: () => turns.map(turn => ({ ...turn })) - }; - agent.prompter = { - promptConvo: async messages => { - requests.push(messages.map(turn => ({ ...turn }))); - return 'reply'; - }, - consumeLastConversationResponseMetadata: () => ({}) - }; - agent.routeResponse = () => {}; - - const first = agent.handleMessage('Steve', 'first', 1); - const second = agent.handleMessage('Steve', 'second', 1); - await Promise.all([first, second]); - - assert.equal(requests.length, 1); - assert.deepEqual(requests[0].map(turn => turn.content), ['Steve: first\nsecond']); - assert.deepEqual(turns.map(turn => turn.content), ['Steve: first\nsecond', 'reply']); -}); - -test('new human message interrupts and drops a stale pending LLM response', async () => { - const { Agent } = await import('../src/agent/agent.js'); - const turns = []; - const requests = []; - const signals = []; - let releaseFirst; - const firstPromptStarted = new Promise(resolve => { - releaseFirst = resolve; - }); - let promptCount = 0; - const agent = Object.create(Agent.prototype); - agent.name = 'bot'; - agent.shut_up = false; - agent.last_sender = null; - agent.active_message_handlers = 0; - agent.active_native_tool_calls = new Map(); - agent.message_interrupt_epoch = 0; - agent.human_message_queue = []; - agent.human_message_interrupt_promise = Promise.resolve(); - agent.checkTaskDone = async () => {}; - agent.self_prompter = { - shouldInterrupt: () => false, - isActive: () => false, - handleUserPromptedCmd: () => {} - }; - agent.bot = { modes: { flushBehaviorLog: () => '' } }; - agent.history = { - addUserContext: async content => turns.push({ role: 'user', content }), - add: async (name, content) => turns.push(name === agent.name ? { role: 'assistant', content } : { role: 'user', content: `${name}: ${content}` }), - save: () => {}, - getHistory: () => turns.map(turn => ({ ...turn })) - }; - agent.prompter = { - promptConvo: async (messages, options = {}) => { - const currentPrompt = ++promptCount; - signals.push(options.signal); - requests.push(messages.map(turn => ({ ...turn }))); - if (currentPrompt === 1) { - await firstPromptStarted; - } - return `reply ${currentPrompt}`; - }, - consumeLastConversationResponseMetadata: () => ({}) - }; - agent.routeResponse = () => {}; - - const first = agent.handleMessage('Steve', 'first', 1); - await new Promise(resolve => setTimeout(resolve, 10)); - const second = agent.handleMessage('Steve', 'second', 1); - await new Promise(resolve => setTimeout(resolve, 10)); - - assert.equal(requests.length, 1); - assert.equal(signals[0].aborted, true); - releaseFirst(); - await Promise.all([first, second]); - - assert.equal(requests.length, 2); - assert.equal(signals[1].aborted, false); - assert.deepEqual(requests[1].map(turn => turn.content), ['Steve: first', 'Steve: second']); - assert.deepEqual(turns.map(turn => turn.content), ['Steve: first', 'Steve: second', 'reply 2']); -}); - -test('new human message skips stale queued self prompt before consuming a ReAct turn', async () => { - const { Agent } = await import('../src/agent/agent.js'); - const turns = []; - const requests = []; - const turnStateKeys = []; - let releaseQueue; - const blockedQueue = new Promise(resolve => { - releaseQueue = resolve; - }); - const agent = Object.create(Agent.prototype); - agent.name = 'bot'; - agent.shut_up = false; - agent.last_sender = null; - agent.active_message_handlers = 0; - agent.active_native_tool_calls = new Map(); - agent.message_interrupt_epoch = 0; - agent.message_handler_queue = blockedQueue; - agent.human_message_queue = []; - agent.human_message_interrupt_promise = Promise.resolve(); - agent.checkTaskDone = async () => {}; - agent.self_prompter = { - shouldInterrupt: () => false, - isActive: () => false, - handleUserPromptedCmd: () => {} - }; - agent.bot = { modes: { flushBehaviorLog: () => '' } }; - agent.history = { - addUserContext: async content => turns.push({ role: 'user', content }), - add: async (name, content) => turns.push(name === agent.name ? { role: 'assistant', content } : { role: 'user', content: `${name}: ${content}` }), - save: () => {}, - getHistory: () => turns.map(turn => ({ ...turn })) - }; - agent.prompter = { - promptConvo: async (messages, options = {}) => { - requests.push(messages.map(turn => ({ ...turn }))); - turnStateKeys.push(options.turnStateKey); - return 'reply'; - }, - consumeLastConversationResponseMetadata: () => ({}) - }; - agent.routeResponse = () => {}; - - const staleSelfPrompt = agent.handleMessage('system', 'continue old goal', 1, { transient: true }); - const humanMessage = agent.handleMessage('Steve', 'new request', 1); - await new Promise(resolve => setTimeout(resolve, 10)); - - assert.equal(requests.length, 0); - releaseQueue(); - await Promise.all([staleSelfPrompt, humanMessage]); - - assert.equal(requests.length, 1); - assert.deepEqual(requests[0].map(turn => turn.content), ['Steve: new request']); - assert.deepEqual(turnStateKeys, ['react-1']); - assert.deepEqual(turns.map(turn => turn.content), ['Steve: new request', 'reply']); -}); - -test('queued human message stops active action before prompting even without native tool calls', async () => { - const { Agent } = await import('../src/agent/agent.js'); - const events = []; - const turns = []; - const requests = []; - const agent = Object.create(Agent.prototype); - agent.name = 'bot'; - agent.shut_up = false; - agent.last_sender = null; - agent.active_message_handlers = 1; - agent.active_native_tool_calls = new Map(); - agent.message_interrupt_epoch = 0; - agent.message_handler_queue = Promise.resolve(); - agent.human_message_queue = []; - agent.human_message_interrupt_promise = Promise.resolve(); - agent.checkTaskDone = async () => {}; - agent.self_prompter = { - shouldInterrupt: () => false, - isActive: () => false, - handleUserPromptedCmd: () => {} - }; - agent.actions = { - executing: true, - stop: async () => { - events.push('stop-action'); - agent.actions.executing = false; - } - }; - agent.bot = { modes: { flushBehaviorLog: () => '' } }; - agent.history = { - addUserContext: async content => { - events.push(`user:${content}`); - turns.push({ role: 'user', content }); - }, - add: async (name, content) => turns.push(name === agent.name ? { role: 'assistant', content } : { role: 'user', content: `${name}: ${content}` }), - save: () => {}, - getHistory: () => turns.map(turn => ({ ...turn })) - }; - agent.prompter = { - promptConvo: async messages => { - events.push('prompt'); - requests.push(messages.map(turn => ({ ...turn }))); - return ''; - } - }; - agent.routeResponse = () => {}; - - await agent.handleMessage('Steve', 'urgent correction', 1); - - assert.ok(events.indexOf('stop-action') !== -1); - assert.ok(events.indexOf('stop-action') < events.findIndex(event => event.startsWith('user:'))); - assert.ok(events.findIndex(event => event.startsWith('user:')) < events.indexOf('prompt')); - assert.deepEqual(requests[0].map(turn => turn.content), ['Steve: urgent correction']); -}); - -test('queued admin message stops action then closes active native tool before prompting', async () => { - const { Agent } = await import('../src/agent/agent.js'); - const toolCall = { id: 'call_running', type: 'function', name: 'collectBlocks', arguments: '{"type":"oak_log","num":1}' }; - const events = []; - const turns = [{ role: 'assistant', content: '', native_tool_calls: [toolCall] }]; - const requests = []; - const agent = Object.create(Agent.prototype); - agent.name = 'bot'; - agent.shut_up = false; - agent.last_sender = null; - agent.active_message_handlers = 0; - agent.active_native_tool_calls = new Map([[toolCall.id, { toolCall, completed: false }]]); - agent.message_interrupt_epoch = 0; - agent.message_handler_queue = Promise.resolve(); - agent.human_message_queue = []; - agent.human_message_interrupt_promise = Promise.resolve(); - agent.checkTaskDone = async () => {}; - agent.self_prompter = { - shouldInterrupt: () => false, - isActive: () => false, - handleUserPromptedCmd: () => {} - }; - agent.actions = { - executing: true, - stop: async () => { - events.push('stop-action'); - agent.actions.executing = false; - } - }; - agent.bot = { modes: { flushBehaviorLog: () => '' } }; - agent.history = { - addUserContext: async content => { - events.push(`user:${content}`); - turns.push({ role: 'user', content }); - }, - addNativeToolResult: async (call, result) => { - events.push(`tool-result:${result}`); - turns.push({ role: 'tool', tool_call_id: call.id, name: call.name, content: result }); - }, - add: async (name, content) => turns.push(name === agent.name ? { role: 'assistant', content } : { role: 'user', content: `${name}: ${content}` }), - save: () => events.push('save'), - getHistory: () => turns.map(turn => ({ ...turn })) - }; - agent.prompter = { - promptConvo: async messages => { - events.push('prompt'); - requests.push(messages.map(turn => ({ ...turn }))); - return ''; - } - }; - agent.routeResponse = () => {}; - - await agent.handleMessage('ADMIN', 'stop and listen', 1); - - const toolResultIndex = events.findIndex(event => event.startsWith('tool-result:')); - const userIndex = events.findIndex(event => event.startsWith('user:')); - assert.ok(events.indexOf('stop-action') < toolResultIndex); - assert.ok(toolResultIndex < userIndex); - assert.ok(userIndex < events.indexOf('prompt')); - assert.match(events[toolResultIndex], /newer user\/admin message/); - assert.deepEqual(requests[0].map(turn => turn.role), ['assistant', 'tool', 'user']); - assert.equal(requests[0][2].content, 'ADMIN: stop and listen'); -}); - - - -test('user stop closes an executing native tool exactly once', async () => { - const { Agent } = await import('../src/agent/agent.js'); - const toolCall = { id: 'call_running', type: 'function', name: 'collectBlocks', arguments: '{"type":"oak_log","num":1}' }; - const results = []; - const agent = Object.create(Agent.prototype); - agent.active_native_tool_calls = new Map(); - agent.history = { - addNativeToolResult: async (call, result) => results.push({ call, result }), - save: () => { results.push({ saved: true }); } - }; - - agent._trackActiveNativeToolCall(toolCall); - const interrupted = await agent.finishInterruptedNativeToolCalls('Tool interrupted by user !stop command.'); - const lateCompletion = await agent._completeActiveNativeToolCall(toolCall, 'Action output arrived after stop.'); - - assert.equal(interrupted, 1); - assert.equal(lateCompletion, false); - assert.equal(results.filter(item => item.result).length, 1); - assert.equal(results[0].call.id, 'call_running'); - assert.equal(results[0].result, 'Tool interrupted by user !stop command.'); - assert.deepEqual(results[1], { saved: true }); -}); - -test('human stop closes active native tool calls before waiting on action stop', () => { - const actionsSource = readFileSync('src/agent/commands/actions.js', 'utf8'); - const stopSection = actionsSource.slice(actionsSource.indexOf("name: '!stop'"), actionsSource.indexOf("name: '!stfu'")); - - assert.ok(stopSection.includes('finishInterruptedNativeToolCalls')); - assert.ok(stopSection.indexOf('finishInterruptedNativeToolCalls') < stopSection.indexOf('agent.actions.stop()')); -}); - -test('web disconnect targets only the selected agent socket before process fallback', () => { - const serverSource = readFileSync('src/mindcraft/mindserver.js', 'utf8'); - const stopSection = serverSource.slice(serverSource.indexOf("socket.on('stop-agent'"), serverSource.indexOf("socket.on('start-agent'")); - const proxySource = readFileSync('src/agent/mindserver_proxy.js', 'utf8'); - - assert.ok(stopSection.includes('agent_connections[agentName]')); - assert.ok(stopSection.includes("agent.socket.emit('stop-agent')")); - assert.ok(stopSection.includes('mindcraft.stopAgent(agentName)')); - assert.ok(proxySource.includes("this.socket.on('stop-agent'")); - assert.ok(proxySource.includes("this.agent.cleanKill('Stopped by MindServer.', 0)")); -}); - -test('native tool execution records structured tool calls and tool results', () => { - const agentSource = readFileSync('src/agent/agent.js', 'utf8'); - const nativeSection = agentSource.slice(agentSource.indexOf('async _executeNativeToolCalls')); - - assert.ok(nativeSection.includes('this.history.addNativeToolCall(toolCall, undefined, metadata)')); - assert.ok(nativeSection.includes('this._trackActiveNativeToolCall(toolCall)')); - assert.ok(nativeSection.includes('this._completeActiveNativeToolCall(toolCall, formatNativeToolResultForModel(toolCall, execute_res))')); -}); - -test('native tool execution sends visible progress without storing display text in history', () => { - const agentSource = readFileSync('src/agent/agent.js', 'utf8'); - const nativeSection = agentSource.slice(agentSource.indexOf('async _executeNativeToolCalls')); - - assert.ok(nativeSection.includes('const display = `*used ${toolCall.name}*`')); - assert.ok(nativeSection.includes('this.routeResponse(source, display)')); - assert.equal(nativeSection.includes('addNativeToolCall(toolCall, display)'), false); -}); - -test('native tool execution always returns a tool result to the model', () => { - const agentSource = readFileSync('src/agent/agent.js', 'utf8'); - - assert.ok(agentSource.includes('function formatNativeToolResultForModel')); - assert.ok(agentSource.includes('return `Tool ${name} completed.`')); - assert.ok(agentSource.includes('await this.history.addNativeToolResult(toolCall, msg)')); -}); - -test('speech does not truncate normal exclamation text as an unknown command', () => { - const agentSource = readFileSync('src/agent/agent.js', 'utf8'); - - assert.ok(agentSource.includes('prepareChatMessageForOutput(message)')); - assert.ok(agentSource.includes('if (command_name && !commandExists(command_name))')); - assert.ok(agentSource.includes('command_name = null;')); - assert.ok(agentSource.includes('speak(spokenMessage, this.prompter.profile.speak_model);')); -}); - - -test('system TTS uses argv-based say/espeak invocation for full text', () => { - const speakSource = readFileSync('src/agent/speak.js', 'utf8'); - - assert.ok(speakSource.includes('buildSystemTTSInvocation(txt, process.platform)')); - assert.ok(speakSource.includes('args: [txt]')); - assert.equal(speakSource.includes('? `say "${txt'), false); - assert.equal(speakSource.includes(': `espeak "${txt'), false); -}); - -test('chat output preparation preserves normal full speech text', async () => { - const { prepareChatMessageForOutput } = await import('../src/agent/agent.js'); - - assert.equal(prepareChatMessageForOutput('hello world, codex').spokenMessage, 'hello world, codex'); - assert.equal(prepareChatMessageForOutput('Hello world!I am codex').spokenMessage, 'Hello world!I am codex'); - assert.equal(prepareChatMessageForOutput('hello !stats').spokenMessage, 'hello '); -}); - -test('minecraft command echoes are filtered without blocking normal human chat', async () => { - const { isMinecraftCommandEchoMessage } = await import('../src/agent/agent.js'); - - assert.equal(isMinecraftCommandEchoMessage('Removed 10 item(s) from 2 players]'), true); - assert.equal(isMinecraftCommandEchoMessage('Gave 64 oak_log to Ninot_Quyi'), true); - assert.equal(isMinecraftCommandEchoMessage('/clear @a'), true); - assert.equal(isMinecraftCommandEchoMessage('Ninot_Quyi: make me a stone pickaxe'), false); - assert.equal(isMinecraftCommandEchoMessage('make me a stone pickaxe'), false); - assert.equal(isMinecraftCommandEchoMessage('I removed 10 items from a chest'), false); -}); - -test('system TTS invocation passes full text as one macOS say argument', async () => { - const { buildSystemTTSInvocation } = await import('../src/agent/speak.js'); - const text = 'hello world, codex'; - const excited = 'Hello world! I am codex'; - - assert.deepEqual(buildSystemTTSInvocation(text, 'darwin'), { - mode: 'spawn', - command: 'say', - args: [text] - }); - assert.deepEqual(buildSystemTTSInvocation(excited, 'darwin').args, [excited]); - assert.deepEqual(buildSystemTTSInvocation(text, 'linux'), { - mode: 'spawn', - command: 'espeak', - args: [text] - }); -}); - -test('init message is not resent when memory already restored conversation history', () => { - const agentSource = readFileSync('src/agent/agent.js', 'utf8'); - - assert.ok(agentSource.includes('init_message && !hasLoadedConversation(save_data)')); - assert.ok(agentSource.includes('function hasLoadedConversation(saveData)')); - assert.ok(agentSource.includes('Array.isArray(saveData.turns) && saveData.turns.length > 0')); -}); - -test('self-prompt continuation is transient and not persisted as history', async () => { - const { Agent } = await import('../src/agent/agent.js'); - const capturedRequests = []; - const persisted = []; - const turns = []; - const agent = Object.create(Agent.prototype); - agent.name = 'bot'; - agent.shut_up = false; - agent.last_sender = null; - agent.checkTaskDone = async () => {}; - agent.self_prompter = { - shouldInterrupt: () => false, - isActive: () => false, - handleUserPromptedCmd: () => {} - }; - agent.bot = { modes: { flushBehaviorLog: () => '' } }; - agent.history = { - add: async (source, message) => persisted.push({ source, message }), - save: () => {}, - getHistory: () => [{ role: 'user', content: 'Steve: ready' }] - }; - agent.prompter = { - promptConvo: async messages => { - capturedRequests.push(messages); - return ''; - } - }; - - await agent._handleMessageImpl('system', 'Continue working on your current goal: "mine".', 1, { transient: true }); - - assert.deepEqual(persisted, []); - assert.equal(capturedRequests.length, 1); - assert.deepEqual(capturedRequests[0], [ - { role: 'user', content: 'Steve: ready' }, - { role: 'user', content: 'System: Continue working on your current goal: "mine".' } - ]); -}); - -test('starting a goal during native tool execution defers the self-prompt loop', async () => { - const { SelfPrompter } = await import('../src/agent/self_prompter.js'); - let handleSelfPromptCalls = 0; - const agent = { - isHandlingMessage: () => true, - isIdle: () => true, - handleSelfPrompt: async () => { - handleSelfPromptCalls++; - return false; - } - }; - const selfPrompter = new SelfPrompter(agent); - - selfPrompter.start('mine iron'); - - assert.equal(selfPrompter.isActive(), true); - assert.equal(selfPrompter.loop_active, false); - assert.equal(selfPrompter.prompt, 'mine iron'); - assert.equal(handleSelfPromptCalls, 0); -}); - -test('newAction code generation uses an isolated tool-internal prompt', async () => { - const { createCodeGenerationMessages } = await import('../src/agent/coder.js'); - const coderSource = readFileSync('src/agent/coder.js', 'utf8'); - const actionsSource = readFileSync('src/agent/commands/actions.js', 'utf8'); - const prompterSource = readFileSync('src/models/prompter.js', 'utf8'); - const messages = createCodeGenerationMessages('build a tower'); - - assert.deepEqual(messages, [{ - role: 'user', - content: 'Code generation task:\nbuild a tower\n\nWrite the implementation as a JavaScript code block.' - }]); - assert.ok(actionsSource.includes('agent.coder.generateCode(prompt)')); - assert.equal(actionsSource.includes('agent.coder.generateCode(agent.history)'), false); - assert.equal(coderSource.includes('agent_history.getHistory()'), false); - assert.equal(messages.some(message => message.content.includes('Code generation started')), false); - assert.ok(prompterSource.includes('extractCodeTaskContent(messages)')); - assert.ok(prompterSource.includes("msg.content.startsWith('Code generation task:')")); - assert.ok(coderSource.includes('beginActiveLLMRequest')); - assert.ok(coderSource.includes('promptCoding(messages_copy, { signal: llmAbortController?.signal })')); - assert.equal(prompterSource.includes('this.code_model = this.chat_model'), false); - assert.ok(prompterSource.includes(': cloneModelProfile(chat_model_profile)')); - assert.ok(prompterSource.includes('createModel(cloneModelProfile(code_model_profile))')); -}); - -test('newAction coding prompt clears in-flight state when interrupted', async () => { - const { Prompter } = await import('../src/models/prompter.js'); - const prompter = Object.create(Prompter.prototype); - const signal = AbortSignal.abort(); - const captured = {}; - prompter.awaiting_coding = false; - prompter.profile = { coding: 'Write code for $NAME.' }; - prompter.agent = { - name: 'codex', - history: { - traceLLMRequest: () => {}, - traceLLMResponse: () => {} - } - }; - prompter.checkCooldown = async () => {}; - prompter.replaceStrings = async prompt => prompt.replaceAll('$NAME', 'codex'); - prompter.code_model = { - getCacheTraceMetadata: () => ({}), - sendRequest: async (_messages, _prompt, _stop, _tools, options) => { - captured.options = options; - const error = new Error('aborted'); - error.name = 'AbortError'; - throw error; - } - }; - - await assert.rejects( - () => prompter.promptCoding([{ role: 'user', content: 'Code generation task:\nstop' }], { signal }), - /aborted/ - ); - - assert.equal(prompter.awaiting_coding, false); - assert.equal(captured.options.cacheScope, 'coding'); - assert.equal(captured.options.transportCacheScope, 'coding'); - assert.equal(captured.options.signal, signal); -}); - -test('botResponder decisions reject native tool calls instead of treating them as responses', async () => { - const { normalizeBotResponderDecision } = await import('../src/models/prompter.js'); - - assert.equal(normalizeBotResponderDecision('respond'), 'respond'); - assert.equal(normalizeBotResponderDecision('ignore because busy'), 'ignore'); - assert.equal(normalizeBotResponderDecision({ - type: 'tool_calls', - tool_calls: [{ id: 'call_1', name: 'newAction', arguments: '{}' }] - }), 'invalid_tool_call'); - assert.equal(normalizeBotResponderDecision(''), 'invalid_empty'); -}); - -test('botResponder forks structured conversation history and appends only a decision user prompt', async () => { - const { Prompter } = await import('../src/models/prompter.js'); - const historyTurns = [{ role: 'user', content: 'Steve: mine iron first' }]; - const captured = {}; - const prompter = Object.create(Prompter.prototype); - prompter.agent = { - name: 'codex', - actions: { currentActionLabel: 'collectBlocks iron_ore' }, - history: { - getHistory: () => historyTurns.map(turn => ({ ...turn })), - traceLLMRequest: (tag, model, systemPrompt, messages, tools, metadata) => { - captured.request = { tag, model, systemPrompt, messages, tools, metadata }; - }, - traceLLMResponse: (tag, model, response, metadata) => { - captured.response = { tag, model, response, metadata }; - } - } - }; - prompter.profile = { - conversing: 'Stable system for $NAME.', - bot_responder: 'Current action: $ACTION\nIncoming message:\n$INCOMING_MESSAGE\nOnly respond or ignore.' - }; - prompter.checkCooldown = async () => {}; - prompter.chat_model = { - supportsNativeToolCalls: true, - getCacheTraceMetadata: options => ({ - cache_scope: options.cacheScope, - transport_cache_scope: options.transportCacheScope || null, - transport_cache: { prompt_cache_key: 'session' } - }), - consumeLastRequestTraceMetadata: () => ({ - transport_cache: { prompt_cache_key: 'session', incremental_reuse: false }, - token_usage: { input_uncached: 12, input_cached: 34, output: 1 } - }), - sendRequest: async (messages, systemPrompt, stop, tools, options) => { - captured.sent = { messages, systemPrompt, stop, tools, options }; - return 'ignore'; - } - }; - - const shouldRespond = await prompter.promptShouldRespondToBot('kimi: (FROM OTHER BOT)\nhelp now'); - - assert.equal(shouldRespond, false); - assert.equal(captured.sent.systemPrompt, 'Stable system for codex.'); - assert.equal(captured.sent.options.cacheScope, 'botResponder'); - assert.equal(captured.sent.options.transportCacheScope, undefined); - assert.equal(Array.isArray(captured.sent.tools), true); - assert.ok(captured.sent.tools.length > 0); - assert.deepEqual(captured.sent.messages[0], historyTurns[0]); - assert.equal(captured.sent.messages.length, 2); - assert.match(captured.sent.messages[1].content, /Current action: collectBlocks iron_ore/); - assert.match(captured.sent.messages[1].content, /Incoming message:\nkimi: \(FROM OTHER BOT\)\nhelp now/); - assert.doesNotMatch(captured.sent.systemPrompt, /kimi|mine iron|Incoming message|Actual Conversation/); - assert.deepEqual(captured.request.messages, captured.sent.messages); - assert.deepEqual(captured.request.tools, captured.sent.tools); - assert.equal(captured.request.metadata.incoming_message, 'kimi: (FROM OTHER BOT)\nhelp now'); - assert.equal(captured.request.metadata.cache_scope, 'botResponder'); - assert.equal(captured.request.metadata.transport_cache_scope, null); - assert.equal(captured.response.metadata.transport_cache.prompt_cache_key, 'session'); - assert.equal(captured.response.metadata.token_usage.input_cached, 34); - assert.equal(historyTurns.length, 1); -}); - - - -test('chat UI projects instruction context trace events', () => { - const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); - const projector = readFileSync('src/mindcraft/public/chat_trace_projector.js', 'utf8'); - - assert.ok(projector.includes("case 'instruction_context':")); - assert.ok(projector.includes('addInstructionContext(event)')); - assert.ok(projector.includes('instructionContexts: []')); - assert.ok(html.includes('function renderInstructionContext(event)')); - assert.ok(html.includes('Instruction payload')); -}); - -test('chat UI adds copy controls to expanded JSON payloads', () => { - const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); - - assert.ok(html.includes('function enableChatCopyButtons(panel)')); - assert.ok(html.includes('enableChatCopyButtons(panel);')); - assert.ok(html.includes('class="chat-copy-btn"')); - assert.ok(html.includes('navigator.clipboard?.writeText')); - assert.ok(html.includes("document.execCommand('copy')")); -}); - -test('chat UI nests coding requests under the active tool instead of top-level turns', () => { - const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); - const projector = readFileSync('src/mindcraft/public/chat_trace_projector.js', 'utf8'); - - assert.ok(html.includes('/chat_trace_projector.js')); - assert.ok(projector.includes('class ChatTraceProjector')); - assert.ok(projector.includes("event.tag === 'coding' && this.attachInternalToolEvent(event)")); - assert.ok(projector.includes('attachInternalToolEvent(event)')); - assert.ok(projector.includes('findInternalToolHost(turn)')); - assert.ok(projector.includes("callHelper('getToolName', item.call) === 'newAction'")); - assert.ok(html.includes('function renderInternalToolEvents(events)')); - assert.ok(html.includes('Internal coding requests')); -}); - -test('chat UI and trace projection render model thinking separately', () => { - const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); - const projector = readFileSync('src/mindcraft/public/chat_trace_projector.js', 'utf8'); - - assert.ok(projector.includes('assistantThinking')); - assert.ok(projector.includes("callHelper('extractResponseThinking'")); - assert.ok(html.includes('function extractResponseThinking')); - assert.ok(html.includes('function renderThinking')); - assert.ok(html.includes('class="chat-thinking"')); - assert.ok(html.includes('class="chat-thinking-preview"')); - assert.ok(html.includes('-webkit-line-clamp: 2')); - assert.ok(html.includes('turn.assistantThinking')); - assert.equal(html.includes('renderThinking(item.event?.thinking)'), false); -}); - -test('chat trace projection renders ephemeral branch decisions outside the main timeline', () => { - const projector = readFileSync('src/mindcraft/public/chat_trace_projector.js', 'utf8'); - const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); - const prompterSource = readFileSync('src/models/prompter.js', 'utf8'); - - assert.ok(projector.includes('isBranchDecisionEvent(event)')); - assert.ok(projector.includes('addBranchEvent(event)')); - assert.ok(projector.includes('branchDecision')); - assert.ok(projector.includes('if (event.ephemeral) return;')); - assert.equal(projector.includes('if (event.ephemeral) return;\n switch'), true); - assert.ok(html.includes('function renderBranchDecision')); - assert.ok(html.includes('function renderBranchRequestMeta')); - assert.ok(html.includes('function renderBranchPayloadDetails')); - assert.ok(html.includes('function renderBranchPayloadBlock')); - assert.ok(html.includes('function parseBranchQuestion')); - assert.ok(html.includes('function normalizeBranchInlineMessage')); - assert.ok(html.includes('request?.incoming_message')); - assert.ok(html.includes('class="chat-branch-event"')); - assert.ok(html.includes('chat-branch-say')); - assert.ok(html.includes(' say:')); - assert.ok(html.includes('chat-branch-status')); - assert.ok(html.includes('chat-branch-bottom')); - assert.ok(html.includes('renderBranchRequestMeta(model, response?.token_usage, timestamp)')); - assert.ok(html.includes('input uncached')); - assert.ok(html.includes('input cached')); - assert.ok(html.includes('output')); - assert.ok(html.includes('chat-branch-payload-list')); - assert.ok(prompterSource.includes('ephemeral: true')); - assert.ok(prompterSource.includes('branch: true')); - assert.ok(prompterSource.includes("cache_scope: options.cacheScope || 'botResponder'")); -}); - -test('chat UI labels non respond-ignore branch outputs as invalid instead of decided', () => { - const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); - - assert.ok(html.includes('function getBranchDecisionInfo(response, errors = [])')); - assert.ok(html.includes('unexpected tool call:')); - assert.ok(html.includes("return { state: 'invalid', label: 'invalid'")); - assert.equal(html.includes("default: return 'decided'"), false); - assert.equal(html.includes('formatBranchDecisionState'), false); -}); - -test('ephemeral branch decisions do not update the main request delta baseline', () => { - const projectorSource = readFileSync('src/mindcraft/public/chat_trace_projector.js', 'utf8'); - const window = { - selectVisibleRequestMessages: (messages, previousMessages = []) => messages.slice(previousMessages.length), - extractResponseText: response => typeof response === 'string' ? response : '', - extractResponseThinking: () => '', - extractResponseToolCalls: () => [], - getToolCallId: call => call?.id || null, - getToolName: call => call?.name || call?.function?.name || 'tool', - isHistoryTurnIncludedInRequest: () => false - }; - vm.runInNewContext(projectorSource, { window }); - - const first = { role: 'user', content: 'Steve: start' }; - const branchQuestion = { role: 'user', content: 'buddy: (FROM OTHER BOT)\nhello while busy' }; - const thread = window.buildChatThread([ - { type: 'llm_request', tag: 'conversation', messages: [first], model: { model: 'gpt-5.5' }, timestamp: 't1' }, - { type: 'llm_response', tag: 'conversation', response: 'ok', model: { model: 'gpt-5.5' }, timestamp: 't2' }, - { type: 'llm_request', tag: 'botResponder', branch: true, ephemeral: true, cache_scope: 'botResponder', messages: [first, branchQuestion], model: { model: 'gpt-5.5' }, timestamp: 't3' }, - { type: 'llm_response', tag: 'botResponder', branch: true, ephemeral: true, cache_scope: 'botResponder', response: 'respond', token_usage: { input_uncached: 1, input_cached: 2, output: 3 }, model: { model: 'gpt-5.5' }, timestamp: 't4' }, - { type: 'llm_request', tag: 'conversation', messages: [first, branchQuestion], model: { model: 'gpt-5.5' }, timestamp: 't5' } - ]); - - assert.equal(thread.turns.length, 3); - assert.equal(thread.turns[1].branchDecision.response.token_usage.input_cached, 2); - assert.deepEqual(thread.turns[2].visibleRequestMessages, [branchQuestion]); -}); - -test('late user history stays at the end while an active tool turn awaits the next request', () => { - const projectorSource = readFileSync('src/mindcraft/public/chat_trace_projector.js', 'utf8'); - const window = { - selectVisibleRequestMessages: (messages, previousMessages = []) => messages.slice(previousMessages.length), - extractResponseText: response => typeof response === 'string' ? response : '', - extractResponseThinking: () => '', - extractResponseToolCalls: response => response?.tool_calls || [], - getToolCallId: call => call?.id || null, - getToolName: call => call?.name || call?.function?.name || 'tool', - isHistoryTurnIncludedInRequest: (turn, requestMessages = []) => requestMessages.some(message => message.role === turn?.role && message.content === turn?.content) - }; - vm.runInNewContext(projectorSource, { window }); - - const first = { role: 'user', content: 'Steve: mine iron' }; - const late = { role: 'user', content: 'Steve: stop and listen' }; - const toolCall = { id: 'call_collect', type: 'function', name: 'collectBlocks', arguments: '{"type":"iron_ore","num":16}' }; - const branchQuestion = { role: 'user', content: 'buddy: (FROM OTHER BOT)\nhello while busy' }; - const beforeRequest = window.buildChatThread([ - { type: 'llm_request', tag: 'conversation', messages: [first], model: { model: 'gpt-5.5' }, timestamp: 't1' }, - { type: 'llm_response', tag: 'conversation', response: { tool_calls: [toolCall] }, model: { model: 'gpt-5.5' }, timestamp: 't2' }, - { type: 'tool_call', tag: 'conversation', tool_call: toolCall, timestamp: 't3' }, - { type: 'llm_request', tag: 'botResponder', branch: true, ephemeral: true, cache_scope: 'botResponder', messages: [first, branchQuestion], model: { model: 'gpt-5.5' }, timestamp: 't4' }, - { type: 'llm_response', tag: 'botResponder', branch: true, ephemeral: true, cache_scope: 'botResponder', response: 'ignore', model: { model: 'gpt-5.5' }, timestamp: 't5' }, - { type: 'history_turn_added', turn: late, timestamp: 't6' } - ]); - - assert.equal(beforeRequest.turns.length, 3); - assert.equal(beforeRequest.turns[2].historyMessages[0].turn.content, late.content); - assert.equal(beforeRequest.turns[0].inlineHistoryMessages.length, 0); - - const afterRequest = window.buildChatThread([ - { type: 'llm_request', tag: 'conversation', messages: [first], model: { model: 'gpt-5.5' }, timestamp: 't1' }, - { type: 'llm_response', tag: 'conversation', response: { tool_calls: [toolCall] }, model: { model: 'gpt-5.5' }, timestamp: 't2' }, - { type: 'tool_call', tag: 'conversation', tool_call: toolCall, timestamp: 't3' }, - { type: 'llm_request', tag: 'botResponder', branch: true, ephemeral: true, cache_scope: 'botResponder', messages: [first, branchQuestion], model: { model: 'gpt-5.5' }, timestamp: 't4' }, - { type: 'llm_response', tag: 'botResponder', branch: true, ephemeral: true, cache_scope: 'botResponder', response: 'ignore', model: { model: 'gpt-5.5' }, timestamp: 't5' }, - { type: 'history_turn_added', turn: late, timestamp: 't6' }, - { type: 'llm_request', tag: 'conversation', messages: [first, late], model: { model: 'gpt-5.5' }, timestamp: 't7' } - ]); - - assert.equal(afterRequest.turns.length, 3); - assert.deepEqual(afterRequest.turns[2].visibleRequestMessages, [late]); - assert.equal(afterRequest.turns[2].historyMessages[0].turn.content, late.content); - assert.equal(afterRequest.turns[2].inlineHistoryMessages.length, 0); -}); - -test('chat trace projection can show reasoning effort in the model label', () => { - const projector = readFileSync('src/mindcraft/public/chat_trace_projector.js', 'utf8'); - - assert.ok(projector.includes('display_label')); - assert.ok(projector.includes("request?.model?.display_label")); -}); - -test('chat request cards avoid duplicate per-message role labels', () => { - const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); - const renderRequestMessagesSection = html.slice(html.indexOf('function renderRequestMessages'), html.indexOf('function selectVisibleRequestMessages')); - - assert.ok(renderRequestMessagesSection.includes('class="chat-message-row"')); - assert.ok(renderRequestMessagesSection.includes('class="chat-message-text"')); - assert.equal(renderRequestMessagesSection.includes('getMessageRoleLabel'), false); - assert.equal(renderRequestMessagesSection.includes('roleLabel'), false); - assert.equal(renderRequestMessagesSection.includes('
'), false); -}); - - -test('behavior logs are persisted with the outbound user turn for append-only cache stability', async () => { - const { Agent } = await import('../src/agent/agent.js'); - const capturedRequests = []; - const persisted = []; - const turns = []; - const agent = Object.create(Agent.prototype); - agent.name = 'bot'; - agent.shut_up = false; - agent.last_sender = null; - agent.checkTaskDone = async () => {}; - agent.self_prompter = { - shouldInterrupt: () => false, - isActive: () => false, - handleUserPromptedCmd: () => {} - }; - agent.bot = { modes: { flushBehaviorLog: () => "I'm stuck! I'm free." } }; - agent.history = { - add: async (source, message) => { - persisted.push({ source, message }); - turns.push({ role: 'user', content: `${source}: ${message}` }); - }, - addUserContext: async content => { - persisted.push({ source: 'user_context', message: content }); - turns.push({ role: 'user', content }); - }, - save: () => {}, - getHistory: () => turns.map(turn => ({ ...turn })) - }; - agent.prompter = { - promptConvo: async messages => { - capturedRequests.push(messages); - return ''; - } - }; - - await agent._handleMessageImpl('Steve', 'hello', 1); - - assert.deepEqual(persisted, [{ - source: 'user_context', - message: "Steve: hello\n\nSystem: Recent behaviors log: \nI'm stuck! I'm free." - }]); - assert.deepEqual(capturedRequests[0], [ - { role: 'user', content: "Steve: hello\n\nSystem: Recent behaviors log: \nI'm stuck! I'm free." } - ]); - assert.deepEqual(turns, [{ role: 'user', content: "Steve: hello\n\nSystem: Recent behaviors log: \nI'm stuck! I'm free." }]); -}); - - -test('chat UI does not render redundant tool argument expanders', () => { - const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); - - assert.equal(html.includes("renderDetails('Tool arguments'"), false); - assert.equal(html.includes('Tool arguments'), false); -}); - -test('chat UI renders inline failures as flat ReAct-style rows', () => { - const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); - const renderInlineError = html.slice(html.indexOf('function renderInlineError'), html.indexOf('function renderErrorCard')); - const assistantErrorRule = html.match(/\.agent-message\.assistant-message \.agent-bubble\.error \{[\s\S]*?\}/)?.[0] || ''; - const chatErrorRule = html.match(/\.chat-card\.error \{[\s\S]*?\}/)?.[0] || ''; - - assert.ok(renderInlineError.includes('agent-error-line')); - assert.ok(renderInlineError.includes('agent-error-body')); - assert.ok(renderInlineError.includes('agent-error-glyph')); - assert.ok(renderInlineError.includes('agent-error-detail')); - assert.equal(renderInlineError.includes('agent-bubble-label">Error'), false); - assert.equal(renderInlineError.includes('class="agent-text"'), false); - assert.ok(assistantErrorRule.includes('background: transparent;')); - assert.ok(assistantErrorRule.includes('border: none;')); - assert.ok(assistantErrorRule.includes('border-radius: 0;')); - assert.ok(chatErrorRule.includes('background: transparent;')); - assert.ok(chatErrorRule.includes('border-radius: 0;')); - assert.ok(chatErrorRule.includes('box-shadow: none;')); -}); - -test('chat UI keeps late user messages below active tool output while waiting for next request', () => { - const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); - const renderChatTurn = html.slice(html.indexOf('function renderChatTurn'), html.indexOf('function renderBranchDecision')); - - assert.ok(renderChatTurn.includes('const inlineHistory = renderInlineHistoryMessages(turn);')); - assert.ok(renderChatTurn.includes("const inlineHistoryBeforeAssistant = turn.toolRuns.length ? '' : inlineHistory;")); - assert.ok(renderChatTurn.includes("const inlineHistoryAfterAssistant = turn.toolRuns.length ? inlineHistory : '';")); - assert.ok(renderChatTurn.indexOf('${renderAssistantWorkMessage(turn, index)}') < renderChatTurn.indexOf('${inlineHistoryAfterAssistant}')); -}); - -test('runtime chat UI batches live trace renders and keeps a cached event-key index', () => { - const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); - const traceHandler = html.slice(html.indexOf("socket.on('agent-trace'"), html.indexOf('// Subscribe to aggregated state updates')); - const appendChatEvents = html.slice(html.indexOf('function appendChatEvents'), html.indexOf('function getChatEventKey')); - - assert.ok(html.includes('const agentChatEventKeys = {};')); - assert.ok(html.includes('const pendingChatPanelRenders = {};')); - assert.ok(html.includes('function scheduleChatPanelRender(name, options = {})')); - assert.ok(html.includes('window.requestAnimationFrame || (callback => window.setTimeout(callback, 16))')); - assert.ok(traceHandler.includes('scheduleChatPanelRender(agentName);')); - assert.equal(traceHandler.includes('renderChatPanel(agentName);'), false); - assert.ok(appendChatEvents.includes('const existingKeys = getAgentChatEventKeys(name);')); - assert.equal(appendChatEvents.includes('new Set(agentChatEvents[name].map(getChatEventKey))'), false); -}); - -test('runtime chat UI lazily hydrates JSON payload DOM only when details are open', () => { - const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); - const renderDetails = html.slice(html.indexOf('function renderDetails'), html.indexOf('function registerChatJsonPayload')); - - assert.ok(html.includes('const chatDetailPayloads = new Map();')); - assert.ok(html.includes('function enableChatLazyDetails(panel)')); - assert.ok(html.includes('function hydrateChatJsonContainers(root)')); - assert.ok(html.includes('function renderLazyChatJsonContainer(payloadId)')); - assert.ok(renderDetails.includes('registerChatJsonPayload(label, value, detailId)')); - assert.ok(renderDetails.includes('renderLazyChatJsonContainer(payloadId)')); - assert.equal(renderDetails.includes('
${escapeHTML(json)}
'), false); -}); - -test('runtime chat JSON hydration does not insert whitespace-only line boxes before payloads', () => { - const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); - const renderJson = html.slice(html.indexOf('function renderChatJsonPayloadHTML'), html.indexOf('function getChatDetailId')); - - assert.ok(renderJson.includes('return `
${escapeHTML(json)}
`;')); - assert.equal(renderJson.includes('return `\\n'), false); -}); - -test('running tool details do not preserve template whitespace as large blank rows', () => { - const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); - - assert.ok(html.includes('.chat-tool-output-detail.chat-tool-result')); - assert.ok(html.includes('white-space: normal;')); - assert.ok(html.includes('.chat-tool-output-extra {')); - assert.ok(html.includes('flex-direction: column;')); - assert.ok(html.includes('.chat-tool-output-extra > .chat-details')); -}); - -test('tool call parameters wrap without displacing the running status column', () => { - const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); - const paramsRules = [...html.matchAll(/\.agent-step-params \{[\s\S]*?\}/g)].map(match => match[0]).join('\n'); - const headerRules = [...html.matchAll(/\.agent-step-header,\n\s*\.agent-steps \.agent-step-header \{[\s\S]*?\}/g)].map(match => match[0]).join('\n'); - const statusRules = [...html.matchAll(/\.agent-step-status \{[\s\S]*?\}/g)].map(match => match[0]).join('\n'); - - assert.ok(paramsRules.includes('white-space: normal;')); - assert.ok(paramsRules.includes('overflow-wrap: anywhere;')); - assert.ok(paramsRules.includes('word-break: break-word;')); - assert.ok(paramsRules.includes('display: block;')); - assert.ok(headerRules.includes('grid-template-columns: 16px max-content minmax(0, 1fr) auto;')); - assert.ok(statusRules.includes('grid-column: 4;')); - assert.ok(statusRules.includes('justify-self: end;')); - assert.ok(statusRules.includes('white-space: nowrap;')); -}); - -test('runtime status UI avoids rebuilding inventory and armor DOM when state is unchanged', () => { - const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); - - assert.ok(html.includes('const agentUiRenderCache = {};')); - assert.ok(html.includes('function setTextIfChanged(element, text)')); - assert.ok(html.includes('function getAgentUiRenderCache(name)')); - assert.ok(html.includes('if (renderCache.armorKey !== armorKey)')); - assert.ok(html.includes('if (renderCache.inventoryKey === inventoryKey) return;')); -}); - -test('New Agent forms hide hidden settings and keep profile upload separate', () => { - const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); - - assert.ok(html.includes('Profile: Not uploaded')); - assert.ok(html.includes('id="uploadProfileBtn"')); - assert.ok(html.includes('function isEditableSetting(key')); - assert.ok(html.includes('!cfg?.hidden')); - assert.ok(html.includes("cfg?.ui !== 'hidden'")); - assert.ok(html.includes('if (!isEditableSetting(key, cfg)) return; // profile handled via upload; hidden settings use server defaults')); - assert.ok(html.includes('if (!isEditableSetting(key, cfg)) return; // profile and hidden settings are not edited here')); -}); - -test('agent delegates ReAct request assembly to a single message manager', () => { - const agentSource = readFileSync('src/agent/agent.js', 'utf8'); - const managerSource = readFileSync('src/agent/react_message_manager.js', 'utf8'); - const snapshotSource = readFileSync('src/agent/state_snapshot.js', 'utf8'); - const settingsSource = readFileSync('settings.js', 'utf8'); - const settingsSpec = readFileSync('src/mindcraft/public/settings_spec.json', 'utf8'); - - assert.ok(agentSource.includes('new ReactMessageManager(this)')); - assert.ok(agentSource.includes('const reactTurn = this.react_messages.startTurn({ source, message, options, behaviorLog })')); - assert.ok(agentSource.includes('await reactTurn.buildRequestMessages()')); - assert.equal(agentSource.includes('await this.react_messages.buildRequestMessages()'), false); - assert.equal(agentSource.includes('buildStateSnapshotDiff(this)'), false); - assert.equal(agentSource.includes('pendingPersistedParts'), false); - assert.equal(agentSource.includes('createTransientRequestMessage(transientParts)'), false); - assert.equal(agentSource.includes('historyBeforeCurrentMessage = this.history.getHistory()'), false); - assert.equal(agentSource.includes('removeLastMatchingMessage'), false); - - assert.ok(managerSource.includes('class ReactMessageManager')); - assert.ok(managerSource.includes('return new ReactMessageTurn')); - assert.ok(managerSource.includes('buildStateSnapshotDiff(this.agent)')); - assert.ok(managerSource.includes('this.pendingPersistedParts.join')); - assert.ok(managerSource.includes('await this.agent.history.addUserContext(content)')); - assert.ok(managerSource.includes('createTransientRequestMessage(requestTransientParts)')); - - assert.ok(snapshotSource.includes('State update:\\n')); - assert.ok(snapshotSource.includes('`* inventory: ${formatMap(snapshot.inventory)')); - assert.ok(snapshotSource.includes('lines.push(`* ${label}:')); - assert.equal(snapshotSource.includes('lines.push(`- ${label}:'), false); - assert.equal(snapshotSource.includes('Current state:'), false); - assert.equal(snapshotSource.includes('State changes:'), false); - assert.equal(snapshotSource.includes('Use this instead of re-checking unchanged state'), false); - assert.equal(settingsSource.includes('state_snapshot_diff'), false); - assert.equal(settingsSpec.includes('state_snapshot_diff'), false); -}); - -test('system prompts carry state snapshot usage guidance', () => { - const prompt = readFileSync('profiles/defaults/prompts/conversing.md', 'utf8'); - - assert.ok(prompt.includes('Use transient state snapshots/diffs as your current baseline')); -}); - -test('chat UI projection suppresses history turns duplicated by the following request', () => { - const projector = readFileSync('src/mindcraft/public/chat_trace_projector.js', 'utf8'); - - assert.ok(projector.includes('class ChatTraceProjector')); - assert.ok(projector.includes('takePendingHistoryOnlyTurn()')); - assert.ok(projector.includes('const pending = this.thread.turns[this.thread.turns.length - 1];')); - assert.ok(projector.includes('isHistoryOnlyProjectionTurn(pending)')); - assert.ok(projector.includes('this.thread.turns.pop()')); - assert.ok(projector.includes('removeRequestIncludedHistory(turn, requestMessages)')); - assert.ok(projector.includes("callHelper('isHistoryTurnIncludedInRequest', historyEvent.turn, requestMessages)")); - const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); - assert.ok(html.includes('requestText.startsWith(`${content}')); -}); - -test('chat UI projection renders only request message deltas instead of repeated full history', () => { - const html = readFileSync('src/mindcraft/public/index.html', 'utf8'); - const projector = readFileSync('src/mindcraft/public/chat_trace_projector.js', 'utf8'); - - assert.ok(projector.includes('this.previousRequestMessages = []')); - assert.ok(projector.includes("visibleRequestMessages: callHelper('selectVisibleRequestMessages', requestMessages, this.previousRequestMessages)")); - assert.ok(projector.includes('this.previousRequestMessages = requestMessages')); - assert.ok(html.includes('getCommonRequestPrefixLength(previousMessages, messages)')); - assert.ok(html.includes('const newMessages = messages.slice(startIndex);')); - assert.ok(html.includes('getTrailingUserMessages(scope)')); - assert.ok(html.includes("if (message?.role !== 'user') break;")); - assert.equal(html.includes("preview.startsWith('Context:')"), false); - assert.ok(html.includes('replace(/^Context:')); - assert.ok(html.includes('State update:')); - assert.ok(html.includes('class="chat-message-text"')); -}); - -test('chat trace projector is display-only and cannot mutate model history or requests', () => { - const projector = readFileSync('src/mindcraft/public/chat_trace_projector.js', 'utf8'); - - assert.equal(projector.includes('addUserContext'), false); - assert.equal(projector.includes('.addNativeTool'), false); - assert.equal(projector.includes('.add('), false); - assert.equal(projector.includes('.save('), false); - assert.equal(projector.includes('sendRequest'), false); - assert.equal(projector.includes('promptConvo'), false); - assert.equal(projector.includes('traceLLM'), false); -}); - -test('conversation and coding requests use separate prompt cache scopes', () => { - const prompterSource = readFileSync('src/models/prompter.js', 'utf8'); - - assert.ok(prompterSource.includes("cacheScope: 'conversation'")); - assert.ok(prompterSource.includes("cacheScope: 'coding'")); - assert.ok(prompterSource.includes("transportCacheScope: 'coding'")); - assert.ok(prompterSource.includes("cacheScope: 'compactSummary'")); - assert.ok(prompterSource.includes("cacheScope: options.cacheScope || 'botResponder'")); - assert.ok(prompterSource.includes("cacheScope: 'vision'")); - assert.ok(prompterSource.includes("cacheScope: 'goalSetting'")); -}); diff --git a/tests/chat_history_trace.test.js b/tests/chat_history_trace.test.js deleted file mode 100644 index 0e5749126..000000000 --- a/tests/chat_history_trace.test.js +++ /dev/null @@ -1,439 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { History } from '../src/agent/history.js'; -import { setSettings } from '../src/agent/settings.js'; - -class FakeModel { - static prefix = 'fake-protocol'; - - constructor() { - this.provider = 'fake-provider'; - this.model_name = 'fake-model'; - this.supportsNativeToolCalls = true; - } -} - -test('runtime chat history persists when Runtime is enabled without full trace logging', async () => { - const originalCwd = process.cwd(); - const dir = mkdtempSync(path.join(tmpdir(), 'mindcraft-trace-')); - try { - process.chdir(dir); - setSettings({ show_chat_history: true, log_chat_trace: false }); - const history = new History({ - name: 'tracebot', - self_prompter: { state: {} }, - task: {} - }); - - const model = new FakeModel(); - model.lastThinking = 'I should inspect inventory.'; - model.lastTokenUsage = { - input_total: 42, - input_uncached: 10, - input_cached: 32, - output: 7, - total: 49 - }; - const messages = [{ role: 'user', content: 'Steve: check inventory' }]; - const tools = [{ type: 'function', function: { name: 'inventory', parameters: { type: 'object' } } }]; - history.traceInstructionContext('AGENTS.md instructions', 'Follow repo-local AGENTS.md guidance.', { source: 'AGENTS.md' }); - history.traceLLMRequest('conversation', model, 'system prompt text', messages, tools); - history.traceLLMResponse('conversation', model, { type: 'tool_calls', tool_calls: [{ name: 'inventory' }] }); - - await history.add('Steve', 'check inventory'); - await history.add('system', 'Action was interrupted by unstuck.'); - const toolCall = { id: 'call_1', type: 'function', name: 'inventory', arguments: '{}' }; - await history.addNativeToolCall(toolCall, undefined, { thinking: 'I should inspect inventory.', thinking_key: 'reasoning_content' }); - await history.addNativeToolResult(toolCall, 'Action output:\nInventory is empty.'); - - assert.ok(history.chat_history_session_fp); - assert.ok(existsSync(history.chat_history_session_fp)); - assert.ok(existsSync(history.chat_history_latest_fp)); - const events = readFileSync(history.chat_history_latest_fp, 'utf8') - .trim() - .split('\n') - .map(line => JSON.parse(line)); - assert.ok(events.some(event => event.type === 'instruction_context')); - assert.ok(events.some(event => event.type === 'llm_request')); - assert.ok(events.some(event => event.type === 'llm_response')); - assert.ok(events.some(event => event.type === 'llm_response' && event.thinking === 'I should inspect inventory.')); - assert.ok(events.some(event => event.type === 'history_turn_added')); - assert.ok(events.some(event => event.type === 'tool_call')); - assert.ok(events.some(event => event.type === 'tool_result')); - assert.equal(history.turns.length, 4); - } finally { - setSettings({}); - process.chdir(originalCwd); - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('chat history trace records prompts, messages, tool calls and tool results when enabled', async () => { - const originalCwd = process.cwd(); - const dir = mkdtempSync(path.join(tmpdir(), 'mindcraft-trace-')); - try { - process.chdir(dir); - setSettings({ show_chat_history: true, log_chat_trace: true }); - const history = new History({ - name: 'tracebot', - self_prompter: { state: {} }, - task: {} - }); - - const model = new FakeModel(); - model.lastThinking = 'I should inspect inventory.'; - model.lastTokenUsage = { - input_total: 42, - input_uncached: 10, - input_cached: 32, - output: 7, - total: 49 - }; - const messages = [{ role: 'user', content: 'Steve: check inventory' }]; - const tools = [{ type: 'function', function: { name: 'inventory', parameters: { type: 'object' } } }]; - history.traceInstructionContext('AGENTS.md instructions', 'Follow repo-local AGENTS.md guidance.', { source: 'AGENTS.md' }); - history.traceLLMRequest('conversation', model, 'system prompt text', messages, tools); - history.traceLLMResponse('conversation', model, { type: 'tool_calls', tool_calls: [{ name: 'inventory' }] }); - - await history.add('Steve', 'check inventory'); - await history.add('system', 'Action was interrupted by unstuck.'); - const toolCall = { id: 'call_1', type: 'function', name: 'inventory', arguments: '{}' }; - await history.addNativeToolCall(toolCall, undefined, { thinking: 'I should inspect inventory.', thinking_key: 'reasoning_content' }); - await history.addNativeToolResult(toolCall, 'Action output:\nInventory is empty.'); - - const events = readFileSync(history.chat_history_latest_fp, 'utf8') - .trim() - .split('\n') - .map(line => JSON.parse(line)); - - assert.ok(events.some(event => event.type === 'session_started')); - const instructions = events.find(event => event.type === 'instruction_context'); - assert.equal(instructions.title, 'AGENTS.md instructions'); - assert.equal(instructions.content, 'Follow repo-local AGENTS.md guidance.'); - assert.deepEqual(instructions.metadata, { source: 'AGENTS.md' }); - const request = events.find(event => event.type === 'llm_request'); - assert.equal(request.system_prompt, 'system prompt text'); - assert.deepEqual(request.messages, messages); - assert.equal(request.tool_count, 1); - assert.equal(request.model.api, 'fake-protocol'); - assert.equal(request.request_fingerprint.message_count, 1); - assert.equal(request.request_fingerprint.tool_count, 1); - assert.ok(request.request_fingerprint.system_prompt_hash); - assert.ok(request.request_fingerprint.messages_hash); - assert.ok(request.request_fingerprint.tools_hash); - - assert.ok(events.some(event => event.type === 'llm_response')); - const response = events.find(event => event.type === 'llm_response'); - assert.deepEqual(response.token_usage, model.lastTokenUsage); - assert.ok(events.some(event => event.type === 'history_turn_added' && event.turn.role === 'user')); - assert.ok(events.some(event => event.type === 'history_turn_added' && event.turn.role === 'user' && event.turn.content.startsWith('System: Action was interrupted'))); - assert.ok(!events.some(event => event.type === 'history_turn_added' && event.turn.role === 'system')); - assert.ok(events.some(event => event.type === 'tool_call' && event.tool_call.name === 'inventory' && event.thinking === 'I should inspect inventory.')); - assert.ok(events.some(event => event.type === 'tool_result' && event.result.includes('Inventory is empty'))); - assert.ok(history.turns.some(turn => turn.role === 'assistant' && turn.thinking === 'I should inspect inventory.')); - } finally { - setSettings({}); - process.chdir(originalCwd); - rmSync(dir, { recursive: true, force: true }); - } -}); - - - -test('chat history trace records configured instruction contexts at session start', async () => { - const originalCwd = process.cwd(); - const dir = mkdtempSync(path.join(tmpdir(), 'mindcraft-trace-instructions-')); - try { - process.chdir(dir); - setSettings({ - show_chat_history: true, - log_chat_trace: true, - trace_instruction_contexts: [ - { - title: 'AGENTS.md instructions', - content: 'Follow repo-local AGENTS.md guidance.', - metadata: { source: 'runtime-test' } - } - ] - }); - const history = new History({ - name: 'tracebot', - self_prompter: { state: {} }, - task: {} - }); - - const events = readFileSync(history.chat_history_latest_fp, 'utf8') - .trim() - .split('\n') - .map(line => JSON.parse(line)); - const instructions = events.find(event => event.type === 'instruction_context'); - assert.equal(instructions.title, 'AGENTS.md instructions'); - assert.equal(instructions.content, 'Follow repo-local AGENTS.md guidance.'); - assert.deepEqual(instructions.metadata, { source: 'runtime-test' }); - } finally { - setSettings({}); - process.chdir(originalCwd); - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('history dedupes repeated self-prompt reminders from active context', async () => { - const originalCwd = process.cwd(); - const dir = mkdtempSync(path.join(tmpdir(), 'mindcraft-selfprompt-')); - try { - process.chdir(dir); - setSettings({ show_chat_history: true, log_chat_trace: true }); - const history = new History({ - name: 'tracebot', - self_prompter: { state: {} }, - task: {} - }); - - const reminder = 'Continue working on your current goal: "mine". Decide the next useful step and proceed. If the goal is complete, finish the goal.'; - await history.add('system', reminder); - await history.addNativeToolCall({ id: 'call_1', type: 'function', name: 'inventory', arguments: '{}' }); - await history.addNativeToolResult({ id: 'call_1', type: 'function', name: 'inventory', arguments: '{}' }, 'Action output:\nempty'); - await history.add('system', reminder); - - const selfPromptTurns = history.turns.filter(turn => turn.content === `System: ${reminder}`); - assert.equal(selfPromptTurns.length, 1); - - const events = readFileSync(history.chat_history_latest_fp, 'utf8') - .trim() - .split('\n') - .map(line => JSON.parse(line)); - assert.ok(events.some(event => event.type === 'history_turn_deduped' && event.reason === 'duplicate_self_prompt_reminder')); - } finally { - setSettings({}); - process.chdir(originalCwd); - rmSync(dir, { recursive: true, force: true }); - } -}); - - - -test('history compaction replaces active context with boundary and summary', async () => { - const originalCwd = process.cwd(); - const dir = mkdtempSync(path.join(tmpdir(), 'mindcraft-compact-')); - try { - process.chdir(dir); - setSettings({ - show_chat_history: false, - log_chat_trace: false, - max_messages: 4, - compact_message_threshold_percent: 100 - }); - const summarized = []; - const history = new History({ - name: 'compactbot', - prompter: { - promptCompactSummary: async turns => { - summarized.push(turns.map(t => t.content || t.name).join('|')); - return `summary of ${turns.length} turns`; - } - }, - self_prompter: { state: {} }, - task: {} - }); - - await history.add('Steve', 'one'); - await history.add('compactbot', 'two'); - await history.add('Steve', 'three'); - await history.add('compactbot', 'four'); - - assert.equal(summarized.length, 1); - assert.equal(history.memory, 'summary of 4 turns'); - assert.equal(history.turns.length, 2); - assert.equal(history.turns[0].compact_boundary, true); - assert.equal(history.turns[1].compact_summary, true); - assert.match(history.turns[1].content, /summary of 4 turns/); - assert.deepEqual(history.getHistory(), [history.turns[1]]); - assert.ok(history.full_history_fp); - assert.ok(existsSync(history.full_history_fp)); - } finally { - setSettings({}); - process.chdir(originalCwd); - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('history compaction counts only new turns after the latest compact summary', async () => { - const originalCwd = process.cwd(); - const dir = mkdtempSync(path.join(tmpdir(), 'mindcraft-compact-budget-')); - try { - process.chdir(dir); - setSettings({ - show_chat_history: false, - log_chat_trace: false, - max_messages: 4, - compact_message_threshold_percent: 100 - }); - const summarized = []; - const history = new History({ - name: 'compactbot', - prompter: { - promptCompactSummary: async turns => { - summarized.push(turns.map(t => t.content || t.name).join('|')); - return `summary pass ${summarized.length}`; - } - }, - self_prompter: { state: {} }, - task: {} - }); - - await history.add('Steve', 'one'); - await history.add('compactbot', 'two'); - await history.add('Steve', 'three'); - await history.add('compactbot', 'four'); - assert.equal(summarized.length, 1); - - await history.add('Steve', 'five'); - await history.add('compactbot', 'six'); - assert.equal(summarized.length, 1); - - await history.add('Steve', 'seven'); - await history.add('compactbot', 'eight'); - assert.equal(summarized.length, 2); - assert.doesNotMatch(summarized[1], /Conversation compacted/); - assert.match(summarized[1], /summary pass 1/); - assert.match(summarized[1], /Steve: five/); - assert.match(summarized[1], /six/); - } finally { - setSettings({}); - process.chdir(originalCwd); - rmSync(dir, { recursive: true, force: true }); - } -}); - - -test('history compaction accepts numeric settings from web/runtime strings', async () => { - const originalCwd = process.cwd(); - const dir = mkdtempSync(path.join(tmpdir(), 'mindcraft-compact-string-settings-')); - try { - process.chdir(dir); - setSettings({ - show_chat_history: false, - log_chat_trace: false, - max_messages: '4', - compact_message_threshold_percent: '100' - }); - const summarized = []; - const history = new History({ - name: 'compactbot', - prompter: { - promptCompactSummary: async turns => { - summarized.push(turns.map(t => t.content || t.name).join('|')); - return `summary of ${turns.length} turns`; - } - }, - self_prompter: { state: {} }, - task: {} - }); - - await history.add('Steve', 'one'); - await history.add('compactbot', 'two'); - await history.add('Steve', 'three'); - await history.add('compactbot', 'four'); - - assert.equal(summarized.length, 1); - assert.equal(history.memory, 'summary of 4 turns'); - assert.equal(history.turns.length, 2); - assert.equal(history.turns[0].compact_boundary, true); - } finally { - setSettings({}); - process.chdir(originalCwd); - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('history compaction closes stale pending native tool calls before later turns', async () => { - const originalCwd = process.cwd(); - const dir = mkdtempSync(path.join(tmpdir(), 'mindcraft-compact-stale-tools-')); - try { - process.chdir(dir); - setSettings({ - show_chat_history: true, - log_chat_trace: true, - max_messages: 4, - compact_message_threshold_percent: 100 - }); - const summarized = []; - const history = new History({ - name: 'compactbot', - prompter: { - promptCompactSummary: async turns => { - summarized.push(turns.map(t => t.content || t.name).join('|')); - return `summary of ${turns.length} turns`; - } - }, - self_prompter: { state: {} }, - task: {} - }); - - const toolCall = { id: 'call_1', type: 'function', name: 'collectBlocks', arguments: '{"type":"oak_log"}' }; - await history.add('Steve', 'collect wood'); - await history.addNativeToolCall(toolCall); - assert.equal(summarized.length, 0); - - await history.add('Steve', 'new request after interrupted tool'); - - assert.equal(summarized.length, 1); - assert.match(summarized[0], /Tool result was not recorded before the next conversation turn/); - assert.equal(history.hasPendingToolCall(), false); - assert.equal(history.turns[0].compact_boundary, true); - - const events = readFileSync(history.chat_history_latest_fp, 'utf8') - .trim() - .split('\n') - .map(line => JSON.parse(line)); - assert.ok(events.some(event => event.type === 'history_tool_results_synthesized')); - } finally { - setSettings({}); - process.chdir(originalCwd); - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('history compaction waits for native tool results before compacting', async () => { - const originalCwd = process.cwd(); - const dir = mkdtempSync(path.join(tmpdir(), 'mindcraft-compact-tools-')); - try { - process.chdir(dir); - setSettings({ - show_chat_history: false, - log_chat_trace: false, - max_messages: 2, - compact_message_threshold_percent: 100 - }); - let summaries = 0; - const history = new History({ - name: 'compactbot', - prompter: { - promptCompactSummary: async turns => { - summaries += 1; - return `summary after ${turns.length}`; - } - }, - self_prompter: { state: {} }, - task: {} - }); - - const toolCall = { id: 'call_1', type: 'function', name: 'inventory', arguments: '{}' }; - await history.add('Steve', 'check inventory'); - await history.addNativeToolCall(toolCall); - assert.equal(summaries, 0); - assert.equal(history.turns.length, 2); - - await history.addNativeToolResult(toolCall, 'empty'); - assert.equal(summaries, 1); - assert.equal(history.turns[0].compact_boundary, true); - assert.equal(history.turns[1].compact_summary, true); - } finally { - setSettings({}); - process.chdir(originalCwd); - rmSync(dir, { recursive: true, force: true }); - } -}); diff --git a/tests/codex_chatgpt.test.js b/tests/codex_chatgpt.test.js deleted file mode 100644 index cf1d9f96c..000000000 --- a/tests/codex_chatgpt.test.js +++ /dev/null @@ -1,1051 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { WebSocketServer } from 'ws'; -import { - CodexChatGPT, - buildAuthorizeUrl, - ensureCodexChatGPTAuth, - hasCodexChatGPTAuth, - parseCodexResponsesSse, - readCodexChatGPTAuth, - runCodexBrowserLogin, - toCodexResponseItem, - toCodexResponsesTools, - writeKeysCodexAuth -} from '../src/models/codex_chatgpt.js'; -import { isNativeToolResponse } from '../src/models/native_tools.js'; - -const tool = { - type: 'function', - function: { - name: 'report_status', - description: 'Report status', - parameters: { - type: 'object', - properties: { status: { type: 'string' } }, - required: ['status'], - additionalProperties: false - } - } -}; - -test('Codex adapter converts OpenAI-style tools to Responses API shape', () => { - assert.deepEqual(toCodexResponsesTools([tool]), [{ - type: 'function', - name: 'report_status', - description: 'Report status', - strict: false, - parameters: tool.function.parameters - }]); -}); - -test('Codex adapter converts chat messages to protocol ResponseItems', () => { - assert.deepEqual(toCodexResponseItem({ role: 'user', content: 'hi' }), { - type: 'message', - role: 'user', - content: [{ type: 'input_text', text: 'hi' }] - }); - assert.deepEqual(toCodexResponseItem({ role: 'assistant', content: 'hello' }), { - type: 'message', - role: 'assistant', - content: [{ type: 'output_text', text: 'hello' }] - }); -}); - -test('Codex SSE parser extracts Responses function_call events', async () => { - const sse = [ - 'event: response.output_item.done', - 'data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_1","name":"report_status","arguments":"{\\"status\\":\\"ok\\"}"}}', - '', - 'event: response.completed', - 'data: {"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":100,"input_tokens_details":{"cached_tokens":60},"output_tokens":8}}}', - '' - ].join('\n'); - - const parsed = await parseCodexResponsesSse(sse); - assert.equal(parsed.toolCalls[0].function.name, 'report_status'); - assert.equal(parsed.toolCalls[0].function.arguments, '{"status":"ok"}'); - assert.equal(parsed.usage.input_tokens, 100); - assert.equal(parsed.usage.input_tokens_details.cached_tokens, 60); - assert.equal(parsed.responseId, 'resp_1'); - assert.equal(parsed.outputItems.length, 1); - assert.equal(parsed.outputItems[0].type, 'function_call'); -}); - -test('Codex SSE parser prefers text deltas over final message to avoid duplicate text', async () => { - const sse = [ - 'event: response.output_text.delta', - 'data: {"type":"response.output_text.delta","delta":"Hi "}', - '', - 'event: response.output_text.delta', - 'data: {"type":"response.output_text.delta","delta":"there"}', - '', - 'event: response.output_item.done', - 'data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"Hi there"}]}}', - '' - ].join('\n'); - - const parsed = await parseCodexResponsesSse(sse); - assert.equal(parsed.text, 'Hi there'); -}); - -test('Codex SSE parser extracts reasoning/thinking output', async () => { - const sse = [ - 'event: response.reasoning_summary_text.delta', - 'data: {"type":"response.reasoning_summary_text.delta","delta":"Need a tool."}', - '', - 'event: response.output_item.done', - 'data: {"type":"response.output_item.done","item":{"type":"reasoning","summary":[{"type":"summary_text","text":"Final reasoning."}]}}', - '' - ].join('\n'); - - const parsed = await parseCodexResponsesSse(sse); - assert.equal(parsed.thinking, 'Need a tool.'); -}); - - -test('Codex browser login builds the same authorize URL shape as Codex CLI', () => { - const url = new URL(buildAuthorizeUrl({ - issuer: 'https://auth.openai.com', - clientId: 'client-test', - redirectUri: 'http://localhost:1455/auth/callback', - pkce: { code_challenge: 'challenge-test' }, - state: 'state-test', - forcedChatgptWorkspaceId: 'workspace-test', - originator: 'codex_cli_rs' - })); - - assert.equal(url.origin + url.pathname, 'https://auth.openai.com/oauth/authorize'); - assert.equal(url.searchParams.get('response_type'), 'code'); - assert.equal(url.searchParams.get('client_id'), 'client-test'); - assert.equal(url.searchParams.get('redirect_uri'), 'http://localhost:1455/auth/callback'); - assert.equal(url.searchParams.get('scope'), 'openid profile email offline_access api.connectors.read api.connectors.invoke'); - assert.equal(url.searchParams.get('code_challenge'), 'challenge-test'); - assert.equal(url.searchParams.get('code_challenge_method'), 'S256'); - assert.equal(url.searchParams.get('id_token_add_organizations'), 'true'); - assert.equal(url.searchParams.get('codex_cli_simplified_flow'), 'true'); - assert.equal(url.searchParams.get('state'), 'state-test'); - assert.equal(url.searchParams.get('originator'), 'codex_cli_rs'); - assert.equal(url.searchParams.get('allowed_workspace_id'), 'workspace-test'); -}); - -test('Codex browser login callback exchanges code and persists project keys auth', async () => { - const { keysPath, cleanup } = writeTempKeys({ includeAuth: false }); - const originalFetch = globalThis.fetch; - const originalLog = console.log; - const logs = []; - const tokenRequests = []; - globalThis.fetch = async (url, init) => { - if (String(url).startsWith('http://localhost:')) { - return originalFetch(url, init); - } - tokenRequests.push({ url, init, body: new URLSearchParams(init.body) }); - assert.equal(String(url), 'https://issuer.test/oauth/token'); - assert.equal(tokenRequests[0].body.get('grant_type'), 'authorization_code'); - assert.equal(tokenRequests[0].body.get('code'), 'auth-code-test'); - assert.equal(tokenRequests[0].body.get('client_id'), 'client-test'); - assert.match(tokenRequests[0].body.get('redirect_uri'), /^http:\/\/localhost:\d+\/auth\/callback$/); - assert.ok(tokenRequests[0].body.get('code_verifier')); - return new Response(JSON.stringify({ - id_token: 'id-token-test', - access_token: 'access-token-test', - refresh_token: 'refresh-token-test', - account_id: 'account-id-test' - }), { status: 200, headers: { 'content-type': 'application/json' } }); - }; - console.log = (...args) => { logs.push(args.join(' ')); }; - - try { - const login = runCodexBrowserLogin({ - keysPath, - issuer: 'https://issuer.test', - clientId: 'client-test', - port: 0, - openBrowser: false - }); - const authUrl = await waitFor(() => { - const match = logs.join('\n').match(/https:\/\/issuer\.test\/oauth\/authorize\?\S+/); - return match?.[0]; - }); - const parsedAuthUrl = new URL(authUrl); - const redirectUri = parsedAuthUrl.searchParams.get('redirect_uri'); - const state = parsedAuthUrl.searchParams.get('state'); - const callback = await fetch(`${redirectUri}?code=auth-code-test&state=${encodeURIComponent(state)}`); - assert.equal(callback.status, 200); - - const authJson = await login; - assert.equal(authJson.tokens.access_token, 'access-token-test'); - assert.equal(readCodexChatGPTAuth(keysPath).accessToken, 'access-token-test'); - assert.equal(tokenRequests.length, 1); - } finally { - globalThis.fetch = originalFetch; - console.log = originalLog; - cleanup(); - } -}); - -test('Codex adapter reads ChatGPT login auth from Codex auth.json shape', () => { - const { keysPath, cleanup } = writeTempKeys(); - try { - const auth = readCodexChatGPTAuth(keysPath); - assert.equal(auth.accessToken, 'access-token-test'); - assert.equal(auth.accountId, 'account-id-test'); - assert.equal(hasCodexChatGPTAuth(keysPath), true); - } finally { - cleanup(); - } -}); - -test('Codex adapter can still read raw auth.json style files when explicitly configured', () => { - const { authPath, cleanup } = writeTempRawAuth(); - try { - const auth = readCodexChatGPTAuth(authPath); - assert.equal(auth.accessToken, 'access-token-test'); - assert.equal(auth.accountId, 'account-id-test'); - assert.equal(hasCodexChatGPTAuth(authPath), true); - } finally { - cleanup(); - } -}); - - -test('Codex login persists auth inside the unified settings LLM provider registry', async () => { - const dir = mkdtempSync(path.join(tmpdir(), 'codex-unified-settings-test-')); - const keysPath = path.join(dir, 'settings_llm_providers.json'); - writeFileSync(keysPath, JSON.stringify({ - keys: { CODEX_CHATGPT_AUTH: {} }, - models: { codex: { format: 'openai-codex-responses' } }, - embeddings: {} - }, null, 4)); - try { - const auth = await ensureCodexChatGPTAuth({ - keysPath, - allowLogin: true, - loginRunner: async ({ keysPath: loginKeysPath }) => { - assert.equal(loginKeysPath, keysPath); - return authFixture(); - } - }); - const saved = JSON.parse(readFileSync(keysPath, 'utf8')); - assert.equal(auth.accessToken, 'access-token-test'); - assert.equal(saved.models.codex.format, 'openai-codex-responses'); - assert.equal(saved.keys.CODEX_CHATGPT_AUTH.tokens.access_token, 'access-token-test'); - assert.equal(saved.tokens, undefined); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); - -test('Codex adapter still supports legacy unified settings_llm_providers.json auth storage', () => { - const { keysPath, dir, cleanup } = writeTempKeys({ includeAuth: false }); - try { - writeKeysCodexAuth(keysPath, authFixture()); - assert.equal(existsSync(path.join(dir, '.mindcraft')), false); - const auth = readCodexChatGPTAuth(keysPath); - assert.equal(auth.accessToken, 'access-token-test'); - } finally { - cleanup(); - } -}); - - -test('Codex adapter defaults to project settings_llm_providers.json instead of ~/.codex auth', () => { - const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', {}); - assert.equal(model.authPath, 'settings_llm_providers.json'); - assert.equal(model.keysPath, 'settings_llm_providers.json'); -}); - -test('Codex adapter starts local login runner when configured auth path is missing', async () => { - const { keysPath, cleanup } = writeTempKeys({ includeAuth: false }); - let called = false; - try { - const auth = await ensureCodexChatGPTAuth({ - keysPath, - allowLogin: true, - loginRunner: async ({ keysPath: loginKeysPath }) => { - called = loginKeysPath === keysPath; - return authFixture(); - } - }); - assert.equal(called, true); - assert.equal(auth.accessToken, 'access-token-test'); - assert.equal(readCodexChatGPTAuth(keysPath).accountId, 'account-id-test'); - } finally { - cleanup(); - } -}); - - -test('Codex adapter forwards abort signals to the Responses request', async () => { - const { keysPath, cleanup } = writeTempKeys(); - const originalFetch = globalThis.fetch; - const requests = []; - const controller = new AbortController(); - globalThis.fetch = async (url, init) => { - requests.push({ url, init, body: JSON.parse(init.body) }); - return new Response([ - 'event: response.output_text.delta', - 'data: {"type":"response.output_text.delta","delta":"ok"}', - '', - 'event: response.completed', - 'data: {"type":"response.completed","response":{"id":"resp_1"}}', - '' - ].join('\n'), { - status: 200, - headers: { 'content-type': 'text/event-stream' } - }); - }; - - try { - const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', { - keysPath, - sessionId: 'session-test', - enablePreviousResponseId: true - }); - await model.sendRequest([{ role: 'user', content: 'hi' }], 'Say ok.', '***', null, { signal: controller.signal }); - assert.equal(requests[0].init.signal, controller.signal); - } finally { - globalThis.fetch = originalFetch; - cleanup(); - } -}); - -test('Codex adapter sends native-login Responses request and normalizes tool call', async () => { - const { keysPath, cleanup } = writeTempKeys(); - const originalFetch = globalThis.fetch; - const requests = []; - globalThis.fetch = async (url, init) => { - requests.push({ url, init, body: JSON.parse(init.body) }); - return new Response([ - 'event: response.output_item.done', - 'data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_1","name":"report_status","arguments":"{\\"status\\":\\"ok\\"}"}}', - '', - 'event: response.completed', - 'data: {"type":"response.completed","response":{"id":"resp_1"}}', - '' - ].join('\n'), { - status: 200, - headers: { 'content-type': 'text/event-stream' } - }); - }; - - try { - const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', { - keysPath, - sessionId: 'session-test', - enablePreviousResponseId: true - }); - const response = await model.sendRequest( - [{ role: 'user', content: 'call the tool' }], - 'Use the function.', - '***', - [tool] - ); - assert.equal(isNativeToolResponse(response), true); - assert.equal(response.tool_calls[0].name, 'report_status'); - assert.equal(requests[0].url, 'https://example.test/backend-api/codex/responses'); - assert.equal(requests[0].init.headers.Authorization, 'Bearer access-token-test'); - assert.equal(requests[0].init.headers['ChatGPT-Account-ID'], 'account-id-test'); - assert.equal(requests[0].init.headers.originator, 'codex_cli_rs'); - assert.equal(requests[0].init.headers.session_id, 'session-test'); - assert.equal(requests[0].init.headers['x-client-request-id'], 'session-test'); - assert.equal(requests[0].body.tools[0].name, 'report_status'); - assert.equal(Object.prototype.hasOwnProperty.call(requests[0].body, 'tool_choice'), false); - assert.equal(requests[0].body.stream, true); - } finally { - globalThis.fetch = originalFetch; - cleanup(); - } -}); - - - -test('Codex adapter expands response continuity when using HTTP transport', async () => { - const { keysPath, cleanup } = writeTempKeys(); - const originalFetch = globalThis.fetch; - const requests = []; - const responses = [ - [ - 'event: response.output_item.done', - 'data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}}', - '', - 'event: response.completed', - 'data: {"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":100,"input_tokens_details":{"cached_tokens":0},"output_tokens":3}}}', - '' - ].join('\n'), - [ - 'event: response.output_item.done', - 'data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_2","name":"report_status","arguments":"{\\"status\\":\\"done\\"}"}}', - '', - 'event: response.completed', - 'data: {"type":"response.completed","response":{"id":"resp_2","usage":{"input_tokens":140,"input_tokens_details":{"cached_tokens":120},"output_tokens":5}}}', - '' - ].join('\n') - ]; - globalThis.fetch = async (url, init) => { - requests.push({ url, init, body: JSON.parse(init.body) }); - return new Response(responses.shift(), { - status: 200, - headers: { 'content-type': 'text/event-stream' } - }); - }; - - try { - const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', { - keysPath, - sessionId: 'session-test', - enablePreviousResponseId: true - }); - const first = await model.sendRequest( - [{ role: 'user', content: 'first' }], - 'Use tools.', - '***', - [tool], - { cacheScope: 'conversation', turnStateKey: 'react-1' } - ); - assert.equal(first, 'ok'); - - const second = await model.sendRequest( - [ - { role: 'user', content: 'first' }, - { role: 'assistant', content: 'ok' }, - { role: 'user', content: 'second' } - ], - 'Use tools.', - '***', - [tool], - { cacheScope: 'conversation', turnStateKey: 'react-1' } - ); - assert.equal(isNativeToolResponse(second), true); - - assert.equal(requests[0].body.previous_response_id, undefined); - assert.equal(requests[0].body.input.length, 1); - assert.equal(requests[1].body.previous_response_id, undefined); - assert.equal(requests[1].body.input.length, 3); - assert.equal(requests[1].body.input[2].role, 'user'); - assert.equal(requests[1].body.input[2].content[0].text, 'second'); - assert.equal(requests[1].body.prompt_cache_key, 'session-test'); - assert.equal(requests[1].init.headers.session_id, 'session-test'); - assert.deepEqual(model.consumeLastRequestCacheTrace(), { - protocol: 'openai-codex-responses', - prompt_cache_key: 'session-test', - session_id: 'session-test', - turn_state_present: false, - previous_response_id: null, - incremental_input_items: null, - full_input_items: 3, - incremental_reuse: false, - incremental_reuse_reason: 'http_previous_response_unsupported' - }); - } finally { - globalThis.fetch = originalFetch; - cleanup(); - } -}); - -test('Codex adapter does not carry turn-state into a new bot-message branch', async () => { - const { keysPath, cleanup } = writeTempKeys(); - const originalFetch = globalThis.fetch; - const requests = []; - const responses = [ - [ - 'event: response.output_item.done', - 'data: {"type":"response.output_item.done","item":{"type":"function_call","call_id":"call_1","name":"report_status","arguments":"{}"}}', - '', - 'event: response.completed', - 'data: {"type":"response.completed","response":{"id":"resp_1"}}', - '' - ].join('\n'), - [ - 'event: response.output_text.delta', - 'data: {"type":"response.output_text.delta","delta":"ok"}', - '', - 'event: response.completed', - 'data: {"type":"response.completed","response":{"id":"resp_2"}}', - '' - ].join('\n') - ]; - globalThis.fetch = async (url, init) => { - requests.push({ url, init, body: JSON.parse(init.body) }); - return new Response(responses.shift(), { - status: 200, - headers: { - 'content-type': 'text/event-stream', - 'x-codex-turn-state': 'sticky-route-1' - } - }); - }; - - try { - const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', { - keysPath, - sessionId: 'session-test', - enablePreviousResponseId: true - }); - const first = await model.sendRequest( - [{ role: 'user', content: 'start a long action' }], - 'Use tools.', - '***', - [tool], - { cacheScope: 'conversation', turnStateKey: 'react-1' } - ); - assert.equal(isNativeToolResponse(first), true); - - await model.sendRequest( - [ - { role: 'user', content: 'start a long action' }, - { - role: 'assistant', - content: '', - native_tool_calls: [{ id: 'call_1', type: 'function', name: 'report_status', arguments: '{}' }] - }, - { role: 'user', content: 'kimi: (FROM OTHER BOT)\nwhat resources do you have?' } - ], - 'Use tools.', - '***', - [tool], - { cacheScope: 'conversation', turnStateKey: 'react-2' } - ); - - assert.equal(requests[0].body.previous_response_id, undefined); - assert.equal(requests[1].body.previous_response_id, undefined); - assert.equal(requests[1].init.headers['x-codex-turn-state'], undefined); - assert.equal(requests[1].body.prompt_cache_key, 'session-test'); - assert.equal(requests[1].body.input.length, 4); - assert.equal(requests[1].body.input[1].type, 'function_call'); - assert.equal(requests[1].body.input[2].type, 'function_call_output'); - assert.equal(requests[1].body.input[3].content[0].text, 'kimi: (FROM OTHER BOT)\nwhat resources do you have?'); - assert.equal(model.consumeLastRequestCacheTrace().incremental_reuse_reason, 'http_previous_response_unsupported'); - } finally { - globalThis.fetch = originalFetch; - cleanup(); - } -}); - -test('Codex adapter keeps forked branches on the shared prompt cache key without sharing turn-state', async () => { - const { keysPath, cleanup } = writeTempKeys(); - const originalFetch = globalThis.fetch; - const requests = []; - const responseTexts = ['root ok', 'branch A ok', 'branch B ok', 'main after branch ok']; - const turnStates = ['sticky-root', 'sticky-branch-a', 'sticky-branch-b', 'sticky-main-after-branch']; - globalThis.fetch = async (url, init) => { - const index = requests.length; - requests.push({ url, init, body: JSON.parse(init.body) }); - return new Response([ - 'event: response.output_item.done', - `data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"${responseTexts[index]}"}]}}`, - '', - 'event: response.completed', - `data: {"type":"response.completed","response":{"id":"resp_${index + 1}"}}`, - '' - ].join('\n'), { - status: 200, - headers: { - 'content-type': 'text/event-stream', - 'x-codex-turn-state': turnStates[index] - } - }); - }; - - try { - const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', { - keysPath, - sessionId: 'session-test', - enablePreviousResponseId: true - }); - await model.sendRequest([{ role: 'user', content: 'root' }], 'Use tools.', '***', [tool], { - cacheScope: 'conversation', - turnStateKey: 'root' - }); - await model.sendRequest([ - { role: 'user', content: 'root' }, - { role: 'assistant', content: 'root ok' }, - { role: 'user', content: 'branch A' } - ], 'Use tools.', '***', [tool], { - cacheScope: 'conversation', - turnStateKey: 'branch-a' - }); - await model.sendRequest([ - { role: 'user', content: 'root' }, - { role: 'assistant', content: 'root ok' }, - { role: 'user', content: 'branch B' } - ], 'Use tools.', '***', [tool], { - cacheScope: 'conversation', - turnStateKey: 'branch-b' - }); - await model.sendRequest([ - { role: 'user', content: 'root' }, - { role: 'assistant', content: 'root ok' }, - { role: 'user', content: 'main after branch' } - ], 'Use tools.', '***', [tool], { - cacheScope: 'conversation', - turnStateKey: 'main-after-branch' - }); - - for (const request of requests) { - assert.equal(request.body.prompt_cache_key, 'session-test'); - assert.equal(request.init.headers.session_id, 'session-test'); - assert.equal(request.init.headers['x-client-request-id'], 'session-test'); - } - assert.equal(requests[1].body.previous_response_id, undefined); - assert.equal(requests[1].body.input.length, 3); - assert.equal(requests[1].body.input[2].content[0].text, 'branch A'); - assert.equal(requests[1].init.headers['x-codex-turn-state'], undefined); - - assert.equal(requests[2].body.previous_response_id, undefined); - assert.equal(requests[2].body.input.length, 3); - assert.equal(requests[2].body.input[2].content[0].text, 'branch B'); - assert.equal(requests[2].init.headers['x-codex-turn-state'], undefined); - - assert.equal(requests[3].body.previous_response_id, undefined); - assert.equal(requests[3].body.input.length, 3); - assert.equal(requests[3].body.input[2].content[0].text, 'main after branch'); - assert.equal(requests[3].init.headers['x-codex-turn-state'], undefined); - assert.equal(model.consumeLastRequestCacheTrace().incremental_reuse_reason, 'http_previous_response_unsupported'); - } finally { - globalThis.fetch = originalFetch; - cleanup(); - } -}); - -test('Codex adapter keeps forked WebSocket branches on the shared prompt cache key', async () => { - const { keysPath, cleanup } = writeTempKeys(); - const wss = new WebSocketServer({ port: 0 }); - const payloads = []; - const handshakes = []; - const texts = ['root ok', 'main 1 ok', 'main 2 ok', 'branch A ok', 'branch B ok', 'main after branch ok']; - wss.on('connection', (ws, request) => { - handshakes.push(request.headers); - ws.on('message', data => { - const payload = JSON.parse(data.toString('utf8')); - const index = payloads.length; - payloads.push(payload); - ws.send(JSON.stringify({ - type: 'response.output_item.done', - item: { - type: 'message', - role: 'assistant', - content: [{ type: 'output_text', text: texts[index] }] - } - })); - ws.send(JSON.stringify({ - type: 'response.completed', - response: { - id: `resp_${index + 1}`, - usage: { - input_tokens: 100 + index, - input_tokens_details: { cached_tokens: index === 0 ? 0 : 80 }, - output_tokens: 3 - } - } - })); - }); - }); - await new Promise(resolve => wss.once('listening', resolve)); - - try { - const { port } = wss.address(); - const model = new CodexChatGPT('gpt-5.5', `http://127.0.0.1:${port}/backend-api/codex`, { - keysPath, - sessionId: 'session-test', - useResponsesWebSocket: true, - responsesWebSocketIdleTimeoutMs: 5000 - }); - await model.sendRequest([{ role: 'user', content: 'root' }], 'Use tools.', '***', [tool], { - cacheScope: 'conversation', - turnStateKey: 'root' - }); - await model.sendRequest([ - { role: 'user', content: 'root' }, - { role: 'assistant', content: 'root ok' }, - { role: 'user', content: 'main 1' } - ], 'Use tools.', '***', [tool], { - cacheScope: 'conversation', - turnStateKey: 'main-1' - }); - await model.sendRequest([ - { role: 'user', content: 'root' }, - { role: 'assistant', content: 'root ok' }, - { role: 'user', content: 'main 1' }, - { role: 'assistant', content: 'main 1 ok' }, - { role: 'user', content: 'main 2 before fork' } - ], 'Use tools.', '***', [tool], { - cacheScope: 'conversation', - turnStateKey: 'main-2' - }); - await model.sendRequest([ - { role: 'user', content: 'root' }, - { role: 'assistant', content: 'root ok' }, - { role: 'user', content: 'main 1' }, - { role: 'assistant', content: 'main 1 ok' }, - { role: 'user', content: 'branch A' } - ], 'Use tools.', '***', [tool], { - cacheScope: 'conversation', - turnStateKey: 'branch-a' - }); - await model.sendRequest([ - { role: 'user', content: 'root' }, - { role: 'assistant', content: 'root ok' }, - { role: 'user', content: 'main 1' }, - { role: 'assistant', content: 'main 1 ok' }, - { role: 'user', content: 'branch B' } - ], 'Use tools.', '***', [tool], { - cacheScope: 'conversation', - turnStateKey: 'branch-b' - }); - await model.sendRequest([ - { role: 'user', content: 'root' }, - { role: 'assistant', content: 'root ok' }, - { role: 'user', content: 'main 1' }, - { role: 'assistant', content: 'main 1 ok' }, - { role: 'user', content: 'main after branch' } - ], 'Use tools.', '***', [tool], { - cacheScope: 'conversation', - turnStateKey: 'main-after-branch' - }); - - assert.equal(handshakes.length, 1); - assert.equal(handshakes[0].authorization, 'Bearer access-token-test'); - assert.equal(handshakes[0]['openai-beta'], 'responses_websockets=2026-02-06'); - assert.equal(handshakes[0].session_id, 'session-test'); - assert.equal(payloads[0].type, 'response.create'); - assert.equal(payloads[0].previous_response_id, undefined); - assert.equal(payloads[0].prompt_cache_key, 'session-test'); - assert.equal(payloads[0].tool_choice, 'auto'); - for (const payload of payloads) { - assert.equal(payload.prompt_cache_key, 'session-test'); - } - assert.equal(payloads[1].previous_response_id, 'resp_1'); - assert.equal(payloads[1].input.length, 1); - assert.equal(payloads[1].input[0].content[0].text, 'main 1'); - assert.equal(payloads[2].previous_response_id, 'resp_2'); - assert.equal(payloads[2].input.length, 1); - assert.equal(payloads[2].input[0].content[0].text, 'main 2 before fork'); - assert.equal(payloads[3].previous_response_id, undefined); - assert.equal(payloads[3].input.length, 5); - assert.equal(payloads[3].input[4].content[0].text, 'branch A'); - assert.equal(payloads[4].previous_response_id, undefined); - assert.equal(payloads[4].input.length, 5); - assert.equal(payloads[4].input[4].content[0].text, 'branch B'); - assert.equal(payloads[5].previous_response_id, undefined); - assert.equal(payloads[5].input.length, 5); - assert.equal(payloads[5].input[4].content[0].text, 'main after branch'); - assert.equal(model.lastTokenUsage.input_cached, 80); - model.closeResponsesWebSocket(); - } finally { - await new Promise(resolve => wss.close(resolve)); - cleanup(); - } -}); - -test('Codex WebSocket transport serializes concurrent requests to avoid response cross-talk', async () => { - const { keysPath, cleanup } = writeTempKeys(); - const wss = new WebSocketServer({ port: 0 }); - const payloads = []; - const handshakes = []; - wss.on('connection', (ws, request) => { - handshakes.push(request.headers); - ws.on('message', data => { - const payload = JSON.parse(data.toString('utf8')); - payloads.push(payload); - const text = payload.input?.[0]?.content?.[0]?.text || 'unknown'; - const delay = text === 'slow' ? 40 : 0; - setTimeout(() => { - ws.send(JSON.stringify({ - type: 'response.output_text.delta', - delta: `${text} ok` - })); - ws.send(JSON.stringify({ - type: 'response.completed', - response: { - id: `resp_${text}`, - usage: { - input_tokens: text === 'slow' ? 111 : 222, - input_tokens_details: { cached_tokens: text === 'slow' ? 11 : 22 }, - output_tokens: 2 - } - } - })); - }, delay); - }); - }); - await new Promise(resolve => wss.once('listening', resolve)); - - try { - const { port } = wss.address(); - const model = new CodexChatGPT('gpt-5.5', `http://127.0.0.1:${port}/backend-api/codex`, { - keysPath, - sessionId: 'session-test', - useResponsesWebSocket: true, - responsesWebSocketIdleTimeoutMs: 5000 - }); - - const slowOptions = { cacheScope: 'conversation' }; - const fastOptions = { cacheScope: 'botResponder' }; - const [slow, fast] = await Promise.all([ - model.sendRequest([{ role: 'user', content: 'slow' }], 'Decide.', '***', null, slowOptions), - model.sendRequest([{ role: 'user', content: 'fast' }], 'Decide.', '***', null, fastOptions) - ]); - - assert.equal(slow, 'slow ok'); - assert.equal(fast, 'fast ok'); - assert.equal(handshakes.length, 1); - assert.equal(payloads.length, 2); - assert.equal(payloads[0].input[0].content[0].text, 'slow'); - assert.equal(payloads[1].input[0].content[0].text, 'fast'); - assert.equal(payloads[0].prompt_cache_key, 'session-test'); - assert.equal(payloads[1].prompt_cache_key, 'session-test'); - const slowMetadata = model.consumeLastRequestTraceMetadata(slowOptions); - const fastMetadata = model.consumeLastRequestTraceMetadata(fastOptions); - assert.equal(slowMetadata.transport_cache.prompt_cache_key, 'session-test'); - assert.equal(fastMetadata.transport_cache.prompt_cache_key, 'session-test'); - assert.equal(slowMetadata.token_usage.input_uncached, 100); - assert.equal(fastMetadata.token_usage.input_uncached, 200); - model.closeResponsesWebSocket(); - } finally { - await new Promise(resolve => wss.close(resolve)); - cleanup(); - } -}); - -test('Codex adapter does not reuse previous_response_id when non-input fields change', async () => { - const { keysPath, cleanup } = writeTempKeys(); - const originalFetch = globalThis.fetch; - const requests = []; - globalThis.fetch = async (url, init) => { - requests.push({ url, init, body: JSON.parse(init.body) }); - const responseId = `resp_${requests.length}`; - return new Response([ - 'event: response.output_item.done', - 'data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}}', - '', - 'event: response.completed', - `data: {"type":"response.completed","response":{"id":"${responseId}"}}`, - '' - ].join('\n'), { - status: 200, - headers: { 'content-type': 'text/event-stream' } - }); - }; - - try { - const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', { - keysPath, - sessionId: 'session-test', - enablePreviousResponseId: true - }); - await model.sendRequest([{ role: 'user', content: 'first' }], 'Use tools.', '***', [tool], { - cacheScope: 'conversation', - turnStateKey: 'react-1' - }); - await model.sendRequest([ - { role: 'user', content: 'first' }, - { role: 'assistant', content: 'ok' }, - { role: 'user', content: 'second' } - ], 'Changed instructions.', '***', [tool], { - cacheScope: 'conversation', - turnStateKey: 'react-1' - }); - - assert.equal(requests[1].body.previous_response_id, undefined); - assert.equal(requests[1].body.input.length, 3); - assert.equal(model.consumeLastRequestCacheTrace().incremental_reuse_reason, 'non_input_fields_changed'); - } finally { - globalThis.fetch = originalFetch; - cleanup(); - } -}); - -test('Codex adapter sends vision images as Responses input_image content', async () => { - const { keysPath, cleanup } = writeTempKeys(); - const originalFetch = globalThis.fetch; - const requests = []; - globalThis.fetch = async (url, init) => { - requests.push({ url, init, body: JSON.parse(init.body) }); - return new Response([ - 'event: response.output_text.delta', - 'data: {"type":"response.output_text.delta","delta":"I see stone."}', - '', - 'event: response.completed', - 'data: {"type":"response.completed","response":{"id":"resp_1"}}', - '' - ].join('\n'), { - status: 200, - headers: { 'content-type': 'text/event-stream' } - }); - }; - - try { - const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', { keysPath, sessionId: 'vision-session-test' }); - const response = await model.sendVisionRequest( - [{ role: 'user', content: 'look around' }], - 'Describe the Minecraft screenshot.', - Buffer.from('image-bytes') - ); - assert.equal(response, 'I see stone.'); - - const visionMessage = requests[0].body.input.at(-1); - assert.equal(visionMessage.type, 'message'); - assert.equal(visionMessage.role, 'user'); - assert.deepEqual(visionMessage.content, [ - { type: 'input_text', text: '' }, - { type: 'input_image', image_url: `data:image/jpeg;base64,${Buffer.from('image-bytes').toString('base64')}` }, - { type: 'input_text', text: '\nDescribe the Minecraft screenshot.' } - ]); - } finally { - globalThis.fetch = originalFetch; - cleanup(); - } -}); - -test('Codex adapter keeps prompt cache key stable across multi-turn tool replay', () => { - const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', { - keysPath: 'settings_llm_providers.json', - sessionId: 'stable-cache-session' - }); - const turns = [ - { role: 'user', content: 'inspect inventory' }, - { - role: 'assistant', - content: '*used inventory*', - native_tool_calls: [{ id: 'call_1', type: 'function', name: 'inventory', arguments: '{}' }] - }, - { role: 'tool', tool_call_id: 'call_1', name: 'inventory', content: '{"oak_log":0}' }, - { role: 'user', content: 'collect wood' }, - { - role: 'assistant', - content: '*used collectBlocks*', - native_tool_calls: [{ id: 'call_2', type: 'function', name: 'collectBlocks', arguments: '{"block":"oak_log","count":2}' }] - }, - { role: 'tool', tool_call_id: 'call_2', name: 'collectBlocks', content: 'Collected 2 oak logs.' } - ]; - - const first = model.buildRequestBody('gpt-5.5', turns, 'Use tools.', [tool]); - const second = model.buildRequestBody('gpt-5.5', turns, 'Use tools.', [tool]); - const conversation = model.buildRequestBody('gpt-5.5', turns, 'Use tools.', [tool], { cacheScope: 'conversation' }); - const coding = model.buildRequestBody('gpt-5.5', [{ role: 'user', content: 'write code' }], 'Write code.', null, { cacheScope: 'coding', transportCacheScope: 'coding' }); - - assert.equal(first.prompt_cache_key, 'stable-cache-session'); - assert.equal(second.prompt_cache_key, 'stable-cache-session'); - assert.equal(conversation.prompt_cache_key, 'stable-cache-session'); - assert.equal(coding.prompt_cache_key, 'stable-cache-session:coding'); - assert.equal(model.buildHeaders({ accessToken: 'token', accountId: 'account' }).session_id, 'stable-cache-session'); - assert.equal(model.buildHeaders({ accessToken: 'token', accountId: 'account' }, { cacheScope: 'conversation' }).session_id, 'stable-cache-session'); - assert.equal(model.buildHeaders({ accessToken: 'token', accountId: 'account' }, { cacheScope: 'coding', transportCacheScope: 'coding' })['x-client-request-id'], 'stable-cache-session:coding'); - assert.deepEqual(second.input, first.input); - assert.deepEqual( - first.input.filter(item => item.type === 'function_call' || item.type === 'function_call_output').map(item => item.call_id), - ['call_1', 'call_1', 'call_2', 'call_2'] - ); -}); - -test('Codex adapter requests reasoning summaries when reasoning effort is configured', () => { - const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', { - keysPath: 'settings_llm_providers.json', - reasoning: { effort: 'high' } - }); - - const body = model.buildRequestBody('gpt-5.5', [{ role: 'user', content: 'think then act' }], 'Use tools.', [tool]); - - assert.deepEqual(body.reasoning, { effort: 'high', summary: 'auto' }); - assert.ok(body.include.includes('reasoning.encrypted_content')); -}); - -test('Codex adapter replays turn-state only inside one ReAct turn', async () => { - const { keysPath, cleanup } = writeTempKeys(); - const originalFetch = globalThis.fetch; - const requests = []; - globalThis.fetch = async (url, init) => { - requests.push({ url, init, body: JSON.parse(init.body) }); - return new Response([ - 'event: response.output_text.delta', - 'data: {"type":"response.output_text.delta","delta":"ok"}', - '', - 'event: response.completed', - 'data: {"type":"response.completed","response":{"id":"resp_1"}}', - '' - ].join('\n'), { - status: 200, - headers: { - 'content-type': 'text/event-stream', - 'x-codex-turn-state': 'sticky-route-1' - } - }); - }; - - try { - const model = new CodexChatGPT('gpt-5.5', 'https://example.test/backend-api/codex', { keysPath, sessionId: 'session-test' }); - await model.sendRequest([{ role: 'user', content: 'first' }], 'Use tools.', '***', [tool], { - cacheScope: 'conversation', - turnStateKey: 'react-1' - }); - await model.sendRequest([{ role: 'user', content: 'first' }, { role: 'assistant', content: 'ok' }], 'Use tools.', '***', [tool], { - cacheScope: 'conversation', - turnStateKey: 'react-1' - }); - await model.sendRequest([{ role: 'user', content: 'new turn' }], 'Use tools.', '***', [tool], { - cacheScope: 'conversation', - turnStateKey: 'react-2' - }); - - assert.equal(requests[0].init.headers.session_id, 'session-test'); - assert.equal(requests[0].init.headers['x-client-request-id'], 'session-test'); - assert.equal(requests[0].init.headers['x-codex-turn-state'], undefined); - assert.equal(requests[1].init.headers['x-codex-turn-state'], 'sticky-route-1'); - assert.equal(requests[2].init.headers['x-codex-turn-state'], undefined); - } finally { - globalThis.fetch = originalFetch; - cleanup(); - } -}); - -async function waitFor(fn, timeoutMs = 1000) { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - const value = fn(); - if (value) return value; - await new Promise(resolve => setTimeout(resolve, 10)); - } - throw new Error('Timed out waiting for condition'); -} - -function writeTempKeys({ includeAuth = true } = {}) { - const dir = mkdtempSync(path.join(tmpdir(), 'codex-keys-test-')); - const keysPath = path.join(dir, 'settings_llm_providers.json'); - const keys = { OPENAI_API_KEY: '' }; - if (includeAuth) keys.CODEX_CHATGPT_AUTH = authFixture(); - writeFileSync(keysPath, JSON.stringify(keys)); - return { - dir, - keysPath, - cleanup: () => rmSync(dir, { recursive: true, force: true }) - }; -} - -function writeTempRawAuth() { - const dir = mkdtempSync(path.join(tmpdir(), 'codex-auth-test-')); - const authPath = path.join(dir, 'auth.json'); - writeFileSync(authPath, JSON.stringify(authFixture())); - return { - dir, - authPath, - cleanup: () => rmSync(dir, { recursive: true, force: true }) - }; -} - -function authFixture() { - return { - auth_mode: 'chatgpt', - OPENAI_API_KEY: null, - tokens: { - id_token: 'id-token-test', - access_token: 'access-token-test', - refresh_token: 'refresh-token-test', - account_id: 'account-id-test' - }, - last_refresh: '2026-04-30T00:00:00Z' - }; -} diff --git a/tests/conversation_queue.test.js b/tests/conversation_queue.test.js deleted file mode 100644 index 07bf8a4d4..000000000 --- a/tests/conversation_queue.test.js +++ /dev/null @@ -1,188 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import convoManager, { compileQueuedBotMessages } from '../src/agent/conversation.js'; - -const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms)); - -function makeAgent({ shouldRespondToBot = true } = {}) { - const calls = []; - const history = []; - const promptShouldRespondCalls = []; - return { - name: 'tester', - last_sender: null, - shut_up: true, - actions: { currentActionLabel: '' }, - active_message_handlers: 0, - isIdle: () => true, - openChat: () => {}, - handleMessage: (source, message) => calls.push({ source, message }), - history: { add: (source, message) => history.push({ source, message }) }, - self_prompter: { - isActive: () => false, - isPaused: () => false, - pause: async () => {}, - start: () => {}, - }, - prompter: { - promptShouldRespondToBot: async (message) => { - promptShouldRespondCalls.push(message); - return shouldRespondToBot; - }, - }, - calls, - historyCalls: history, - promptShouldRespondCalls, - }; -} - -function resetConversationManager(agent = makeAgent()) { - for (const convo of Object.values(convoManager.convos)) { - if (convo.inMessageTimer) - clearTimeout(convo.inMessageTimer); - } - if (convoManager.connection_monitor) - clearInterval(convoManager.connection_monitor); - if (convoManager.connection_timeout) - clearTimeout(convoManager.connection_timeout); - - convoManager.convos = {}; - convoManager.activeConversation = null; - convoManager.awaiting_response = false; - convoManager.connection_timeout = null; - convoManager.connection_monitor = null; - convoManager.initAgent(agent); - convoManager.updateAgents([{ name: 'buddy', in_game: true }]); - return agent; -} - -test.afterEach(() => { - resetConversationManager(); -}); - -test('compileQueuedBotMessages preserves queued bot message boundaries', () => { - const compiled = compileQueuedBotMessages([ - { message: '*used equip*', start: true, end: false }, - { message: '*used collectBlocks*', start: false, end: false }, - { message: 'State update:\n* action: Idle', start: false, end: true }, - ]); - - assert.equal(compiled.message, '*used equip*\n*used collectBlocks*\nState update:\n* action: Idle'); - assert.equal(compiled.start, true); - assert.equal(compiled.end, true); -}); - - -test('delivered bot messages include a distinct source marker line', async () => { - const agent = resetConversationManager(); - - await convoManager.receiveFromBot('buddy', { message: 'hi', start: true, end: false }); - await delay(260); - - assert.deepEqual(agent.calls, [{ source: 'buddy', message: '(FROM OTHER BOT)\nhi' }]); -}); - -test('ending a conversation drops queued messages without stale response flush', async () => { - const agent = resetConversationManager(); - - await convoManager.receiveFromBot('buddy', { message: 'hello', start: true, end: false }); - assert.equal(convoManager.responseScheduledFor('buddy'), true); - - convoManager.endConversation('buddy'); - await delay(260); - - assert.deepEqual(agent.calls, []); - assert.deepEqual(agent.historyCalls, []); -}); - -test('reset clears stale timers so old flushes cannot send later queued messages', async () => { - const agent = resetConversationManager(); - - await convoManager.receiveFromBot('buddy', { message: 'old', start: true, end: false }); - const convo = convoManager._getConvo('buddy'); - convo.reset(); - convo.queue({ message: 'new', start: false, end: false }); - - await delay(260); - - assert.deepEqual(agent.calls, []); -}); - -test('empty queued bot message flush does not call handleMessage', async () => { - const agent = resetConversationManager(); - - await convoManager.receiveFromBot('buddy', { message: 'will be removed', start: true, end: false }); - const convo = convoManager._getConvo('buddy'); - convo.in_queue = []; - - await delay(260); - - assert.deepEqual(agent.calls, []); - assert.equal(convo.inMessageTimer, null); -}); - -test('busy bot action notices can be ignored without entering main ReAct history', async () => { - const agent = resetConversationManager(); - agent.isIdle = () => false; - agent.active_message_handlers = 1; - agent.actions.currentActionLabel = 'action:collectBlocks'; - - await convoManager.receiveFromBot('buddy', { message: '*used collectBlocks*', start: true, end: false }); - - assert.equal(convoManager.responseScheduledFor('buddy'), false); - assert.deepEqual(agent.promptShouldRespondCalls, []); - assert.deepEqual(agent.calls, []); - assert.deepEqual(convoManager._getConvo('buddy').in_queue, []); -}); - -test('busy normal bot messages branch-decide before serialized ReAct delivery', async () => { - const agent = resetConversationManager(); - agent.isIdle = () => false; - agent.active_message_handlers = 1; - agent.actions.currentActionLabel = 'action:collectBlocks'; - - await convoManager.receiveFromBot('buddy', { message: 'hello while busy', start: true, end: false }); - await delay(260); - - assert.deepEqual(agent.promptShouldRespondCalls, ['buddy: (FROM OTHER BOT)\nhello while busy']); - assert.deepEqual(agent.calls, [{ source: 'buddy', message: '(FROM OTHER BOT)\nhello while busy' }]); -}); - -test('busy bot action notices with state updates do not enter the main ReAct history', async () => { - const agent = resetConversationManager(); - agent.isIdle = () => true; - agent.active_message_handlers = 0; - agent.actions.currentActionLabel = 'action:newAction'; - - await convoManager.receiveFromBot('buddy', { message: '*used equip*', start: true, end: false }); - await convoManager.receiveFromBot('buddy', { - message: [ - 'State update:', - '* action: Idle', - '* inventory: water_bucket:1, diamond_pickaxe:1' - ].join('\n'), - start: false, - end: true - }); - await delay(260); - - assert.deepEqual(agent.promptShouldRespondCalls, []); - assert.deepEqual(agent.calls, []); - assert.equal(convoManager.responseScheduledFor('buddy'), false); - assert.deepEqual(convoManager._getConvo('buddy').in_queue, []); -}); - -test('busy normal bot messages can be ignored without stale queued replay', async () => { - const agent = resetConversationManager(makeAgent({ shouldRespondToBot: false })); - agent.isIdle = () => false; - agent.active_message_handlers = 1; - agent.actions.currentActionLabel = 'action:collectBlocks'; - - await convoManager.receiveFromBot('buddy', { message: 'status ping', start: true, end: false }); - await delay(260); - - assert.deepEqual(agent.promptShouldRespondCalls, ['buddy: (FROM OTHER BOT)\nstatus ping']); - assert.deepEqual(agent.calls, []); - assert.equal(convoManager.responseScheduledFor('buddy'), false); - assert.deepEqual(convoManager._getConvo('buddy').in_queue, []); -}); diff --git a/tests/llm_providers_config.test.js b/tests/llm_providers_config.test.js deleted file mode 100644 index 5d71105ce..000000000 --- a/tests/llm_providers_config.test.js +++ /dev/null @@ -1,143 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; - -const config = JSON.parse(readFileSync('settings_llm_providers.json', 'utf8')); -const example = JSON.parse(readFileSync('settings_llm_providers.example.json', 'utf8')); - -test('llm provider config uses readable format/baseUrl/keyName schema', () => { - assert.equal(config.schemaVersion, 1); - assert.equal('examples' in config, false, 'custom examples should live under models/embeddings in the example file, not a separate top-level field'); - for (const sectionName of ['models', 'embeddings']) { - const section = config[sectionName]; - assert.equal(typeof section, 'object', `${sectionName} section is required`); - for (const [id, provider] of Object.entries(section)) { - assert.equal(typeof provider.format, 'string', `${sectionName}.${id}.format is required`); - assert.equal('api' in provider, false, `${sectionName}.${id} should use format, not api`); - assert.equal('name' in provider, false, `${sectionName}.${id} should use the provider id instead of a duplicate name`); - assert.equal('url' in provider, false, `${sectionName}.${id} should use baseUrl, not url`); - assert.equal('apiKeyName' in provider, false, `${sectionName}.${id} should use keyName, not apiKeyName`); - assert.notEqual(provider.format, 'openai-compatible', `${sectionName}.${id} should use protocol names such as openai-completions`); - assert.notEqual(provider.format, 'openai-chat-completions', `${sectionName}.${id} should use protocol name openai-completions`); - } - } -}); - -test('example config documents custom providers without name mapping', () => { - assert.equal('examples' in example, false); - assert.equal(example.models._example_openai_chat_completions.format, 'openai-completions'); - assert.equal(example.models._example_openai_chat_completions.keyName, 'MY_PROVIDER_API_KEY'); - assert.equal(example.models._example_openai_chat_completions.baseUrl, 'https://api.example.com/v1'); - assert.equal('name' in example.models._example_openai_chat_completions, false); - - assert.equal(example.models._example_openai_responses.format, 'openai-responses'); - assert.equal(example.models._example_openai_responses.keyName, 'MY_RESPONSES_API_KEY'); - - assert.equal(example.embeddings._example_openai_embeddings.format, 'openai-embeddings'); - assert.equal(example.embeddings._example_openai_embeddings.defaultModel, 'text-embedding-model-name'); - assert.equal(example.embeddings._example_openai_embeddings.keyName, 'MY_EMBEDDING_API_KEY'); -}); - -test('kimi uses the coding endpoint through the OpenAI-compatible protocol', () => { - assert.deepEqual(config.models.kimi, { - format: 'openai-completions', - baseUrl: 'https://api.kimi.com/coding/v1', - keyName: 'KIMI_API_KEY', - defaultModel: 'kimi-k2.6', - params: { - transport: 'curl', - defaultHeaders: { - 'User-Agent': 'KimiCLI/1.0.0' - } - } - }); -}); - -test('google relay uses Gemini base URL, not a full generateContent endpoint', () => { - assert.deepEqual(config.models.google_relay, { - format: 'google-generative-ai', - keyName: 'GEMINI_RELAY_API_KEY', - baseUrl: 'https://mydamoxing.cn', - defaultModel: 'gemini-3.1-pro-preview', - params: { - apiVersion: 'v1beta' - }, - comment: 'Gemini protocol relay. Direct endpoint would be https://mydamoxing.cn/v1beta/models/gemini-3.1-pro-preview:generateContent; keep baseUrl at the host because the SDK appends /v1beta/models/{model}:generateContent.' - }); -}); - -test('PR 752 OpenAI-compatible providers live in the shared registry', () => { - const providers = { - ai21: ['AI21_API_KEY', 'https://api.ai21.com/studio/v1', 'jamba-1.5-large'], - anyscale: ['ANYSCALE_API_KEY', 'https://api.endpoints.anyscale.com/v1', 'meta-llama/Meta-Llama-3-70B-Instruct'], - cohere: ['COHERE_API_KEY', 'https://api.cohere.com/v1', 'command-r-plus'], - deepinfra: ['DEEPINFRA_API_KEY', 'https://api.deepinfra.com/v1/openai', 'meta-llama/Meta-Llama-3-70B-Instruct'], - fireworks: ['FIREWORKS_API_KEY', 'https://api.fireworks.ai/inference/v1', 'accounts/fireworks/models/llama-v3p1-70b-instruct'], - nvidia: ['NVIDIA_API_KEY', 'https://integrate.api.nvidia.com/v1', 'meta/llama3-70b-instruct'], - perplexity: ['PERPLEXITY_API_KEY', 'https://api.perplexity.ai', 'llama-3-sonar-large-32k-online'], - together: ['TOGETHER_API_KEY', 'https://api.together.xyz/v1', 'meta-llama/Llama-3-70b-chat-hf'] - }; - - for (const [id, [keyName, baseUrl, defaultModel]] of Object.entries(providers)) { - assert.deepEqual(config.models[id], { - format: 'openai-completions', - baseUrl, - keyName, - defaultModel - }); - assert.equal(example.models[id].format, 'openai-completions'); - assert.equal(example.models[id].keyName, keyName); - assert.ok(Object.hasOwn(example.keys, keyName), `example keys must include ${keyName}`); - } -}); - -test('remote llm providers explicitly declare which key they use', () => { - const localOrLogin = new Set(['codex', 'ollama_local', 'vllm']); - for (const [id, provider] of Object.entries(config.models)) { - if (localOrLogin.has(id)) continue; - assert.equal(typeof provider.keyName, 'string', `models.${id}.keyName is required`); - assert.ok(provider.keyName.length > 0, `models.${id}.keyName must not be empty`); - assert.ok(Object.hasOwn(config.keys, provider.keyName), `keys.${provider.keyName} must exist for models.${id}`); - } - - for (const [id, provider] of Object.entries(config.embeddings)) { - if (localOrLogin.has(id)) continue; - assert.equal(typeof provider.keyName, 'string', `embeddings.${id}.keyName is required`); - assert.ok(provider.keyName.length > 0, `embeddings.${id}.keyName must not be empty`); - assert.ok(Object.hasOwn(config.keys, provider.keyName), `keys.${provider.keyName} must exist for embeddings.${id}`); - } -}); - -test('llm provider example mirrors provider ids without real key material', () => { - const privateExampleOmissions = new Set(['google_relay']); - assert.deepEqual( - new Set(Object.keys(example.models).filter(id => !id.startsWith('_example_'))), - new Set(Object.keys(config.models).filter(id => !privateExampleOmissions.has(id))) - ); - assert.deepEqual( - new Set(Object.keys(example.embeddings).filter(id => !id.startsWith('_example_'))), - new Set(Object.keys(config.embeddings)) - ); - assert.equal(example.keys.XIAOAI_API_KEY, undefined); - assert.equal(example.keys.GEMINI_RELAY_API_KEY, undefined); - for (const [keyName, value] of Object.entries(example.keys)) { - if (keyName === 'CODEX_CHATGPT_AUTH') continue; - assert.equal(value, '', `${keyName} should be blank in the example`); - } -}); - - -test('codex provider stores auth in the unified project config instead of a fixed user path', () => { - assert.equal(config.models.codex.format, 'openai-codex-responses'); - assert.equal(config.models.codex.adapter, 'codex'); - assert.equal(config.models.codex.params.keysPath, 'settings_llm_providers.json'); - assert.equal('authPath' in config.models.codex.params, false); - if (Object.hasOwn(config.keys, 'CODEX_CHATGPT_AUTH')) { - assert.equal(typeof config.keys.CODEX_CHATGPT_AUTH, 'object'); - } - - assert.equal(example.models.codex.params.keysPath, 'settings_llm_providers.json'); - assert.equal('authPath' in example.models.codex.params, false); - assert.ok(Object.hasOwn(example.keys, 'CODEX_CHATGPT_AUTH')); - assert.deepEqual(example.keys.CODEX_CHATGPT_AUTH, {}); -}); diff --git a/tests/memory_summary_tool_history.test.js b/tests/memory_summary_tool_history.test.js deleted file mode 100644 index 718ac2656..000000000 --- a/tests/memory_summary_tool_history.test.js +++ /dev/null @@ -1,25 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { stringifyTurns } from '../src/utils/text.js'; - -test('memory summary text includes native tool calls and tool results', () => { - const summaryInput = stringifyTurns([ - { role: 'user', content: 'collect wood' }, - { - role: 'assistant', - content: '', - native_tool_calls: [{ - id: 'call_1', - type: 'function', - name: 'collectBlocks', - arguments: '{"type":"oak_log","num":2}' - }] - }, - { role: 'tool', tool_call_id: 'call_1', name: 'collectBlocks', content: 'Action output:\nCollected 2 oak_log.' } - ]); - - assert.match(summaryInput, /User input: collect wood/); - assert.match(summaryInput, /Tool call \(collectBlocks\): \{"type":"oak_log","num":2\}/); - assert.match(summaryInput, /Tool result \(collectBlocks\): Action output:\nCollected 2 oak_log\./); - assert.doesNotMatch(summaryInput, /\*used collectBlocks\*/); -}); diff --git a/tests/mindserver_chat_history.test.js b/tests/mindserver_chat_history.test.js deleted file mode 100644 index f9cabd36a..000000000 --- a/tests/mindserver_chat_history.test.js +++ /dev/null @@ -1,138 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import path from 'node:path'; -import { readSavedChatHistory } from '../src/mindcraft/mindserver.js'; - -function withTempProject(fn) { - const dir = mkdtempSync(path.join(tmpdir(), 'mindcraft-chat-history-')); - try { - return fn(dir); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -} - -test('saved chat history loads from memory trace when load_memory is true', () => withTempProject((dir) => { - const botDir = path.join(dir, 'bots', 'tracebot'); - const traceDir = path.join(botDir, 'chat-history'); - mkdirSync(traceDir, { recursive: true }); - const tracePath = path.join(traceDir, 'session.jsonl'); - writeFileSync(tracePath, [ - JSON.stringify({ timestamp: '2026-05-02T00:00:00.000Z', agent: 'tracebot', type: 'llm_request', messages: [] }), - JSON.stringify({ timestamp: '2026-05-02T00:00:01.000Z', agent: 'tracebot', type: 'llm_response', response: { content: 'ok' } }) - ].join('\n') + '\n'); - writeFileSync(path.join(botDir, 'memory.json'), JSON.stringify({ - chat_history_trace: './bots/tracebot/chat-history/session.jsonl', - chat_history_latest: './bots/tracebot/chat_history.jsonl' - })); - - const result = readSavedChatHistory('tracebot', { loadMemory: true, cwd: dir }); - - assert.equal(result.loaded, true); - assert.equal(result.events.length, 2); - assert.equal(result.events[0].type, 'llm_request'); - assert.equal(result.events[1].response.content, 'ok'); -})); - -test('saved chat history merges all session traces instead of only the latest file', () => withTempProject((dir) => { - const botDir = path.join(dir, 'bots', 'tracebot'); - const traceDir = path.join(botDir, 'chat-history'); - mkdirSync(traceDir, { recursive: true }); - writeFileSync(path.join(traceDir, '2026-05-02T00-00-00-000Z.jsonl'), [ - JSON.stringify({ timestamp: '2026-05-02T00:00:00.000Z', agent: 'tracebot', type: 'llm_request', messages: [{ role: 'user', content: 'first' }] }), - JSON.stringify({ timestamp: '2026-05-02T00:00:01.000Z', agent: 'tracebot', type: 'llm_response', response: 'one' }) - ].join('\n') + '\n'); - writeFileSync(path.join(traceDir, '2026-05-02T00-01-00-000Z.jsonl'), [ - JSON.stringify({ timestamp: '2026-05-02T00:01:00.000Z', agent: 'tracebot', type: 'llm_request', messages: [{ role: 'user', content: 'second' }] }), - JSON.stringify({ timestamp: '2026-05-02T00:01:01.000Z', agent: 'tracebot', type: 'llm_response', response: 'two' }) - ].join('\n') + '\n'); - writeFileSync(path.join(botDir, 'memory.json'), JSON.stringify({ - chat_history_trace: './bots/tracebot/chat-history/2026-05-02T00-01-00-000Z.jsonl' - })); - - const result = readSavedChatHistory('tracebot', { loadMemory: true, cwd: dir }); - - assert.equal(result.loaded, true); - assert.equal(result.events.length, 4); - assert.equal(result.events[0].messages[0].content, 'first'); - assert.equal(result.events[2].messages[0].content, 'second'); - assert.equal(result.sources.length, 2); -})); - -test('saved chat history expands compact archive turns when full trace is not otherwise present', () => withTempProject((dir) => { - const botDir = path.join(dir, 'bots', 'tracebot'); - const traceDir = path.join(botDir, 'chat-history'); - const archiveDir = path.join(botDir, 'histories'); - mkdirSync(traceDir, { recursive: true }); - mkdirSync(archiveDir, { recursive: true }); - const archivePath = path.join(archiveDir, 'archive.json'); - writeFileSync(archivePath, JSON.stringify([ - { role: 'user', content: 'Steve: archived question' }, - { role: 'assistant', content: 'archived answer' } - ])); - writeFileSync(path.join(traceDir, 'session.jsonl'), [ - JSON.stringify({ - timestamp: '2026-05-02T00:02:00.000Z', - agent: 'tracebot', - type: 'history_compacted', - summary: 'short summary', - full_history_file: archivePath - }), - JSON.stringify({ - timestamp: '2026-05-02T00:02:01.000Z', - agent: 'tracebot', - type: 'llm_request', - messages: [{ role: 'user', content: 'after compact' }] - }) - ].join('\n') + '\n'); - - const result = readSavedChatHistory('tracebot', { loadMemory: true, cwd: dir }); - - assert.equal(result.loaded, true); - assert.equal(result.events[0].restored_from_archive, true); - assert.equal(result.events[0].turn.content, 'Steve: archived question'); - assert.equal(result.events[1].turn.content, 'archived answer'); - assert.equal(result.events[2].type, 'history_compacted'); -})); - -test('saved chat history is intentionally skipped when load_memory is false', () => withTempProject((dir) => { - const botDir = path.join(dir, 'bots', 'tracebot'); - mkdirSync(botDir, { recursive: true }); - writeFileSync(path.join(botDir, 'chat_history.jsonl'), JSON.stringify({ type: 'llm_request' }) + '\n'); - - const result = readSavedChatHistory('tracebot', { loadMemory: false, cwd: dir }); - - assert.equal(result.loaded, false); - assert.equal(result.reason, 'load_memory_disabled'); - assert.deepEqual(result.events, []); -})); - -test('saved chat history rejects unsafe agent names', () => withTempProject((dir) => { - const result = readSavedChatHistory('../tracebot', { loadMemory: true, cwd: dir }); - - assert.equal(result.loaded, false); - assert.equal(result.reason, 'invalid_agent_name'); - assert.deepEqual(result.events, []); -})); - -test('saved chat history falls back to memory turns when no trace file exists', () => withTempProject((dir) => { - const botDir = path.join(dir, 'bots', 'tracebot'); - mkdirSync(botDir, { recursive: true }); - writeFileSync(path.join(botDir, 'memory.json'), JSON.stringify({ - turns: [ - { role: 'user', content: 'Steve: hello' }, - { role: 'assistant', content: 'Hi Steve.' } - ] - })); - - const result = readSavedChatHistory('tracebot', { loadMemory: true, cwd: dir }); - - assert.equal(result.loaded, true); - assert.equal(result.restored_from_memory, true); - assert.equal(result.source, path.join(botDir, 'memory.json')); - assert.equal(result.events.length, 2); - assert.equal(result.events[0].type, 'history_turn_added'); - assert.equal(result.events[0].turn.content, 'Steve: hello'); -})); diff --git a/tests/mindserver_settings_spec.test.js b/tests/mindserver_settings_spec.test.js deleted file mode 100644 index 4bde70dae..000000000 --- a/tests/mindserver_settings_spec.test.js +++ /dev/null @@ -1,17 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { buildRuntimeSettingsSpec } from '../src/mindcraft/mindserver.js'; - -test('New Agent settings spec only inherits hidden runtime LLM provider registry path', () => { - const spec = buildRuntimeSettingsSpec({ - llm_providers: 'settings_llm_providers.json', - host: 'example.test', - profile: { name: 'ignored' } - }); - - assert.equal(spec.llm_providers.default, 'settings_llm_providers.json'); - assert.equal(spec.llm_providers.hidden, true); - assert.equal(spec.state_snapshot_diff, undefined); - assert.equal(spec.host.default, '127.0.0.1'); - assert.equal(spec.profile.default, undefined); -}); diff --git a/tests/native_prompt_hygiene.test.js b/tests/native_prompt_hygiene.test.js deleted file mode 100644 index e72225ea2..000000000 --- a/tests/native_prompt_hygiene.test.js +++ /dev/null @@ -1,98 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { readdirSync, readFileSync } from 'node:fs'; -import path from 'node:path'; -import { stripVolatileConversationPlaceholders } from '../src/models/prompter.js'; - -const legacyToolSyntax = /(^|\s)![A-Za-z_][A-Za-z0-9_]*\b|\*used\s+[A-Za-z_][A-Za-z0-9_]*\*/i; - -function readPromptRef(value) { - if (typeof value === 'string') return value; - if (value?.prompt_file) return readFileSync(value.prompt_file, 'utf8'); - return ''; -} - -test('default native conversation examples are fixed in markdown instead of JSON', () => { - const profile = JSON.parse(readFileSync('profiles/defaults/_default.json', 'utf8')); - const conversing = readFileSync('profiles/defaults/prompts/conversing.md', 'utf8'); - - assert.equal('conversation_examples' in profile, false); - assert.match(conversing, /Fixed examples of how to respond:/); - assert.match(conversing, /miner_32: Hey! What are you up to\?/); - assert.match(conversing, /rupert: Let us work together on a small house\./); -}); - -test('default coding examples are fixed in markdown instead of JSON', () => { - const profile = JSON.parse(readFileSync('profiles/defaults/_default.json', 'utf8')); - const coding = readFileSync('profiles/defaults/prompts/coding.md', 'utf8'); - - assert.equal('coding_examples' in profile, false); - assert.match(coding, /Fixed coding examples:/); - assert.match(coding, /greg: Collect 10 wood/); - assert.match(coding, /brug: build a dirt house/); -}); - -test('native prompt markdown rejects text-command and fake tool-call examples in conversation prompt', () => { - const conversing = readFileSync('profiles/defaults/prompts/conversing.md', 'utf8'); - const botResponder = readFileSync('profiles/defaults/prompts/bot_responder.md', 'utf8'); - - assert.match(conversing, /native tools\/function calls/i); - assert.match(conversing, /Do NOT write legacy text commands/i); - assert.doesNotMatch(conversing, legacyToolSyntax); - assert.doesNotMatch(botResponder, legacyToolSyntax); - assert.match(botResponder, /\$INCOMING_MESSAGE/); - assert.doesNotMatch(botResponder, /\$TO_SUMMARIZE/); - assert.doesNotMatch(botResponder, /Actual Conversation/i); -}); - - -test('conversation system prompt no longer uses dynamic example or command-doc placeholders', () => { - const conversing = readFileSync('profiles/defaults/prompts/conversing.md', 'utf8'); - const stable = stripVolatileConversationPlaceholders(conversing); - - assert.doesNotMatch(stable, /\$SELF_PROMPT/); - assert.doesNotMatch(stable, /\$MEMORY/); - assert.doesNotMatch(stable, /\$STATS/); - assert.doesNotMatch(stable, /\$INVENTORY/); - assert.doesNotMatch(stable, /\$COMMAND_DOCS/); - assert.doesNotMatch(stable, /stale prompt text/i); -}); - -test('conversation prompt sanitizer strips legacy dynamic context placeholders from task profiles', () => { - const construction = JSON.parse(readFileSync('profiles/tasks/construction_profile.json', 'utf8')); - const stable = stripVolatileConversationPlaceholders(readPromptRef(construction.conversing)); - - assert.doesNotMatch(stable, /\$SELF_PROMPT/); - assert.doesNotMatch(stable, /\$MEMORY/); - assert.doesNotMatch(stable, /\$STATS/); - assert.doesNotMatch(stable, /\$INVENTORY/); - assert.doesNotMatch(stable, /\$COMMAND_DOCS/); - assert.doesNotMatch(stable, /\$EXAMPLES/); -}); - -test('task profile conversation prompts are native-tool cache-safe', () => { - const taskDir = 'profiles/tasks'; - const volatile = /\$(SELF_PROMPT|MEMORY|STATS|INVENTORY|COMMAND_DOCS|EXAMPLES)/; - - for (const file of readdirSync(taskDir).filter(name => name.endsWith('.json'))) { - const profile = JSON.parse(readFileSync(path.join(taskDir, file), 'utf8')); - if (!profile.conversing) continue; - const conversing = readPromptRef(profile.conversing); - - assert.equal(profile.conversing.prompt_file, path.join(taskDir, file.replace(/_profile\.json$/, '_prompt.md'))); - assert.doesNotMatch(conversing, volatile, `${file} should not inject dynamic conversation context`); - assert.doesNotMatch(conversing, legacyToolSyntax, `${file} should not include legacy command examples`); - assert.match(conversing, /native tools\/function calls/i, `${file} should describe native tools`); - assert.equal('saving_memory' in profile, false, `${file} should inherit the shared compact prompt`); - } -}); - -test('conversation request messages do not append runtime context', async () => { - const { Prompter } = await import('../src/models/prompter.js'); - const messages = [{ role: 'user', content: 'Steve: what now?' }]; - const result = await Prompter.prototype.buildConversationMessages.call({}, messages); - - assert.equal(result, messages); - assert.equal(result.length, 1); - assert.doesNotMatch(JSON.stringify(result), /SYSTEM CONTEXT FOR THE PREVIOUS USER MESSAGE|CURRENT WORLD STATE|CURRENT INVENTORY|SUMMARIZED MEMORY/); -}); diff --git a/tests/native_tools.test.js b/tests/native_tools.test.js deleted file mode 100644 index 5f95a14bd..000000000 --- a/tests/native_tools.test.js +++ /dev/null @@ -1,370 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { - commandToToolDefinition, - getCommandToolDefinitions, - executeCommandToolCall -} from '../src/agent/commands/tool_adapter.js'; -import { - createNativeToolResponse, - isNativeToolResponse, - parseToolArguments -} from '../src/models/native_tools.js'; -import * as nativeTools from '../src/models/native_tools.js'; -import { containsCommand, parseCommandMessage } from '../src/agent/commands/index.js'; -import { normalizeGeminiHttpOptions } from '../src/models/google_generative_ai.js'; - -test('human !command parser remains available', () => { - assert.equal(containsCommand('please !stats'), '!stats'); - assert.deepEqual(parseCommandMessage('!stats'), { commandName: '!stats', args: [] }); -}); - -test('command schema conversion preserves required and optional parameters', () => { - const tool = commandToToolDefinition({ - name: '!sample', - description: 'Sample command', - params: { - count: { type: 'int', description: 'Count', domain: [1, 5] }, - note: { type: 'string', description: 'Optional note', optional: true } - } - }); - - assert.equal(tool.type, 'function'); - assert.equal(tool.function.name, 'sample'); - assert.deepEqual(tool.function.parameters.required, ['count']); - assert.equal(tool.function.parameters.properties.count.type, 'integer'); - assert.equal(tool.function.parameters.properties.count.minimum, 1); - assert.equal(tool.function.parameters.properties.count.maximum, 5); -}); - -test('native command tool schemas are stable across repeated builds', () => { - const agent = { blocked_actions: ['!stop', '!stats'] }; - const first = getCommandToolDefinitions(agent); - const firstJson = JSON.stringify(first); - - for (let i = 0; i < 5; i++) { - assert.equal(JSON.stringify(getCommandToolDefinitions(agent)), firstJson); - } - - assert.deepEqual( - getCommandToolDefinitions({ blocked_actions: ['!stats', '!stop'] }), - first, - 'blocked action input order must not affect schema order or content' - ); -}); - -test('blocked native tools preserve source command order as a subsequence', () => { - const allTools = getCommandToolDefinitions({ blocked_actions: [] }); - const blocked = new Set(['!stats', '!inventory', '!nearbyBlocks']); - const filteredTools = getCommandToolDefinitions({ blocked_actions: Array.from(blocked) }); - - const allNames = allTools.map(tool => tool.function.name); - const filteredNames = filteredTools.map(tool => tool.function.name); - const expectedNames = allNames.filter(name => !blocked.has(`!${name}`)); - - assert.deepEqual(filteredNames, expectedNames); -}); - -test('craftRecipe tool asks for output item count', () => { - const craftTool = getCommandToolDefinitions({ blocked_actions: [] }) - .find(tool => tool.function.name === 'craftRecipe'); - - assert.ok(craftTool); - assert.match(craftTool.function.description, /output items/i); - assert.match(craftTool.function.parameters.properties.num.description, /output items/i); - assert.doesNotMatch(craftTool.function.parameters.properties.num.description, /NOT the number of output items/); -}); - -test('native tool response normalizes and parses OpenAI-compatible tool calls', () => { - const response = createNativeToolResponse([ - { - id: 'call_1', - function: { - name: 'sample', - arguments: '{"count":2}' - } - } - ], 'mock'); - - assert.equal(isNativeToolResponse(response), true); - assert.equal(response.tool_calls[0].name, 'sample'); - assert.deepEqual(parseToolArguments(response.tool_calls[0].arguments), { count: 2 }); -}); - -test('native tool argument parser recovers a leading JSON object before provider markers', () => { - const args = '{"count":2,"note":"brace } inside string"}'; - - assert.deepEqual(parseToolArguments(args), { - count: 2, - note: 'brace } inside string' - }); -}); - -test('tool execution adapter coerces args and calls command implementation', async () => { - const commands = [{ - name: '!sample', - description: 'Sample command', - params: { - count: { type: 'int', description: 'Count', domain: [1, 5] }, - enabled: { type: 'boolean', description: 'Flag' } - }, - perform: (_agent, count, enabled) => `count=${count}; enabled=${enabled}` - }]; - - const result = await executeCommandToolCall( - { blocked_actions: [] }, - { name: 'sample', arguments: '{"count":"3","enabled":"true"}' }, - commands - ); - - assert.equal(result.ok, true); - assert.equal(result.result, 'count=3; enabled=true'); -}); - -test('native tool turns serialize to protocol-specific tool result fields', () => { - const call = { - id: 'call_1', - type: 'function', - name: 'sample', - arguments: '{"count":2}' - }; - const turns = [ - { role: 'user', content: 'do it' }, - { role: 'assistant', content: '*used sample*', native_tool_calls: [call] }, - { role: 'tool', tool_call_id: 'call_1', name: 'sample', content: 'count=2' } - ]; - - const openAI = nativeTools.toOpenAIChatMessages(turns, 'system prompt'); - assert.equal(openAI[0].role, 'system'); - assert.deepEqual(openAI[2].tool_calls[0].function, { name: 'sample', arguments: '{"count":2}' }); - assert.equal(openAI[3].role, 'tool'); - assert.equal(openAI[3].tool_call_id, 'call_1'); - - const responses = nativeTools.toResponsesInputItems(turns); - assert.equal(responses[1].type, 'function_call'); - assert.equal(Object.prototype.hasOwnProperty.call(responses[1], 'id'), false); - assert.equal(responses[2].type, 'function_call_output'); - assert.equal(responses[2].call_id, 'call_1'); - - const anthropic = nativeTools.toAnthropicMessages(turns); - assert.equal(anthropic[1].content[0].type, 'text'); - assert.equal(anthropic[1].content[1].type, 'tool_use'); - assert.equal(anthropic[2].content[0].type, 'tool_result'); - - const gemini = nativeTools.toGeminiContents(turns); - assert.equal(gemini[1].parts[1].functionCall.name, 'sample'); - assert.equal(gemini[2].parts[0].functionResponse.name, 'sample'); -}); - -test('OpenAI chat serialization preserves provider reasoning content for tool-call replay', () => { - const call = { - id: 'call_1', - type: 'function', - name: 'sample', - arguments: '{"count":2}' - }; - const turns = [ - { role: 'assistant', content: '', native_tool_calls: [call], thinking: 'call reasoning', thinking_key: 'reasoning_content' }, - { role: 'tool', tool_call_id: 'call_1', name: 'sample', content: 'count=2' }, - { role: 'assistant', content: 'done' } - ]; - - const messages = nativeTools.toOpenAIChatMessages(turns, 'system prompt', { - reasoningKey: 'reasoning_content', - requireReasoningContent: true - }); - - assert.equal(messages[1].reasoning_content, 'call reasoning'); - assert.equal(messages[3].reasoning_content, ''); -}); - -test('Anthropic and Gemini serialization can replay captured thinking blocks', () => { - const turns = [{ - role: 'assistant', - content: 'I will act.', - thinking_blocks: [{ type: 'thinking', thinking: 'signed reasoning', signature: 'sig-1' }] - }]; - - const anthropic = nativeTools.toAnthropicMessages(turns); - assert.equal(anthropic[1].content[0].type, 'thinking'); - assert.equal(anthropic[1].content[0].thinking, 'signed reasoning'); - assert.equal(anthropic[1].content[0].signature, 'sig-1'); - - const gemini = nativeTools.toGeminiContents(turns); - assert.equal(gemini[0].parts[0].thought, true); - assert.equal(gemini[0].parts[0].text, 'signed reasoning'); -}); - -test('multimodal message content keeps protocol-specific image payloads', () => { - const imageUrl = 'data:image/jpeg;base64,abc123'; - const turns = [{ - role: 'user', - content: [ - { type: 'text', text: 'describe this' }, - { type: 'image_url', image_url: { url: imageUrl } } - ] - }]; - - const openAI = nativeTools.toOpenAIChatMessages(turns); - assert.deepEqual(openAI[0].content, turns[0].content); - - const responses = nativeTools.toResponsesInputItems(turns); - assert.deepEqual(responses[0].content, [ - { type: 'input_text', text: 'describe this' }, - { type: 'input_image', image_url: imageUrl } - ]); - - const anthropicTurns = [{ - role: 'user', - content: [ - { type: 'text', text: 'describe this' }, - { type: 'image', source: { type: 'base64', media_type: 'image/jpeg', data: 'abc123' } } - ] - }]; - const anthropic = nativeTools.toAnthropicMessages(anthropicTurns); - assert.deepEqual(anthropic[0].content, anthropicTurns[0].content); -}); - -test('Responses multimodal content converts back to Chat Completions shape', () => { - const imageUrl = 'data:image/jpeg;base64,abc123'; - const turns = [{ - role: 'user', - content: [ - { type: 'input_text', text: 'describe this' }, - { type: 'input_image', image_url: imageUrl } - ] - }]; - - const openAI = nativeTools.toOpenAIChatMessages(turns); - assert.deepEqual(openAI[0].content, [ - { type: 'text', text: 'describe this' }, - { type: 'image_url', image_url: { url: imageUrl } } - ]); -}); - -test('history system turns are downgraded to user messages for provider protocols', () => { - const turns = [ - { role: 'system', content: 'runtime state changed' }, - { role: 'user', content: 'hello' } - ]; - - const openAI = nativeTools.toOpenAIChatMessages(turns, 'stable system prompt'); - assert.equal(openAI.filter(message => message.role === 'system').length, 1); - assert.equal(openAI[0].content, 'stable system prompt'); - assert.equal(openAI[1].role, 'user'); - assert.match(openAI[1].content, /^SYSTEM: runtime state changed/); - - const responses = nativeTools.toResponsesInputItems(turns); - assert.equal(responses[0].role, 'user'); - assert.match(responses[0].content[0].text, /^SYSTEM: runtime state changed/); - - const anthropic = nativeTools.toAnthropicMessages(turns); - assert.ok(anthropic.every(message => message.role !== 'system')); - assert.equal(anthropic[0].role, 'user'); - assert.match(anthropic[0].content, /^SYSTEM: runtime state changed/); - - const gemini = nativeTools.toGeminiContents(turns); - assert.ok(gemini.every(content => content.role !== 'system')); - assert.equal(gemini[0].role, 'user'); - assert.match(gemini[0].parts[0].text, /^SYSTEM: runtime state changed/); -}); - -test('native tool turn repair drops orphan results and can synthesize missing results', () => { - const call = { - id: 'call_1', - type: 'function', - name: 'sample', - arguments: '{}' - }; - const turns = [ - { role: 'tool', tool_call_id: 'orphan', name: 'sample', content: 'old orphan' }, - { role: 'assistant', content: '', native_tool_calls: [call] }, - { role: 'user', content: 'next prompt' } - ]; - - const openAI = nativeTools.toOpenAIChatMessages(turns); - assert.equal(openAI.some(message => message.role === 'tool' && message.tool_call_id === 'orphan'), false); - - const anthropic = nativeTools.toAnthropicMessages(turns); - assert.equal(anthropic[1].content[0].type, 'tool_use'); - assert.equal(anthropic[2].content[0].type, 'tool_result'); - assert.equal(anthropic[2].content[0].tool_use_id, 'call_1'); -}); - -test('multi-turn native tool replay remains deterministic across protocols', () => { - const firstCall = { id: 'call_1', type: 'function', name: 'inventory', arguments: '{}' }; - const secondCall = { id: 'call_2', type: 'function', name: 'collectBlocks', arguments: '{"block":"oak_log","count":2}' }; - const turns = [ - { role: 'user', content: 'what do you have?' }, - { role: 'assistant', content: '*used inventory*', native_tool_calls: [firstCall] }, - { role: 'tool', tool_call_id: 'call_1', name: 'inventory', content: '{"oak_log":0}' }, - { role: 'assistant', content: 'I need wood.' }, - { role: 'user', content: 'collect two logs' }, - { role: 'assistant', content: '*used collectBlocks*', native_tool_calls: [secondCall] }, - { role: 'tool', tool_call_id: 'call_2', name: 'collectBlocks', content: 'Collected 2 oak logs.' }, - { role: 'assistant', content: 'Done.' } - ]; - - const openAIFirst = nativeTools.toOpenAIChatMessages(turns, 'system prompt'); - const openAISecond = nativeTools.toOpenAIChatMessages(turns, 'system prompt'); - assert.deepEqual(openAISecond, openAIFirst); - assert.equal(openAIFirst.filter(message => message.role === 'tool').length, 2); - assert.deepEqual( - openAIFirst.filter(message => message.tool_call_id).map(message => message.tool_call_id), - ['call_1', 'call_2'] - ); - - const responsesFirst = nativeTools.toResponsesInputItems(turns); - const responsesSecond = nativeTools.toResponsesInputItems(turns); - assert.deepEqual(responsesSecond, responsesFirst); - assert.deepEqual( - responsesFirst.filter(item => item.type === 'function_call' || item.type === 'function_call_output').map(item => item.call_id), - ['call_1', 'call_1', 'call_2', 'call_2'] - ); - - const anthropicFirst = nativeTools.toAnthropicMessages(turns); - const anthropicSecond = nativeTools.toAnthropicMessages(turns); - assert.deepEqual(anthropicSecond, anthropicFirst); - assert.equal(JSON.stringify(anthropicFirst).includes('"tool_use"'), true); - assert.equal(JSON.stringify(anthropicFirst).includes('"tool_result"'), true); - - const geminiFirst = nativeTools.toGeminiContents(turns); - const geminiSecond = nativeTools.toGeminiContents(turns); - assert.deepEqual(geminiSecond, geminiFirst); - assert.equal(JSON.stringify(geminiFirst).includes('"functionCall"'), true); - assert.equal(JSON.stringify(geminiFirst).includes('"functionResponse"'), true); -}); - -test('Gemini relay URL normalization keeps only the API root in baseUrl', () => { - assert.deepEqual( - normalizeGeminiHttpOptions( - 'https://mydamoxing.cn/v1beta/models/gemini-3.1-pro-preview:generateContent', - {} - ).httpOptions, - { - baseUrl: 'https://mydamoxing.cn', - apiVersion: 'v1beta' - } - ); - - assert.deepEqual( - normalizeGeminiHttpOptions('https://mydamoxing.cn', { apiVersion: 'v1beta' }).httpOptions, - { - baseUrl: 'https://mydamoxing.cn', - apiVersion: 'v1beta' - } - ); -}); - -test('repair inserts missing result before the next assistant tool call', () => { - const turns = [ - { role: 'assistant', content: '', native_tool_calls: [{ id: 'call_1', type: 'function', name: 'inventory', arguments: '{}' }] }, - { role: 'assistant', content: '', native_tool_calls: [{ id: 'call_2', type: 'function', name: 'collectBlocks', arguments: '{}' }] }, - { role: 'tool', tool_call_id: 'call_2', name: 'collectBlocks', content: 'ok' } - ]; - - const repaired = nativeTools.repairNativeToolTurns(turns, { synthesizeMissingResults: true }); - assert.deepEqual(repaired.map(turn => turn.role), ['assistant', 'tool', 'assistant', 'tool']); - assert.equal(repaired[1].tool_call_id, 'call_1'); - assert.equal(repaired[3].tool_call_id, 'call_2'); -}); diff --git a/tests/openai_compatible.test.js b/tests/openai_compatible.test.js deleted file mode 100644 index 430beab92..000000000 --- a/tests/openai_compatible.test.js +++ /dev/null @@ -1,389 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { OpenAICompletions, OpenAICompatible } from '../src/models/openai_compatible.js'; -import { isNativeToolResponse } from '../src/models/native_tools.js'; -import { selectAPI, selectEmbeddingAPI, createModel } from '../src/models/_model_map.js'; - -const tool = { - type: 'function', - function: { - name: 'report_status', - description: 'Report status', - parameters: { - type: 'object', - properties: { status: { type: 'string' } }, - required: ['status'], - additionalProperties: false - } - } -}; - -test('openai-completions API format can be selected by profile', () => { - const profile = selectAPI({ - api: 'openai-completions', - url: 'https://example.test/v1', - model: 'provider-model', - params: { apiKeyName: 'OPENAI_API_KEY', provider: 'example-provider' } - }); - const model = createModel(profile); - assert.equal(model.constructor.name, 'OpenAICompletions'); - assert.equal(model.provider, 'example-provider'); - assert.equal(model.supportsNativeToolCalls, true); - - const oldAlias = createModel(selectAPI({ - api: 'openai-compatible', - url: 'https://example.test/v1', - model: 'provider-model', - params: { apiKeyName: 'OPENAI_API_KEY', provider: 'example-provider' } - })); - assert.ok(oldAlias instanceof OpenAICompatible); - assert.ok(model instanceof OpenAICompletions); -}); - -test('provider registry expands provider shorthand into a concrete transport', () => { - const profile = selectAPI({ - provider: 'siliconflow', - model: 'Pro/deepseek-ai/DeepSeek-V3', - params: { temperature: 0 } - }); - - assert.equal(profile.api, 'openai-completions'); - assert.equal(profile.url, 'https://api.siliconflow.cn/v1'); - assert.equal(profile.model, 'Pro/deepseek-ai/DeepSeek-V3'); - assert.deepEqual(profile.params, { - apiKeyName: 'SILICONFLOW_API_KEY', - provider: 'siliconflow', - temperature: 0 - }); - - assert.equal(profile.params.provider, 'siliconflow'); -}); - -test('provider registry supports non OpenAI-compatible transport families', () => { - assert.equal(selectAPI({ provider: 'codex', model: 'gpt-5.5' }).api, 'codex'); - assert.equal(selectAPI({ provider: 'google', model: 'gemini-3-flash-preview' }).api, 'google-generative-ai'); - assert.equal(selectAPI({ provider: 'azure', model: 'gpt-5-nano' }).api, 'azure-openai-responses'); - assert.equal(selectAPI({ provider: 'mistral', model: 'mistral-small-latest' }).api, 'openai-completions'); -}); - -test('embedding provider registry is separate from chat model providers', () => { - const qwenEmbedding = selectEmbeddingAPI({ provider: 'qwen_cn' }); - assert.equal(qwenEmbedding.api, 'openai-completions'); - assert.equal(qwenEmbedding.url, 'https://dashscope.aliyuncs.com/compatible-mode/v1'); - assert.equal(qwenEmbedding.model, 'text-embedding-v3'); - assert.equal(qwenEmbedding.params.apiKeyName, 'QWEN_API_KEY'); - assert.equal(qwenEmbedding.params.provider, 'qwen_cn'); - - const openaiEmbedding = selectEmbeddingAPI('openai'); - assert.equal(openaiEmbedding.api, 'openai-completions'); - assert.equal(openaiEmbedding.model, 'text-embedding-3-small'); -}); - -test('ollama defaults to cloud OpenAI-compatible endpoint and can be overridden to local', () => { - const chat = selectAPI({ - provider: 'ollama', - model: 'gpt-oss:120b-cloud' - }); - assert.equal(chat.api, 'openai-completions'); - assert.equal(chat.url, 'https://ollama.com/v1'); - assert.equal(chat.params.provider, 'ollama'); - assert.equal(chat.params.apiKeyName, 'OLLAMA_API_KEY'); - - const model = createModel(chat); - assert.equal(model.constructor.name, 'OpenAICompletions'); - - const local = selectAPI({ - provider: 'ollama', - model: 'llama3.1', - baseUrl: 'http://127.0.0.1:11434/v1', - params: { apiKeyName: null } - }); - assert.equal(local.url, 'http://127.0.0.1:11434/v1'); - assert.equal('apiKeyName' in local.params, false); -}); - -test('profile embedding model can override the provider default embedding name', () => { - const profile = selectEmbeddingAPI({ - provider: 'qwen_cn', - model: 'text-embedding-v4', - params: { dimensions: 1024 } - }); - - assert.equal(profile.api, 'openai-completions'); - assert.equal(profile.model, 'text-embedding-v4'); - assert.deepEqual(profile.params, { - apiKeyName: 'QWEN_API_KEY', - provider: 'qwen_cn', - dimensions: 1024 - }); -}); - -test('provider registry rejects unknown provider ids', () => { - assert.throws( - () => selectAPI({ provider: 'missing-provider', model: 'anything' }), - /Unknown model provider: missing-provider/ - ); -}); - -test('embedding provider registry rejects unknown provider ids independently', () => { - assert.throws( - () => selectEmbeddingAPI({ provider: 'missing-embedding', model: 'anything' }), - /Unknown embedding provider: missing-embedding/ - ); -}); - -test('openai-compatible transport sends Chat Completions tools and normalizes tool calls', async () => { - const model = new OpenAICompatible('provider-model', 'https://example.test/v1', { - apiKeyName: 'OPENAI_API_KEY', - provider: 'example-provider' - }); - let requestPack; - model.openai = { - chat: { - completions: { - create: async pack => { - requestPack = pack; - return { - usage: { - prompt_tokens: 100, - completion_tokens: 12, - prompt_tokens_details: { cached_tokens: 70 } - }, - choices: [{ - message: { - reasoning_content: 'I should call report_status.', - tool_calls: [{ - id: 'call_1', - type: 'function', - function: { - name: 'report_status', - arguments: '{"status":"ok"}' - } - }] - } - }] - }; - } - } - } - }; - - const response = await model.sendRequest( - [{ role: 'user', content: 'call tool' }], - 'Use tools.', - '***', - [tool] - ); - - assert.equal(requestPack.model, 'provider-model'); - assert.equal(requestPack.messages[0].role, 'system'); - assert.equal(requestPack.messages[1].role, 'user'); - assert.equal(requestPack.tools[0].function.name, 'report_status'); - assert.equal(Object.prototype.hasOwnProperty.call(requestPack, 'tool_choice'), false); - assert.equal(isNativeToolResponse(response), true); - assert.equal(response.thinking, 'I should call report_status.'); - assert.equal(response.provider, 'example-provider'); - assert.equal(response.tool_calls[0].name, 'report_status'); - assert.equal(model.lastTokenUsage.input_uncached, 30); - assert.equal(model.lastTokenUsage.input_cached, 70); - assert.equal(model.lastTokenUsage.output, 12); -}); - -test('Kimi OpenAI-compatible transport replays blank reasoning_content when thinking is enabled', async () => { - const model = new OpenAICompatible('kimi-k2.6', 'https://api.kimi.com/coding/v1', { - apiKeyName: 'OPENAI_API_KEY', - provider: 'kimi' - }); - let requestPack; - model.openai = { - chat: { - completions: { - create: async pack => { - requestPack = pack; - return { choices: [{ message: { content: 'ok' } }] }; - } - } - } - }; - - await model.sendRequest( - [{ role: 'assistant', content: 'previous assistant response' }], - 'Use tools.', - '***', - [] - ); - - assert.equal(requestPack.messages[1].role, 'assistant'); - assert.equal(requestPack.messages[1].reasoning_content, ''); -}); - -test('openai-completions transport strips tool choice even if configured', async () => { - const model = new OpenAICompatible('provider-model', 'https://example.test/v1', { - apiKeyName: 'OPENAI_API_KEY', - provider: 'example-provider', - tool_choice: { type: 'function', function: { name: 'report_status' } } - }); - let requestPack; - model.openai = { - chat: { - completions: { - create: async pack => { - requestPack = pack; - return { choices: [{ message: { content: 'ok' } }] }; - } - } - } - }; - - await model.sendRequest( - [{ role: 'user', content: 'call tool' }], - 'Use tools.', - '***', - [tool] - ); - - assert.equal(requestPack.tools[0].function.name, 'report_status'); - assert.equal(Object.prototype.hasOwnProperty.call(requestPack, 'tool_choice'), false); -}); - -test('azure provider can use an explicit deployment name separate from model id', () => { - const model = createModel(selectAPI({ - provider: 'azure', - model: 'gpt-5-nano', - params: { deploymentName: 'my-gpt-5-nano-deployment' } - })); - - assert.equal(model.constructor.name, 'AzureOpenAIResponses'); - assert.equal(model.model_name, 'gpt-5-nano'); - assert.equal(model.deployment, 'my-gpt-5-nano-deployment'); -}); - - - - -test('ollama_local uses local OpenAI-compatible endpoint without an API key', () => { - const chat = selectAPI({ provider: 'ollama_local', model: 'sweaterdog/andy-4:micro-q8_0' }); - assert.equal(chat.api, 'openai-completions'); - assert.equal(chat.url, 'http://127.0.0.1:11434/v1'); - assert.equal(chat.params.provider, 'ollama_local'); - assert.equal('apiKeyName' in chat.params, false); - - const embedding = selectEmbeddingAPI({ provider: 'ollama_local', model: 'embeddinggemma' }); - assert.equal(embedding.api, 'openai-completions'); - assert.equal(embedding.url, 'http://127.0.0.1:11434/v1'); - assert.equal(embedding.params.provider, 'ollama_local'); -}); - -test('replicate provider is a separate Predictions API protocol with native tool normalization', async () => { - const profile = selectAPI({ provider: 'replicate', model: 'google/gemini-2.5-flash' }); - assert.equal(profile.api, 'replicate'); - assert.equal(profile.model, 'google/gemini-2.5-flash'); - assert.equal(profile.params.apiKeyName, 'REPLICATE_API_KEY'); - assert.equal(profile.params.provider, 'replicate'); - - const prefixed = selectAPI('replicate/google/gemini-2.5-flash'); - assert.equal(prefixed.api, 'replicate'); - assert.equal(prefixed.model, 'google/gemini-2.5-flash'); - - const model = createModel(selectAPI({ - api: 'replicate', - model: 'google/gemini-2.5-flash', - params: { apiKeyName: 'OPENAI_API_KEY', provider: 'replicate-test' } - })); - assert.equal(model.constructor.name, 'ReplicateAPI'); - assert.equal(model.supportsNativeToolCalls, true); - - let runArgs; - model.replicate = { - run: async (modelName, args) => { - runArgs = { modelName, args }; - return { - tool_calls: [{ - id: 'call_rep_1', - type: 'function', - function: { - name: 'report_status', - arguments: { status: 'ok' } - } - }] - }; - } - }; - - const response = await model.sendRequest( - [{ role: 'user', content: 'call tool' }], - 'Use tools.', - '***', - [tool] - ); - - assert.equal(runArgs.modelName, 'google/gemini-2.5-flash'); - assert.equal(runArgs.args.input.messages[0].role, 'system'); - assert.equal(runArgs.args.input.tools[0].function.name, 'report_status'); - assert.equal(isNativeToolResponse(response), true); - assert.equal(response.provider, 'replicate-test'); - assert.equal(response.tool_calls[0].name, 'report_status'); - assert.equal(response.tool_calls[0].arguments, '{"status":"ok"}'); -}); - -test('replicate gemini text requests use run with prompt-shaped input', async () => { - const model = createModel(selectAPI({ - api: 'replicate', - model: 'google/gemini-2.5-flash', - params: { apiKeyName: 'OPENAI_API_KEY', provider: 'replicate-test' } - })); - let runArgs; - let streamCalled = false; - model.replicate = { - run: async (modelName, args) => { - runArgs = { modelName, args }; - return ['ok']; - }, - stream: async function* () { - streamCalled = true; - yield 'bad'; - } - }; - - const response = await model.sendRequest( - [{ role: 'user', content: 'Reply exactly: ok' }], - 'System instruction.', - '***' - ); - - assert.equal(response, 'ok'); - assert.equal(streamCalled, false); - assert.equal(runArgs.modelName, 'google/gemini-2.5-flash'); - assert.match(runArgs.args.input.prompt, /System instruction\./); - assert.match(runArgs.args.input.prompt, /Reply exactly: ok/); - assert.equal(runArgs.args.input.system_instruction, 'System instruction.'); - assert.equal(Object.prototype.hasOwnProperty.call(runArgs.args.input, 'system_prompt'), false); -}); - -test('replicate embeddings use a dedicated embedding model when chat model is configured', async () => { - const model = createModel(selectAPI({ - api: 'replicate', - model: 'google/gemini-2.5-flash', - params: { apiKeyName: 'OPENAI_API_KEY', provider: 'replicate-test' } - })); - let runArgs; - model.replicate = { - run: async (modelName, args) => { - runArgs = { modelName, args }; - return { embeddings: [[0.1, 0.2, 0.3]] }; - } - }; - - const embedding = await model.embed('hello'); - - assert.deepEqual(embedding, [0.1, 0.2, 0.3]); - assert.equal(runArgs.modelName, 'mark3labs/embeddings-gte-base'); - assert.deepEqual(runArgs.args.input.text, 'hello'); -}); - -test('replicate embedding provider can be selected separately', () => { - const embedding = selectEmbeddingAPI({ provider: 'replicate', model: 'mark3labs/embeddings-gte-base' }); - assert.equal(embedding.api, 'replicate'); - assert.equal(embedding.model, 'mark3labs/embeddings-gte-base'); - assert.equal(embedding.params.apiKeyName, 'REPLICATE_API_KEY'); -}); diff --git a/tests/profile_shape.test.js b/tests/profile_shape.test.js deleted file mode 100644 index e1f0115a2..000000000 --- a/tests/profile_shape.test.js +++ /dev/null @@ -1,97 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { readdirSync, readFileSync } from 'node:fs'; -import path from 'node:path'; - -const profilesDir = path.resolve('profiles'); -const profileFiles = listJsonFiles(profilesDir); -const rootDefaultProfile = path.resolve('andy.json'); -const selectableProfileFiles = [ - rootDefaultProfile, - ...profileFiles.filter(file => path.dirname(file) === profilesDir) -]; - -test('profiles select providers and model names without transport connection details', () => { - for (const file of selectableProfileFiles) { - const profile = JSON.parse(readFileSync(file, 'utf8')); - const name = path.relative(profilesDir, file); - for (const key of ['model', 'code_model', 'vision_model', 'embedding']) { - if (!profile[key] || typeof profile[key] !== 'object') continue; - assert.equal('api' in profile[key], false, `${name}.${key} must not set api; use provider registry`); - assert.equal('url' in profile[key], false, `${name}.${key} must not set url; use provider registry`); - assert.equal('baseUrl' in profile[key], false, `${name}.${key} must not set baseUrl; use provider registry`); - assert.equal('keyName' in profile[key], false, `${name}.${key} must not set keyName; use provider registry`); - assert.equal('apiKeyName' in profile[key], false, `${name}.${key} must not set apiKeyName; use provider registry`); - } - } -}); - -test('preset profiles expose inert code and vision model placeholders', () => { - for (const file of selectableProfileFiles) { - const profile = JSON.parse(readFileSync(file, 'utf8')); - const name = path.relative(profilesDir, file); - assert.ok('code_model' in profile, `${name}.code_model placeholder is required`); - assert.ok('vision_model' in profile, `${name}.vision_model placeholder is required`); - assert.equal(typeof profile.code_model, 'object', `${name}.code_model must be a provider/model object placeholder`); - assert.equal(typeof profile.vision_model, 'object', `${name}.vision_model must be a provider/model object placeholder`); - assert.equal(profile.code_model.provider, '', `${name}.code_model.provider should be blank until enabled`); - assert.equal(profile.code_model.model, '', `${name}.code_model.model should be blank until enabled`); - assert.equal(profile.vision_model.provider, '', `${name}.vision_model.provider should be blank until enabled`); - assert.equal(profile.vision_model.model, '', `${name}.vision_model.model should be blank until enabled`); - } -}); - -test('profile embeddings explicitly select both provider and embedding model name', () => { - for (const file of selectableProfileFiles) { - const profile = JSON.parse(readFileSync(file, 'utf8')); - const name = path.relative(profilesDir, file); - assert.ok('embedding' in profile, `${name}.embedding placeholder is required`); - assert.equal(typeof profile.embedding, 'object', `${name}.embedding must be an object`); - assert.equal(typeof profile.embedding.provider, 'string', `${name}.embedding.provider is required`); - assert.equal(typeof profile.embedding.model, 'string', `${name}.embedding.model is required`); - if (profile.embedding.provider === '' || profile.embedding.model === '') { - assert.equal(profile.embedding.provider, '', `${name}.embedding provider/model placeholders must both be blank`); - assert.equal(profile.embedding.model, '', `${name}.embedding provider/model placeholders must both be blank`); - } else { - assert.ok(profile.embedding.provider.length > 0, `${name}.embedding.provider must not be empty`); - assert.ok(profile.embedding.model.length > 0, `${name}.embedding.model must not be empty`); - } - } -}); - -test('default and task profile fragments do not carry selectable model placeholders', () => { - for (const file of profileFiles.filter(file => path.dirname(file) !== profilesDir)) { - const profile = JSON.parse(readFileSync(file, 'utf8')); - const name = path.relative(profilesDir, file); - for (const key of ['embedding', 'code_model', 'vision_model']) { - assert.equal(key in profile, false, `${name} should not define ${key}`); - } - } -}); - -function listJsonFiles(dir) { - return readdirSync(dir, { withFileTypes: true }).flatMap(entry => { - const fullPath = path.join(dir, entry.name); - if (entry.isDirectory()) return listJsonFiles(fullPath); - return entry.name.endsWith('.json') ? [fullPath] : []; - }); -} - -test('preset profiles do not silently depend on another provider for embeddings', () => { - const allowedCrossProviderEmbeddings = new Set([ - // Add explicit exceptions here only when a provider has no embedding API and the profile name makes that choice obvious. - ]); - for (const file of readdirSync(profilesDir)) { - if (!file.endsWith('.json')) continue; - const profile = JSON.parse(readFileSync(path.join(profilesDir, file), 'utf8')); - if (!profile.embedding || typeof profile.model !== 'object') continue; - const modelProvider = profile.model.provider; - const embeddingProvider = profile.embedding.provider; - if (!embeddingProvider) continue; - assert.equal( - modelProvider === embeddingProvider || allowedCrossProviderEmbeddings.has(file), - true, - `${file} should not use embedding provider ${embeddingProvider} with model provider ${modelProvider}` - ); - } -}); diff --git a/tests/prompt_markdown_refs.test.js b/tests/prompt_markdown_refs.test.js deleted file mode 100644 index 2d31b2cb9..000000000 --- a/tests/prompt_markdown_refs.test.js +++ /dev/null @@ -1,18 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; -import path from 'node:path'; - -const defaultsDir = path.resolve('profiles/defaults'); -const promptKeys = ['conversing', 'coding', 'saving_memory', 'bot_responder', 'image_analysis']; - -test('default profile keeps editable prompt text in markdown files', () => { - const profile = JSON.parse(readFileSync(path.join(defaultsDir, '_default.json'), 'utf8')); - for (const key of promptKeys) { - assert.equal(typeof profile[key], 'object', `${key} should be a prompt reference object`); - assert.equal(typeof profile[key].prompt_file, 'string', `${key} should reference a markdown file`); - assert.match(profile[key].prompt_file, /\.md$/); - const markdown = readFileSync(path.join(defaultsDir, profile[key].prompt_file), 'utf8'); - assert.ok(markdown.trim().length > 0, `${key} markdown should not be empty`); - } -}); diff --git a/tests/token_usage.test.js b/tests/token_usage.test.js deleted file mode 100644 index bd89668ef..000000000 --- a/tests/token_usage.test.js +++ /dev/null @@ -1,50 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { normalizeTokenUsage } from '../src/models/token_usage.js'; - -test('token usage normalizes OpenAI cached prompt tokens', () => { - assert.deepEqual(normalizeTokenUsage({ - prompt_tokens: 100, - completion_tokens: 20, - total_tokens: 120, - prompt_tokens_details: { cached_tokens: 64 } - }), { - input_total: 100, - input_uncached: 36, - input_cached: 64, - output: 20, - total: 120, - raw: { - prompt_tokens: 100, - completion_tokens: 20, - total_tokens: 120, - prompt_tokens_details: { cached_tokens: 64 } - } - }); -}); - -test('token usage normalizes Anthropic cache creation and cache reads', () => { - const usage = normalizeTokenUsage({ - input_tokens: 12, - cache_creation_input_tokens: 30, - cache_read_input_tokens: 80, - output_tokens: 7 - }); - - assert.equal(usage.input_uncached, 42); - assert.equal(usage.input_cached, 80); - assert.equal(usage.output, 7); -}); - -test('token usage normalizes Gemini cached content tokens', () => { - const usage = normalizeTokenUsage({ - promptTokenCount: 50, - cachedContentTokenCount: 35, - candidatesTokenCount: 9, - totalTokenCount: 59 - }); - - assert.equal(usage.input_uncached, 15); - assert.equal(usage.input_cached, 35); - assert.equal(usage.output, 9); -}); diff --git a/tests/tool_result_policy.test.js b/tests/tool_result_policy.test.js deleted file mode 100644 index f4787c388..000000000 --- a/tests/tool_result_policy.test.js +++ /dev/null @@ -1,64 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { readFileSync } from 'node:fs'; - -test('action tools provide explicit LLM-visible results even when game output is empty', () => { - const actionManagerSource = readFileSync('src/agent/action_manager.js', 'utf8'); - const actionsSource = readFileSync('src/agent/commands/actions.js', 'utf8'); - - assert.ok(actionManagerSource.includes('Action completed with no additional output.')); - assert.ok(actionsSource.includes('Action interrupted before completion.')); - assert.ok(actionsSource.includes('Action completed.')); -}); - -test('uninterruptible actions still restart as a last-resort fallback', () => { - const actionManagerSource = readFileSync('src/agent/action_manager.js', 'utf8'); - const actionsSource = readFileSync('src/agent/commands/actions.js', 'utf8'); - - assert.ok(actionManagerSource.includes('Code execution refused stop after ${timeoutMs}ms. Killing process.')); - assert.ok(actionManagerSource.includes('this.agent.cleanKill')); - assert.equal(actionManagerSource.includes('could not start'), false); - assert.equal(actionManagerSource.includes('leaving current action running'), false); - assert.equal(actionsSource.includes('The agent process was kept alive.'), false); - assert.ok(actionsSource.includes('code_return.message')); - assert.ok(actionsSource.includes('newAction did not produce code or a tool result.')); -}); - - -test('collect block actions can resolve gracefully on interrupt', () => { - const skillsSource = readFileSync('src/agent/library/skills.js', 'utf8'); - const agentSource = readFileSync('src/agent/agent.js', 'utf8'); - - assert.ok(agentSource.includes("this.bot.emit('mindcraft_interrupt')")); - assert.ok(agentSource.includes('this.collectBlockCancelPromise')); - assert.ok(skillsSource.includes('waitForInterruptOrResult(bot, bot.collectBlock.collect(block)')); - assert.ok(skillsSource.includes("bot.once('mindcraft_interrupt', interrupt)")); -}); - - - -test('place block failures include the underlying action error', () => { - const skillsSource = readFileSync('src/agent/library/skills.js', 'utf8'); - - assert.ok(skillsSource.includes('const MAX_ACTION_ERROR_LENGTH = 300;')); - assert.ok(skillsSource.includes('function formatActionError')); - assert.ok(skillsSource.includes('text.slice(0, MAX_ACTION_ERROR_LENGTH)')); - assert.ok(skillsSource.includes('Failed to place ${blockType} at ${target_dest}: ${formatActionError(err)}.')); -}); - -test('torch placing mode does not interrupt active self prompting or actions', () => { - const modesSource = readFileSync('src/agent/modes.js', 'utf8'); - const torchSection = modesSource.slice(modesSource.indexOf("name: 'torch_placing'"), modesSource.indexOf("name: 'elbow_room'")); - - assert.ok(torchSection.includes('!agent.isIdle()')); - assert.ok(torchSection.includes('agent.self_prompter.isActive()')); - assert.ok(torchSection.includes('this.active')); -}); - -test('conversation action reports status through tool result instead of system history', () => { - const actionsSource = readFileSync('src/agent/commands/actions.js', 'utf8'); - const startConversationSection = actionsSource.slice(actionsSource.indexOf("name: '!startConversation'")); - - assert.ok(startConversationSection.includes('Conversation with ${player_name} started.')); - assert.equal(startConversationSection.includes("agent.history.add('system'"), false); -}); diff --git a/tests/vision_interpreter.test.js b/tests/vision_interpreter.test.js deleted file mode 100644 index f0f26de69..000000000 --- a/tests/vision_interpreter.test.js +++ /dev/null @@ -1,33 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { VisionInterpreter } from '../src/agent/vision/vision_interpreter.js'; - -test('VisionInterpreter reports settings-disabled vision separately from adapter support', () => { - const interpreter = Object.create(VisionInterpreter.prototype); - interpreter.allow_vision = false; - interpreter.agent = { prompter: { vision_model: { sendVisionRequest: async () => 'ok' } } }; - - assert.equal( - interpreter.getVisionUnavailableMessage(), - 'Vision is disabled in settings. Set allow_vision to true and restart the agent.' - ); -}); - -test('VisionInterpreter reports missing image support when settings allow vision', () => { - const interpreter = Object.create(VisionInterpreter.prototype); - interpreter.allow_vision = true; - interpreter.agent = { prompter: { vision_model: {} } }; - - assert.equal( - interpreter.getVisionUnavailableMessage(), - 'Vision model does not support image input. Configure a vision-capable model or adapter.' - ); -}); - -test('VisionInterpreter accepts adapters that implement sendVisionRequest', () => { - const interpreter = Object.create(VisionInterpreter.prototype); - interpreter.allow_vision = true; - interpreter.agent = { prompter: { vision_model: { sendVisionRequest: async () => 'ok' } } }; - - assert.equal(interpreter.getVisionUnavailableMessage(), null); -}); From f64082e8916c167acf59c1d1aea19a67a23d1d0c Mon Sep 17 00:00:00 2001 From: uukelele Date: Sun, 3 May 2026 18:27:02 +0100 Subject: [PATCH 48/56] chore: update model names to latest Co-authored-by: uukelele --- profiles/replicate.json | 2 +- profiles/tasks/cooking_profile.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/profiles/replicate.json b/profiles/replicate.json index 5c7dfbfd7..959370a7c 100644 --- a/profiles/replicate.json +++ b/profiles/replicate.json @@ -2,7 +2,7 @@ "name": "replicate", "model": { "provider": "replicate", - "model": "google/gemini-2.5-flash" + "model": "google/gemini-flash-latest" }, "embedding": { "provider": "replicate", diff --git a/profiles/tasks/cooking_profile.json b/profiles/tasks/cooking_profile.json index 39694ed8f..9f555c5dc 100644 --- a/profiles/tasks/cooking_profile.json +++ b/profiles/tasks/cooking_profile.json @@ -1,6 +1,6 @@ { "name": "andy", - "model": "claude-3-5-sonnet-latest", + "model": "claude-opus-4-7", "modes": { "hunting": false, "item_collecting": true, From 8b7a5939d44d1148c6d647cbbf4be63940c6fa09 Mon Sep 17 00:00:00 2001 From: riqvip <95001376+riqvip@users.noreply.github.com> Date: Sun, 3 May 2026 15:47:33 -0700 Subject: [PATCH 49/56] Update andy-4.2.json --- profiles/andy-4.2.json | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/profiles/andy-4.2.json b/profiles/andy-4.2.json index e6e68f927..51e298259 100644 --- a/profiles/andy-4.2.json +++ b/profiles/andy-4.2.json @@ -1,9 +1,11 @@ { "name": "andy_4_2", - "model": { - "api": "openai", - "model": "andy-4.2", - "url": "http://127.0.0.1:1234/v1" + "api": "lmstudio", + "model": "andy-4.2" + }, + "embedding": { + "api": "lmstudio", + "model": "text-embedding-nomic-embed-text-v1.5" } } From 4c8cbbec423c0e4df8f2e7e9a2715de587a5f33b Mon Sep 17 00:00:00 2001 From: NinotQuyi Date: Mon, 4 May 2026 07:07:12 +0800 Subject: [PATCH 50/56] Make Azure provider example user-fillable The provider example should not ship with a project-specific Azure resource or implicit deployment names. The Azure chat and embedding entries now use clear placeholders and comments that tell users to supply their own resource endpoint and deployment names. Constraint: Review feedback asked that the Azure embedding example not be left ambiguous or pre-filled incorrectly Confidence: high Scope-risk: narrow Tested: node -e "JSON.parse(require('fs').readFileSync('settings_llm_providers.example.json','utf8'))" Tested: git diff --check -- settings_llm_providers.example.json Not-tested: Live Azure request with a real deployment Co-authored-by: OmX --- settings_llm_providers.example.json | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/settings_llm_providers.example.json b/settings_llm_providers.example.json index e3fb11f99..c29c4ab1c 100644 --- a/settings_llm_providers.example.json +++ b/settings_llm_providers.example.json @@ -60,11 +60,13 @@ }, "azure": { "format": "azure-openai-responses", - "baseUrl": "https://quyim-mhpzy06q-eastus2.cognitiveservices.azure.com/", + "baseUrl": "https://.openai.azure.com", "params": { "apiVersion": "2025-01-01-preview" }, - "keyName": "AZURE_OPENAI_API_KEY" + "keyName": "AZURE_OPENAI_API_KEY", + "defaultModel": "", + "comment": "Azure OpenAI uses deployment names as model names. Replace baseUrl and defaultModel with your Azure resource endpoint and chat deployment name." }, "codex": { "format": "openai-codex-responses", @@ -243,12 +245,13 @@ }, "azure": { "format": "azure-openai-responses", - "baseUrl": "https://quyim-mhpzy06q-eastus2.cognitiveservices.azure.com/", - "defaultModel": "text-embedding-v3", + "baseUrl": "https://.openai.azure.com", + "defaultModel": "", "params": { "apiVersion": "2025-01-01-preview" }, - "keyName": "AZURE_OPENAI_API_KEY" + "keyName": "AZURE_OPENAI_API_KEY", + "comment": "Azure OpenAI embeddings use the embedding deployment name as defaultModel. Replace this with your own deployment name." }, "qwen_cn": { "format": "openai-embeddings", From 59ecf3f9509b0ac6fe93338ee9b66b9774c67a44 Mon Sep 17 00:00:00 2001 From: NinotQuyi Date: Mon, 4 May 2026 07:14:58 +0800 Subject: [PATCH 51/56] Make Azure profile select Azure embeddings explicitly The Azure profile should demonstrate the matching embedding provider instead of leaving embedding blank. It now points to the Azure embedding provider and uses a deployment-name placeholder that users replace with their own Azure OpenAI embedding deployment. Constraint: User explicitly requested profiles/azure.json prefill the embedding provider Confidence: high Scope-risk: narrow Tested: node -e "JSON.parse(require('fs').readFileSync('profiles/azure.json','utf8'))" Tested: git diff --check -- profiles/azure.json Not-tested: Live Azure embedding request with a real deployment Co-authored-by: OmX --- profiles/azure.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/profiles/azure.json b/profiles/azure.json index 4e0c5d6a1..3acfcf244 100644 --- a/profiles/azure.json +++ b/profiles/azure.json @@ -5,8 +5,8 @@ "model": "gpt-5-nano" }, "embedding": { - "provider": "", - "model": "" + "provider": "azure", + "model": "" }, "code_model": { "provider": "", From a56938fe3ff9e7c6b62f71ed0e5a3dce604b95bf Mon Sep 17 00:00:00 2001 From: NinotQuyi Date: Mon, 4 May 2026 07:16:58 +0800 Subject: [PATCH 52/56] Remove "native" --- profiles/defaults/prompts/conversing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/profiles/defaults/prompts/conversing.md b/profiles/defaults/prompts/conversing.md index 4ec649327..eab601f1e 100644 --- a/profiles/defaults/prompts/conversing.md +++ b/profiles/defaults/prompts/conversing.md @@ -1,4 +1,4 @@ -You are an AI Minecraft bot named $NAME that can converse with players, see, move, mine, build, and interact with the world by using native tools/function calls. +You are an AI Minecraft bot named $NAME that can converse with players, see, move, mine, build, and interact with the world by using tools/function calls. 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; when you need to act, call the appropriate native tool/function instead of writing command text. Do NOT write legacy text commands, function-looking text, or fake tool-use log text in your message; call the native tool and optionally respond briefly after the tool result. 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 ' '. This is extremely important to me, take a deep breath and have fun :) Use transient state snapshots/diffs as your current baseline instead of re-checking unchanged state; call tools only when you need fresh details or information not covered by the context. From 6a99ae505f9833648b2d78e5b730be8fb92ae57d Mon Sep 17 00:00:00 2001 From: NinotQuyi Date: Mon, 4 May 2026 07:17:44 +0800 Subject: [PATCH 53/56] Change to "claude-opus-4-7" --- profiles/tasks/crafting_profile.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/profiles/tasks/crafting_profile.json b/profiles/tasks/crafting_profile.json index 7a3a8a0f4..2be247b5b 100644 --- a/profiles/tasks/crafting_profile.json +++ b/profiles/tasks/crafting_profile.json @@ -1,6 +1,6 @@ { "name": "andy", - "model": "claude-3-5-sonnet-latest", + "model": "claude-opus-4-7", "modes": { "hunting": false, "elbow_room": false From e1c69fba8d9d3ef110d1d993d8baaecf824a4832 Mon Sep 17 00:00:00 2001 From: NinotQuyi Date: Mon, 4 May 2026 07:41:38 +0800 Subject: [PATCH 54/56] Stop tracking local native-tool smoke loop The dev native-tool loop is a local smoke utility and should not be versioned with the shared repository. Removing it from the index keeps existing local copies available without making the script part of future checkouts. Constraint: User requested removing scripts/smoke/dev_native_tool_loop.js from git without adding a gitignore rule Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep this script local unless it becomes a documented shared smoke test Tested: Verified cached diff contains only scripts/smoke/dev_native_tool_loop.js deletion Not-tested: No runtime tests needed for index-only removal Co-authored-by: OmX --- scripts/smoke/dev_native_tool_loop.js | 45 --------------------------- 1 file changed, 45 deletions(-) delete mode 100644 scripts/smoke/dev_native_tool_loop.js diff --git a/scripts/smoke/dev_native_tool_loop.js b/scripts/smoke/dev_native_tool_loop.js deleted file mode 100644 index 7f617c832..000000000 --- a/scripts/smoke/dev_native_tool_loop.js +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env node -import assert from 'node:assert/strict'; -import { commandToToolDefinition, executeCommandToolCall } from '../../src/agent/commands/tool_adapter.js'; -import { createNativeToolResponse, isNativeToolResponse } from '../../src/models/native_tools.js'; - -const mockCommand = { - name: '!reportStatus', - description: 'Report mock tool-loop status.', - params: { - status: { type: 'string', description: 'Status text.' } - }, - perform: async (_agent, status) => `mock status: ${status}` -}; - -const mockModel = { - supportsNativeToolCalls: true, - async sendRequest(_turns, _systemMessage, _stopSeq, tools) { - assert.equal(tools.length, 1); - assert.equal(tools[0].function.name, 'reportStatus'); - return createNativeToolResponse([ - { - id: 'mock_call_1', - function: { - name: 'reportStatus', - arguments: JSON.stringify({ status: 'ok' }) - } - } - ], 'mock'); - } -}; - -const tools = [commandToToolDefinition(mockCommand)]; -const toolResponse = await mockModel.sendRequest( - [{ role: 'user', content: 'run mock status report' }], - 'Use native tools.', - '***', - tools -); - -assert.equal(isNativeToolResponse(toolResponse), true); -const result = await executeCommandToolCall({ blocked_actions: [] }, toolResponse.tool_calls[0], [mockCommand]); -assert.equal(result.ok, true); -assert.equal(result.result, 'mock status: ok'); - -console.log('Dev native tool-loop passed without Minecraft.'); From d410766d92194d55b7d84032c32eff4909d8af69 Mon Sep 17 00:00:00 2001 From: NinotQuyi Date: Mon, 4 May 2026 07:54:05 +0800 Subject: [PATCH 55/56] Guide Azure profile provider setup The Azure profile now points users at the provider registry template for chat and embedding deployment endpoint configuration. This keeps profile selection concise while making the Azure baseUrl/defaultModel setup path visible where users first inspect the profile. Constraint: profiles are parsed as strict JSON, so guidance must be a JSON field instead of a comment token Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep Azure deployment names and endpoints in settings_llm_providers*.json rather than hardcoding user-specific values in profiles/azure.json Tested: node -e JSON.parse(require('fs').readFileSync('profiles/azure.json','utf8')) Not-tested: Live Azure request; documentation-only profile guidance Co-authored-by: OmX --- profiles/azure.json | 1 + 1 file changed, 1 insertion(+) diff --git a/profiles/azure.json b/profiles/azure.json index 3acfcf244..523228ea2 100644 --- a/profiles/azure.json +++ b/profiles/azure.json @@ -1,5 +1,6 @@ { "name": "azure", + "comment": "Configure the Azure chat and embedding baseUrl/defaultModel values in settings_llm_providers.example.json, then copy them into settings_llm_providers.json for local use.", "model": { "provider": "azure", "model": "gpt-5-nano" From 4e74744d7a5e2eb846525585f8ea0517a0b54a62 Mon Sep 17 00:00:00 2001 From: Qu Yi Date: Mon, 25 May 2026 23:47:47 +0800 Subject: [PATCH 56/56] Delete scripts/smoke directory --- scripts/smoke/live_function_call_smoke.js | 169 ------------- scripts/smoke/live_model_matrix.js | 282 ---------------------- 2 files changed, 451 deletions(-) delete mode 100644 scripts/smoke/live_function_call_smoke.js delete mode 100644 scripts/smoke/live_model_matrix.js diff --git a/scripts/smoke/live_function_call_smoke.js b/scripts/smoke/live_function_call_smoke.js deleted file mode 100644 index cacb5a004..000000000 --- a/scripts/smoke/live_function_call_smoke.js +++ /dev/null @@ -1,169 +0,0 @@ -#!/usr/bin/env node -import { selectAPI, createModel } from '../../src/models/_model_map.js'; -import { hasCodexChatGPTAuth } from '../../src/models/codex_chatgpt.js'; -import { isNativeToolResponse, parseToolArguments } from '../../src/models/native_tools.js'; -import { hasKey } from '../../src/utils/keys.js'; - -const TIMEOUT_MS = Number.parseInt(process.env.LIVE_FUNCTION_CALL_TIMEOUT_MS || '45000', 10); - -const tools = [{ - type: 'function', - function: { - name: 'report_status', - description: 'Report a short status for a live native function-calling smoke test.', - parameters: { - type: 'object', - properties: { - status: { type: 'string', description: 'Use ok if function calling works.' }, - provider: { type: 'string', description: 'The provider or route under test.' } - }, - required: ['status', 'provider'], - additionalProperties: false - } - } -}]; - - -function providerCase(name, key, provider, model, params = undefined, available = undefined) { - return { - name, - key, - ...(available ? { available } : {}), - create: () => createModel(selectAPI({ - provider, - model, - ...(params ? { params } : {}) - })) - }; -} - -const cases = [ - { - name: 'codex-chatgpt:gpt-5.5', - key: 'CODEX_CHATGPT_AUTH', - available: () => hasCodexChatGPTAuth(), - create: () => createModel(selectAPI({ provider: 'codex', model: 'gpt-5.5' })) - }, - providerCase('openai:gpt-5.5', 'OPENAI_API_KEY', 'openai', 'gpt-5.5', { reasoning: { effort: 'medium' } }), - providerCase('siliconflow:deepseek-ai/DeepSeek-V4-Flash', 'SILICONFLOW_API_KEY', 'siliconflow', 'deepseek-ai/DeepSeek-V4-Flash'), - providerCase('siliconflow:Pro/deepseek-ai/DeepSeek-R1', 'SILICONFLOW_API_KEY', 'siliconflow', 'Pro/deepseek-ai/DeepSeek-R1'), - providerCase('siliconflow:THUDM/GLM-Z1-32B-0414', 'SILICONFLOW_API_KEY', 'siliconflow', 'THUDM/GLM-Z1-32B-0414'), - providerCase('qwen_cn:qwen-max', 'QWEN_API_KEY', 'qwen_cn', 'qwen-max'), - providerCase('deepseek:deepseek-v4-pro', 'DEEPSEEK_API_KEY', 'deepseek', 'deepseek-v4-pro'), - providerCase('openrouter:openai/gpt-5.5', 'OPENROUTER_API_KEY', 'openrouter', 'openai/gpt-5.5'), - providerCase('xai:grok-4-fast-reasoning', 'XAI_API_KEY', 'xai', 'grok-4-fast-reasoning'), - providerCase('minimax_cn:MiniMax-M2.7', 'MINIMAX_CN_API_KEY', 'minimax_cn', 'MiniMax-M2.7'), - providerCase('kimi:kimi-k2.6', 'KIMI_API_KEY', 'kimi', 'kimi-k2.6'), - providerCase('hyperbolic:Qwen/Qwen3-Coder-480B-A35B-Instruct', 'HYPERBOLIC_API_KEY', 'hyperbolic', 'Qwen/Qwen3-Coder-480B-A35B-Instruct'), - providerCase('huggingface:meta-llama/Llama-3.3-70B-Instruct', 'HUGGINGFACE_API_KEY', 'huggingface', 'meta-llama/Llama-3.3-70B-Instruct:novita'), - providerCase('novita:deepseek/deepseek-v4-flash', 'NOVITA_API_KEY', 'novita', 'deepseek/deepseek-v4-flash'), - providerCase('mercury:mercury-coder-small', 'MERCURY_API_KEY', 'mercury', 'mercury-coder-small'), - providerCase('groq:qwen/qwen3-32b', 'GROQCLOUD_API_KEY', 'groq', 'qwen/qwen3-32b'), - providerCase('cerebras:qwen-3-235b-a22b-instruct-2507', 'CEREBRAS_API_KEY', 'cerebras', 'qwen-3-235b-a22b-instruct-2507'), - providerCase('mistral:mistral-small-latest', 'MISTRAL_API_KEY', 'mistral', 'mistral-small-latest'), - providerCase('gemini:gemini-3-flash-preview', 'GEMINI_API_KEY', 'google', 'gemini-3-flash-preview') -]; - -const includeRegex = process.env.LIVE_FUNCTION_CALL_INCLUDE ? new RegExp(process.env.LIVE_FUNCTION_CALL_INCLUDE, 'i') : null; -const excludeRegex = process.env.LIVE_FUNCTION_CALL_EXCLUDE ? new RegExp(process.env.LIVE_FUNCTION_CALL_EXCLUDE, 'i') : null; -const selectedCases = cases.filter(testCase => { - if (includeRegex && !includeRegex.test(testCase.name)) return false; - if (excludeRegex && excludeRegex.test(testCase.name)) return false; - return true; -}); - -const results = []; - -for (const testCase of selectedCases) { - if (typeof testCase.available === 'function' && !testCase.available()) { - results.push({ name: testCase.name, status: 'skip', reason: `${testCase.key} missing` }); - continue; - } - if (!testCase.available && !hasKey(testCase.key)) { - results.push({ name: testCase.name, status: 'skip', reason: `${testCase.key} missing` }); - continue; - } - - const started = Date.now(); - try { - const model = testCase.create(); - const response = await withTimeout( - model.sendRequest( - [{ role: 'user', content: `report status ok for ${testCase.name}` }], - 'Use report_status.', - '***', - tools - ), - TIMEOUT_MS - ); - if (!isNativeToolResponse(response)) { - results.push({ - name: testCase.name, - status: 'fail', - reason: `no native tool response: ${preview(response)}`, - elapsed_ms: Date.now() - started - }); - continue; - } - const call = response.tool_calls[0]; - const parsedArgs = safeParse(call?.arguments); - const validArgs = parsedArgs && typeof parsedArgs === 'object' && parsedArgs.status === 'ok'; - results.push({ - name: testCase.name, - status: call?.name === 'report_status' && validArgs ? 'pass' : 'fail', - tool_name: call?.name, - arguments: parsedArgs, - reason: validArgs ? undefined : 'tool call arguments were missing, malformed, or did not include status=ok', - elapsed_ms: Date.now() - started - }); - } catch (error) { - results.push({ - name: testCase.name, - status: 'fail', - reason: sanitizeError(error), - elapsed_ms: Date.now() - started - }); - } -} - -const summary = { - totals: { - pass: results.filter(result => result.status === 'pass').length, - fail: results.filter(result => result.status === 'fail').length, - skip: results.filter(result => result.status === 'skip').length - }, - selected: selectedCases.length, - results -}; - -console.log(JSON.stringify(summary, null, 2)); -if (summary.totals.fail > 0) { - process.exit(1); -} -process.exit(0); - -function withTimeout(promise, timeoutMs) { - return Promise.race([ - promise, - new Promise((_, reject) => setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs)) - ]); -} - -function safeParse(value) { - try { - return parseToolArguments(value); - } catch { - return value; - } -} - -function preview(value) { - return String(typeof value === 'string' ? value : JSON.stringify(value)).slice(0, 180); -} - -function sanitizeError(error) { - const status = error?.status ? `status=${error.status} ` : ''; - const code = error?.code ? `code=${error.code} ` : ''; - const message = error?.error?.message || error?.message || String(error); - return `${status}${code}${message}`.replace(/sk-[A-Za-z0-9_-]+/g, '[REDACTED_KEY]').slice(0, 300); -} diff --git a/scripts/smoke/live_model_matrix.js b/scripts/smoke/live_model_matrix.js deleted file mode 100644 index 29dcadac6..000000000 --- a/scripts/smoke/live_model_matrix.js +++ /dev/null @@ -1,282 +0,0 @@ -#!/usr/bin/env node -import { mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; -import path from 'node:path'; -import { selectAPI, createModel } from '../../src/models/_model_map.js'; -import { hasCodexChatGPTAuth } from '../../src/models/codex_chatgpt.js'; -import { isNativeToolResponse, parseToolArguments } from '../../src/models/native_tools.js'; -import { hasKey } from '../../src/utils/keys.js'; - -const TIMEOUT_MS = Number.parseInt(process.env.LIVE_MODEL_MATRIX_TIMEOUT_MS || '60000', 10); -const CONCURRENCY = Number.parseInt(process.env.LIVE_MODEL_MATRIX_CONCURRENCY || '6', 10); -const OUTPUT_DIR = process.env.LIVE_MODEL_MATRIX_OUTPUT_DIR || 'results/live-model-matrix'; -const OUTPUT_FILE = process.env.LIVE_MODEL_MATRIX_OUTPUT || path.join(OUTPUT_DIR, `live-model-matrix-${new Date().toISOString().replace(/[:.]/g, '-')}.csv`); -const INCLUDE = process.env.LIVE_MODEL_MATRIX_INCLUDE ? new RegExp(process.env.LIVE_MODEL_MATRIX_INCLUDE, 'i') : null; -const EXCLUDE = process.env.LIVE_MODEL_MATRIX_EXCLUDE ? new RegExp(process.env.LIVE_MODEL_MATRIX_EXCLUDE, 'i') : null; - -const providerConfig = JSON.parse(readFileSync('settings_llm_providers.json', 'utf8')); -const profilesDir = path.resolve('profiles'); - -const tool = { - type: 'function', - function: { - name: 'report_status', - description: 'Report status for a live model compatibility test.', - parameters: { - type: 'object', - properties: { - status: { type: 'string', description: 'Use ok if tool calling works.' }, - provider: { type: 'string', description: 'Provider id under test.' } - }, - required: ['status', 'provider'], - additionalProperties: false - } - } -}; - -const cases = loadProviderDefaultCases().concat(loadProfileCases()); -const selected = cases.filter(testCase => { - const haystack = `${testCase.profile},${testCase.provider},${testCase.model || ''}`; - if (INCLUDE && !INCLUDE.test(haystack)) return false; - if (EXCLUDE && EXCLUDE.test(haystack)) return false; - return true; -}); - -const rows = await runWithConcurrency(selected, Math.max(1, CONCURRENCY), runCase); - -async function runCase(testCase) { - const baseRow = { - profile: testCase.profile, - provider: testCase.provider || '', - model: testCase.model || '', - api_format: providerConfig.models?.[testCase.provider]?.format || '', - base_url: providerConfig.models?.[testCase.provider]?.baseUrl || '', - key_name: keyNameFor(testCase.provider), - available: 'yes', - chat_status: 'not_run', - chat_elapsed_ms: '', - chat_reason: '', - tool_status: 'not_run', - tool_elapsed_ms: '', - tool_reason: '', - tool_name: '', - tool_arguments: '' - }; - - const label = `${testCase.profile} | ${testCase.provider} | ${testCase.model}`; - console.log(`[matrix] start ${label}`); - const availability = isAvailable(testCase); - if (!availability.ok) { - console.log(`[matrix] skip ${label}: ${availability.reason}`); - return { ...baseRow, available: 'no', chat_status: 'skip', chat_reason: availability.reason, tool_status: 'skip', tool_reason: availability.reason }; - } - - const [chatResult, toolResult] = await Promise.all([ - runChatCheck(testCase), - runToolCheck(testCase) - ]); - - const row = { - ...baseRow, - chat_status: chatResult.status, - chat_elapsed_ms: String(chatResult.elapsed_ms), - chat_reason: chatResult.reason, - tool_status: toolResult.status, - tool_elapsed_ms: String(toolResult.elapsed_ms), - tool_reason: toolResult.reason, - tool_name: toolResult.tool_name, - tool_arguments: toolResult.tool_arguments - }; - console.log(`[matrix] done ${label}: chat=${row.chat_status} tool=${row.tool_status}`); - return row; -} - -async function runChatCheck(testCase) { - const started = Date.now(); - try { - const model = createModel(selectAPI(testCase.profileModel)); - const response = await withTimeout( - model.sendRequest([{ role: 'user', content: 'Reply exactly: ok' }], 'You are running a live chat smoke test.', '***'), - TIMEOUT_MS - ); - const text = typeof response === 'string' ? response.trim() : JSON.stringify(response); - const ok = Boolean(text) && !isKnownFailureText(text) && !isNativeToolResponse(response); - return { - status: ok ? 'pass' : 'fail', - reason: ok ? preview(text, 160) : `bad chat response: ${preview(text, 220)}`, - elapsed_ms: Date.now() - started - }; - } catch (error) { - return { status: 'fail', reason: sanitizeError(error), elapsed_ms: Date.now() - started }; - } -} - -async function runToolCheck(testCase) { - const started = Date.now(); - try { - const model = createModel(selectAPI(testCase.profileModel)); - const response = await withTimeout( - model.sendRequest( - [{ role: 'user', content: `report status ok for ${testCase.provider || testCase.profile}` }], - 'Use report_status.', - '***', - [tool] - ), - TIMEOUT_MS - ); - if (!isNativeToolResponse(response)) { - return { - status: 'fail', - reason: `no native tool response: ${preview(response, 220)}`, - elapsed_ms: Date.now() - started, - tool_name: '', - tool_arguments: '' - }; - } - const call = response.tool_calls?.[0]; - const args = safeParse(call?.arguments); - const valid = call?.name === 'report_status' && args && typeof args === 'object' && args.status === 'ok'; - return { - status: valid ? 'pass' : 'fail', - reason: valid ? '' : 'tool call arguments missing status=ok or wrong tool name', - elapsed_ms: Date.now() - started, - tool_name: call?.name || '', - tool_arguments: typeof args === 'string' ? args : JSON.stringify(args || {}) - }; - } catch (error) { - return { status: 'fail', reason: sanitizeError(error), elapsed_ms: Date.now() - started, tool_name: '', tool_arguments: '' }; - } -} - -async function runWithConcurrency(items, concurrency, worker) { - const results = new Array(items.length); - let nextIndex = 0; - const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => { - while (nextIndex < items.length) { - const index = nextIndex++; - results[index] = await worker(items[index], index); - } - }); - await Promise.all(workers); - return results; -} - -mkdirSync(OUTPUT_DIR, { recursive: true }); -writeFileSync(OUTPUT_FILE, toCsv(rows)); -const summary = summarize(rows); -console.log(JSON.stringify({ output: OUTPUT_FILE, selected: selected.length, summary }, null, 2)); -process.exit(summary.failed > 0 ? 1 : 0); - -function loadProviderDefaultCases() { - return Object.entries(providerConfig.models || {}) - .filter(([, config]) => typeof config.defaultModel === 'string' && config.defaultModel.length > 0) - .map(([provider, config]) => ({ - profile: `@provider-default/${provider}`, - provider, - model: config.defaultModel, - profileModel: { provider, model: config.defaultModel }, - parseError: null - })); -} - -function loadProfileCases() { - const out = []; - for (const file of readdirSync(profilesDir).sort()) { - if (!file.endsWith('.json')) continue; - const fullPath = path.join(profilesDir, file); - let profile; - try { - profile = JSON.parse(readFileSync(fullPath, 'utf8')); - } catch (error) { - out.push({ profile: file, provider: '', model: '', profileModel: {}, parseError: error }); - continue; - } - const profileModel = profile.model; - if (!profileModel) continue; - let provider = ''; - let model = ''; - if (typeof profileModel === 'object') { - provider = profileModel.provider || ''; - model = profileModel.model || providerConfig.models?.[provider]?.defaultModel || ''; - } else if (typeof profileModel === 'string') { - const [maybeProvider, ...rest] = profileModel.split('/'); - provider = providerConfig.models?.[maybeProvider] ? maybeProvider : ''; - model = rest.length > 0 ? rest.join('/') : profileModel; - } - out.push({ profile: file, provider, model, profileModel, parseError: null }); - } - return out; -} - -function keyNameFor(provider) { - return providerConfig.models?.[provider]?.keyName || ''; -} - -function isAvailable(testCase) { - if (testCase.parseError) return { ok: false, reason: `parse error: ${testCase.parseError.message}` }; - if (testCase.provider === 'codex') { - return hasCodexChatGPTAuth() ? { ok: true } : { ok: false, reason: 'Codex auth missing' }; - } - const keyName = keyNameFor(testCase.provider); - if (keyName && !hasKey(keyName)) return { ok: false, reason: `${keyName} missing` }; - return { ok: true }; -} - -function withTimeout(promise, timeoutMs) { - return Promise.race([ - promise, - new Promise((_, reject) => setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs)) - ]); -} - -function safeParse(value) { - try { - return parseToolArguments(value); - } catch { - return value; - } -} - -function isKnownFailureText(text) { - return [ - 'My brain disconnected, try again.', - 'No response received.', - 'No response from Claude.', - 'An unexpected error occurred, please try again.', - 'Azure deployment not found.', - 'Ollama Cloud rejected the request.' - ].some(marker => text.includes(marker)); -} - -function sanitizeError(error) { - const status = error?.status ? `status=${error.status} ` : ''; - const code = error?.code ? `code=${error.code} ` : ''; - const message = error?.error?.message || error?.message || String(error); - return `${status}${code}${message}` - .replace(/sk-[A-Za-z0-9_-]+/g, '[REDACTED_KEY]') - .replace(/Bearer\s+[A-Za-z0-9._-]+/g, 'Bearer [REDACTED_TOKEN]') - .slice(0, 500); -} - -function preview(value, max = 180) { - return String(typeof value === 'string' ? value : JSON.stringify(value)).replace(/\s+/g, ' ').slice(0, max); -} - -function toCsv(rows) { - const headers = ['profile', 'provider', 'model', 'api_format', 'base_url', 'key_name', 'available', 'chat_status', 'chat_elapsed_ms', 'chat_reason', 'tool_status', 'tool_elapsed_ms', 'tool_name', 'tool_arguments', 'tool_reason']; - return [headers.join(','), ...rows.map(row => headers.map(header => csvCell(row[header] ?? '')).join(','))].join('\n') + '\n'; -} - -function csvCell(value) { - const text = String(value); - return /[",\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; -} - -function summarize(rows) { - const total = rows.length; - const bothPass = rows.filter(r => r.chat_status === 'pass' && r.tool_status === 'pass').length; - const chatPass = rows.filter(r => r.chat_status === 'pass').length; - const toolPass = rows.filter(r => r.tool_status === 'pass').length; - const skipped = rows.filter(r => r.available === 'no').length; - const failed = rows.filter(r => r.available === 'yes' && (r.chat_status !== 'pass' || r.tool_status !== 'pass')).length; - return { total, bothPass, chatPass, toolPass, skipped, failed }; -}