From 8a4a5a47903d354daab75bb26ea72b229b5a0571 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 5 Nov 2025 17:15:17 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=A4=96=20feat:=20Agent=20Handoffs=20(Rout?= =?UTF-8?q?ing)=20(#10176)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add support for agent handoffs with edges in agent forms and schemas chore: Mark `agent_ids` field as deprecated in favor of edges across various schemas and types chore: Update dependencies for @langchain/core and @librechat/agents to latest versions chore: Update peer dependency for @librechat/agents to version 3.0.0-rc2 in package.json chore: Update @librechat/agents dependency to version 3.0.0-rc3 in package.json and package-lock.json feat: first pass, multi-agent handoffs fix: update output type to ToolMessage in memory handling functions fix: improve type checking for graphConfig in createRun function refactor: remove unused content filtering logic in AgentClient chore: update @librechat/agents dependency to version 3.0.0-rc4 in package.json and package-lock.json fix: update @langchain/core peer dependency version to ^0.3.72 in package.json and package-lock.json fix: update @librechat/agents dependency to version 3.0.0-rc6 in package.json and package-lock.json; refactor stream rate handling in various endpoints feat: Agent handoff UI chore: update @librechat/agents dependency to version 3.0.0-rc8 in package.json and package-lock.json fix: improve hasInfo condition and adjust UI element classes in AgentHandoff component refactor: remove current fixed agent display from AgentHandoffs component due to redundancy feat: enhance AgentHandoffs UI with localized beta label and improved layout chore: update @librechat/agents dependency to version 3.0.0-rc10 in package.json and package-lock.json feat: add `createSequentialChainEdges` function to add back agent chaining via multi-agents feat: update `createSequentialChainEdges` call to only provide conversation context between agents feat: deprecate Agent Chain functionality and update related methods for improved clarity * chore: update @librechat/agents dependency to version 3.0.0-rc11 in package.json and package-lock.json * refactor: remove unused addCacheControl function and related imports and import from @librechat/agents * chore: remove unused i18n keys * refactor: remove unused format export from index.ts * chore: update @librechat/agents to v3.0.0-rc13 * chore: remove BEDROCK_LEGACY provider from Providers enum * chore: update @librechat/agents to version 3.0.2 in package.json --- api/app/clients/AnthropicClient.js | 3 +- api/app/clients/prompts/addCacheControl.js | 45 - .../clients/prompts/addCacheControl.spec.js | 227 -- api/app/clients/prompts/index.js | 2 - api/package.json | 4 +- api/server/controllers/agents/callbacks.js | 23 +- api/server/controllers/agents/client.js | 274 +- .../services/Endpoints/agents/initialize.js | 116 +- .../Endpoints/anthropic/initialize.js | 4 +- .../services/Endpoints/bedrock/options.js | 16 +- .../services/Endpoints/custom/initialize.js | 7 +- .../Endpoints/custom/initialize.spec.js | 1 - .../services/Endpoints/openAI/initialize.js | 7 +- client/src/common/agents-types.ts | 7 +- .../Chat/Messages/Content/AgentHandoff.tsx | 92 + .../components/Chat/Messages/Content/Part.tsx | 10 + .../Messages/Content/Parts/AgentUpdate.tsx | 4 +- .../Agents/Advanced/AdvancedPanel.tsx | 7 + .../Agents/Advanced/AgentHandoffs.tsx | 296 ++ .../SidePanel/Agents/AgentPanel.tsx | 3 + .../SidePanel/Agents/AgentSelect.tsx | 5 + client/src/locales/en/translation.json | 14 + package-lock.json | 3057 ++--------------- packages/api/package.json | 4 +- packages/api/src/agents/chain.ts | 47 + packages/api/src/agents/index.ts | 1 + packages/api/src/agents/memory.ts | 6 +- packages/api/src/agents/run.ts | 136 +- packages/api/src/agents/validation.ts | 13 + .../api/src/endpoints/openai/initialize.ts | 18 +- packages/api/src/format/content.spec.ts | 340 -- packages/api/src/format/content.ts | 57 - packages/api/src/format/index.ts | 1 - packages/api/src/index.ts | 1 - packages/api/src/types/openai.ts | 8 +- packages/data-provider/src/config.ts | 4 + packages/data-provider/src/schemas.ts | 2 +- packages/data-provider/src/types/agents.ts | 42 + .../data-provider/src/types/assistants.ts | 6 +- packages/data-schemas/src/schema/agent.ts | 5 + packages/data-schemas/src/types/agent.ts | 3 + 41 files changed, 1108 insertions(+), 3810 deletions(-) delete mode 100644 api/app/clients/prompts/addCacheControl.js delete mode 100644 api/app/clients/prompts/addCacheControl.spec.js create mode 100644 client/src/components/Chat/Messages/Content/AgentHandoff.tsx create mode 100644 client/src/components/SidePanel/Agents/Advanced/AgentHandoffs.tsx create mode 100644 packages/api/src/agents/chain.ts delete mode 100644 packages/api/src/format/content.spec.ts delete mode 100644 packages/api/src/format/content.ts delete mode 100644 packages/api/src/format/index.ts diff --git a/api/app/clients/AnthropicClient.js b/api/app/clients/AnthropicClient.js index 43e546a0a3..cb884f2d54 100644 --- a/api/app/clients/AnthropicClient.js +++ b/api/app/clients/AnthropicClient.js @@ -10,7 +10,7 @@ const { getResponseSender, validateVisionModel, } = require('librechat-data-provider'); -const { sleep, SplitStreamHandler: _Handler } = require('@librechat/agents'); +const { sleep, SplitStreamHandler: _Handler, addCacheControl } = require('@librechat/agents'); const { Tokenizer, createFetch, @@ -25,7 +25,6 @@ const { const { truncateText, formatMessage, - addCacheControl, titleFunctionPrompt, parseParamFromPrompt, createContextHandlers, diff --git a/api/app/clients/prompts/addCacheControl.js b/api/app/clients/prompts/addCacheControl.js deleted file mode 100644 index 6bfd901a65..0000000000 --- a/api/app/clients/prompts/addCacheControl.js +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Anthropic API: Adds cache control to the appropriate user messages in the payload. - * @param {Array} messages - The array of message objects. - * @returns {Array} - The updated array of message objects with cache control added. - */ -function addCacheControl(messages) { - if (!Array.isArray(messages) || messages.length < 2) { - return messages; - } - - const updatedMessages = [...messages]; - let userMessagesModified = 0; - - for (let i = updatedMessages.length - 1; i >= 0 && userMessagesModified < 2; i--) { - const message = updatedMessages[i]; - if (message.getType != null && message.getType() !== 'human') { - continue; - } else if (message.getType == null && message.role !== 'user') { - continue; - } - - if (typeof message.content === 'string') { - message.content = [ - { - type: 'text', - text: message.content, - cache_control: { type: 'ephemeral' }, - }, - ]; - userMessagesModified++; - } else if (Array.isArray(message.content)) { - for (let j = message.content.length - 1; j >= 0; j--) { - if (message.content[j].type === 'text') { - message.content[j].cache_control = { type: 'ephemeral' }; - userMessagesModified++; - break; - } - } - } - } - - return updatedMessages; -} - -module.exports = addCacheControl; diff --git a/api/app/clients/prompts/addCacheControl.spec.js b/api/app/clients/prompts/addCacheControl.spec.js deleted file mode 100644 index c46ffd95e3..0000000000 --- a/api/app/clients/prompts/addCacheControl.spec.js +++ /dev/null @@ -1,227 +0,0 @@ -const addCacheControl = require('./addCacheControl'); - -describe('addCacheControl', () => { - test('should add cache control to the last two user messages with array content', () => { - const messages = [ - { role: 'user', content: [{ type: 'text', text: 'Hello' }] }, - { role: 'assistant', content: [{ type: 'text', text: 'Hi there' }] }, - { role: 'user', content: [{ type: 'text', text: 'How are you?' }] }, - { role: 'assistant', content: [{ type: 'text', text: 'I\'m doing well, thanks!' }] }, - { role: 'user', content: [{ type: 'text', text: 'Great!' }] }, - ]; - - const result = addCacheControl(messages); - - expect(result[0].content[0]).not.toHaveProperty('cache_control'); - expect(result[2].content[0].cache_control).toEqual({ type: 'ephemeral' }); - expect(result[4].content[0].cache_control).toEqual({ type: 'ephemeral' }); - }); - - test('should add cache control to the last two user messages with string content', () => { - const messages = [ - { role: 'user', content: 'Hello' }, - { role: 'assistant', content: 'Hi there' }, - { role: 'user', content: 'How are you?' }, - { role: 'assistant', content: 'I\'m doing well, thanks!' }, - { role: 'user', content: 'Great!' }, - ]; - - const result = addCacheControl(messages); - - expect(result[0].content).toBe('Hello'); - expect(result[2].content[0]).toEqual({ - type: 'text', - text: 'How are you?', - cache_control: { type: 'ephemeral' }, - }); - expect(result[4].content[0]).toEqual({ - type: 'text', - text: 'Great!', - cache_control: { type: 'ephemeral' }, - }); - }); - - test('should handle mixed string and array content', () => { - const messages = [ - { role: 'user', content: 'Hello' }, - { role: 'assistant', content: 'Hi there' }, - { role: 'user', content: [{ type: 'text', text: 'How are you?' }] }, - ]; - - const result = addCacheControl(messages); - - expect(result[0].content[0]).toEqual({ - type: 'text', - text: 'Hello', - cache_control: { type: 'ephemeral' }, - }); - expect(result[2].content[0].cache_control).toEqual({ type: 'ephemeral' }); - }); - - test('should handle less than two user messages', () => { - const messages = [ - { role: 'user', content: 'Hello' }, - { role: 'assistant', content: 'Hi there' }, - ]; - - const result = addCacheControl(messages); - - expect(result[0].content[0]).toEqual({ - type: 'text', - text: 'Hello', - cache_control: { type: 'ephemeral' }, - }); - expect(result[1].content).toBe('Hi there'); - }); - - test('should return original array if no user messages', () => { - const messages = [ - { role: 'assistant', content: 'Hi there' }, - { role: 'assistant', content: 'How can I help?' }, - ]; - - const result = addCacheControl(messages); - - expect(result).toEqual(messages); - }); - - test('should handle empty array', () => { - const messages = []; - const result = addCacheControl(messages); - expect(result).toEqual([]); - }); - - test('should handle non-array input', () => { - const messages = 'not an array'; - const result = addCacheControl(messages); - expect(result).toBe('not an array'); - }); - - test('should not modify assistant messages', () => { - const messages = [ - { role: 'user', content: 'Hello' }, - { role: 'assistant', content: 'Hi there' }, - { role: 'user', content: 'How are you?' }, - ]; - - const result = addCacheControl(messages); - - expect(result[1].content).toBe('Hi there'); - }); - - test('should handle multiple content items in user messages', () => { - const messages = [ - { - role: 'user', - content: [ - { type: 'text', text: 'Hello' }, - { type: 'image', url: 'http://example.com/image.jpg' }, - { type: 'text', text: 'This is an image' }, - ], - }, - { role: 'assistant', content: 'Hi there' }, - { role: 'user', content: 'How are you?' }, - ]; - - const result = addCacheControl(messages); - - expect(result[0].content[0]).not.toHaveProperty('cache_control'); - expect(result[0].content[1]).not.toHaveProperty('cache_control'); - expect(result[0].content[2].cache_control).toEqual({ type: 'ephemeral' }); - expect(result[2].content[0]).toEqual({ - type: 'text', - text: 'How are you?', - cache_control: { type: 'ephemeral' }, - }); - }); - - test('should handle an array with mixed content types', () => { - const messages = [ - { role: 'user', content: 'Hello' }, - { role: 'assistant', content: 'Hi there' }, - { role: 'user', content: [{ type: 'text', text: 'How are you?' }] }, - { role: 'assistant', content: 'I\'m doing well, thanks!' }, - { role: 'user', content: 'Great!' }, - ]; - - const result = addCacheControl(messages); - - expect(result[0].content).toEqual('Hello'); - expect(result[2].content[0]).toEqual({ - type: 'text', - text: 'How are you?', - cache_control: { type: 'ephemeral' }, - }); - expect(result[4].content).toEqual([ - { - type: 'text', - text: 'Great!', - cache_control: { type: 'ephemeral' }, - }, - ]); - expect(result[1].content).toBe('Hi there'); - expect(result[3].content).toBe('I\'m doing well, thanks!'); - }); - - test('should handle edge case with multiple content types', () => { - const messages = [ - { - role: 'user', - content: [ - { - type: 'image', - source: { type: 'base64', media_type: 'image/png', data: 'some_base64_string' }, - }, - { - type: 'image', - source: { type: 'base64', media_type: 'image/png', data: 'another_base64_string' }, - }, - { type: 'text', text: 'what do all these images have in common' }, - ], - }, - { role: 'assistant', content: 'I see multiple images.' }, - { role: 'user', content: 'Correct!' }, - ]; - - const result = addCacheControl(messages); - - expect(result[0].content[0]).not.toHaveProperty('cache_control'); - expect(result[0].content[1]).not.toHaveProperty('cache_control'); - expect(result[0].content[2].cache_control).toEqual({ type: 'ephemeral' }); - expect(result[2].content[0]).toEqual({ - type: 'text', - text: 'Correct!', - cache_control: { type: 'ephemeral' }, - }); - }); - - test('should handle user message with no text block', () => { - const messages = [ - { - role: 'user', - content: [ - { - type: 'image', - source: { type: 'base64', media_type: 'image/png', data: 'some_base64_string' }, - }, - { - type: 'image', - source: { type: 'base64', media_type: 'image/png', data: 'another_base64_string' }, - }, - ], - }, - { role: 'assistant', content: 'I see two images.' }, - { role: 'user', content: 'Correct!' }, - ]; - - const result = addCacheControl(messages); - - expect(result[0].content[0]).not.toHaveProperty('cache_control'); - expect(result[0].content[1]).not.toHaveProperty('cache_control'); - expect(result[2].content[0]).toEqual({ - type: 'text', - text: 'Correct!', - cache_control: { type: 'ephemeral' }, - }); - }); -}); diff --git a/api/app/clients/prompts/index.js b/api/app/clients/prompts/index.js index 2549ccda5c..4749cf0b48 100644 --- a/api/app/clients/prompts/index.js +++ b/api/app/clients/prompts/index.js @@ -1,4 +1,3 @@ -const addCacheControl = require('./addCacheControl'); const formatMessages = require('./formatMessages'); const summaryPrompts = require('./summaryPrompts'); const handleInputs = require('./handleInputs'); @@ -9,7 +8,6 @@ const createVisionPrompt = require('./createVisionPrompt'); const createContextHandlers = require('./createContextHandlers'); module.exports = { - addCacheControl, ...formatMessages, ...summaryPrompts, ...handleInputs, diff --git a/api/package.json b/api/package.json index 977cd13668..f3325ebf12 100644 --- a/api/package.json +++ b/api/package.json @@ -44,11 +44,11 @@ "@googleapis/youtube": "^20.0.0", "@keyv/redis": "^4.3.3", "@langchain/community": "^0.3.47", - "@langchain/core": "^0.3.62", + "@langchain/core": "^0.3.72", "@langchain/google-genai": "^0.2.13", "@langchain/google-vertexai": "^0.2.13", "@langchain/textsplitters": "^0.1.0", - "@librechat/agents": "^2.4.90", + "@librechat/agents": "^3.0.2", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index d700f0a9cb..a66fe9a053 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -95,6 +95,19 @@ class ModelEndHandler { } } +/** + * @deprecated Agent Chain helper + * @param {string | undefined} [last_agent_id] + * @param {string | undefined} [langgraph_node] + * @returns {boolean} + */ +function checkIfLastAgent(last_agent_id, langgraph_node) { + if (!last_agent_id || !langgraph_node) { + return false; + } + return langgraph_node?.endsWith(last_agent_id); +} + /** * Get default handlers for stream events. * @param {Object} options - The options object. @@ -125,7 +138,7 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU handle: (event, data, metadata) => { if (data?.stepDetails.type === StepTypes.TOOL_CALLS) { sendEvent(res, { event, data }); - } else if (metadata?.last_agent_index === metadata?.agent_index) { + } else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) { sendEvent(res, { event, data }); } else if (!metadata?.hide_sequential_outputs) { sendEvent(res, { event, data }); @@ -154,7 +167,7 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU handle: (event, data, metadata) => { if (data?.delta.type === StepTypes.TOOL_CALLS) { sendEvent(res, { event, data }); - } else if (metadata?.last_agent_index === metadata?.agent_index) { + } else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) { sendEvent(res, { event, data }); } else if (!metadata?.hide_sequential_outputs) { sendEvent(res, { event, data }); @@ -172,7 +185,7 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU handle: (event, data, metadata) => { if (data?.result != null) { sendEvent(res, { event, data }); - } else if (metadata?.last_agent_index === metadata?.agent_index) { + } else if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) { sendEvent(res, { event, data }); } else if (!metadata?.hide_sequential_outputs) { sendEvent(res, { event, data }); @@ -188,7 +201,7 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU * @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata. */ handle: (event, data, metadata) => { - if (metadata?.last_agent_index === metadata?.agent_index) { + if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) { sendEvent(res, { event, data }); } else if (!metadata?.hide_sequential_outputs) { sendEvent(res, { event, data }); @@ -204,7 +217,7 @@ function getDefaultHandlers({ res, aggregateContent, toolEndCallback, collectedU * @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata. */ handle: (event, data, metadata) => { - if (metadata?.last_agent_index === metadata?.agent_index) { + if (checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node)) { sendEvent(res, { event, data }); } else if (!metadata?.hide_sequential_outputs) { sendEvent(res, { event, data }); diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 27da7d5cc1..13d779e95a 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -3,7 +3,6 @@ const { logger } = require('@librechat/data-schemas'); const { DynamicStructuredTool } = require('@langchain/core/tools'); const { getBufferString, HumanMessage } = require('@langchain/core/messages'); const { - sendEvent, createRun, Tokenizer, checkAccess, @@ -12,14 +11,12 @@ const { resolveHeaders, getBalanceConfig, memoryInstructions, - formatContentStrings, getTransactionsConfig, createMemoryProcessor, } = require('@librechat/api'); const { Callback, Providers, - GraphEvents, TitleMethod, formatMessage, formatAgentMessages, @@ -38,12 +35,12 @@ const { bedrockInputSchema, removeNullishValues, } = require('librechat-data-provider'); -const { addCacheControl, createContextHandlers } = require('~/app/clients/prompts'); const { initializeAgent } = require('~/server/services/Endpoints/agents/agent'); const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens'); const { getFormattedMemories, deleteMemory, setMemory } = require('~/models'); const { encodeAndFormat } = require('~/server/services/Files/images/encode'); const { getProviderConfig } = require('~/server/services/Endpoints'); +const { createContextHandlers } = require('~/app/clients/prompts'); const { checkCapability } = require('~/server/services/Config'); const BaseClient = require('~/app/clients/BaseClient'); const { getRoleByName } = require('~/models/Role'); @@ -80,8 +77,6 @@ const payloadParser = ({ req, agent, endpoint }) => { return req.body.endpointOption.model_parameters; }; -const noSystemModelRegex = [/\b(o1-preview|o1-mini|amazon\.titan-text)\b/gi]; - function createTokenCounter(encoding) { return function (message) { const countTokens = (text) => Tokenizer.getTokenCount(text, encoding); @@ -803,137 +798,81 @@ class AgentClient extends BaseClient { ); /** - * - * @param {Agent} agent * @param {BaseMessage[]} messages - * @param {number} [i] - * @param {TMessageContentParts[]} [contentData] - * @param {Record} [currentIndexCountMap] */ - const runAgent = async (agent, _messages, i = 0, contentData = [], _currentIndexCountMap) => { - config.configurable.model = agent.model_parameters.model; - const currentIndexCountMap = _currentIndexCountMap ?? indexTokenCountMap; - if (i > 0) { - this.model = agent.model_parameters.model; + const runAgents = async (messages) => { + const agents = [this.options.agent]; + if ( + this.agentConfigs && + this.agentConfigs.size > 0 && + ((this.options.agent.edges?.length ?? 0) > 0 || + (await checkCapability(this.options.req, AgentCapabilities.chain))) + ) { + agents.push(...this.agentConfigs.values()); } - if (i > 0 && config.signal == null) { - config.signal = abortController.signal; - } - if (agent.recursion_limit && typeof agent.recursion_limit === 'number') { - config.recursionLimit = agent.recursion_limit; + + if (agents[0].recursion_limit && typeof agents[0].recursion_limit === 'number') { + config.recursionLimit = agents[0].recursion_limit; } + if ( agentsEConfig?.maxRecursionLimit && config.recursionLimit > agentsEConfig?.maxRecursionLimit ) { config.recursionLimit = agentsEConfig?.maxRecursionLimit; } - config.configurable.agent_id = agent.id; - config.configurable.name = agent.name; - config.configurable.agent_index = i; - const noSystemMessages = noSystemModelRegex.some((regex) => - agent.model_parameters.model.match(regex), - ); - const systemMessage = Object.values(agent.toolContextMap ?? {}) - .join('\n') - .trim(); + // TODO: needs to be added as part of AgentContext initialization + // const noSystemModelRegex = [/\b(o1-preview|o1-mini|amazon\.titan-text)\b/gi]; + // const noSystemMessages = noSystemModelRegex.some((regex) => + // agent.model_parameters.model.match(regex), + // ); + // if (noSystemMessages === true && systemContent?.length) { + // const latestMessageContent = _messages.pop().content; + // if (typeof latestMessageContent !== 'string') { + // latestMessageContent[0].text = [systemContent, latestMessageContent[0].text].join('\n'); + // _messages.push(new HumanMessage({ content: latestMessageContent })); + // } else { + // const text = [systemContent, latestMessageContent].join('\n'); + // _messages.push(new HumanMessage(text)); + // } + // } + // let messages = _messages; + // if (agent.useLegacyContent === true) { + // messages = formatContentStrings(messages); + // } + // if ( + // agent.model_parameters?.clientOptions?.defaultHeaders?.['anthropic-beta']?.includes( + // 'prompt-caching', + // ) + // ) { + // messages = addCacheControl(messages); + // } - let systemContent = [ - systemMessage, - agent.instructions ?? '', - i !== 0 ? (agent.additional_instructions ?? '') : '', - ] - .join('\n') - .trim(); - - if (noSystemMessages === true) { - agent.instructions = undefined; - agent.additional_instructions = undefined; - } else { - agent.instructions = systemContent; - agent.additional_instructions = undefined; - } - - if (noSystemMessages === true && systemContent?.length) { - const latestMessageContent = _messages.pop().content; - if (typeof latestMessageContent !== 'string') { - latestMessageContent[0].text = [systemContent, latestMessageContent[0].text].join('\n'); - _messages.push(new HumanMessage({ content: latestMessageContent })); - } else { - const text = [systemContent, latestMessageContent].join('\n'); - _messages.push(new HumanMessage(text)); - } - } - - let messages = _messages; - if (agent.useLegacyContent === true) { - messages = formatContentStrings(messages); - } - const defaultHeaders = - agent.model_parameters?.clientOptions?.defaultHeaders ?? - agent.model_parameters?.configuration?.defaultHeaders; - if (defaultHeaders?.['anthropic-beta']?.includes('prompt-caching')) { - messages = addCacheControl(messages); - } - - if (i === 0) { - memoryPromise = this.runMemory(messages); - } - - /** Resolve request-based headers for Custom Endpoints. Note: if this is added to - * non-custom endpoints, needs consideration of varying provider header configs. - */ - if (agent.model_parameters?.configuration?.defaultHeaders != null) { - agent.model_parameters.configuration.defaultHeaders = resolveHeaders({ - headers: agent.model_parameters.configuration.defaultHeaders, - body: config.configurable.requestBody, - }); - } + memoryPromise = this.runMemory(messages); run = await createRun({ - agent, - req: this.options.req, + agents, + indexTokenCountMap, runId: this.responseMessageId, signal: abortController.signal, customHandlers: this.options.eventHandlers, + requestBody: config.configurable.requestBody, + tokenCounter: createTokenCounter(this.getEncoding()), }); if (!run) { throw new Error('Failed to create run'); } - if (i === 0) { - this.run = run; - } - - if (contentData.length) { - const agentUpdate = { - type: ContentTypes.AGENT_UPDATE, - [ContentTypes.AGENT_UPDATE]: { - index: contentData.length, - runId: this.responseMessageId, - agentId: agent.id, - }, - }; - const streamData = { - event: GraphEvents.ON_AGENT_UPDATE, - data: agentUpdate, - }; - this.options.aggregateContent(streamData); - sendEvent(this.options.res, streamData); - contentData.push(agentUpdate); - run.Graph.contentData = contentData; - } - + this.run = run; if (userMCPAuthMap != null) { config.configurable.userMCPAuthMap = userMCPAuthMap; } + + /** @deprecated Agent Chain */ + config.configurable.last_agent_id = agents[agents.length - 1].id; await run.processStream({ messages }, config, { - keepContent: i !== 0, - tokenCounter: createTokenCounter(this.getEncoding()), - indexTokenCountMap: currentIndexCountMap, - maxContextTokens: agent.maxContextTokens, callbacks: { [Callback.TOOL_ERROR]: logToolError, }, @@ -942,109 +881,22 @@ class AgentClient extends BaseClient { config.signal = null; }; - await runAgent(this.options.agent, initialMessages); - let finalContentStart = 0; - if ( - this.agentConfigs && - this.agentConfigs.size > 0 && - (await checkCapability(this.options.req, AgentCapabilities.chain)) - ) { - const windowSize = 5; - let latestMessage = initialMessages.pop().content; - if (typeof latestMessage !== 'string') { - latestMessage = latestMessage[0].text; - } - let i = 1; - let runMessages = []; - - const windowIndexCountMap = {}; - const windowMessages = initialMessages.slice(-windowSize); - let currentIndex = 4; - for (let i = initialMessages.length - 1; i >= 0; i--) { - windowIndexCountMap[currentIndex] = indexTokenCountMap[i]; - currentIndex--; - if (currentIndex < 0) { - break; - } - } - const encoding = this.getEncoding(); - const tokenCounter = createTokenCounter(encoding); - for (const [agentId, agent] of this.agentConfigs) { - if (abortController.signal.aborted === true) { - break; - } - const currentRun = await run; - - if ( - i === this.agentConfigs.size && - config.configurable.hide_sequential_outputs === true - ) { - const content = this.contentParts.filter( - (part) => part.type === ContentTypes.TOOL_CALL, - ); - - this.options.res.write( - `event: message\ndata: ${JSON.stringify({ - event: 'on_content_update', - data: { - runId: this.responseMessageId, - content, - }, - })}\n\n`, - ); - } - const _runMessages = currentRun.Graph.getRunMessages(); - finalContentStart = this.contentParts.length; - runMessages = runMessages.concat(_runMessages); - const contentData = currentRun.Graph.contentData.slice(); - const bufferString = getBufferString([new HumanMessage(latestMessage), ...runMessages]); - if (i === this.agentConfigs.size) { - logger.debug(`SEQUENTIAL AGENTS: Last buffer string:\n${bufferString}`); - } - try { - const contextMessages = []; - const runIndexCountMap = {}; - for (let i = 0; i < windowMessages.length; i++) { - const message = windowMessages[i]; - const messageType = message._getType(); - if ( - (!agent.tools || agent.tools.length === 0) && - (messageType === 'tool' || (message.tool_calls?.length ?? 0) > 0) - ) { - continue; - } - runIndexCountMap[contextMessages.length] = windowIndexCountMap[i]; - contextMessages.push(message); - } - const bufferMessage = new HumanMessage(bufferString); - runIndexCountMap[contextMessages.length] = tokenCounter(bufferMessage); - const currentMessages = [...contextMessages, bufferMessage]; - await runAgent(agent, currentMessages, i, contentData, runIndexCountMap); - } catch (err) { - logger.error( - `[api/server/controllers/agents/client.js #chatCompletion] Error running agent ${agentId} (${i})`, - err, - ); - } - i++; - } + await runAgents(initialMessages); + /** @deprecated Agent Chain */ + if (config.configurable.hide_sequential_outputs) { + this.contentParts = this.contentParts.filter((part, index) => { + // Include parts that are either: + // 1. At or after the finalContentStart index + // 2. Of type tool_call + // 3. Have tool_call_ids property + return ( + index >= this.contentParts.length - 1 || + part.type === ContentTypes.TOOL_CALL || + part.tool_call_ids + ); + }); } - /** Note: not implemented */ - if (config.configurable.hide_sequential_outputs !== true) { - finalContentStart = 0; - } - - this.contentParts = this.contentParts.filter((part, index) => { - // Include parts that are either: - // 1. At or after the finalContentStart index - // 2. Of type tool_call - // 3. Have tool_call_ids property - return ( - index >= finalContentStart || part.type === ContentTypes.TOOL_CALL || part.tool_call_ids - ); - }); - try { const attachments = await this.awaitMemoryWithTimeout(memoryPromise); if (attachments && attachments.length > 0) { diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 7cc0a39fba..3064a03662 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -1,6 +1,10 @@ const { logger } = require('@librechat/data-schemas'); const { createContentAggregator } = require('@librechat/agents'); -const { validateAgentModel, getCustomEndpointConfig } = require('@librechat/api'); +const { + validateAgentModel, + getCustomEndpointConfig, + createSequentialChainEdges, +} = require('@librechat/api'); const { Constants, EModelEndpoint, @@ -119,44 +123,90 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { const agent_ids = primaryConfig.agent_ids; let userMCPAuthMap = primaryConfig.userMCPAuthMap; - if (agent_ids?.length) { - for (const agentId of agent_ids) { - const agent = await getAgent({ id: agentId }); - if (!agent) { - throw new Error(`Agent ${agentId} not found`); + + async function processAgent(agentId) { + const agent = await getAgent({ id: agentId }); + if (!agent) { + throw new Error(`Agent ${agentId} not found`); + } + + const validationResult = await validateAgentModel({ + req, + res, + agent, + modelsConfig, + logViolation, + }); + + if (!validationResult.isValid) { + throw new Error(validationResult.error?.message); + } + + const config = await initializeAgent({ + req, + res, + agent, + loadTools, + requestFiles, + conversationId, + endpointOption, + allowedProviders, + }); + if (userMCPAuthMap != null) { + Object.assign(userMCPAuthMap, config.userMCPAuthMap ?? {}); + } else { + userMCPAuthMap = config.userMCPAuthMap; + } + agentConfigs.set(agentId, config); + } + + let edges = primaryConfig.edges; + const checkAgentInit = (agentId) => agentId === primaryConfig.id || agentConfigs.has(agentId); + if ((edges?.length ?? 0) > 0) { + for (const edge of edges) { + if (Array.isArray(edge.to)) { + for (const to of edge.to) { + if (checkAgentInit(to)) { + continue; + } + await processAgent(to); + } + } else if (typeof edge.to === 'string' && checkAgentInit(edge.to)) { + continue; + } else if (typeof edge.to === 'string') { + await processAgent(edge.to); } - const validationResult = await validateAgentModel({ - req, - res, - agent, - modelsConfig, - logViolation, - }); - - if (!validationResult.isValid) { - throw new Error(validationResult.error?.message); + if (Array.isArray(edge.from)) { + for (const from of edge.from) { + if (checkAgentInit(from)) { + continue; + } + await processAgent(from); + } + } else if (typeof edge.from === 'string' && checkAgentInit(edge.from)) { + continue; + } else if (typeof edge.from === 'string') { + await processAgent(edge.from); } - - const config = await initializeAgent({ - req, - res, - agent, - loadTools, - requestFiles, - conversationId, - endpointOption, - allowedProviders, - }); - if (userMCPAuthMap != null) { - Object.assign(userMCPAuthMap, config.userMCPAuthMap ?? {}); - } else { - userMCPAuthMap = config.userMCPAuthMap; - } - agentConfigs.set(agentId, config); } } + /** @deprecated Agent Chain */ + if (agent_ids?.length) { + for (const agentId of agent_ids) { + if (checkAgentInit(agentId)) { + continue; + } + await processAgent(agentId); + } + + const chain = await createSequentialChainEdges([primaryConfig.id].concat(agent_ids), '{convo}'); + edges = edges ? edges.concat(chain) : chain; + } + + primaryConfig.edges = edges; + let endpointConfig = appConfig.endpoints?.[primaryConfig.endpoint]; if (!isAgentsEndpoint(primaryConfig.endpoint) && !endpointConfig) { try { diff --git a/api/server/services/Endpoints/anthropic/initialize.js b/api/server/services/Endpoints/anthropic/initialize.js index 6e661da671..88639b3d7c 100644 --- a/api/server/services/Endpoints/anthropic/initialize.js +++ b/api/server/services/Endpoints/anthropic/initialize.js @@ -27,13 +27,13 @@ const initializeClient = async ({ req, res, endpointOption, overrideModel, optio const anthropicConfig = appConfig.endpoints?.[EModelEndpoint.anthropic]; if (anthropicConfig) { - clientOptions.streamRate = anthropicConfig.streamRate; + clientOptions._lc_stream_delay = anthropicConfig.streamRate; clientOptions.titleModel = anthropicConfig.titleModel; } const allConfig = appConfig.endpoints?.all; if (allConfig) { - clientOptions.streamRate = allConfig.streamRate; + clientOptions._lc_stream_delay = allConfig.streamRate; } if (optionsOnly) { diff --git a/api/server/services/Endpoints/bedrock/options.js b/api/server/services/Endpoints/bedrock/options.js index 2bc18f9a76..0d02d09b07 100644 --- a/api/server/services/Endpoints/bedrock/options.js +++ b/api/server/services/Endpoints/bedrock/options.js @@ -1,8 +1,6 @@ const { HttpsProxyAgent } = require('https-proxy-agent'); -const { createHandleLLMNewToken } = require('@librechat/api'); const { AuthType, - Constants, EModelEndpoint, bedrockInputParser, bedrockOutputParser, @@ -11,7 +9,6 @@ const { const { getUserKey, checkUserKeyExpiry } = require('~/server/services/UserService'); const getOptions = async ({ req, overrideModel, endpointOption }) => { - const appConfig = req.config; const { BEDROCK_AWS_SECRET_ACCESS_KEY, BEDROCK_AWS_ACCESS_KEY_ID, @@ -47,10 +44,12 @@ const getOptions = async ({ req, overrideModel, endpointOption }) => { checkUserKeyExpiry(expiresAt, EModelEndpoint.bedrock); } - /** @type {number} */ + /* + Callback for stream rate no longer awaits and may end the stream prematurely + /** @type {number} let streamRate = Constants.DEFAULT_STREAM_RATE; - /** @type {undefined | TBaseEndpoint} */ + /** @type {undefined | TBaseEndpoint} const bedrockConfig = appConfig.endpoints?.[EModelEndpoint.bedrock]; if (bedrockConfig && bedrockConfig.streamRate) { @@ -61,6 +60,7 @@ const getOptions = async ({ req, overrideModel, endpointOption }) => { if (allConfig && allConfig.streamRate) { streamRate = allConfig.streamRate; } + */ /** @type {BedrockClientOptions} */ const requestOptions = { @@ -88,12 +88,6 @@ const getOptions = async ({ req, overrideModel, endpointOption }) => { llmConfig.endpointHost = BEDROCK_REVERSE_PROXY; } - llmConfig.callbacks = [ - { - handleLLMNewToken: createHandleLLMNewToken(streamRate), - }, - ]; - return { /** @type {BedrockClientOptions} */ llmConfig, diff --git a/api/server/services/Endpoints/custom/initialize.js b/api/server/services/Endpoints/custom/initialize.js index 066c9430ce..e6fbf65e77 100644 --- a/api/server/services/Endpoints/custom/initialize.js +++ b/api/server/services/Endpoints/custom/initialize.js @@ -3,7 +3,6 @@ const { isUserProvided, getOpenAIConfig, getCustomEndpointConfig, - createHandleLLMNewToken, } = require('@librechat/api'); const { CacheKeys, @@ -157,11 +156,7 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid if (!clientOptions.streamRate) { return options; } - options.llmConfig.callbacks = [ - { - handleLLMNewToken: createHandleLLMNewToken(clientOptions.streamRate), - }, - ]; + options.llmConfig._lc_stream_delay = clientOptions.streamRate; return options; } diff --git a/api/server/services/Endpoints/custom/initialize.spec.js b/api/server/services/Endpoints/custom/initialize.spec.js index 8b4a1303ee..a69ff9ef58 100644 --- a/api/server/services/Endpoints/custom/initialize.spec.js +++ b/api/server/services/Endpoints/custom/initialize.spec.js @@ -4,7 +4,6 @@ jest.mock('@librechat/api', () => ({ ...jest.requireActual('@librechat/api'), resolveHeaders: jest.fn(), getOpenAIConfig: jest.fn(), - createHandleLLMNewToken: jest.fn(), getCustomEndpointConfig: jest.fn().mockReturnValue({ apiKey: 'test-key', baseURL: 'https://test.com', diff --git a/api/server/services/Endpoints/openAI/initialize.js b/api/server/services/Endpoints/openAI/initialize.js index ab2e80640a..cd691c6240 100644 --- a/api/server/services/Endpoints/openAI/initialize.js +++ b/api/server/services/Endpoints/openAI/initialize.js @@ -5,7 +5,6 @@ const { isUserProvided, getOpenAIConfig, getAzureCredentials, - createHandleLLMNewToken, } = require('@librechat/api'); const { getUserKeyValues, checkUserKeyExpiry } = require('~/server/services/UserService'); const OpenAIClient = require('~/app/clients/OpenAIClient'); @@ -151,11 +150,7 @@ const initializeClient = async ({ if (!streamRate) { return options; } - options.llmConfig.callbacks = [ - { - handleLLMNewToken: createHandleLLMNewToken(streamRate), - }, - ]; + options.llmConfig._lc_stream_delay = streamRate; return options; } diff --git a/client/src/common/agents-types.ts b/client/src/common/agents-types.ts index a49586b8a0..43448a478f 100644 --- a/client/src/common/agents-types.ts +++ b/client/src/common/agents-types.ts @@ -1,9 +1,10 @@ import { AgentCapabilities, ArtifactModes } from 'librechat-data-provider'; import type { - Agent, - AgentProvider, AgentModelParameters, SupportContact, + AgentProvider, + GraphEdge, + Agent, } from 'librechat-data-provider'; import type { OptionWithIcon, ExtendedFile } from './types'; @@ -33,7 +34,9 @@ export type AgentForm = { model_parameters: AgentModelParameters; tools?: string[]; provider?: AgentProvider | OptionWithIcon; + /** @deprecated Use edges instead */ agent_ids?: string[]; + edges?: GraphEdge[]; [AgentCapabilities.artifacts]?: ArtifactModes | string; recursion_limit?: number; support_contact?: SupportContact; diff --git a/client/src/components/Chat/Messages/Content/AgentHandoff.tsx b/client/src/components/Chat/Messages/Content/AgentHandoff.tsx new file mode 100644 index 0000000000..989cf4d3c4 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/AgentHandoff.tsx @@ -0,0 +1,92 @@ +import React, { useMemo, useState } from 'react'; +import { EModelEndpoint, Constants } from 'librechat-data-provider'; +import { ChevronDown } from 'lucide-react'; +import type { TMessage } from 'librechat-data-provider'; +import MessageIcon from '~/components/Share/MessageIcon'; +import { useAgentsMapContext } from '~/Providers'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; + +interface AgentHandoffProps { + name: string; + args: string | Record; + output?: string | null; +} + +const AgentHandoff: React.FC = ({ name, args: _args = '' }) => { + const localize = useLocalize(); + const agentsMap = useAgentsMapContext(); + const [showInfo, setShowInfo] = useState(false); + + /** Extracted agent ID from tool name (e.g., "lc_transfer_to_agent_gUV0wMb7zHt3y3Xjz-8_4" -> "agent_gUV0wMb7zHt3y3Xjz-8_4") */ + const targetAgentId = useMemo(() => { + if (typeof name !== 'string' || !name.startsWith(Constants.LC_TRANSFER_TO_)) { + return null; + } + return name.replace(Constants.LC_TRANSFER_TO_, ''); + }, [name]); + + const targetAgent = useMemo(() => { + if (!targetAgentId || !agentsMap) { + return null; + } + return agentsMap[targetAgentId]; + }, [agentsMap, targetAgentId]); + + const args = useMemo(() => { + if (typeof _args === 'string') { + return _args; + } + try { + return JSON.stringify(_args, null, 2); + } catch { + return ''; + } + }, [_args]) as string; + + /** Requires more than 2 characters as can be an empty object: `{}` */ + const hasInfo = useMemo(() => (args?.trim()?.length ?? 0) > 2, [args]); + + return ( +
+
hasInfo && setShowInfo(!showInfo)} + > +
+ +
+ {localize('com_ui_transferred_to')} + + {targetAgent?.name || localize('com_ui_agent')} + + {hasInfo && ( + + )} +
+ {hasInfo && showInfo && ( +
+
+ {localize('com_ui_handoff_instructions')}: +
+
{args}
+
+ )} +
+ ); +}; + +export default AgentHandoff; diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index b8d70f33e4..b37010447d 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -1,5 +1,6 @@ import { Tools, + Constants, ContentTypes, ToolCallTypes, imageGenTools, @@ -10,6 +11,7 @@ import type { TMessageContentParts, TAttachment } from 'librechat-data-provider' import { OpenAIImageGen, EmptyText, Reasoning, ExecuteCode, AgentUpdate, Text } from './Parts'; import { ErrorMessage } from './MessageContent'; import RetrievalCall from './RetrievalCall'; +import AgentHandoff from './AgentHandoff'; import CodeAnalyze from './CodeAnalyze'; import Container from './Container'; import WebSearch from './WebSearch'; @@ -123,6 +125,14 @@ const Part = memo( isLast={isLast} /> ); + } else if (isToolCall && toolCall.name?.startsWith(Constants.LC_TRANSFER_TO_)) { + return ( + + ); } else if (isToolCall) { return ( = ({ currentAgentId }) => { const localize = useLocalize(); - const agentsMap = useAgentsMapContext() || {}; - const currentAgent = useMemo(() => agentsMap[currentAgentId], [agentsMap, currentAgentId]); + const agentsMap = useAgentsMapContext(); + const currentAgent = useMemo(() => agentsMap?.[currentAgentId], [agentsMap, currentAgentId]); if (!currentAgentId) { return null; } diff --git a/client/src/components/SidePanel/Agents/Advanced/AdvancedPanel.tsx b/client/src/components/SidePanel/Agents/Advanced/AdvancedPanel.tsx index f99bce6f3b..6bc4cf5a0d 100644 --- a/client/src/components/SidePanel/Agents/Advanced/AdvancedPanel.tsx +++ b/client/src/components/SidePanel/Agents/Advanced/AdvancedPanel.tsx @@ -5,6 +5,7 @@ import { useFormContext, Controller } from 'react-hook-form'; import type { AgentForm } from '~/common'; import { useAgentPanelContext } from '~/Providers'; import MaxAgentSteps from './MaxAgentSteps'; +import AgentHandoffs from './AgentHandoffs'; import { useLocalize } from '~/hooks'; import AgentChain from './AgentChain'; import { Panel } from '~/common'; @@ -42,6 +43,12 @@ export default function AdvancedPanel() {
+ } + /> {chainEnabled && ( ; + currentAgentId: string; +} + +/** TODO: make configurable */ +const MAX_HANDOFFS = 10; + +const AgentHandoffs: React.FC = ({ field, currentAgentId }) => { + const localize = useLocalize(); + const [newAgentId, setNewAgentId] = useState(''); + const [expandedIndices, setExpandedIndices] = useState>(new Set()); + const agentsMap = useAgentsMapContext(); + const edgesValue = field.value; + const edges = useMemo(() => edgesValue || [], [edgesValue]); + + const agents = useMemo(() => (agentsMap ? Object.values(agentsMap) : []), [agentsMap]); + + const selectableAgents = useMemo( + () => + agents + .filter((agent) => agent?.id !== currentAgentId) + .map( + (agent) => + ({ + label: agent?.name || '', + value: agent?.id || '', + icon: ( + + ), + }) as OptionWithIcon, + ), + [agents, currentAgentId], + ); + + const getAgentDetails = useCallback((id: string) => agentsMap?.[id], [agentsMap]); + + useEffect(() => { + if (newAgentId && edges.length < MAX_HANDOFFS) { + const newEdge: GraphEdge = { + from: currentAgentId, + to: newAgentId, + edgeType: 'handoff', + }; + field.onChange([...edges, newEdge]); + setNewAgentId(''); + } + }, [newAgentId, edges, field, currentAgentId]); + + const removeHandoffAt = (index: number) => { + field.onChange(edges.filter((_, i) => i !== index)); + // Also remove from expanded set + setExpandedIndices((prev) => { + const newSet = new Set(prev); + newSet.delete(index); + return newSet; + }); + }; + + const updateHandoffAt = (index: number, agentId: string) => { + const updated = [...edges]; + updated[index] = { ...updated[index], to: agentId }; + field.onChange(updated); + }; + + const updateHandoffDetailsAt = (index: number, updates: Partial) => { + const updated = [...edges]; + updated[index] = { ...updated[index], ...updates }; + field.onChange(updated); + }; + + const toggleExpanded = (index: number) => { + setExpandedIndices((prev) => { + const newSet = new Set(prev); + if (newSet.has(index)) { + newSet.delete(index); + } else { + newSet.add(index); + } + return newSet; + }); + }; + + const getTargetAgentId = (to: string | string[]): string => { + return Array.isArray(to) ? to[0] : to; + }; + + return ( + +
+
+ + + + +
+
+
+ {localize('com_ui_beta')} +
+
+ {edges.length} / {MAX_HANDOFFS} +
+
+
+
+ {edges.map((edge, idx) => { + const targetAgentId = getTargetAgentId(edge.to); + const isExpanded = expandedIndices.has(idx); + + return ( + +
+
+ updateHandoffAt(idx, id)} + selectPlaceholder={localize('com_ui_agent_var', { + 0: localize('com_ui_select'), + })} + searchPlaceholder={localize('com_ui_agent_var', { + 0: localize('com_ui_search'), + })} + items={selectableAgents} + displayValue={getAgentDetails(targetAgentId)?.name ?? ''} + SelectIcon={ + + } + className="flex-1 border-border-heavy" + containerClassName="px-0" + /> + + +
+ + {isExpanded && ( +
+
+ + + updateHandoffDetailsAt(idx, { description: e.target.value }) + } + className="mt-1 h-8 text-sm" + /> +
+ +
+ +