diff --git a/.github/workflows/playwright-mock.yml b/.github/workflows/playwright-mock.yml index 59aef8185a2..138e5160b21 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/__tests__/resume.spec.js b/api/server/controllers/agents/__tests__/resume.spec.js index b5baf0c4a71..654fc795d90 100644 --- a/api/server/controllers/agents/__tests__/resume.spec.js +++ b/api/server/controllers/agents/__tests__/resume.spec.js @@ -1719,6 +1719,288 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => { expect(mockInitializeClient).not.toHaveBeenCalled(); }); + it.each([ + { + name: 'graph-only edited member', + capabilities: [AgentCapabilities.subagent_graphs], + enabled: false, + graphsEnabled: true, + failure: 'content_filter_block', + }, + { + name: 'legacy edited member', + capabilities: [AgentCapabilities.subagents], + enabled: true, + failure: 'content_filter_block', + }, + { + name: 'missing active member', + capabilities: [AgentCapabilities.subagent_graphs], + enabled: false, + graphsEnabled: true, + missing: true, + failure: 'content_filter_uninspectable', + }, + { + name: 'revoked VIEW on active member', + capabilities: [AgentCapabilities.subagent_graphs], + enabled: false, + graphsEnabled: true, + denied: true, + failure: 'content_filter_uninspectable', + }, + { + name: 'disabled graph capability', + capabilities: [AgentCapabilities.subagents], + enabled: false, + graphsEnabled: true, + }, + { + name: 'explicitly disabled graph', + capabilities: [AgentCapabilities.subagent_graphs], + enabled: false, + graphsEnabled: false, + }, + ])( + 'checks $name before claiming the pause', + async ({ capabilities, enabled, graphsEnabled, missing, denied, failure }) => { + requestConfigOverrides = { + endpoints: { agents: { checkpointer: { type: 'mongo' }, capabilities } }, + filters: { + agentInstructions: { + pii: { fields: ['instructions'], starterPatterns: ['sk_prefix'] }, + }, + }, + }; + endpointAgent.subagents = { + enabled, + ...(graphsEnabled == null ? {} : { graphsEnabled }), + graphs: [ + { + type: 'team', + name: 'Team', + description: 'Work', + agent_ids: ['member'], + edges: [], + entry_agent_id: 'member', + result_agent_id: 'member', + }, + ], + }; + mockGetAgent.mockResolvedValue( + missing + ? null + : { + _id: 'mongo-member', + id: 'member', + provider: 'openAI', + model: 'test', + instructions: 'Use sk-current-graph-member-secret', + tools: [], + }, + ); + mockCheckPermission.mockResolvedValue(!denied); + mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob()); + const res = await post(approveBody()); + if (failure) { + expect(res.status).toBe(400); + expect(res.body.error).toBe(failure); + expect(mockGetAgent).toHaveBeenCalledTimes(1); + expect(mockGetAgent).toHaveBeenCalledWith({ id: 'member' }); + expect(mockCheckAndIncrementPendingRequest).not.toHaveBeenCalled(); + expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled(); + expect(mockInitializeClient).not.toHaveBeenCalled(); + expect(mockDeleteAgentCheckpoint).not.toHaveBeenCalled(); + } else { + expect(res.status).toBe(200); + await settled; + await flush(); + expect(mockGetAgent).not.toHaveBeenCalled(); + expect(mockGenerationJobManager.approvals.resolve).toHaveBeenCalledTimes(1); + } + }, + ); + + it.each(['safe', 'blocked', 'missing', 'revoked', 'over-budget'])( + 'checks discovered handoffs plus graph members before claiming a parent ask: %s', + async (state) => { + requestConfigOverrides = { + endpoints: { + agents: { + checkpointer: { type: 'mongo' }, + capabilities: [AgentCapabilities.subagent_graphs], + }, + }, + filters: { + agentInstructions: { + pii: { fields: ['instructions'], starterPatterns: ['sk_prefix'] }, + }, + }, + }; + const count = 50 + (state === 'over-budget' ? 1 : 0); + const ids = Array.from({ length: count }, (_, index) => `member-${index}`); + const graphs = []; + for (let start = 0; start < count; start += 25) { + const members = ids.slice(start, start + 25); + graphs.push({ + type: `team-${start}`, + name: 'Team', + description: 'Work', + agent_ids: members, + entry_agent_id: members[0], + result_agent_id: members[members.length - 1], + edges: members + .slice(1) + .map((id, index) => ({ from: members[index], to: id, edgeType: 'direct' })), + }); + } + endpointAgent.edges = [{ from: AGENT_ID, to: 'handoff', edgeType: 'handoff' }]; + endpointAgent.subagents = { enabled: false, graphsEnabled: true, graphs }; + const last = ids[count - 1]; + mockGetAgent.mockImplementation(async ({ id }) => + id === last && state === 'missing' + ? null + : { + _id: `mongo-${id}`, + id, + provider: 'openAI', + model: 'test', + tools: [], + instructions: + id === last && state === 'blocked' ? 'Use sk-boundary-member-secret' : '', + }, + ); + mockCheckPermission.mockImplementation( + async ({ resourceId }) => resourceId !== `mongo-${last}` || state !== 'revoked', + ); + mockGenerationJobManager.getJob.mockResolvedValue(makeAskUserJob()); + const res = await post({ + conversationId: CONVO_ID, + actionId: ACTION_ID, + agent_id: AGENT_ID, + endpoint: 'agents', + answer: 'continue', + }); + if (state === 'safe') { + expect(res.status).toBe(200); + await settled; + await flush(); + expect(mockGenerationJobManager.approvals.resolve).toHaveBeenCalledTimes(1); + expect(mockInitializeClient).toHaveBeenCalledTimes(1); + } else { + expect(res.status).toBe(400); + expect(res.body.error).toBe( + state === 'blocked' ? 'content_filter_block' : 'content_filter_uninspectable', + ); + expect(mockCheckAndIncrementPendingRequest).not.toHaveBeenCalled(); + expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled(); + expect(mockInitializeClient).not.toHaveBeenCalled(); + expect(mockDeleteAgentCheckpoint).not.toHaveBeenCalled(); + } + expect(mockGetAgent).toHaveBeenCalledWith({ id: 'handoff' }); + expect(mockGetAgent).toHaveBeenCalledTimes(state === 'over-budget' ? 1 : count + 1); + }, + ); + + it.each(['missing', 'revoked', 'required-missing', 'required-revoked', 'extra-allowed'])( + 'counts admitted ordinary members before claiming a parent ask: %s', + async (state) => { + requestConfigOverrides = { + endpoints: { + agents: { + checkpointer: { type: 'mongo' }, + capabilities: [AgentCapabilities.subagents, AgentCapabilities.subagent_graphs], + }, + }, + filters: { + agentInstructions: { + pii: { fields: ['instructions'], starterPatterns: ['sk_prefix'] }, + }, + }, + }; + const ids = Array.from({ length: 49 }, (_, index) => `member-c19-${index}`); + const graphs = [0, 25].map((start) => { + const members = ids.slice(start, start + 25); + return { + type: `team-${start}`, + name: 'Team', + description: 'Work', + agent_ids: members, + entry_agent_id: members[0], + result_agent_id: members[members.length - 1], + edges: members + .slice(1) + .map((id, index) => ({ from: members[index], to: id, edgeType: 'direct' })), + }; + }); + endpointAgent.subagents = { + enabled: true, + graphsEnabled: true, + agent_ids: ['child'], + graphs, + }; + const child = { + _id: 'mongo-child', + id: 'child', + provider: 'openAI', + model: 'test', + tools: [], + subagents: { + enabled: true, + agent_ids: ['stale', ...(state === 'extra-allowed' ? ['extra'] : [])], + ...(state.startsWith('required') + ? { + graphsEnabled: true, + graphs: [ + { + type: 'required', + name: 'Required', + description: 'Work', + agent_ids: ['stale'], + entry_agent_id: 'stale', + result_agent_id: 'stale', + edges: [], + }, + ], + } + : {}), + }, + }; + mockGetAgent.mockImplementation(async ({ id }) => { + if (id === 'stale' && !state.endsWith('revoked')) return null; + if (id === 'child') return child; + return { _id: `mongo-${id}`, id, provider: 'openAI', model: 'test', tools: [] }; + }); + mockCheckPermission.mockImplementation( + async ({ resourceId }) => resourceId !== 'mongo-stale', + ); + mockGenerationJobManager.getJob.mockResolvedValue(makeAskUserJob()); + const res = await post({ + conversationId: CONVO_ID, + actionId: ACTION_ID, + agent_id: AGENT_ID, + endpoint: 'agents', + answer: 'continue', + }); + if (state === 'missing' || state === 'revoked') { + expect(res.status).toBe(200); + await settled; + await flush(); + expect(mockGenerationJobManager.approvals.resolve).toHaveBeenCalledTimes(1); + expect(mockInitializeClient).toHaveBeenCalledTimes(1); + } else { + expect(res.status).toBe(400); + expect(res.body.error).toBe('content_filter_uninspectable'); + expect(mockCheckAndIncrementPendingRequest).not.toHaveBeenCalled(); + expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled(); + expect(mockInitializeClient).not.toHaveBeenCalled(); + expect(mockDeleteAgentCheckpoint).not.toHaveBeenCalled(); + } + expect(mockGetAgent).toHaveBeenCalledWith({ id: 'stale' }); + if (state === 'extra-allowed') expect(mockGetAgent).toHaveBeenCalledWith({ id: 'extra' }); + }, + ); + it('blocks current action metadata before consuming approval', async () => { requestConfigOverrides = { filters: { diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index fd2fc1f2593..802c7e940d9 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, @@ -704,7 +705,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 ec164191642..94c1062dad9 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, @@ -959,7 +960,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 5d49b6bca3c..463bed2432c 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 2f102f8f8d9..b74f43e2aec 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -59,6 +59,8 @@ const { hasEntityEmbeddings, } = require('@librechat/api'); const { + resolveSubagents, + hasSubagentCapability, ResourceType, EModelEndpoint, PermissionBits, @@ -1027,7 +1029,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 @@ -1187,6 +1189,7 @@ const initializeClientWithProvider = async ({ getInheritedEnvironments: () => req.codeWorkspaceInheritance, sharedRunFiles: isRunFileSharingRequested({ policy: appConfig.endpoints?.agents?.fileSharing, + capabilities: [...enabledCapabilities], agent: primaryConfig, }), }); @@ -1268,7 +1271,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, @@ -1433,6 +1436,7 @@ const initializeClientWithProvider = async ({ requestFiles, authorizedRunFiles: getAuthorizedRunFileSnapshot({ policy: appConfig.endpoints?.agents?.fileSharing, + capabilities: [...enabledCapabilities], agent: primaryConfig, files: primaryConfig.currentRequestAttachments, }), @@ -1680,6 +1684,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..e8a0034cbf7 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'; @@ -13,6 +14,7 @@ interface AgentSubagentsProps { currentAgentId: string; maxSubagents: number; fileSharingEnabled?: boolean; + capabilities?: readonly string[]; } const AgentSubagents: React.FC = ({ @@ -20,6 +22,7 @@ const AgentSubagents: React.FC = ({ currentAgentId, maxSubagents, fileSharingEnabled = false, + capabilities, }) => { const localize = useLocalize(); @@ -28,7 +31,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, capabilities) ? (value.graphs?.length ?? 0) : 0; const { options, getAgent } = useSelectableAgents({ currentAgentId, exclude: agentIds }); @@ -106,8 +109,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 +129,7 @@ const AgentSubagents: React.FC = ({ checked={allowSelf} onCheckedChange={setAllowSelf} info={ -

+

{localize('com_ui_agent_subagents_allow_self_info')}

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

+

{localize('com_ui_agent_subagents_share_files_info')}

} @@ -178,14 +181,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..ffb9782fc61 --- /dev/null +++ b/client/src/components/SidePanel/Agents/Advanced/Graphs.tsx @@ -0,0 +1,496 @@ +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 { CountPill, ToggleSetting } from './ui'; +import { useLocalize } from '~/hooks'; + +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 ( +