From 45138f955d6d242dd0c92ecc45e5a45bfa6deea7 Mon Sep 17 00:00:00 2001 From: Lia Date: Mon, 5 Oct 2026 03:15:20 +0000 Subject: [PATCH 01/12] feat: expose subagent graphs as a native tool --- .github/workflows/playwright-mock.yml | 10 + api/server/controllers/agents/openai.js | 3 +- api/server/controllers/agents/responses.js | 3 +- api/server/controllers/agents/v1.js | 5 +- .../services/Endpoints/agents/initialize.js | 6 +- .../Agents/Advanced/AgentSubagents.tsx | 18 +- .../SidePanel/Agents/Advanced/Graphs.tsx | 400 ++++++++++++++++++ .../Agents/Advanced/OrchestrationHub.tsx | 9 +- .../Agents/Advanced/__tests__/Graphs.spec.tsx | 65 +++ .../Agents/Tools/ItemDialog/ItemDialog.tsx | 6 +- .../ItemDialog/sections/BuiltinSection.tsx | 7 +- .../Agents/Tools/ToolsMarketplaceDialog.tsx | 7 + .../SidePanel/Agents/Tools/ToolsSection.tsx | 7 + .../Tools/items/__tests__/catalog.spec.ts | 12 + .../items/__tests__/orchestration.spec.ts | 39 +- .../SidePanel/Agents/Tools/items/catalog.ts | 9 + .../Agents/Tools/items/configurable.ts | 1 + .../SidePanel/Agents/Tools/items/icons.ts | 1 + .../SidePanel/Agents/Tools/items/mutations.ts | 4 + .../Agents/Tools/items/orchestration.ts | 4 + .../SidePanel/Agents/Tools/items/selectors.ts | 3 + .../SidePanel/Agents/Tools/items/types.ts | 1 + .../src/hooks/Agents/useCodeApprovalMode.ts | 11 +- client/src/locales/en/translation.json | 26 ++ e2e/config/librechat.e2e.yaml | 1 + e2e/playwright.config.mock.ts | 6 + e2e/specs/mock/agent-graphs.spec.ts | 148 +++++++ librechat.example.yaml | 2 +- .../__tests__/run-summarization.test.ts | 31 ++ packages/api/src/agents/discovery.ts | 8 +- packages/api/src/agents/files/session.ts | 19 +- packages/api/src/agents/initialize.ts | 2 + packages/api/src/agents/run.ts | 10 +- packages/api/src/agents/validation.ts | 159 +------ packages/api/src/code/inheritance.ts | 14 +- packages/api/src/schedules/requirements.ts | 17 +- packages/data-provider/src/config.ts | 2 + packages/data-provider/src/index.ts | 2 + .../data-provider/src/subagentGraphs.spec.ts | 84 ++++ packages/data-provider/src/subagentGraphs.ts | 199 +++++++++ packages/data-provider/src/types/agents.ts | 2 + 41 files changed, 1160 insertions(+), 203 deletions(-) create mode 100644 client/src/components/SidePanel/Agents/Advanced/Graphs.tsx create mode 100644 client/src/components/SidePanel/Agents/Advanced/__tests__/Graphs.spec.tsx create mode 100644 e2e/specs/mock/agent-graphs.spec.ts create mode 100644 packages/data-provider/src/subagentGraphs.spec.ts create mode 100644 packages/data-provider/src/subagentGraphs.ts diff --git a/.github/workflows/playwright-mock.yml b/.github/workflows/playwright-mock.yml index 6f740ca27d0..ba597a01280 100644 --- a/.github/workflows/playwright-mock.yml +++ b/.github/workflows/playwright-mock.yml @@ -414,6 +414,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 817723c29db..dee7cdc5da7 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, @@ -697,7 +698,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 d0a4eeb495e..c03eae1cd55 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, @@ -950,7 +951,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 c9ef4f2d33d..dcd074bd57b 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -55,6 +55,8 @@ const { getMCPRequestContext, } = require('@librechat/api'); const { + resolveSubagents, + hasSubagentCapability, ResourceType, EModelEndpoint, PermissionBits, @@ -991,7 +993,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 @@ -1220,7 +1222,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, diff --git a/client/src/components/SidePanel/Agents/Advanced/AgentSubagents.tsx b/client/src/components/SidePanel/Agents/Advanced/AgentSubagents.tsx index 84b00b39746..b23af7842b0 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 }); @@ -43,12 +44,13 @@ const AgentSubagents: React.FC = ({ */ field.onChange({ ...value, + ...(graphCount > 0 && { graphsEnabled: isSubagentGraphsEnabled(value) }), enabled: next, allowSelf: value.allowSelf ?? true, agent_ids: value.agent_ids ?? [], }); }, - [field, value], + [field, value, graphCount], ); const setAllowSelf = useCallback( @@ -106,8 +108,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 +128,7 @@ const AgentSubagents: React.FC = ({ checked={allowSelf} onCheckedChange={setAllowSelf} info={ -

+

{localize('com_ui_agent_subagents_allow_self_info')}

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

+

{localize('com_ui_agent_subagents_share_files_info')}

} @@ -178,14 +180,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..9daa6462c31 --- /dev/null +++ b/client/src/components/SidePanel/Agents/Advanced/Graphs.tsx @@ -0,0 +1,400 @@ +import { useContext, useState } 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, + isSubagentGraphsEnabled, + MAX_SUBAGENTS, + MAX_GRAPH_SUBAGENT_MEMBERS, +} from 'librechat-data-provider'; +import type { AgentSubagentGraph, AgentSubagentGraphEdge } from 'librechat-data-provider'; +import type { AgentForm, OptionWithIcon } from '~/common'; +import { AgentPickerPortalContext, AddAgentSelect, useSelectableAgents } from './AgentList'; +import OrchestrationPattern from './OrchestrationPattern'; +import { useAgentPanelContext } from '~/Providers'; +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 { control, getValues, setValue } = useFormContext(); + const subagents = useWatch({ control, name: 'subagents' }); + const graphs = subagents?.graphs ?? []; + const enabled = isSubagentGraphsEnabled(subagents); + 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 write = (next: AgentSubagentGraph[], active = true) => { + const value = getValues('subagents'); + setValue( + 'subagents', + { + ...value, + enabled: value?.enabled ?? false, + allowSelf: value?.allowSelf ?? true, + agent_ids: value?.agent_ids ?? [], + 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; + write(next); + 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 ( +