diff --git a/.github/workflows/playwright-mock.yml b/.github/workflows/playwright-mock.yml index 10085c52f0b..cd0408a631f 100644 --- a/.github/workflows/playwright-mock.yml +++ b/.github/workflows/playwright-mock.yml @@ -420,6 +420,16 @@ jobs: conversation-management.spec.ts --grep 'first-turn|final-title polling' --reporter=line --output=e2e/specs/.test-results/title-ownership + - name: Run graph subagent execution regression + if: ${{ !cancelled() && matrix.stream_store == 'memory' && startsWith(matrix.shard, '1/') }} + env: + CI: 'true' + E2E_GRAPH_SUBAGENTS: 'true' + run: >- + npx playwright test --config=e2e/playwright.config.mock.ts + agent-graphs.spec.ts --reporter=line + --output=e2e/specs/.test-results/graph-subagents + - name: Upload title ownership diagnostics if: ${{ always() && matrix.stream_store == 'memory' && startsWith(matrix.shard, '1/') }} uses: actions/upload-artifact@v6 diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index c768bdefbcf..5d0972288ad 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -5,6 +5,7 @@ const { createOpenAIToolCallStream: createAcceptedToolCallStream, } = require('@librechat/agents/openai'); const { + hasSubagentCapability, EModelEndpoint, ResourceType, PermissionBits, @@ -699,7 +700,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { let handoffAgentConfigs = new Map(); let discoveredEdges = []; let discoveredMCPAuthMap; - const subagentsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.subagents); + const subagentsCapabilityEnabled = hasSubagentCapability([...enabledCapabilities]); const primaryHasGraphSubagents = subagentsCapabilityEnabled && primaryConfig.subagents?.enabled === true && diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index 21c4076e014..f0a1d988490 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -3,6 +3,7 @@ const { v4: uuidv4 } = require('uuid'); const { logger } = require('@librechat/data-schemas'); const { Callback, formatAgentMessages } = require('@librechat/agents'); const { + hasSubagentCapability, EModelEndpoint, ResourceType, PermissionBits, @@ -952,7 +953,7 @@ const executeResponse = async (envelope, { req, res }) => { let handoffAgentConfigs = new Map(); let discoveredEdges = []; let discoveredMCPAuthMap; - const subagentsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.subagents); + const subagentsCapabilityEnabled = hasSubagentCapability([...enabledCapabilities]); const primaryHasGraphSubagents = subagentsCapabilityEnabled && primaryConfig.subagents?.enabled === true && diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index 5aad55ad35a..7dbadac4fe9 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -54,6 +54,8 @@ const { instructionsContentForScan, } = require('@librechat/api'); const { + resolveSubagents, + hasSubagentCapability, Time, Tools, SkillsScope, @@ -476,7 +478,7 @@ const validateSubagentReferences = async ( const isSubagentsCapabilityEnabled = (req) => { const capabilities = req.config?.endpoints?.[EModelEndpoint.agents]?.capabilities; if (!Array.isArray(capabilities)) return false; - return capabilities.includes(AgentCapabilities.subagents); + return hasSubagentCapability(capabilities); }; const isCodeInterpreterCapabilityEnabled = (req) => { @@ -491,6 +493,7 @@ const isCodeInterpreterCapabilityEnabled = (req) => { * @returns {Promise<{ status: number, body: { error: string, agent_ids: string[] } } | null>} */ const getSubagentReferenceError = async (subagents, req, allowedMissingIds = new Set()) => { + subagents = resolveSubagents(subagents, req.config?.endpoints?.agents?.capabilities ?? []); if ( !isSubagentsCapabilityEnabled(req) || subagents?.enabled !== true || diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index b82704399ce..fa2a31a7492 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -57,6 +57,8 @@ const { createSubagentCodeRouting, } = require('@librechat/api'); const { + resolveSubagents, + hasSubagentCapability, ResourceType, EModelEndpoint, PermissionBits, @@ -1004,7 +1006,7 @@ const initializeClientWithProvider = async ({ const atSubagentThreadDepthLimit = !subagentThreadTaskStore.canCreateChildThread( requestConversation?.subagentThread?.depth ?? 0, ); - const subagentsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.subagents); + const subagentsCapabilityEnabled = hasSubagentCapability([...enabledCapabilities]); const subagentsAvailableForRun = subagentsCapabilityEnabled && !atSubagentThreadDepthLimit; /** Track skipped ids locally so repeated failures short-circuit within * the subagent loading loop. Seeded from the discovery helper's skip @@ -1164,6 +1166,7 @@ const initializeClientWithProvider = async ({ getInheritedEnvironments: () => req.codeWorkspaceInheritance, sharedRunFiles: isRunFileSharingRequested({ policy: appConfig.endpoints?.agents?.fileSharing, + capabilities: [...enabledCapabilities], agent: primaryConfig, }), }); @@ -1245,7 +1248,7 @@ const initializeClientWithProvider = async ({ memory_scope: agent.memory_scope, memoryToolsRegistered: memoryAvailable === true && agent.tools?.includes(Tools.memory) === true, - subagents: agent.subagents, + subagents: resolveSubagents(agent.subagents, [...enabledCapabilities]), configId: getLazySubagentConfigId(agent), ...codeAvailability, statefulCodeEnvironment, @@ -1410,6 +1413,7 @@ const initializeClientWithProvider = async ({ requestFiles, authorizedRunFiles: getAuthorizedRunFileSnapshot({ policy: appConfig.endpoints?.agents?.fileSharing, + capabilities: [...enabledCapabilities], agent: primaryConfig, files: primaryConfig.currentRequestAttachments, }), @@ -1647,6 +1651,7 @@ const initializeClientWithProvider = async ({ request: runtimeRequestBody, }), roots: rootSubagentConfigs.filter((config) => config?.id), + capabilities: [...enabledCapabilities], loadSubagent: (agentId) => resolveLazyMetadata(() => loadViewableSubagent(agentId)), environments: statefulCodeSessionsConfig?.environments, allowEnvironmentSelection: statefulCodeSessionsConfig?.allowEnvironmentSelection, diff --git a/client/src/components/SidePanel/Agents/Advanced/AgentSubagents.tsx b/client/src/components/SidePanel/Agents/Advanced/AgentSubagents.tsx index 84b00b39746..5dd95bd1cfd 100644 --- a/client/src/components/SidePanel/Agents/Advanced/AgentSubagents.tsx +++ b/client/src/components/SidePanel/Agents/Advanced/AgentSubagents.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useMemo } from 'react'; import { Switch } from '@librechat/client'; import { Network, Users } from 'lucide-react'; +import { isSubagentGraphsEnabled } from 'librechat-data-provider'; import type { ControllerRenderProps } from 'react-hook-form'; import type { AgentForm } from '~/common'; import { StaticAgentRow, AddAgentSelect, ListMeta, useSelectableAgents } from './AgentList'; @@ -28,7 +29,7 @@ const AgentSubagents: React.FC = ({ const enabled = value.enabled === true; const allowSelf = value.allowSelf !== false; const agentIds = useMemo(() => value.agent_ids ?? [], [value.agent_ids]); - const graphCount = value.graphs?.length ?? 0; + const graphCount = isSubagentGraphsEnabled(value) ? (value.graphs?.length ?? 0) : 0; const { options, getAgent } = useSelectableAgents({ currentAgentId, exclude: agentIds }); @@ -106,8 +107,8 @@ const AgentSubagents: React.FC = ({ beta info={ <> -

{localize('com_ui_agent_subagents_info')}

-

{localize('com_ui_agent_subagents_info_2')}

+

{localize('com_ui_agent_subagents_info')}

+

{localize('com_ui_agent_subagents_info_2')}

} trailing={ @@ -126,7 +127,7 @@ const AgentSubagents: React.FC = ({ checked={allowSelf} onCheckedChange={setAllowSelf} info={ -

+

{localize('com_ui_agent_subagents_allow_self_info')}

} @@ -139,7 +140,7 @@ const AgentSubagents: React.FC = ({ checked={value.shareFiles === true} onCheckedChange={setShareFiles} info={ -

+

{localize('com_ui_agent_subagents_share_files_info')}

} @@ -178,14 +179,14 @@ const AgentSubagents: React.FC = ({ )} {agentIds.length >= maxSubagents && ( -

+

{localize('com_ui_agent_subagents_max', { 0: maxSubagents })}

)} {nothingToSpawn && ( -

+

diff --git a/client/src/components/SidePanel/Agents/Advanced/Graphs.tsx b/client/src/components/SidePanel/Agents/Advanced/Graphs.tsx new file mode 100644 index 00000000000..d9b3328b521 --- /dev/null +++ b/client/src/components/SidePanel/Agents/Advanced/Graphs.tsx @@ -0,0 +1,483 @@ +import { useContext, useState, useMemo } from 'react'; +import { Workflow, Plus, Pencil, X } from 'lucide-react'; +import { useFormContext, useWatch } from 'react-hook-form'; +import { + Button, + Input, + Label, + Textarea, + Checkbox, + Switch, + ControlCombobox, +} from '@librechat/client'; +import { + graphSubagentSchema, + getSubagentGraphMemberCount, + MAX_SUBAGENT_GRAPH_NODES, + AgentCapabilities, + EModelEndpoint, + Tools, + isSubagentGraphsEnabled, + MAX_SUBAGENTS, + MAX_GRAPH_SUBAGENT_MEMBERS, +} from 'librechat-data-provider'; +import type { + AgentSubagentGraph, + AgentSubagentGraphEdge, + TConfig, + Agent, +} from 'librechat-data-provider'; +import type { AgentForm, OptionWithIcon } from '~/common'; +import { + collectReachableAgents, + findExecutionEnvironment, +} from '~/hooks/Agents/useCodeApprovalMode'; +import { AgentPickerPortalContext, AddAgentSelect, useSelectableAgents } from './AgentList'; +import { useAgentPanelContext, useAgentsMapContext } from '~/Providers'; +import OrchestrationPattern from './OrchestrationPattern'; +import { useLocalize } from '~/hooks'; +import { CountPill } from './ui'; + +interface Draft { + index: number | null; + graph: AgentSubagentGraph; +} +const endpoints = (value: string | string[]): string[] => (Array.isArray(value) ? value : [value]); + +export default function Graphs({ currentAgentId }: { currentAgentId: string }) { + const localize = useLocalize(); + const { agentsConfig } = useAgentPanelContext(); + const agentsMap = useAgentsMapContext(); + const { control, getValues, setValue } = useFormContext(); + const subagents = useWatch({ control, name: 'subagents' }); + const graphs = subagents?.graphs ?? []; + const enabled = isSubagentGraphsEnabled(subagents, agentsConfig?.capabilities); + const maximum = agentsConfig?.maxSubagents ?? MAX_SUBAGENTS; + const { options, getAgent } = useSelectableAgents({ currentAgentId }); + const portalElement = useContext(AgentPickerPortalContext) ?? undefined; + const [draft, setDraft] = useState(null); + const [invalid, setInvalid] = useState(false); + const allOptions: OptionWithIcon[] = [ + { value: '__current_agent__', label: localize('com_ui_agent_graphs_self') }, + ...options, + ]; + const nameOf = (id: string) => + id === currentAgentId || id === '' + ? localize('com_ui_agent_graphs_self') + : (getAgent(id)?.name ?? id); + const [codeEnabled, statefulSessions, codeEnvironmentId, codeEnvironmentIds, agentIds, edges] = + useWatch({ + control, + name: [ + 'execute_code', + 'stateful_code_sessions', + 'code_environment_id', + 'code_environment_ids', + 'agent_ids', + 'edges', + ], + }); + const reachable = useMemo(() => { + const currentAgent: Agent = { + id: currentAgentId, + name: null, + description: null, + avatar: null, + created_at: 0, + provider: EModelEndpoint.openAI, + model: null, + model_parameters: getValues('model_parameters'), + tools: codeEnabled ? [Tools.execute_code] : [], + stateful_code_sessions: statefulSessions, + code_environment_id: codeEnvironmentId, + code_environment_ids: codeEnvironmentIds, + agent_ids: agentIds, + edges, + subagents, + }; + return collectReachableAgents( + [currentAgent], + agentsMap, + [currentAgentId], + agentsConfig?.capabilities, + ); + }, [ + currentAgentId, + codeEnabled, + statefulSessions, + codeEnvironmentId, + codeEnvironmentIds, + agentIds, + edges, + subagents, + agentsMap, + agentsConfig?.capabilities, + getValues, + ]); + const statefulCodeSessions = agentsConfig?.statefulCodeSessions as + | TConfig['statefulCodeSessions'] + | undefined; + const attachedCode = + agentsConfig?.capabilities.includes(AgentCapabilities.execute_code) && + agentsConfig.capabilities.includes(AgentCapabilities.stateful_code_sessions) && + reachable.agents.some( + (agent) => + agent.stateful_code_sessions === true && + agent.tools?.includes(Tools.execute_code) && + findExecutionEnvironment(agent, statefulCodeSessions?.environments)?.type === 'attached', + ); + const approvalEnabled = + agentsConfig?.toolApproval?.enabled === true || + (statefulCodeSessions?.approvalsEnabled === true && attachedCode); + const write = (next: AgentSubagentGraph[], active?: boolean) => { + const value = getValues('subagents'); + setValue( + 'subagents', + { + ...value, + enabled: value?.enabled ?? false, + allowSelf: value?.allowSelf ?? true, + agent_ids: value?.agent_ids ?? [], + ...(active === undefined ? {} : { graphsEnabled: active }), + graphs: next, + }, + { shouldDirty: true }, + ); + }; + const update = (patch: Partial) => { + if (!draft) return; + setDraft({ ...draft, graph: { ...draft.graph, ...patch } }); + setInvalid(false); + }; + const addMember = (id: string) => { + if ( + !draft || + draft.graph.agent_ids.includes(id) || + draft.graph.agent_ids.length >= MAX_GRAPH_SUBAGENT_MEMBERS + ) + return; + update({ + agent_ids: [...draft.graph.agent_ids, id], + ...(draft.graph.agent_ids.length === 0 ? { entry_agent_id: id, result_agent_id: id } : {}), + }); + }; + const save = () => { + if (!draft) return; + const parsed = graphSubagentSchema.safeParse(draft.graph); + const reserved = new Set([ + 'self', + ...(subagents?.agent_ids ?? []), + ...graphs.filter((_, index) => index !== draft.index).map((graph) => graph.type), + ]); + if (!parsed.success || reserved.has(draft.graph.type.trim())) { + setInvalid(true); + return; + } + const next = graphs.slice(); + if (draft.index === null) { + if (graphs.length >= maximum) { + setInvalid(true); + return; + } + next.push(parsed.data); + } else next[draft.index] = parsed.data; + if (getSubagentGraphMemberCount({ ...subagents, graphs: next }) > MAX_SUBAGENT_GRAPH_NODES) { + setInvalid(true); + return; + } + write(next, draft.index === null ? true : undefined); + setDraft(null); + }; + const graph = draft?.graph; + const memberOptions = graph?.agent_ids.map((id) => ({ value: id, label: nameOf(id) })) ?? []; + const changeEdge = (index: number, patch: Partial) => { + if (!graph) return; + update({ edges: graph.edges.map((edge, i) => (i === index ? { ...edge, ...patch } : edge)) }); + }; + + return ( +