Files
chat/api/app/clients/tools/util/handleTools.js
zeekay 2f381f0398 a code run is watched while it runs, and stopping it keeps the sandbox
A tool call's `output` is set when the tool RETURNS, so running code — the
most expensive thing this product does — was also the most silent: a spinner,
then a wall of text. Cloud now appends a command's output to a session log as
the program produces it, and this is the half that asks for it.

The chain, and each link is the smallest thing that could work:

  - `POST /v1/sandboxes/run` instead of `/v1/sandboxes/:id/exec`. The same
    command through the same code path, and exactly one of them carries a
    session to narrate into — `/:id/exec` has no field for one. It is also the
    op the fleet publishes to agents as `run_in_sandbox`, so a person driving
    a sandbox and an agent driving one share one address.
  - `services/Tools/watch.js` opens the session and says where it is, on an
    `on_run_step_delta` addressed at the step the browser is already drawing
    — the channel the OAuth prompt already uses to attach state to one call
    mid-run. It is INJECTED into the tool the way web search's callback is,
    so `packages/api` still has no Express in it. Every failure answers an
    empty handle: watching is how a run is READ, never how it is run.
  - `/v1/chat/runs/{stream,stop}` relays both, resolving the credential
    through `currentBearer` like `/v1/chat/ask`. No guests — a session and a
    sandbox belong to the org that leased them, and the shared guest key
    carries no tenant.
  - `useRunLog` tails the feed and `ExecuteCode` renders it until the real
    output lands, so the finished result replaces the narration instead of
    appearing under a second copy of it.

Stop ends the WORK. The sandbox stays leased, so the checkout, the files and
the log are all still there afterwards — and the control says so, because a
button that might delete your work is one people hesitate over instead of
using.

Threading `run` through `useStepHandler` keeps it across deltas rather than
assigning from each one: `auth` is cleared by omission (that is how a prompt
comes down), but the ids are sent once, before the command starts, so reading
them the same way would drop them on the next frame and close the tail
mid-command.

Proven in a browser against a real backend and the real store
(e2e/specs/live-run.spec.ts): each line asserted BEFORE the next is sent, so
every one is a moment when the tool has not returned; Stop offered, clicked,
and shown acting on the sandbox.
2026-08-07 08:01:13 -07:00

522 lines
18 KiB
JavaScript

