feat: OAuth for Actions (#5693)

* feat: OAuth for Actions

* WIP: PoC flow state manager

* refactor: Add identifier field to token model from action schema

* chore: fix potential file type issues

* ci: fix type issue with action metadata auth

* fix: ensure FlowManagerOptions has a default ttl value

* WIP: OAUTH actions

* WIP: first pass OAuth Action

* fix: standardize identifier usage in OAuth flow handling

* fix: update token retrieval to include userId in query and use correct identifier

* refacotr: update token retrieval to use userId for OAuth token query

* feat: Tool Call Auth styling

* fix: streamline token creation and add type field to token schema

* refactor: cleanup OAuth flow by encrypting client credentials and ensuring oauth operations only run under condition

* refactor: use encrypted credentials in OAuth callback

* fix: update Token collection indexes to use expiresAt TTL index and not createdAt legacy index

* refactor: enhance Token index cleanup by improving logging and removing redundant index creation logic

* refactor: remove unused OAuth login route and related logic for improved clarity

* refactor: replace fetch with axios for OAuth token exchange and improve error handling

* refactor: better UX after authentication before oauth tool execution

* refactor: implement cleanup handlers for FlowStateManager intervals to enhance resource management

* refactor: encrypt OAuth tokens before storing and decrypt upon retrieval for enhanced security

* refactor: enhance authentication success page with improved styling and countdown feature

* refactor: add response_type parameter to OAuth redirect URI for improved compatibility

* chore: update translation.json new localizations

* chore: remove unused OGDialog import from OGDialogTemplate component

* refactor: Actions Auth using new Dialog styling, use same component with Agents/Assistants

* refactor: update removeNullishValues function to support removal of empty strings and adjust transform usage in schemas

* chore: bump version of librechat-data-provider to 0.7.6991

* refactor: integrate removeNullishValues function to clean metadata before encryption in agent and assistant routes

* refactor: update OAuth input fields to use 'password' type for better security

* refactor: update localization placeholders for sign-in message to use double curly braces

* refactor: add access_type parameter for offline access in createActionTool function

* refactor: implement handleOAuthToken function for token management and encryption

* feat: refresh token support

* refactor: add default expiration for access token and error handling for missing token

* feat: localizations for ActionAuth

* refactor: set refresh token expiration to null to not expire if expiry never given

* fix: prevent crash fromerror within async handleAbortError in AskController, EditController, and AgentController

* feat: Action Callback URL

* 🌍 i18n: Update translation.json with latest translations

* refactor: handle errors in flow state checking to prevent unhandled promise rejections

* fix: improve flow state concurrency to prevent multiple token creation calls

* refactor: RequestExecutor to use separate axios instance

* refactor: improve concurrency flows by keeping completed state until TTL expiry

* refactor: increase TTL for flow state management and adjust monitoring interval

* ci: mock axios instance creation in actions spec

* feat: add Babel and Jest configuration files; implement FlowStateManager tests with concurrency handling

* chore: add disableOAuth prop to ActionsAuth (not implemented for Assistants yet)

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Ruben Talstra 2025-02-10 21:56:08 +01:00 committed by GitHub
parent 71c30a3640
commit d99a9db3f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 2146 additions and 1223 deletions

View file

@ -155,6 +155,8 @@ const AskController = async (req, res, next, initializeClient, addTitle) => {
sender,
messageId: responseMessageId,
parentMessageId: userMessageId ?? parentMessageId,
}).catch((err) => {
logger.error('[AskController] Error in `handleAbortError`', err);
});
}
};

View file

@ -140,6 +140,8 @@ const EditController = async (req, res, next, initializeClient) => {
sender,
messageId: responseMessageId,
parentMessageId: userMessageId ?? parentMessageId,
}).catch((err) => {
logger.error('[EditController] Error in `handleAbortError`', err);
});
}
};

View file

@ -143,6 +143,8 @@ const AgentController = async (req, res, next, initializeClient, addTitle) => {
sender,
messageId: responseMessageId,
parentMessageId: userMessageId ?? parentMessageId,
}).catch((err) => {
logger.error('[api/server/controllers/agents/request] Error in `handleAbortError`', err);
});
}
};

View file

