merlyn/server/utils/agents/aibitat/providers/helpers/untooled.js
Timothy Carambat f395083978
Automatic mode for workspace (Agent mode default) (#5143)
* Add automatic chat mode with native tool calling support

    Introduces a new automatic chat mode (now the default) that automatically invokes tools when the provider supports native tool calling. Conditionally shows/hides the @agent command based on whether native tooling is available.

    - Add supportsNativeToolCalling() to AI providers (OpenAI, Anthropic, Azure always support; others opt-in via ENV)
    - Update all locale translations with new mode descriptions
    - Enhance translator to preserve Trans component tags
    - Remove deprecated ability tags UI

* rebase translations

* WIP on image attachments. Supports initial image attachment + subsequent attachments

* persist images

* Image attachments and updates for providers

* desktop pre-change

* always show command on failure

* add back gemini streaming detection

* move provider native tooling flag to Provider func

* whoops - forgot to delete

* strip "@agent" from prompts to prevent weird replies

* translations for automatic-mode (#5145)

* translations for automatic-mode

* rebase

* translations

* lint

* fix dead translations

* change default for now to chat mode just for rollout

* remove pfp for workspace

* passthrough workspace for showAgentCommand detection and rendering

* Agent API automatic mode support

* ephemeral attachments passthrough

* support reading of pinned documents in agent context
2026-03-18 12:35:43 -07:00

438 lines
15 KiB
JavaScript

const { safeJsonParse } = require("../../../../http");
const { Deduplicator } = require("../../utils/dedupe");
const { v4 } = require("uuid");
// Useful inheritance class for a model which supports OpenAi schema for API requests
// but does not have tool-calling or JSON output support.
class UnTooled {
constructor() {
this.deduplicator = new Deduplicator();
}
cleanMsgs(messages) {
const modifiedMessages = [];
messages.forEach((msg) => {
if (msg.role === "function") {
const prevMsg = modifiedMessages[modifiedMessages.length - 1].content;
modifiedMessages[modifiedMessages.length - 1].content =
`${prevMsg}\n${msg.content}`;
return;
}
// Format messages with attachments for multimodal support
// Uses formatMessageWithAttachments inherited from Provider base class
modifiedMessages.push(this.formatMessageWithAttachments(msg));
});
return modifiedMessages;
}
showcaseFunctions(functions = []) {
let output = "";
functions.forEach((def) => {
let shotExample = `-----------
Function name: ${def.name}
Function Description: ${def.description}
Function parameters in JSON format:
${JSON.stringify(def.parameters.properties, null, 4)}\n`;
if (Array.isArray(def.examples)) {
def.examples.forEach(({ prompt, call }) => {
shotExample += `Query: "${prompt}"\nJSON: ${JSON.stringify({
name: def.name,
arguments: safeJsonParse(call, {}),
})}\n`;
});
}
output += `${shotExample}-----------\n`;
});
return output;
}
/**
* Check if a function call is an MCP tool.
* We do this because some MCP tools dont return values and will cause infinite loops in calling for Untooled to call the same function over and over again.
* Any MCP tool is automatically marked with a cooldown to prevent infinite loops of the same function over and over again.
*
* This can lead to unexpected behavior if you want a model using Untooled to call a repeat action multiple times.
* eg: Create 3 Jira tickets about x, y, and z. -> will skip y and z if you don't disable the cooldown.
*
* You can disable this check by setting the `MCP_NO_COOLDOWN` flag to any value in the ENV.
*
* @param {{name: string, arguments: Object}} functionCall - The function call to check.
* @param {Object[]} functions - The list of functions definitions to check against.
* @return {boolean} - True if the function call is an MCP tool, false otherwise.
*/
isMCPTool(functionCall = {}, functions = []) {
if (process.env.MCP_NO_COOLDOWN) return false;
const foundFunc = functions.find(
(def) => def?.name?.toLowerCase() === functionCall.name?.toLowerCase()
);
if (!foundFunc) return false;
return foundFunc?.isMCPTool || false;
}
/**
* Validate a function call against a list of functions.
* @param {{name: string, arguments: Object}} functionCall - The function call to validate.
* @param {Object[]} functions - The list of functions definitions to validate against.
* @return {{valid: boolean, reason: string|null}} - The validation result.
*/
validFuncCall(functionCall = {}, functions = []) {
if (
!functionCall ||
!functionCall?.hasOwnProperty("name") ||
!functionCall?.hasOwnProperty("arguments")
) {
return {
valid: false,
reason: "Missing name or arguments in function call.",
};
}
const foundFunc = functions.find((def) => def.name === functionCall.name);
if (!foundFunc)
return { valid: false, reason: "Function name does not exist." };
const schemaProps = Object.keys(foundFunc?.parameters?.properties || {});
const requiredProps = foundFunc?.parameters?.required || [];
const providedProps = Object.keys(functionCall.arguments);
for (const requiredProp of requiredProps) {
if (!providedProps.includes(requiredProp)) {
return {
valid: false,
reason: `Missing required argument: ${requiredProp}`,
};
}
}
// Ensure all provided arguments are valid for the schema
// This is to prevent the model from hallucinating or providing invalid additional arguments.
for (const providedProp of providedProps) {
if (!schemaProps.includes(providedProp)) {
return {
valid: false,
reason: `Unknown argument: ${providedProp} provided but not in schema.`,
};
}
}
return { valid: true, reason: null };
}
buildToolCallMessages(history = [], functions = []) {
// Format history messages with attachments for multimodal support
const formattedHistory = history.map((msg) =>
this.formatMessageWithAttachments(msg)
);
return [
{
content: `You are a program which picks the most optimal function and parameters to call.
DO NOT HAVE TO PICK A FUNCTION IF IT WILL NOT HELP ANSWER OR FULFILL THE USER'S QUERY.
When a function is selection, respond in JSON with no additional text.
When there is no relevant function to call - return with a regular chat text response.
Your task is to pick a **single** function that we will use to call, if any seem useful or relevant for the user query.
All JSON responses should have two keys.
'name': this is the name of the function name to call. eg: 'web-scraper', 'rag-memory', etc..
'arguments': this is an object with the function properties to invoke the function.
DO NOT INCLUDE ANY OTHER KEYS IN JSON RESPONSES.
Here are the available tools you can use an examples of a query and response so you can understand how each one works.
${this.showcaseFunctions(functions)}
Now pick a function if there is an appropriate one to use given the last user message and the given conversation so far.`,
role: "system",
},
...formattedHistory,
];
}
async functionCall(messages, functions, chatCb = null) {
const history = [...messages].filter((msg) =>
["user", "assistant"].includes(msg.role)
);
if (history[history.length - 1].role !== "user") return null;
const historyMessages = this.buildToolCallMessages(history, functions);
const response = await chatCb({ messages: historyMessages });
const call = safeJsonParse(response, null);
if (call === null) return { toolCall: null, text: response }; // failed to parse, so must be text.
const { valid, reason } = this.validFuncCall(call, functions);
if (!valid) {
this.providerLog(`Invalid function tool call: ${reason}.`);
return { toolCall: null, text: null };
}
const { isDuplicate, reason: duplicateReason } =
this.deduplicator.isDuplicate(call.name, call.arguments);
if (isDuplicate) {
this.providerLog(
`Cannot call ${call.name} again because ${duplicateReason}.`
);
return { toolCall: null, text: null };
}
return { toolCall: call, text: null };
}
async streamingFunctionCall(
messages,
functions,
chatCb = null,
eventHandler = null
) {
const history = [...messages].filter((msg) =>
["user", "assistant"].includes(msg.role)
);
if (history[history.length - 1].role !== "user") return null;
const msgUUID = v4();
let textResponse = "";
const historyMessages = this.buildToolCallMessages(history, functions);
const stream = await chatCb({ messages: historyMessages });
eventHandler?.("reportStreamEvent", {
type: "statusResponse",
uuid: v4(),
content: "Agent is thinking...",
});
for await (const chunk of stream) {
if (!chunk?.choices?.[0]) continue; // Skip if no choices
const choice = chunk.choices[0];
if (choice.delta?.content) {
textResponse += choice.delta.content;
eventHandler?.("reportStreamEvent", {
type: "statusResponse",
uuid: msgUUID,
content: choice.delta.content,
});
}
}
const call = safeJsonParse(textResponse, null);
if (call === null)
return { toolCall: null, text: textResponse, uuid: msgUUID }; // failed to parse, so must be regular text response.
const { valid, reason } = this.validFuncCall(call, functions);
if (!valid) {
this.providerLog(`Invalid function tool call: ${reason}.`);
eventHandler?.("reportStreamEvent", {
type: "removeStatusResponse",
uuid: msgUUID,
content:
"The model attempted to make an invalid function call - it was ignored.",
});
return { toolCall: null, text: null, uuid: msgUUID };
}
const { isDuplicate, reason: duplicateReason } =
this.deduplicator.isDuplicate(call.name, call.arguments);
if (isDuplicate) {
this.providerLog(
`Cannot call ${call.name} again because ${duplicateReason}.`
);
eventHandler?.("reportStreamEvent", {
type: "removeStatusResponse",
uuid: msgUUID,
content:
"The model tried to call a function with the same arguments as a previous call - it was ignored.",
});
return { toolCall: null, text: null, uuid: msgUUID };
}
eventHandler?.("reportStreamEvent", {
uuid: `${msgUUID}:tool_call_invocation`,
type: "toolCallInvocation",
content: `Parsed Tool Call: ${call.name}(${JSON.stringify(call.arguments)})`,
});
return { toolCall: call, text: null, uuid: msgUUID };
}
/**
* Stream a chat completion from the LLM with tool calling
* Note: This using the OpenAI API format and may need to be adapted for other providers.
*
* @param {any[]} messages - The messages to send to the LLM.
* @param {any[]} functions - The functions to use in the LLM.
* @param {function} chatCallback - A callback function to handle the chat completion.
* @param {function} eventHandler - The event handler to use to report stream events.
* @returns {Promise<{ functionCall: any, textResponse: string }>} - The result of the chat completion.
*/
async stream(
messages,
functions = [],
chatCallback = null,
eventHandler = null
) {
this.providerLog("Untooled.stream - will process this chat completion.");
// eslint-disable-next-line
try {
let completion = { content: "" };
if (functions.length > 0) {
const {
toolCall,
text,
uuid: msgUUID,
} = await this.streamingFunctionCall(
messages,
functions,
chatCallback,
eventHandler
);
if (toolCall !== null) {
this.providerLog(`Valid tool call found - running ${toolCall.name}.`);
this.deduplicator.trackRun(toolCall.name, toolCall.arguments, {
cooldown: this.isMCPTool(toolCall, functions),
});
return {
result: null,
functionCall: {
name: toolCall.name,
arguments: toolCall.arguments,
},
cost: 0,
};
}
if (text) {
this.providerLog(
`No tool call found in the response - will send as a full text response.`
);
completion.content = text;
eventHandler?.("reportStreamEvent", {
type: "removeStatusResponse",
uuid: msgUUID,
content: "No tool call found in the response",
});
eventHandler?.("reportStreamEvent", {
type: "statusResponse",
uuid: v4(),
content: "Done thinking.",
});
eventHandler?.("reportStreamEvent", {
type: "fullTextResponse",
uuid: v4(),
content: text,
});
}
}
if (!completion?.content) {
eventHandler?.("reportStreamEvent", {
type: "statusResponse",
uuid: v4(),
content: "Done thinking.",
});
this.providerLog(
"Will assume chat completion without tool call inputs."
);
const msgUUID = v4();
completion = { content: "" };
const stream = await chatCallback({
messages: this.cleanMsgs(messages),
});
for await (const chunk of stream) {
if (!chunk?.choices?.[0]) continue; // Skip if no choices
const choice = chunk.choices[0];
if (choice.delta?.content) {
completion.content += choice.delta.content;
eventHandler?.("reportStreamEvent", {
type: "textResponseChunk",
uuid: msgUUID,
content: choice.delta.content,
});
}
}
}
// The UnTooled class inherited Deduplicator is mostly useful to prevent the agent
// from calling the exact same function over and over in a loop within a single chat exchange
// _but_ we should enable it to call previously used tools in a new chat interaction.
this.deduplicator.reset("runs");
return {
textResponse: completion.content,
cost: 0,
};
} catch (error) {
throw error;
}
}
/**
* Create a completion based on the received messages.
*
* @param messages A list of messages to send to the API.
* @param functions
* @param chatCallback - A callback function to handle the chat completion.
* @returns The completion.
*/
async complete(messages, functions = [], chatCallback = null) {
this.providerLog("Untooled.complete - will process this chat completion.");
// eslint-disable-next-line
try {
let completion = { content: "" };
if (functions.length > 0) {
const { toolCall, text } = await this.functionCall(
messages,
functions,
chatCallback
);
if (toolCall !== null) {
this.providerLog(`Valid tool call found - running ${toolCall.name}.`);
this.deduplicator.trackRun(toolCall.name, toolCall.arguments, {
cooldown: this.isMCPTool(toolCall, functions),
});
return {
result: null,
functionCall: {
name: toolCall.name,
arguments: toolCall.arguments,
},
cost: 0,
};
}
completion.content = text;
}
// If there are no functions, we want to run a normal chat completion.
if (!completion?.content) {
this.providerLog(
"Will assume chat completion without tool call inputs."
);
const response = await chatCallback({
messages: this.cleanMsgs(messages),
});
// If the response from the callback is the raw OpenAI Spec response object, we can use that directly.
// Otherwise, we will assume the response is just the string output we wanted (see: `#handleFunctionCallChat` which returns the content only)
// This handles both streaming and non-streaming completions.
completion =
typeof response === "string"
? { content: response }
: response.choices?.[0]?.message;
}
// The UnTooled class inherited Deduplicator is mostly useful to prevent the agent
// from calling the exact same function over and over in a loop within a single chat exchange
// _but_ we should enable it to call previously used tools in a new chat interaction.
this.deduplicator.reset("runs");
return {
textResponse: completion.content,
cost: 0,
};
} catch (error) {
throw error;
}
}
}
module.exports = UnTooled;