🤖 feat: Agent Handoffs (Routing) (#10176)

* 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
This commit is contained in:
Danny Avila 2025-11-05 17:15:17 -05:00 committed by GitHub
parent 958a6c7872
commit 8a4a5a4790
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 1108 additions and 3810 deletions

View file

@ -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;

View file

@ -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<string, unknown>;
output?: string | null;
}
const AgentHandoff: React.FC<AgentHandoffProps> = ({ 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 (
<div className="my-3">
<div
className={cn(
'flex items-center gap-2.5 text-sm text-text-secondary',
hasInfo && 'cursor-pointer transition-colors hover:text-text-primary',
)}
onClick={() => hasInfo && setShowInfo(!showInfo)}
>
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
<MessageIcon
message={
{
endpoint: EModelEndpoint.agents,
isCreatedByUser: false,
} as TMessage
}
agent={targetAgent || undefined}
/>
</div>
<span className="select-none">{localize('com_ui_transferred_to')}</span>
<span className="select-none font-medium text-text-primary">
{targetAgent?.name || localize('com_ui_agent')}
</span>
{hasInfo && (
<ChevronDown
className={cn('ml-1 h-3 w-3 transition-transform', showInfo && 'rotate-180')}
/>
)}
</div>
{hasInfo && showInfo && (
<div className="ml-8 mt-2 rounded-md bg-surface-secondary p-3 text-xs">
<div className="mb-1 font-medium text-text-secondary">
{localize('com_ui_handoff_instructions')}:
</div>
<pre className="overflow-x-auto whitespace-pre-wrap text-text-primary">{args}</pre>
</div>
)}
</div>
);
};
export default AgentHandoff;

View file

@ -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 (
<AgentHandoff
args={toolCall.args ?? ''}
name={toolCall.name || ''}
output={toolCall.output ?? ''}
/>
);
} else if (isToolCall) {
return (
<ToolCall

View file

@ -11,8 +11,8 @@ interface AgentUpdateProps {
const AgentUpdate: React.FC<AgentUpdateProps> = ({ 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;
}

View file

@ -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() {
</div>
<div className="flex flex-col gap-4 px-2">
<MaxAgentSteps />
<Controller
name="edges"
control={control}
defaultValue={[]}
render={({ field }) => <AgentHandoffs field={field} currentAgentId={currentAgentId} />}
/>
{chainEnabled && (
<Controller
name="agent_ids"

View file

@ -0,0 +1,296 @@
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { EModelEndpoint } from 'librechat-data-provider';
import { X, Waypoints, PlusCircle, ChevronDown } from 'lucide-react';
import {
Label,
Input,
Textarea,
HoverCard,
CircleHelpIcon,
HoverCardPortal,
ControlCombobox,
HoverCardContent,
HoverCardTrigger,
} from '@librechat/client';
import type { TMessage, GraphEdge } from 'librechat-data-provider';
import type { ControllerRenderProps } from 'react-hook-form';
import type { AgentForm, OptionWithIcon } from '~/common';
import MessageIcon from '~/components/Share/MessageIcon';
import { useAgentsMapContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import { ESide } from '~/common';
interface AgentHandoffsProps {
field: ControllerRenderProps<AgentForm, 'edges'>;
currentAgentId: string;
}
/** TODO: make configurable */
const MAX_HANDOFFS = 10;
const AgentHandoffs: React.FC<AgentHandoffsProps> = ({ field, currentAgentId }) => {
const localize = useLocalize();
const [newAgentId, setNewAgentId] = useState('');
const [expandedIndices, setExpandedIndices] = useState<Set<number>>(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: (
<MessageIcon
message={
{
endpoint: EModelEndpoint.agents,
isCreatedByUser: false,
} as TMessage
}
agent={agent}
/>
),
}) 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<GraphEdge>) => {
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 (
<HoverCard openDelay={50}>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<label className="font-semibold text-text-primary">
{localize('com_ui_agent_handoffs')}
</label>
<HoverCardTrigger>
<CircleHelpIcon className="h-4 w-4 text-text-tertiary" />
</HoverCardTrigger>
</div>
<div className="flex items-center gap-3">
<div className="rounded-full border border-purple-600/40 bg-purple-500/10 px-2 py-0.5 text-xs font-medium text-purple-700 hover:bg-purple-700/10 dark:text-purple-400">
{localize('com_ui_beta')}
</div>
<div className="text-xs text-text-secondary">
{edges.length} / {MAX_HANDOFFS}
</div>
</div>
</div>
<div className="space-y-1">
{edges.map((edge, idx) => {
const targetAgentId = getTargetAgentId(edge.to);
const isExpanded = expandedIndices.has(idx);
return (
<React.Fragment key={idx}>
<div className="space-y-1">
<div className="flex h-10 items-center gap-2 rounded-md border border-border-medium bg-surface-tertiary pr-2">
<ControlCombobox
isCollapsed={false}
ariaLabel={localize('com_ui_agent_var', { 0: localize('com_ui_select') })}
selectedValue={targetAgentId}
setValue={(id) => 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={
<MessageIcon
message={
{
endpoint: EModelEndpoint.agents,
isCreatedByUser: false,
} as TMessage
}
agent={targetAgentId && agentsMap ? agentsMap[targetAgentId] : undefined}
/>
}
className="flex-1 border-border-heavy"
containerClassName="px-0"
/>
<button
type="button"
className="rounded p-1 transition hover:bg-surface-hover"
onClick={() => toggleExpanded(idx)}
>
<ChevronDown
size={16}
className={`text-text-secondary transition-transform ${
isExpanded ? 'rotate-180' : ''
}`}
/>
</button>
<button
type="button"
className="rounded-xl p-1 transition hover:bg-surface-hover"
onClick={() => removeHandoffAt(idx)}
>
<X size={18} className="text-text-secondary" />
</button>
</div>
{isExpanded && (
<div className="space-y-3 rounded-md border border-border-light bg-surface-primary p-3">
<div>
<Label
htmlFor={`handoff-desc-${idx}`}
className="text-xs text-text-secondary"
>
{localize('com_ui_agent_handoff_description')}
</Label>
<Input
id={`handoff-desc-${idx}`}
placeholder={localize('com_ui_agent_handoff_description_placeholder')}
value={edge.description || ''}
onChange={(e) =>
updateHandoffDetailsAt(idx, { description: e.target.value })
}
className="mt-1 h-8 text-sm"
/>
</div>
<div>
<Label
htmlFor={`handoff-prompt-${idx}`}
className="text-xs text-text-secondary"
>
{localize('com_ui_agent_handoff_prompt')}
</Label>
<Textarea
id={`handoff-prompt-${idx}`}
placeholder={localize('com_ui_agent_handoff_prompt_placeholder')}
value={typeof edge.prompt === 'string' ? edge.prompt : ''}
onChange={(e) => updateHandoffDetailsAt(idx, { prompt: e.target.value })}
className="mt-1 h-20 resize-none text-sm"
/>
</div>
{edge.prompt && (
<div>
<Label
htmlFor={`handoff-promptkey-${idx}`}
className="text-xs text-text-secondary"
>
{localize('com_ui_agent_handoff_prompt_key')}
</Label>
<Input
id={`handoff-promptkey-${idx}`}
placeholder={localize('com_ui_agent_handoff_prompt_key_placeholder')}
value={edge.promptKey || ''}
onChange={(e) =>
updateHandoffDetailsAt(idx, { promptKey: e.target.value })
}
className="mt-1 h-8 text-sm"
/>
</div>
)}
</div>
)}
</div>
{idx < edges.length - 1 && (
<Waypoints className="mx-auto text-text-secondary" size={14} />
)}
</React.Fragment>
);
})}
{edges.length < MAX_HANDOFFS && (
<>
{edges.length > 0 && <Waypoints className="mx-auto text-text-secondary" size={14} />}
<ControlCombobox
isCollapsed={false}
ariaLabel={localize('com_ui_agent_var', { 0: localize('com_ui_add') })}
selectedValue=""
setValue={setNewAgentId}
selectPlaceholder={localize('com_ui_agent_handoff_add')}
searchPlaceholder={localize('com_ui_agent_var', { 0: localize('com_ui_search') })}
items={selectableAgents}
className="h-10 w-full border-dashed border-border-heavy text-center text-text-secondary hover:text-text-primary"
containerClassName="px-0"
SelectIcon={<PlusCircle size={16} className="text-text-secondary" />}
/>
</>
)}
{edges.length >= MAX_HANDOFFS && (
<p className="pt-1 text-center text-xs italic text-text-tertiary">
{localize('com_ui_agent_handoff_max', { 0: MAX_HANDOFFS })}
</p>
)}
</div>
<HoverCardPortal>
<HoverCardContent side={ESide.Top} className="w-80">
<div className="space-y-2">
<p className="text-sm text-text-secondary">{localize('com_ui_agent_handoff_info')}</p>
<p className="text-sm text-text-secondary">{localize('com_ui_agent_handoff_info_2')}</p>
</div>
</HoverCardContent>
</HoverCardPortal>
</HoverCard>
);
};
export default AgentHandoffs;

View file

@ -168,6 +168,7 @@ export default function AgentPanel() {
model_parameters,
provider: _provider,
agent_ids,
edges,
end_after_tools,
hide_sequential_outputs,
recursion_limit,
@ -192,6 +193,7 @@ export default function AgentPanel() {
provider,
model_parameters,
agent_ids,
edges,
end_after_tools,
hide_sequential_outputs,
recursion_limit,
@ -225,6 +227,7 @@ export default function AgentPanel() {
provider,
model_parameters,
agent_ids,
edges,
end_after_tools,
hide_sequential_outputs,
recursion_limit,

View file

@ -103,6 +103,11 @@ export default function AgentSelect({
return;
}
if (name === 'edges' && Array.isArray(value)) {
formValues[name] = value;
return;
}
if (!keys.has(name)) {
return;
}

View file

@ -655,6 +655,20 @@
"com_ui_agent_chain": "Agent Chain (Mixture-of-Agents)",
"com_ui_agent_chain_info": "Enables creating sequences of agents. Each agent can access outputs from previous agents in the chain. Based on the \"Mixture-of-Agents\" architecture where agents use previous outputs as auxiliary information.",
"com_ui_agent_chain_max": "You have reached the maximum of {{0}} agents.",
"com_ui_agent_handoffs": "Agent Handoffs",
"com_ui_agent_handoff_add": "Add handoff agent",
"com_ui_agent_handoff_description": "Handoff description",
"com_ui_agent_handoff_description_placeholder": "e.g., Transfer to data analyst for statistical analysis",
"com_ui_agent_handoff_info": "Configure agents that this agent can transfer conversations to when specific expertise is needed.",
"com_ui_agent_handoff_info_2": "Each handoff creates a transfer tool that enables seamless routing to specialist agents with context.",
"com_ui_agent_handoff_max": "Maximum {{0}} handoff agents reached.",
"com_ui_agent_handoff_prompt": "Passthrough content",
"com_ui_agent_handoff_prompt_placeholder": "Tell this agent what content to generate and pass to the handoff agent. You need to add something here to enable this feature",
"com_ui_agent_handoff_prompt_key": "Content parameter name (default: 'instructions')",
"com_ui_agent_handoff_prompt_key_placeholder": "Label the content passed (default: 'instructions')",
"com_ui_transferred_to": "Transferred to",
"com_ui_handoff_instructions": "Handoff instructions",
"com_ui_beta": "Beta",
"com_ui_agent_delete_error": "There was an error deleting the agent",
"com_ui_agent_deleted": "Successfully deleted agent",
"com_ui_agent_duplicate_error": "There was an error duplicating the agent",