@ -84,6 +84,7 @@ const startServer = async () => {
app.use('/oauth', routes.oauth);
/* API Endpoints */
app.use('/api/auth', routes.auth);
app.use('/api/actions', routes.actions);
app.use('/api/keys', routes.keys);
app.use('/api/user', routes.user);
app.use('/api/search', routes.search);

View file

@ -0,0 +1,136 @@
const express = require('express');
const jwt = require('jsonwebtoken');
const { getAccessToken } = require('~/server/services/TokenService');
const { logger, getFlowStateManager } = require('~/config');
const { getLogStores } = require('~/cache');
const router = express.Router();
const JWT_SECRET = process.env.JWT_SECRET;
/**
* Handles the OAuth callback and exchanges the authorization code for tokens.
*
* @route GET /actions/:action_id/oauth/callback
* @param {string} req.params.action_id - The ID of the action.
* @param {string} req.query.code - The authorization code returned by the provider.
* @param {string} req.query.state - The state token to verify the authenticity of the request.
* @returns {void} Sends a success message after updating the action with OAuth tokens.
*/
router.get('/:action_id/oauth/callback', async (req, res) => {
const { action_id } = req.params;
const { code, state } = req.query;
const flowManager = await getFlowStateManager(getLogStores);
let identifier = action_id;
try {
let decodedState;
try {
decodedState = jwt.verify(state, JWT_SECRET);
} catch (err) {
await flowManager.failFlow(identifier, 'oauth', 'Invalid or expired state parameter');
return res.status(400).send('Invalid or expired state parameter');
}
if (decodedState.action_id !== action_id) {
await flowManager.failFlow(identifier, 'oauth', 'Mismatched action ID in state parameter');
return res.status(400).send('Mismatched action ID in state parameter');
}
if (!decodedState.user) {
await flowManager.failFlow(identifier, 'oauth', 'Invalid user ID in state parameter');
return res.status(400).send('Invalid user ID in state parameter');
}
identifier = `${decodedState.user}:${action_id}`;
const flowState = await flowManager.getFlowState(identifier, 'oauth');
if (!flowState) {
throw new Error('OAuth flow not found');
}
const tokenData = await getAccessToken({
code,
userId: decodedState.user,
identifier,
client_url: flowState.metadata.client_url,
redirect_uri: flowState.metadata.redirect_uri,
/** Encrypted values */
encrypted_oauth_client_id: flowState.metadata.encrypted_oauth_client_id,
encrypted_oauth_client_secret: flowState.metadata.encrypted_oauth_client_secret,
});
await flowManager.completeFlow(identifier, 'oauth', tokenData);
res.send(`
<!DOCTYPE html>
<html>
<head>
<title>Authentication Successful</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<style>
body {
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont;
background-color: rgb(249, 250, 251);
margin: 0;
padding: 2rem;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
}
.card {
background-color: white;
border-radius: 0.5rem;
padding: 2rem;
max-width: 28rem;
width: 100%;
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
text-align: center;
}
.heading {
color: rgb(17, 24, 39);
font-size: 1.875rem;
font-weight: 700;
margin: 0 0 1rem;
}
.description {
color: rgb(75, 85, 99);
font-size: 0.875rem;
margin: 0.5rem 0;
}
.countdown {
color: rgb(99, 102, 241);
font-weight: 500;
}
</style>
</head>
<body>
<div class="card">
<h1 class="heading">Authentication Successful</h1>
<p class="description">
Your authentication was successful. This window will close in
<span class="countdown" id="countdown">3</span> seconds.
</p>
</div>
<script>
let secondsLeft = 3;
const countdownElement = document.getElementById('countdown');
const countdown = setInterval(() => {
secondsLeft--;
countdownElement.textContent = secondsLeft;
if (secondsLeft <= 0) {
clearInterval(countdown);
window.close();
}
}, 1000);
</script>
</body>
</html>
`);
} catch (error) {
logger.error('Error in OAuth callback:', error);
await flowManager.failFlow(identifier, 'oauth', error);
res.status(500).send('Authentication failed. Please try again.');
}
});
module.exports = router;

View file

@ -1,6 +1,6 @@
const express = require('express');
const { nanoid } = require('nanoid');
const { actionDelimiter, SystemRoles } = require('librechat-data-provider');
const { actionDelimiter, SystemRoles, removeNullishValues } = require('librechat-data-provider');
const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
const { updateAction, getActions, deleteAction } = require('~/models/Action');
const { isActionDomainAllowed } = require('~/server/services/domains');
@ -51,7 +51,7 @@ router.post('/:agent_id', async (req, res) => {
return res.status(400).json({ message: 'No functions provided' });
}
let metadata = await encryptMetadata(_metadata);
let metadata = await encryptMetadata(removeNullishValues(_metadata, true));
const isDomainAllowed = await isActionDomainAllowed(metadata.domain);
if (!isDomainAllowed) {
return res.status(400).json({ message: 'Domain not allowed' });
@ -117,10 +117,7 @@ router.post('/:agent_id', async (req, res) => {
}
/** @type {[Action]} */
const updatedAction = await updateAction(
{ action_id },
actionUpdateData,
);
const updatedAction = await updateAction({ action_id }, actionUpdateData);
const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'];
for (let field of sensitiveFields) {

View file

@ -1,6 +1,6 @@
const express = require('express');
const { nanoid } = require('nanoid');
const { actionDelimiter, EModelEndpoint } = require('librechat-data-provider');
const { actionDelimiter, EModelEndpoint, removeNullishValues } = require('librechat-data-provider');
const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
const { updateAction, getActions, deleteAction } = require('~/models/Action');
@ -29,7 +29,7 @@ router.post('/:assistant_id', async (req, res) => {
return res.status(400).json({ message: 'No functions provided' });
}
let metadata = await encryptMetadata(_metadata);
let metadata = await encryptMetadata(removeNullishValues(_metadata, true));
const isDomainAllowed = await isActionDomainAllowed(metadata.domain);
if (!isDomainAllowed) {
return res.status(400).json({ message: 'Domain not allowed' });

View file

@ -9,6 +9,7 @@ const prompts = require('./prompts');
const balance = require('./balance');
const plugins = require('./plugins');
const bedrock = require('./bedrock');
const actions = require('./actions');
const search = require('./search');
const models = require('./models');
const convos = require('./convos');
@ -45,6 +46,7 @@ module.exports = {
config,
models,
plugins,
actions,
presets,
balance,
messages,

View file

@ -1,20 +1,28 @@
const jwt = require('jsonwebtoken');
const { nanoid } = require('nanoid');
const { tool } = require('@langchain/core/tools');
const { GraphEvents, sleep } = require('@librechat/agents');
const {
Time,
CacheKeys,
StepTypes,
Constants,
AuthTypeEnum,
actionDelimiter,
isImageVisionTool,
actionDomainSeparator,
} = require('librechat-data-provider');
const { tool } = require('@langchain/core/tools');
const { refreshAccessToken } = require('~/server/services/TokenService');
const { isActionDomainAllowed } = require('~/server/services/domains');
const { logger, getFlowStateManager, sendEvent } = require('~/config');
const { encryptV2, decryptV2 } = require('~/server/utils/crypto');
const { getActions, deleteActions } = require('~/models/Action');
const { deleteAssistant } = require('~/models/Assistant');
const { findToken } = require('~/models/Token');
const { logAxiosError } = require('~/utils');
const { getLogStores } = require('~/cache');
const { logger } = require('~/config');
const JWT_SECRET = process.env.JWT_SECRET;
const toolNameRegex = /^[a-zA-Z0-9_-]+$/;
const replaceSeparatorRegex = new RegExp(actionDomainSeparator, 'g');
@ -115,6 +123,8 @@ async function loadActionSets(searchParams) {
* Creates a general tool for an entire action set.
*
* @param {Object} params - The parameters for loading action sets.
* @param {ServerRequest} params.req
* @param {ServerResponse} params.res
* @param {Action} params.action - The action set. Necessary for decrypting authentication values.
* @param {ActionRequest} params.requestBuilder - The ActionRequest builder class to execute the API call.
* @param {string | undefined} [params.name] - The name of the tool.
@ -122,33 +132,185 @@ async function loadActionSets(searchParams) {
* @param {import('zod').ZodTypeAny | undefined} [params.zodSchema] - The Zod schema for tool input validation/definition
* @returns { Promise<typeof tool | { _call: (toolInput: Object | string) => unknown}> } An object with `_call` method to execute the tool input.
*/
async function createActionTool({ action, requestBuilder, zodSchema, name, description }) {
action.metadata = await decryptMetadata(action.metadata);
async function createActionTool({
req,
res,
action,
requestBuilder,
zodSchema,
name,
description,
}) {
const isDomainAllowed = await isActionDomainAllowed(action.metadata.domain);
if (!isDomainAllowed) {
return null;
}
/** @type {(toolInput: Object | string) => Promise<unknown>} */
const _call = async (toolInput) => {
try {
const executor = requestBuilder.createExecutor();
const encrypted = {
oauth_client_id: action.metadata.oauth_client_id,
oauth_client_secret: action.metadata.oauth_client_secret,
};
action.metadata = await decryptMetadata(action.metadata);
// Chain the operations
/** @type {(toolInput: Object | string, config: GraphRunnableConfig) => Promise<unknown>} */
const _call = async (toolInput, config) => {
try {
/** @type {import('librechat-data-provider').ActionMetadataRuntime} */
const metadata = action.metadata;
const executor = requestBuilder.createExecutor();
const preparedExecutor = executor.setParams(toolInput);
if (action.metadata.auth && action.metadata.auth.type !== AuthTypeEnum.None) {
await preparedExecutor.setAuth(action.metadata);
if (metadata.auth && metadata.auth.type !== AuthTypeEnum.None) {
try {
const action_id = action.action_id;
const identifier = `${req.user.id}:${action.action_id}`;
if (metadata.auth.type === AuthTypeEnum.OAuth && metadata.auth.authorization_url) {
const requestLogin = async () => {
const { args: _args, stepId, ...toolCall } = config.toolCall ?? {};
if (!stepId) {
throw new Error('Tool call is missing stepId');
}
const statePayload = {
nonce: nanoid(),
user: req.user.id,
action_id,
};
const stateToken = jwt.sign(statePayload, JWT_SECRET, { expiresIn: '10m' });
try {
const redirectUri = `${process.env.DOMAIN_CLIENT}/api/actions/${action_id}/oauth/callback`;
const params = new URLSearchParams({
client_id: metadata.oauth_client_id,
scope: metadata.auth.scope,
redirect_uri: redirectUri,
access_type: 'offline',
response_type: 'code',
state: stateToken,
});
const authURL = `${metadata.auth.authorization_url}?${params.toString()}`;
/** @type {{ id: string; delta: AgentToolCallDelta }} */
const data = {
id: stepId,
delta: {
type: StepTypes.TOOL_CALLS,
tool_calls: [{ ...toolCall, args: '' }],
auth: authURL,
expires_at: Date.now() + Time.TWO_MINUTES,
},
};
const flowManager = await getFlowStateManager(getLogStores);
await flowManager.createFlowWithHandler(
`${identifier}:login`,
'oauth_login',
async () => {
sendEvent(res, { event: GraphEvents.ON_RUN_STEP_DELTA, data });
logger.debug('Sent OAuth login request to client', { action_id, identifier });
return true;
},
);
logger.debug('Waiting for OAuth Authorization response', { action_id, identifier });
const result = await flowManager.createFlow(identifier, 'oauth', {
state: stateToken,
userId: req.user.id,
client_url: metadata.auth.client_url,
redirect_uri: `${process.env.DOMAIN_CLIENT}/api/actions/${action_id}/oauth/callback`,
/** Encrypted values */
encrypted_oauth_client_id: encrypted.oauth_client_id,
encrypted_oauth_client_secret: encrypted.oauth_client_secret,
});
logger.debug('Received OAuth Authorization response', { action_id, identifier });
data.delta.auth = undefined;
data.delta.expires_at = undefined;
sendEvent(res, { event: GraphEvents.ON_RUN_STEP_DELTA, data });
await sleep(3000);
metadata.oauth_access_token = result.access_token;
metadata.oauth_refresh_token = result.refresh_token;
const expiresAt = new Date(Date.now() + result.expires_in * 1000);
metadata.oauth_token_expires_at = expiresAt.toISOString();
} catch (error) {
const errorMessage = 'Failed to authenticate OAuth tool';
logger.error(errorMessage, error);
throw new Error(errorMessage);
}
};
const tokenPromises = [];
tokenPromises.push(findToken({ userId: req.user.id, type: 'oauth', identifier }));
tokenPromises.push(
findToken({
userId: req.user.id,
type: 'oauth_refresh',
identifier: `${identifier}:refresh`,
}),
);
const [tokenData, refreshTokenData] = await Promise.all(tokenPromises);
if (tokenData) {
// Valid token exists, add it to metadata for setAuth
metadata.oauth_access_token = await decryptV2(tokenData.token);
if (refreshTokenData) {
metadata.oauth_refresh_token = await decryptV2(refreshTokenData.token);
}
metadata.oauth_token_expires_at = tokenData.expiresAt.toISOString();
} else if (!refreshTokenData) {
// No tokens exist, need to authenticate
await requestLogin();
} else if (refreshTokenData) {
// Refresh token is still valid, use it to get new access token
try {
const refresh_token = await decryptV2(refreshTokenData.token);
const refreshTokens = async () =>
await refreshAccessToken({
identifier,
refresh_token,
userId: req.user.id,
client_url: metadata.auth.client_url,
encrypted_oauth_client_id: encrypted.oauth_client_id,
encrypted_oauth_client_secret: encrypted.oauth_client_secret,
});
const flowManager = await getFlowStateManager(getLogStores);
const refreshData = await flowManager.createFlowWithHandler(
`${identifier}:refresh`,
'oauth_refresh',
refreshTokens,
);
metadata.oauth_access_token = refreshData.access_token;
if (refreshData.refresh_token) {
metadata.oauth_refresh_token = refreshData.refresh_token;
}
const expiresAt = new Date(Date.now() + refreshData.expires_in * 1000);
metadata.oauth_token_expires_at = expiresAt.toISOString();
} catch (error) {
logger.error('Failed to refresh token, requesting new login:', error);
await requestLogin();
}
} else {
await requestLogin();
}
}
await preparedExecutor.setAuth(metadata);
} catch (error) {
if (
error.message.includes('No access token found') ||
error.message.includes('Access token is expired')
) {
throw error;
}
throw new Error(`Authentication failed: ${error.message}`);
}
}
const res = await preparedExecutor.execute();
const response = await preparedExecutor.execute();
if (typeof res.data === 'object') {
return JSON.stringify(res.data);
if (typeof response.data === 'object') {
return JSON.stringify(response.data);
}
return res.data;
return response.data;
} catch (error) {
const logMessage = `API call to ${action.metadata.domain} failed`;
logAxiosError({ message: logMessage, error });
throw error;
}
};

View file

@ -82,6 +82,7 @@ const initializeAgentOptions = async ({
}) => {
const { tools, toolContextMap } = await loadAgentTools({
req,
res,
agent,
tool_resources,
});

View file

@ -0,0 +1,170 @@
const axios = require('axios');
const { handleOAuthToken } = require('~/models/Token');
const { decryptV2 } = require('~/server/utils/crypto');
const { logAxiosError } = require('~/utils');
const { logger } = require('~/config');
/**
* Processes the access tokens and stores them in the database.
* @param {object} tokenData
* @param {string} tokenData.access_token
* @param {number} tokenData.expires_in
* @param {string} [tokenData.refresh_token]
* @param {number} [tokenData.refresh_token_expires_in]
* @param {object} metadata
* @param {string} metadata.userId
* @param {string} metadata.identifier
* @returns {Promise<void>}
*/
async function processAccessTokens(tokenData, { userId, identifier }) {
const { access_token, expires_in = 3600, refresh_token, refresh_token_expires_in } = tokenData;
if (!access_token) {
logger.error('Access token not found: ', tokenData);
throw new Error('Access token not found');
}
await handleOAuthToken({
identifier,
token: access_token,
expiresIn: expires_in,
userId,
});
if (refresh_token != null) {
logger.debug('Processing refresh token');
await handleOAuthToken({
token: refresh_token,
type: 'oauth_refresh',
userId,
identifier: `${identifier}:refresh`,
expiresIn: refresh_token_expires_in ?? null,
});
}
logger.debug('Access tokens processed');
}
/**
* Refreshes the access token using the refresh token.
* @param {object} fields
* @param {string} fields.userId - The ID of the user.
* @param {string} fields.client_url - The URL of the OAuth provider.
* @param {string} fields.identifier - The identifier for the token.
* @param {string} fields.refresh_token - The refresh token to use.
* @param {string} fields.encrypted_oauth_client_id - The client ID for the OAuth provider.
* @param {string} fields.encrypted_oauth_client_secret - The client secret for the OAuth provider.
* @returns {Promise<{
* access_token: string,
* expires_in: number,
* refresh_token?: string,
* refresh_token_expires_in?: number,
* }>}
*/
const refreshAccessToken = async ({
userId,
client_url,
identifier,
refresh_token,
encrypted_oauth_client_id,
encrypted_oauth_client_secret,
}) => {
try {
const oauth_client_id = await decryptV2(encrypted_oauth_client_id);
const oauth_client_secret = await decryptV2(encrypted_oauth_client_secret);
const params = new URLSearchParams({
client_id: oauth_client_id,
client_secret: oauth_client_secret,
grant_type: 'refresh_token',
refresh_token,
});
const response = await axios({
method: 'POST',
url: client_url,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
data: params.toString(),
});
await processAccessTokens(response.data, {
userId,
identifier,
});
logger.debug(`Access token refreshed successfully for ${identifier}`);
return response.data;
} catch (error) {
const message = 'Error refreshing OAuth tokens';
logAxiosError({
message,
error,
});
throw new Error(message);
}
};
/**
* Handles the OAuth callback and exchanges the authorization code for tokens.
* @param {object} fields
* @param {string} fields.code - The authorization code returned by the provider.
* @param {string} fields.userId - The ID of the user.
* @param {string} fields.identifier - The identifier for the token.
* @param {string} fields.client_url - The URL of the OAuth provider.
* @param {string} fields.redirect_uri - The redirect URI for the OAuth provider.
* @param {string} fields.encrypted_oauth_client_id - The client ID for the OAuth provider.
* @param {string} fields.encrypted_oauth_client_secret - The client secret for the OAuth provider.
* @returns {Promise<{
* access_token: string,
* expires_in: number,
* refresh_token?: string,
* refresh_token_expires_in?: number,
* }>}
*/
const getAccessToken = async ({
code,
userId,
identifier,
client_url,
redirect_uri,
encrypted_oauth_client_id,
encrypted_oauth_client_secret,
}) => {
const oauth_client_id = await decryptV2(encrypted_oauth_client_id);
const oauth_client_secret = await decryptV2(encrypted_oauth_client_secret);
const params = new URLSearchParams({
code,
client_id: oauth_client_id,
client_secret: oauth_client_secret,
grant_type: 'authorization_code',
redirect_uri,
});
try {
const response = await axios({
method: 'POST',
url: client_url,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
},
data: params.toString(),
});
await processAccessTokens(response.data, {
userId,
identifier,
});
logger.debug(`Access tokens successfully created for ${identifier}`);
return response.data;
} catch (error) {
const message = 'Error exchanging OAuth code';
logAxiosError({
message,
error,
});
throw new Error(message);
}
};
module.exports = {
getAccessToken,
refreshAccessToken,
};

View file

@ -409,11 +409,12 @@ async function processRequiredActions(client, requiredActions) {
* Processes the runtime tool calls and returns the tool classes.
* @param {Object} params - Run params containing user and request information.
* @param {ServerRequest} params.req - The request object.
* @param {ServerResponse} params.res - The request object.
* @param {Agent} params.agent - The agent to load tools for.
* @param {string | undefined} [params.openAIApiKey] - The OpenAI API key.
* @returns {Promise<{ tools?: StructuredTool[] }>} The agent tools.
*/
async function loadAgentTools({ req, agent, tool_resources, openAIApiKey }) {
async function loadAgentTools({ req, res, agent, tool_resources, openAIApiKey }) {
if (!agent.tools || agent.tools.length === 0) {
return {};
}
@ -546,6 +547,8 @@ async function loadAgentTools({ req, agent, tool_resources, openAIApiKey }) {
if (requestBuilder) {
const tool = await createActionTool({
req,
res,
action: actionSet,
requestBuilder,
zodSchema,