const { logger } = require('@hanzochat/data-schemas');
const { Calculator, createSearchTool } = require('@hanzochat/agents');
const {
checkAccess,
createSafeUser,
sandboxBaseUrl,
mcpToolPattern,
loadWebSearchAuth,
resolveActiveOrg,
tenantBearerOrThrow,
buildImageToolContext,
buildWebSearchContext,
resolveTenantBearer,
createCodeExecutionTool,
OPENID_BEARER_SENTINEL,
} = require('@hanzochat/api');
const { getMCPServersRegistry } = require('~/config');
const {
Tools,
Constants,
Permissions,
EToolResources,
PermissionTypes,
} = require('@hanzochat/data-provider');
const {
availableTools,
manifestToolMap,
// Basic Tools
GoogleSearchAPI,
// Structured Tools
DALLE3,
FluxAPI,
OpenWeather,
StructuredSD,
StructuredACS,
TraversaalSearch,
StructuredWolfram,
TavilySearchResults,
createGeminiImageTool,
createOpenAIImageTools,
} = require('../');
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const { createMCPTool, createMCPTools } = require('~/server/services/MCP');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { createWatch } = require('~/server/services/Tools/watch');
const { getMCPServerTools } = require('~/server/services/Config');
const { getRoleByName } = require('~/models/Role');
/**
* Validates the availability and authentication of tools for a user based on environment variables or user-specific plugin authentication values.
* Tools without required authentication or with valid authentication are considered valid.
*
* @param {Object} user The user object for whom to validate tool access.
* @param {Array<string>} tools An array of tool identifiers to validate. Defaults to an empty array.
* @returns {Promise<Array<string>>} A promise that resolves to an array of valid tool identifiers.
*/
const validateTools = async (user, tools = []) => {
try {
const validToolsSet = new Set(tools);
const availableToolsToValidate = availableTools.filter((tool) =>
validToolsSet.has(tool.pluginKey),
);
/**
* Validates the credentials for a given auth field or set of alternate auth fields for a tool.
* If valid admin or user authentication is found, the function returns early. Otherwise, it removes the tool from the set of valid tools.
*
* @param {string} authField The authentication field or fields (separated by "||" for alternates) to validate.
* @param {string} toolName The identifier of the tool being validated.
*/
const validateCredentials = async (authField, toolName) => {
const fields = authField.split('||');
for (const field of fields) {
const adminAuth = process.env[field];
if (adminAuth && adminAuth.length > 0) {
return;
}
let userAuth = null;
try {
userAuth = await getUserPluginAuthValue(user, field);
} catch (err) {
if (field === fields[fields.length - 1] && !userAuth) {
throw err;
}
}
if (userAuth && userAuth.length > 0) {
return;
}
}
validToolsSet.delete(toolName);
};
for (const tool of availableToolsToValidate) {
if (!tool.authConfig || tool.authConfig.length === 0) {
continue;
}
for (const auth of tool.authConfig) {
await validateCredentials(auth.authField, tool.pluginKey);
}
}
return Array.from(validToolsSet.values());
} catch (err) {
logger.error('[validateTools] There was a problem validating tools', err);
throw new Error(err);
}
};
/** @typedef {typeof import('@langchain/core/tools').Tool} ToolConstructor */
/** @typedef {import('@langchain/core/tools').Tool} Tool */
/**
* Initializes a tool with authentication values for the given user, supporting alternate authentication fields.
* Authentication fields can have alternates separated by "||", and the first defined variable will be used.
*
* @param {string} userId The user ID for which the tool is being loaded.
* @param {Array<string>} authFields Array of strings representing the authentication fields. Supports alternate fields delimited by "||".
* @param {ToolConstructor} ToolConstructor The constructor function for the tool to be initialized.
* @param {Object} options Optional parameters to be passed to the tool constructor alongside authentication values.
* @returns {() => Promise<Tool>} An Async function that, when called, asynchronously initializes and returns an instance of the tool with authentication.
*/
const loadToolWithAuth = (userId, authFields, ToolConstructor, options = {}) => {
return async function () {
const authValues = await loadAuthValues({ userId, authFields });
return new ToolConstructor({ ...options, ...authValues, userId });
};
};
/**
* @param {string} toolKey
* @returns {Array<string>}
*/
const getAuthFields = (toolKey) => {
return manifestToolMap[toolKey]?.authConfig.map((auth) => auth.authField) ?? [];
};
/**
*
* @param {object} params
* @param {string} params.user
* @param {Record<string, Record<string, string>>} [object.userMCPAuthMap]
* @param {AbortSignal} [object.signal]
* @param {Pick<Agent, 'id' | 'provider' | 'model'>} [params.agent]
* @param {string} [params.model]
* @param {EModelEndpoint} [params.endpoint]
* @param {LoadToolOptions} [params.options]
* @param {boolean} [params.useSpecs]
* @param {Array<string>} params.tools
* @param {boolean} [params.functions]
* @param {boolean} [params.returnMap]
* @param {AppConfig['webSearch']} [params.webSearch]
* @param {AppConfig['fileStrategy']} [params.fileStrategy]
* @param {AppConfig['imageOutputType']} [params.imageOutputType]
* @returns {Promise<{ loadedTools: Tool[], toolContextMap: Object<string, any> } | Record<string,Tool>>}
*/
const loadTools = async ({
user,
agent,
model,
signal,
endpoint,
userMCPAuthMap,
tools = [],
options = {},
functions = true,
returnMap = false,
webSearch,
fileStrategy,
imageOutputType,
}) => {
const toolConstructors = {
flux: FluxAPI,
calculator: Calculator,
google: GoogleSearchAPI,
open_weather: OpenWeather,
wolfram: StructuredWolfram,
'stable-diffusion': StructuredSD,
'azure-ai-search': StructuredACS,
traversaal_search: TraversaalSearch,
tavily_search_results_json: TavilySearchResults,
};
const customConstructors = {
image_gen_oai: async (toolContextMap) => {
const authFields = getAuthFields('image_gen_oai');
const authValues = await loadAuthValues({ userId: user, authFields });
const imageFiles = options.tool_resources?.[EToolResources.image_edit]?.files ?? [];
const toolContext = buildImageToolContext({
imageFiles,
toolName: `${EToolResources.image_edit}_oai`,
contextDescription: 'image editing',
});
if (toolContext) {
toolContextMap.image_edit_oai = toolContext;
}
return createOpenAIImageTools({
...authValues,
isAgent: !!agent,
req: options.req,
imageOutputType,
fileStrategy,
imageFiles,
});
},
gemini_image_gen: async (toolContextMap) => {
const authFields = getAuthFields('gemini_image_gen');
const authValues = await loadAuthValues({ userId: user, authFields });
const imageFiles = options.tool_resources?.[EToolResources.image_edit]?.files ?? [];
const toolContext = buildImageToolContext({
imageFiles,
toolName: 'gemini_image_gen',
contextDescription: 'image context',
});
if (toolContext) {
toolContextMap.gemini_image_gen = toolContext;
}
return createGeminiImageTool({
...authValues,
isAgent: !!agent,
req: options.req,
imageFiles,
processFileURL: options.processFileURL,
userId: user,
fileStrategy,
});
},
};
const requestedTools = {};
if (functions === true) {
// dalle (image generation) drives the Hanzo image family via a PER-USER hk-
// key so generation is metered to the signed-in user — mirroring the chat
// per-user key path in custom/initialize.ts. A guest keeps the shared env
// key; an authenticated user whose key cannot be resolved FAILS CLOSED
// (throws) rather than silently billing the shared org. (Custom constructors
// take precedence over the generic toolConstructors path in the loop below.)
customConstructors.dalle = async () => {
const authFields = getAuthFields('dalle');
const authValues = await loadAuthValues({ userId: user, authFields });
// Canonical Hanzo Cloud billing (mirrors custom/initialize.ts): when the
// image endpoint is configured to forward the user's IAM bearer
// (DALLE3_API_KEY === the OIDC-token sentinel), resolve the signed-in
// user's own IAM token and forward it so cloud meters THEIR org. Fail
// closed if there is no forwardable bearer — no shared key to spend.
if (authValues.DALLE3_API_KEY === OPENID_BEARER_SENTINEL) {
const bearer = resolveTenantBearer(options.req);
if (!bearer) {
throw new Error('Sign in with Hanzo to generate images — your Hanzo account funds this request.');
}
authValues.DALLE3_API_KEY = bearer;
}
return new DALLE3({ ...imageGenOptions, ...authValues, userId: user });
};
}
/** @type {ImageGenOptions} */
const imageGenOptions = {
isAgent: !!agent,
req: options.req,
fileStrategy,
processFileURL: options.processFileURL,
returnMetadata: options.returnMetadata,
uploadImageBuffer: options.uploadImageBuffer,
};
const toolOptions = {
flux: imageGenOptions,
dalle: imageGenOptions,
'stable-diffusion': imageGenOptions,
gemini_image_gen: imageGenOptions,
};
/** @type {Record<string, string>} */
const toolContextMap = {};
const requestedMCPTools = {};
for (const tool of tools) {
if (tool === Tools.execute_code) {
requestedTools[tool] = async () => {
/* The credential is the caller's, resolved once here and handed to both
* the file priming and the tool. `tenantBearerOrThrow` refuses rather
* than falling back: a run with no tenant is a run nobody can be billed
* for, which is precisely the state the old shared service key left every
* execution in. */
const token = tenantBearerOrThrow(options.req);
const { files, toolContext } = await primeCodeFiles({
...options,
agentId: agent?.id,
});
if (toolContext) {
toolContextMap[tool] = toolContext;
}
/* Running code is the product's most expensive act and, until now, its
* most silent one: the command's output reached the browser only when
* the command was over. `watch` opens a session for the run, tells the
* browser where to tail it, and hands the tool the session the sandbox
* narrates into. `res` and the resumable stream id are read the same way
* the MCP tools read them, a few lines down. */
return createCodeExecutionTool({
baseUrl: sandboxBaseUrl(),
token,
files,
watch: createWatch({
res: options.res,
streamId: options.req?._resumableStreamId || null,
baseUrl: sandboxBaseUrl(),
token,
org: resolveActiveOrg(options.req),
}),
});
};
continue;
} else if (tool === Tools.file_search) {
requestedTools[tool] = async () => {
const { files, toolContext } = await primeSearchFiles({
...options,
agentId: agent?.id,
});
if (toolContext) {
toolContextMap[tool] = toolContext;
}
/** @type {boolean | undefined} Check if user has FILE_CITATIONS permission */
let fileCitations;
if (fileCitations == null && options.req?.user != null) {
try {
fileCitations = await checkAccess({
user: options.req.user,
permissionType: PermissionTypes.FILE_CITATIONS,
permissions: [Permissions.USE],
getRoleByName,
});
} catch (error) {
logger.error('[handleTools] FILE_CITATIONS permission check failed:', error);
fileCitations = false;
}
}
return createFileSearchTool({
req: options.req,
userId: user,
files,
entity_id: agent?.id,
fileCitations,
});
};
continue;
} else if (tool === Tools.web_search) {
const result = await loadWebSearchAuth({
userId: user,
loadAuthValues,
webSearchConfig: webSearch,
});
const { onSearchResults, onGetHighlights } = options?.[Tools.web_search] ?? {};
requestedTools[tool] = async () => {
toolContextMap[tool] = buildWebSearchContext();
return createSearchTool({
...result.authResult,
onSearchResults,
onGetHighlights,
logger,
});
};
continue;
} else if (tool && mcpToolPattern.test(tool)) {
const [toolName, serverName] = tool.split(Constants.mcp_delimiter);
if (toolName === Constants.mcp_server) {
/** Placeholder used for UI purposes */
continue;
}
const serverConfig = serverName
? await getMCPServersRegistry().getServerConfig(serverName, user)
: null;
if (!serverConfig) {
logger.warn(
`MCP server "${serverName}" for "${toolName}" tool is not configured${agent?.id != null && agent.id ? ` but attached to "${agent.id}"` : ''}`,
);
continue;
}
if (toolName === Constants.mcp_all) {
requestedMCPTools[serverName] = [
{
type: 'all',
serverName,
config: serverConfig,
},
];
continue;
}
requestedMCPTools[serverName] = requestedMCPTools[serverName] || [];
requestedMCPTools[serverName].push({
type: 'single',
toolKey: tool,
serverName,
config: serverConfig,
});
continue;
}
if (customConstructors[tool]) {
requestedTools[tool] = async () => customConstructors[tool](toolContextMap);
continue;
}
if (toolConstructors[tool]) {
const options = toolOptions[tool] || {};
const toolInstance = loadToolWithAuth(
user,
getAuthFields(tool),
toolConstructors[tool],
options,
);
requestedTools[tool] = toolInstance;
continue;
}
}
if (returnMap) {
return requestedTools;
}
const toolPromises = [];
for (const tool of tools) {
const validTool = requestedTools[tool];
if (validTool) {
toolPromises.push(
validTool().catch((error) => {
logger.error(`Error loading tool ${tool}:`, error);
return null;
}),
);
}
}
const loadedTools = (await Promise.all(toolPromises)).flatMap((plugin) => plugin || []);
const mcpToolPromises = [];
/** MCP server tools are initialized sequentially by server */
let index = -1;
const failedMCPServers = new Set();
const safeUser = createSafeUser(options.req?.user);
for (const [serverName, toolConfigs] of Object.entries(requestedMCPTools)) {
index++;
/** @type {LCAvailableTools} */
let availableTools;
for (const config of toolConfigs) {
try {
if (failedMCPServers.has(serverName)) {
continue;
}
const mcpParams = {
index,
signal,
user: safeUser,
userMCPAuthMap,
res: options.res,
streamId: options.req?._resumableStreamId || null,
model: agent?.model ?? model,
serverName: config.serverName,
provider: agent?.provider ?? endpoint,
config: config.config,
};
if (config.type === 'all' && toolConfigs.length === 1) {
/** Handle async loading for single 'all' tool config */
mcpToolPromises.push(
createMCPTools(mcpParams).catch((error) => {
logger.error(`Error loading ${serverName} tools:`, error);
return null;
}),
);
continue;
}
if (!availableTools) {
try {
availableTools = await getMCPServerTools(safeUser.id, serverName);
} catch (error) {
logger.error(`Error fetching available tools for MCP server ${serverName}:`, error);
}
}
/** Handle synchronous loading */
const mcpTool =
config.type === 'all'
? await createMCPTools(mcpParams)
: await createMCPTool({
...mcpParams,
availableTools,
toolKey: config.toolKey,
});
if (Array.isArray(mcpTool)) {
loadedTools.push(...mcpTool);
} else if (mcpTool) {
loadedTools.push(mcpTool);
} else {
failedMCPServers.add(serverName);
logger.warn(
`MCP tool creation failed for "${config.toolKey}", server may be unavailable or unauthenticated.`,
);
}
} catch (error) {
logger.error(`Error loading MCP tool for server ${serverName}:`, error);
}
}
}
loadedTools.push(...(await Promise.all(mcpToolPromises)).flatMap((plugin) => plugin || []));
return { loadedTools, toolContextMap };
};
module.exports = {
loadToolWithAuth,
validateTools,
loadTools,
};