mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-30 23:28:52 +01:00
🤖 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:
parent
958a6c7872
commit
8a4a5a4790
41 changed files with 1108 additions and 3810 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -103,6 +103,11 @@ export default function AgentSelect({
|
|||
return;
|
||||
}
|
||||
|
||||
if (name === 'edges' && Array.isArray(value)) {
|
||||
formValues[name] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!keys.has(name)) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue