From 7d62be2ad3ebc263fa19a71740cd762f981220a7 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 17 Aug 2026 18:02:52 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=95=B8=EF=B8=8F=20feat:=20Run=20Saved=20A?= =?UTF-8?q?gent=20Teams=20as=20Subagents=20(#14944)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Add graph subagent integration * style: Sort response usage test imports * fix: Preserve lazy graph runtime context * fix: Use isolated graph input helper * test: Align graph integration fixtures * fix: Preserve lazy graph runtime capabilities * fix: Bound lazy graph metadata preload * fix: Harden lazy graph resolution lifecycle * fix: Coalesce lazy graph member resolution * fix: Snapshot initialized graph members only * fix: Preserve lazy agent runtime context * fix: Preserve batched lazy context preparation * fix: Preserve graph member capability bounds * fix: reconcile graph subagents with execution profiles * style: align graph subagent types with formatter --- .../agents/__tests__/openai.spec.js | 107 ++++ .../agents/__tests__/responses.unit.spec.js | 98 ++++ .../controllers/agents/__tests__/v1.spec.js | 41 ++ api/server/controllers/agents/client.js | 164 ++++-- api/server/controllers/agents/client.test.js | 96 ++++ api/server/controllers/agents/openai.js | 231 +++++--- api/server/controllers/agents/responses.js | 234 +++++--- api/server/controllers/agents/v1.js | 205 +++++-- api/server/controllers/agents/v1.spec.js | 233 ++++++++ .../services/Endpoints/agents/initialize.js | 204 ++++++- .../Endpoints/agents/initialize.spec.js | 517 ++++++++++++++++++ .../services/Endpoints/agents/skillDeps.js | 20 + .../Agents/Advanced/AgentSubagents.tsx | 6 +- .../__tests__/graph-subagent.e2e.test.ts | 185 +++++++ .../__tests__/run-summarization.test.ts | 393 +++++++++++++ packages/api/src/agents/attachments.test.ts | 16 + packages/api/src/agents/attachments.ts | 23 +- .../api/src/agents/codeFilesSession.spec.ts | 43 ++ packages/api/src/agents/codeFilesSession.ts | 42 +- packages/api/src/agents/discovery.spec.ts | 331 ++++++++++- packages/api/src/agents/discovery.ts | 374 ++++++++----- packages/api/src/agents/memory.spec.ts | 42 ++ packages/api/src/agents/memory.ts | 33 ++ .../responses/__tests__/service.test.ts | 39 +- packages/api/src/agents/responses/service.ts | 71 ++- packages/api/src/agents/run.ts | 217 ++++++-- packages/api/src/agents/usage.spec.ts | 24 + packages/api/src/agents/usage.ts | 34 +- packages/api/src/agents/validation.spec.ts | 163 +++++- packages/api/src/agents/validation.ts | 284 +++++++--- packages/data-provider/src/config.ts | 1 + packages/data-provider/src/limits.ts | 3 + packages/data-provider/src/models.ts | 4 +- packages/data-provider/src/schemas.ts | 14 +- .../data-provider/src/types/assistants.ts | 29 +- packages/data-provider/src/types/runs.ts | 18 + 36 files changed, 3903 insertions(+), 636 deletions(-) create mode 100644 packages/api/src/agents/__tests__/graph-subagent.e2e.test.ts diff --git a/api/server/controllers/agents/__tests__/openai.spec.js b/api/server/controllers/agents/__tests__/openai.spec.js index e11532a969..389d58d8eb 100644 --- a/api/server/controllers/agents/__tests__/openai.spec.js +++ b/api/server/controllers/agents/__tests__/openai.spec.js @@ -13,6 +13,12 @@ const mockRecordCollectedUsage = jest .mockResolvedValue({ input_tokens: 100, output_tokens: 50 }); const mockGetBalanceConfig = jest.fn().mockReturnValue({ enabled: true }); const mockGetTransactionsConfig = jest.fn().mockReturnValue({ enabled: true }); +const mockResolveMemoryAvailability = jest.fn().mockResolvedValue(true); +const mockBuildAgentScopedContext = jest.fn().mockResolvedValue(new Map()); +const mockBuildAgentContextAttachmentsByAgentId = jest.fn().mockReturnValue(new Map()); +const mockBuildInlineMemoryContext = jest.fn().mockResolvedValue(''); +const mockApplyContextToAgent = jest.fn().mockResolvedValue(undefined); +const mockInitialSessions = new Map([['execute_code', { session_id: 'seeded' }]]); class MockAgentRunEnvelopeError extends TypeError { constructor(message) { super(message); @@ -46,6 +52,7 @@ const mockBuildSkillPrimedIdsByName = jest.fn((manualSkillPrimes, alwaysApplySki const mockEnrichWithSkillConfigurable = jest.fn((result) => result); const mockBuildAgentToolContext = jest.fn(({ agent, config }) => ({ agent, + endpointTokenConfig: config.endpointTokenConfig, toolRegistry: config.toolRegistry, userMCPAuthMap: config.userMCPAuthMap, tool_resources: config.tool_resources, @@ -108,8 +115,14 @@ jest.mock('@librechat/api', () => ({ createRun: jest.fn().mockResolvedValue({ processStream: mockProcessStream, }), + applyContextToAgent: (...args) => mockApplyContextToAgent(...args), + buildAgentScopedContext: (...args) => mockBuildAgentScopedContext(...args), + buildInlineMemoryContext: (...args) => mockBuildInlineMemoryContext(...args), + buildAgentContextAttachmentsByAgentId: (...args) => + mockBuildAgentContextAttachmentsByAgentId(...args), createChunk: jest.fn().mockReturnValue({}), buildToolSet: jest.fn().mockReturnValue(new Set()), + buildInitialToolSessions: jest.fn().mockReturnValue(mockInitialSessions), AgentRunEnvelopeError: MockAgentRunEnvelopeError, createAgentRunEnvelope: (...args) => mockCreateAgentRunEnvelope(...args), scopeSkillIds: jest.fn().mockImplementation((ids) => ids), @@ -134,6 +147,9 @@ jest.mock('@librechat/api', () => ({ getTransactionsConfig: mockGetTransactionsConfig, recordCollectedUsage: mockRecordCollectedUsage, createSubagentUsageSink: jest.fn().mockReturnValue(jest.fn()), + resolveAgentTokenConfig: jest.fn(({ agentId, byAgentId, fallback }) => + agentId != null && byAgentId?.has(agentId) ? byAgentId.get(agentId) : fallback, + ), extractManualSkills: jest.fn().mockReturnValue(undefined), injectSkillPrimes: jest.fn().mockReturnValue({ initialMessages: [], @@ -168,12 +184,21 @@ jest.mock('@librechat/api', () => ({ skippedAgentIds: new Set(), userMCPAuthMap: undefined, }), + resolveSubagentGraphs: jest.fn().mockResolvedValue(undefined), })); jest.mock('~/server/controllers/ModelController', () => ({ getModelsConfig: jest.fn().mockResolvedValue({}), })); +jest.mock('~/server/services/MCP', () => ({ + resolveConfigServers: jest.fn().mockResolvedValue({}), +})); + +jest.mock('~/config', () => ({ + getMCPManager: jest.fn().mockReturnValue({}), +})); + jest.mock('~/server/services/Files/permissions', () => ({ filterFilesByAgentAccess: jest.fn(), })); @@ -186,6 +211,7 @@ jest.mock('~/server/services/Endpoints/agents/skillDeps', () => ({ enrichWithSkillConfigurable: mockEnrichWithSkillConfigurable, buildSkillPrimedIdsByName: mockBuildSkillPrimedIdsByName, buildAgentToolContext: mockBuildAgentToolContext, + resolveMemoryAvailability: mockResolveMemoryAvailability, enrichLoadedToolsWithAgentContext: mockEnrichLoadedToolsWithAgentContext, })); @@ -248,6 +274,7 @@ jest.mock('~/models', () => ({ getMultiplier: mockGetMultiplier, getCacheMultiplier: mockGetCacheMultiplier, getConvoFiles: jest.fn().mockResolvedValue([]), + getFormattedMemories: jest.fn().mockResolvedValue({ withKeys: '', withoutKeys: '' }), getConvo: jest.fn().mockResolvedValue(null), })); @@ -286,6 +313,86 @@ describe('OpenAIChatCompletionController', () => { }; }); + it('resolves saved graph subagents for remote chat-completion runs', async () => { + const { + initializeAgent, + resolveSubagentGraphs, + createSubagentUsageSink, + } = require('@librechat/api'); + const primaryConfig = { + id: 'agent-123', + model: 'gpt-4', + endpointTokenConfig: { 'gpt-4': { prompt: 1 } }, + model_parameters: {}, + toolRegistry: {}, + edges: [], + subagents: { + enabled: true, + graphs: [{ type: 'team', agent_ids: ['agent-123'], edges: [] }], + }, + }; + initializeAgent.mockResolvedValueOnce(primaryConfig); + const memberTokenConfig = { 'custom-model': { prompt: 7 } }; + const memberConfig = { + id: 'agent-graph-member', + endpointTokenConfig: memberTokenConfig, + agentContextAttachments: [{ file_id: 'member-file' }], + }; + resolveSubagentGraphs.mockImplementationOnce(async ({ rootConfigs }, deps) => { + rootConfigs[0].subagentGraphConfigs = [ + { definition: { type: 'team' }, memberConfigs: [memberConfig] }, + ]; + deps.onAgentInitialized('agent-graph-member', { id: 'agent-graph-member' }, memberConfig); + }); + req.config.endpoints.agents.capabilities = ['subagents']; + + await OpenAIChatCompletionController(req, res); + + expect(resolveSubagentGraphs).toHaveBeenCalledWith( + expect.objectContaining({ + primaryConfig, + rootConfigs: [primaryConfig], + resourceType: ResourceType.REMOTE_AGENT, + memoryAvailable: true, + }), + expect.objectContaining({ getAgent: expect.any(Function) }), + ); + const usageParams = mockRecordCollectedUsage.mock.calls[0][1]; + expect(usageParams.endpointTokenConfig).toBe(primaryConfig.endpointTokenConfig); + expect(usageParams.resolveEndpointTokenConfig({ agentId: 'agent-graph-member' })).toBe( + memberTokenConfig, + ); + expect(mockResolveMemoryAvailability).toHaveBeenCalledWith( + expect.objectContaining({ enabledCapabilities: expect.any(Set), user: req.user }), + ); + expect(mockBuildAgentContextAttachmentsByAgentId).toHaveBeenCalledWith([ + primaryConfig, + memberConfig, + ]); + expect(mockBuildAgentScopedContext).toHaveBeenCalledWith( + expect.objectContaining({ agentIds: ['agent-123', 'agent-graph-member'] }), + ); + expect(mockApplyContextToAgent).toHaveBeenCalledWith( + expect.objectContaining({ agent: memberConfig, agentId: 'agent-graph-member' }), + ); + expect(mockBuildInlineMemoryContext).toHaveBeenCalledWith( + expect.objectContaining({ agent: memberConfig, memoryAvailable: true }), + ); + const { createRun } = require('@librechat/api'); + expect(createRun).toHaveBeenCalledWith( + expect.objectContaining({ initialSessions: mockInitialSessions }), + ); + expect(createSubagentUsageSink).toHaveBeenCalledWith(expect.any(Array), expect.any(Function)); + const aggregator = + require('@librechat/api').createOpenAIContentAggregator.mock.results.at(-1).value; + const initialPromptTokens = aggregator.usage.promptTokens; + const initialCompletionTokens = aggregator.usage.completionTokens; + const onSubagentUsage = createSubagentUsageSink.mock.calls.at(-1)[1]; + onSubagentUsage({ input_tokens: 25, output_tokens: 10 }); + expect(aggregator.usage.promptTokens).toBe(initialPromptTokens + 25); + expect(aggregator.usage.completionTokens).toBe(initialCompletionTokens + 10); + }); + describe('conversation ownership validation', () => { it('should skip ownership check when conversation_id is not provided', async () => { const { getConvo } = require('~/models'); diff --git a/api/server/controllers/agents/__tests__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js index 5c5c49db0d..943411d3e7 100644 --- a/api/server/controllers/agents/__tests__/responses.unit.spec.js +++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js @@ -12,6 +12,8 @@ const mockRecordCollectedUsage = jest .mockResolvedValue({ input_tokens: 100, output_tokens: 50 }); const mockGetBalanceConfig = jest.fn().mockReturnValue({ enabled: true }); const mockGetTransactionsConfig = jest.fn().mockReturnValue({ enabled: true }); +const mockResolveMemoryAvailability = jest.fn().mockResolvedValue(true); +const mockInitialSessions = new Map([['execute_code', { session_id: 'seeded' }]]); class MockAgentRunEnvelopeError extends TypeError { constructor(message) { super(message); @@ -45,6 +47,7 @@ const mockBuildSkillPrimedIdsByName = jest.fn((manualSkillPrimes, alwaysApplySki const mockEnrichWithSkillConfigurable = jest.fn((result) => result); const mockBuildAgentToolContext = jest.fn(({ agent, config }) => ({ agent, + endpointTokenConfig: config.endpointTokenConfig, toolRegistry: config.toolRegistry, userMCPAuthMap: config.userMCPAuthMap, tool_resources: config.tool_resources, @@ -78,6 +81,7 @@ const mockCanAuthorSkillFiles = jest.fn( const mockGetSkillToolDeps = jest.fn(() => ({})); const mockBuildAgentScopedContext = jest.fn().mockResolvedValue(new Map()); const mockBuildAgentContextAttachmentsByAgentId = jest.fn().mockReturnValue(new Map()); +const mockBuildInlineMemoryContext = jest.fn().mockResolvedValue(''); const mockApplyContextToAgent = jest.fn().mockResolvedValue(undefined); jest.mock('nanoid', () => ({ @@ -113,11 +117,13 @@ jest.mock('@librechat/api', () => ({ createRun: jest.fn().mockResolvedValue({ processStream: jest.fn().mockResolvedValue(undefined), }), + buildInitialToolSessions: jest.fn().mockReturnValue(mockInitialSessions), applyContextToAgent: (...args) => mockApplyContextToAgent(...args), buildToolSet: jest.fn().mockReturnValue(new Set()), AgentRunEnvelopeError: MockAgentRunEnvelopeError, createAgentRunEnvelope: (...args) => mockCreateAgentRunEnvelope(...args), buildAgentScopedContext: (...args) => mockBuildAgentScopedContext(...args), + buildInlineMemoryContext: (...args) => mockBuildInlineMemoryContext(...args), buildAgentContextAttachmentsByAgentId: (...args) => mockBuildAgentContextAttachmentsByAgentId(...args), scopeSkillIds: jest.fn().mockImplementation((ids) => ids), @@ -148,6 +154,7 @@ jest.mock('@librechat/api', () => ({ userMCPAuthMap: undefined, }; }), + resolveSubagentGraphs: jest.fn().mockResolvedValue(undefined), getBalanceConfig: mockGetBalanceConfig, getTransactionsConfig: mockGetTransactionsConfig, recordCollectedUsage: mockRecordCollectedUsage, @@ -156,6 +163,9 @@ jest.mock('@librechat/api', () => ({ langfuseSampled: true, langfuseDestinationIds: ['destination-1'], }), + resolveAgentTokenConfig: jest.fn(({ agentId, byAgentId, fallback }) => + agentId != null && byAgentId?.has(agentId) ? byAgentId.get(agentId) : fallback, + ), extractManualSkills: jest.fn().mockReturnValue(undefined), injectSkillPrimes: jest.fn().mockReturnValue({ initialMessages: [], @@ -266,6 +276,7 @@ jest.mock('~/server/services/Endpoints/agents/skillDeps', () => ({ enrichWithSkillConfigurable: mockEnrichWithSkillConfigurable, buildSkillPrimedIdsByName: mockBuildSkillPrimedIdsByName, buildAgentToolContext: mockBuildAgentToolContext, + resolveMemoryAvailability: mockResolveMemoryAvailability, enrichLoadedToolsWithAgentContext: mockEnrichLoadedToolsWithAgentContext, })); @@ -307,6 +318,7 @@ jest.mock('~/models', () => ({ getMultiplier: mockGetMultiplier, getCacheMultiplier: mockGetCacheMultiplier, getConvoFiles: jest.fn().mockResolvedValue([]), + getFormattedMemories: jest.fn().mockResolvedValue({ withKeys: '', withoutKeys: '' }), saveConvo: jest.fn().mockResolvedValue({}), getConvo: jest.fn().mockResolvedValue(null), })); @@ -349,6 +361,73 @@ describe('createResponse controller', () => { }; }); + it('resolves saved graph subagents for remote Responses API runs', async () => { + const { initializeAgent, resolveSubagentGraphs } = require('@librechat/api'); + const primaryConfig = { + id: 'agent-123', + model: 'claude-3', + endpointTokenConfig: { 'claude-3': { prompt: 1 } }, + model_parameters: {}, + toolRegistry: {}, + edges: [], + agentContextAttachments: [], + subagents: { + enabled: true, + graphs: [{ type: 'team', agent_ids: ['agent-123'], edges: [] }], + }, + }; + initializeAgent.mockResolvedValueOnce(primaryConfig); + const memberConfig = { + id: 'agent-graph-member', + endpointTokenConfig: { 'custom-model': { prompt: 7 } }, + agentContextAttachments: [{ file_id: 'member-file' }], + }; + resolveSubagentGraphs.mockImplementationOnce(async ({ rootConfigs }, deps) => { + rootConfigs[0].subagentGraphConfigs = [ + { definition: { type: 'team' }, memberConfigs: [memberConfig] }, + ]; + deps.onAgentInitialized(memberConfig.id, memberConfig, memberConfig); + }); + req.config.endpoints.agents.capabilities = ['subagents']; + + await createResponse(req, res); + + expect(resolveSubagentGraphs).toHaveBeenCalledWith( + expect.objectContaining({ + primaryConfig, + rootConfigs: [primaryConfig], + resourceType: ResourceType.REMOTE_AGENT, + memoryAvailable: true, + }), + expect.objectContaining({ getAgent: expect.any(Function) }), + ); + expect(mockBuildAgentContextAttachmentsByAgentId).toHaveBeenCalledWith([ + primaryConfig, + memberConfig, + ]); + expect(mockBuildAgentScopedContext).toHaveBeenCalledWith( + expect.objectContaining({ agentIds: ['agent-123', 'agent-graph-member'] }), + ); + expect(mockApplyContextToAgent).toHaveBeenCalledWith( + expect.objectContaining({ agent: memberConfig, agentId: 'agent-graph-member' }), + ); + expect(mockBuildInlineMemoryContext).toHaveBeenCalledWith( + expect.objectContaining({ agent: memberConfig, memoryAvailable: true }), + ); + const usageParams = mockRecordCollectedUsage.mock.calls[0][1]; + expect(usageParams.endpointTokenConfig).toBe(primaryConfig.endpointTokenConfig); + expect(usageParams.resolveEndpointTokenConfig({ agentId: memberConfig.id })).toBe( + memberConfig.endpointTokenConfig, + ); + expect(mockResolveMemoryAvailability).toHaveBeenCalledWith( + expect.objectContaining({ enabledCapabilities: expect.any(Set), user: req.user }), + ); + const { createRun } = require('@librechat/api'); + expect(createRun).toHaveBeenCalledWith( + expect.objectContaining({ initialSessions: mockInitialSessions }), + ); + }); + it('returns 503 when an agent expects MCP tools but resolves none', async () => { const { initializeAgent, sendResponsesErrorResponse } = require('@librechat/api'); const { loadAgentTools } = require('~/server/services/ToolService'); @@ -836,6 +915,25 @@ describe('createResponse controller', () => { }), ); }); + + it('adds subagent usage to the response usage handler', async () => { + const api = require('@librechat/api'); + + await createResponse(req, res); + + const onSubagentUsage = api.createSubagentUsageSink.mock.calls.at(-1)[1]; + const aggregatorHandlers = + api.createAggregatorEventHandlers.mock.results.at(-1)?.value ?? + api.createResponsesEventHandlers.mock.results.at(-1)?.value.handlers; + onSubagentUsage({ input_tokens: 25, output_tokens: 10 }); + + expect(aggregatorHandlers.on_chat_model_end.handle).toHaveBeenCalledWith( + 'on_chat_model_end', + { + output: { usage_metadata: { input_tokens: 25, output_tokens: 10 } }, + }, + ); + }); }); describe('sub-agent skill priming', () => { diff --git a/api/server/controllers/agents/__tests__/v1.spec.js b/api/server/controllers/agents/__tests__/v1.spec.js index 39cf994fef..be2e4553f7 100644 --- a/api/server/controllers/agents/__tests__/v1.spec.js +++ b/api/server/controllers/agents/__tests__/v1.spec.js @@ -144,6 +144,47 @@ describe('duplicateAgent', () => { expect(res.status).toHaveBeenCalledWith(201); }); + it('rewrites graph-team self references to the duplicated agent id', async () => { + getAgent.mockResolvedValue({ + id: 'agent_123', + name: 'Graph parent', + subagents: { + enabled: true, + graphs: [ + { + type: 'team', + name: 'Team', + description: 'A self-contained team', + agent_ids: ['agent_123', 'agent_member'], + edges: [{ from: ['agent_member'], to: 'agent_123', edgeType: 'direct' }], + entry_agent_id: 'agent_member', + result_agent_id: 'agent_123', + }, + ], + }, + }); + getActions.mockResolvedValue([]); + nanoid.mockReturnValue('new_123'); + createAgent.mockResolvedValue({ id: 'agent_new_123' }); + + await duplicateAgent(req, res); + + expect(createAgent).toHaveBeenCalledWith( + expect.objectContaining({ + subagents: expect.objectContaining({ + graphs: [ + expect.objectContaining({ + agent_ids: ['agent_new_123', 'agent_member'], + edges: [expect.objectContaining({ from: ['agent_member'], to: 'agent_new_123' })], + entry_agent_id: 'agent_member', + result_agent_id: 'agent_new_123', + }), + ], + }), + }), + ); + }); + it('should return 404 if agent not found', async () => { getAgent.mockResolvedValue(null); diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 6e2ec7e7bd..70fdec95b0 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -91,6 +91,7 @@ const { collectFileIds, processTextWithTokenLimit, buildAgentScopedContext, + buildAgentContextAttachmentsByAgentId, buildSkillPrimeContentParts, buildInitialToolSessions, hasUrlContextTool, @@ -1415,16 +1416,23 @@ class AgentClient extends BaseClient { return agent; }; - /** Collect all agents for unified processing while preserving stable/dynamic instruction fields. */ - const allAgents = [ - { agent: normalizeInstructions(this.options.agent), agentId: this.options.agent.id }, - ...(this.agentConfigs?.size > 0 - ? Array.from(this.agentConfigs.entries()).map(([agentId, agent]) => ({ - agent: normalizeInstructions(agent), - agentId, - })) - : []), - ]; + /** Collect all runtime agents without promoting isolated subagents into the top-level graph. */ + const agentsById = new Map(); + const pendingAgents = [this.options.agent, ...(this.agentConfigs?.values() ?? [])]; + for (let i = 0; i < pendingAgents.length; i++) { + const agent = pendingAgents[i]; + if (!agent?.id || agentsById.has(agent.id)) { + continue; + } + agentsById.set(agent.id, normalizeInstructions(agent)); + for (const subagent of agent.subagentAgentConfigs?.values() ?? []) { + pendingAgents.push(subagent); + } + for (const graph of agent.subagentGraphConfigs ?? []) { + pendingAgents.push(...graph.memberConfigs); + } + } + const allAgents = [...agentsById].map(([agentId, agent]) => ({ agent, agentId })); /** * Memory authorization/loading and MCP config resolution do not depend on @@ -1781,36 +1789,120 @@ class AgentClient extends BaseClient { const ephemeralAgent = this.options.req.body.ephemeralAgent; const mcpManager = getMCPManager(); - await Promise.all( - allAgents.map(async ({ agent, agentId }) => { - const agentRunContextParts = [sharedRunContext]; - const agentHasMemory = agentHasInlineMemoryTools(agent); - if (agentId === this.options.agent.id || memoryAgentEnabled || agentHasMemory) { - const partitionMemories = await getAgentPartitionMemories(agent); - const agentMemoryContext = buildMemoryContext( - agentHasMemory ? partitionMemories?.withKeys : partitionMemories?.withoutKeys, - ); - if (agentMemoryContext) { - agentRunContextParts.push(agentMemoryContext); - } - } - const scopedContext = agentScopedContext.get(agentId); - if (scopedContext) { - agentRunContextParts.push(scopedContext); + const prepareRuntimeAgent = async ({ agent, agentId }, scopedContext) => { + const agentRunContextParts = [sharedRunContext]; + const agentHasMemory = agentHasInlineMemoryTools(agent); + if (agentId === this.options.agent.id || memoryAgentEnabled || agentHasMemory) { + const partitionMemories = await getAgentPartitionMemories(agent); + const agentMemoryContext = buildMemoryContext( + agentHasMemory ? partitionMemories?.withKeys : partitionMemories?.withoutKeys, + ); + if (agentMemoryContext) { + agentRunContextParts.push(agentMemoryContext); } + } + if (scopedContext) { + agentRunContextParts.push(scopedContext); + } - return applyContextToAgent({ - agent, - agentId, - logger, - mcpManager, - configServers, - sharedRunContext: agentRunContextParts.filter(Boolean).join('\n\n'), - ephemeralAgent: agentId === this.options.agent.id ? ephemeralAgent : undefined, - }); - }), + return applyContextToAgent({ + agent, + agentId, + logger, + mcpManager, + configServers, + sharedRunContext: agentRunContextParts.filter(Boolean).join('\n\n'), + ephemeralAgent: agentId === this.options.agent.id ? ephemeralAgent : undefined, + }); + }; + + const runtimeAgentPreparations = new WeakMap(); + const prepareRuntimeAgentOnce = (agent, scopedContext) => { + const existing = runtimeAgentPreparations.get(agent); + if (existing) { + return existing; + } + const pending = prepareRuntimeAgent({ agent, agentId: agent.id }, scopedContext); + runtimeAgentPreparations.set(agent, pending); + return pending; + }; + await Promise.all( + allAgents.map(({ agent, agentId }) => + prepareRuntimeAgentOnce(agent, agentScopedContext.get(agentId)), + ), ); + const wrappedLazyDescriptors = new WeakSet(); + const wrapLazyResolvers = (configs) => { + const pending = [...configs]; + const visitedConfigs = new WeakSet(); + for (let index = 0; index < pending.length; index++) { + const config = pending[index]; + if (!config || visitedConfigs.has(config)) { + continue; + } + visitedConfigs.add(config); + pending.push(...(config.subagentAgentConfigs ?? [])); + for (const graph of config.subagentGraphConfigs ?? []) { + pending.push(...graph.memberConfigs); + } + for (const descriptor of config.lazySubagentConfigs ?? []) { + pending.push(descriptor); + if (wrappedLazyDescriptors.has(descriptor)) { + continue; + } + wrappedLazyDescriptors.add(descriptor); + const resolve = descriptor.resolve; + descriptor.resolve = async (context) => { + const resolved = await resolve(context); + const resolvedAgents = []; + const resolvedPending = [resolved]; + const resolvedIds = new Set(); + for (let resolvedIndex = 0; resolvedIndex < resolvedPending.length; resolvedIndex++) { + const resolvedAgent = resolvedPending[resolvedIndex]; + if (!resolvedAgent?.id || resolvedIds.has(resolvedAgent.id)) { + continue; + } + resolvedIds.add(resolvedAgent.id); + resolvedAgents.push(resolvedAgent); + resolvedPending.push(...(resolvedAgent.subagentAgentConfigs ?? [])); + for (const graph of resolvedAgent.subagentGraphConfigs ?? []) { + resolvedPending.push(...graph.memberConfigs); + } + } + const unpreparedAgents = resolvedAgents.filter( + (agent) => !runtimeAgentPreparations.has(agent), + ); + if (unpreparedAgents.length > 0) { + const pending = buildAgentScopedContext({ + agentIds: unpreparedAgents.map((agent) => agent.id), + attachmentsByAgentId: buildAgentContextAttachmentsByAgentId(unpreparedAgents), + sharedRunAttachmentIds, + req: this.options.req, + tokenCountFn: (text) => countTokens(text), + }).then((lateScopedContext) => + Promise.all( + unpreparedAgents.map((agent) => + prepareRuntimeAgent( + { agent, agentId: agent.id }, + lateScopedContext.get(agent.id), + ), + ), + ), + ); + for (const agent of unpreparedAgents) { + runtimeAgentPreparations.set(agent, pending); + } + } + await Promise.all(resolvedAgents.map((agent) => runtimeAgentPreparations.get(agent))); + wrapLazyResolvers(resolvedAgents); + return resolved; + }; + } + } + }; + wrapLazyResolvers([this.options.agent, ...(this.agentConfigs?.values() ?? [])]); + return result; } diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index cbe1d6615c..5405ad01fc 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -3179,6 +3179,102 @@ describe('AgentClient - titleConvo', () => { expect(parallelAgent2.additional_instructions ?? '').not.toContain(memoryContent); }); + it('applies scoped context to graph-only members without promoting them', async () => { + client.useMemory = jest.fn().mockResolvedValue(undefined); + const graphMember = { + id: 'graph-member', + name: 'Graph Member', + instructions: 'Graph member instructions', + provider: EModelEndpoint.openAI, + }; + mockAgent.subagentGraphConfigs = [ + { + definition: { type: 'review_team' }, + memberConfigs: [mockAgent, graphMember], + }, + ]; + client.agentConfigs = new Map(); + mockBuildAgentScopedContext.mockResolvedValueOnce( + new Map([['graph-member', 'Graph member context']]), + ); + + await client.buildMessages( + [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Hello', + isCreatedByUser: true, + }, + ], + null, + { instructions: 'Base instructions', additional_instructions: null }, + ); + + expect(mockBuildAgentScopedContext).toHaveBeenCalledWith( + expect.objectContaining({ agentIds: ['primary-agent', 'graph-member'] }), + ); + expect(graphMember.additional_instructions).toContain('Graph member context'); + expect(client.agentConfigs).toEqual(new Map()); + }); + + it('applies scoped context to graph members resolved by a lazy child', async () => { + client.useMemory = jest.fn().mockResolvedValue(undefined); + const graphMember = { + id: 'lazy-graph-member', + name: 'Lazy Graph Member', + instructions: 'Lazy graph member instructions', + provider: EModelEndpoint.openAI, + }; + const resolvedChild = { + id: 'lazy-child', + name: 'Lazy Child', + instructions: 'Lazy child instructions', + provider: EModelEndpoint.openAI, + subagentGraphConfigs: [ + { + definition: { type: 'lazy_team' }, + memberConfigs: [graphMember], + }, + ], + }; + const descriptor = { + id: 'lazy-child', + resolve: jest.fn().mockResolvedValue(resolvedChild), + }; + mockAgent.lazySubagentConfigs = [descriptor]; + client.agentConfigs = new Map(); + mockBuildAgentScopedContext.mockResolvedValueOnce(new Map()).mockResolvedValueOnce( + new Map([ + ['lazy-child', 'Lazy child context'], + ['lazy-graph-member', 'Lazy graph member context'], + ]), + ); + + await client.buildMessages( + [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Hello', + isCreatedByUser: true, + }, + ], + null, + { instructions: 'Base instructions', additional_instructions: null }, + ); + const resolved = await descriptor.resolve({ signal: new AbortController().signal }); + + expect(resolved).toBe(resolvedChild); + expect(resolvedChild.additional_instructions).toContain('Lazy child context'); + expect(graphMember.additional_instructions).toContain('Lazy graph member context'); + expect(mockBuildAgentScopedContext).toHaveBeenLastCalledWith( + expect.objectContaining({ agentIds: ['lazy-child', 'lazy-graph-member'] }), + ); + }); + it('should pass memory context to parallel agents when automatic memory updates are enabled', async () => { const memoryContent = 'User prefers dark mode. User is a software developer.'; client.useMemory = jest diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index e8a66e4368..edb1a32c9f 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -12,7 +12,12 @@ const { writeSSE, createRun, createChunk, + applyContextToAgent, buildToolSet, + buildInitialToolSessions, + buildAgentScopedContext, + buildInlineMemoryContext, + buildAgentContextAttachmentsByAgentId, AgentRunEnvelopeError, createAgentRunEnvelope, loadSkillStates, @@ -27,9 +32,11 @@ const { recordCollectedUsage, createSubagentUsageSink, getTransactionsConfig, + resolveAgentTokenConfig, resolveRecursionLimit, findPiiMatchInMessages, discoverConnectedAgents, + resolveSubagentGraphs, getRemoteAgentPermissions, createToolExecuteHandler, buildNonStreamingResponse, @@ -61,10 +68,13 @@ const { canAuthorSkillFiles, withDeploymentSkillIds, buildAgentToolContext, + resolveMemoryAvailability, enrichLoadedToolsWithAgentContext, } = require('~/server/services/Endpoints/agents/skillDeps'); const { getModelsConfig } = require('~/server/controllers/ModelController'); const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions'); +const { resolveConfigServers } = require('~/server/services/MCP'); +const { getMCPManager } = require('~/config'); const { logViolation } = require('~/cache'); const db = require('~/models'); @@ -277,6 +287,12 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { }; const enabledCapabilities = new Set(agentsEConfig?.capabilities); + const memoryAvailable = await resolveMemoryAvailability({ + enabledCapabilities, + memoryConfig: appConfig?.memory, + user: req.user, + getRoleByName: db.getRoleByName, + }); const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills); const ephemeralSkillsToggle = request.ephemeralAgent?.skills === true; const accessibleSkillIds = skillsCapabilityEnabled @@ -349,6 +365,8 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { statefulSessionsAvailable: enabledCapabilities.has( AgentCapabilities.stateful_code_sessions, ), + allowedStatefulCodeEnvironments: agentsEConfig?.statefulCodeSessions?.allowedEnvironments, + memoryAvailable, skillStates, defaultActiveOnShare, manualSkills, @@ -375,90 +393,109 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { buildAgentToolContext({ agent, config: primaryConfig }), ); - // Only run BFS discovery (and pay `getModelsConfig` upfront) when the - // primary has edges to follow — the common API case is single-agent. let handoffAgentConfigs = new Map(); let discoveredEdges = []; let discoveredMCPAuthMap; - if (primaryConfig.edges?.length) { + const subagentsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.subagents); + const primaryHasGraphSubagents = + subagentsCapabilityEnabled && + primaryConfig.subagents?.enabled === true && + (primaryConfig.subagents.graphs?.length ?? 0) > 0; + if (primaryConfig.edges?.length || primaryHasGraphSubagents) { const modelsConfig = await getModelsConfig(req); - ({ - agentConfigs: handoffAgentConfigs, - edges: discoveredEdges, - userMCPAuthMap: discoveredMCPAuthMap, - } = await discoverConnectedAgents( - { - req, - res, - primaryConfig, - endpointOption, - allowedProviders, - modelsConfig, - loadTools, - requestFiles: [], - conversationId, - parentMessageId, - // The route enforces REMOTE_AGENT on the primary; every discovered - // sub-agent must clear the same sharing boundary, not the looser - // in-app AGENT one. - resourceType: ResourceType.REMOTE_AGENT, - computeAccessibleSkillIds: (handoffAgent) => - resolveAgentScopedSkillIds({ + const discoveryParams = { + req, + res, + primaryConfig, + endpointOption, + allowedProviders, + modelsConfig, + loadTools, + requestFiles: [], + conversationId, + parentMessageId, + resourceType: ResourceType.REMOTE_AGENT, + computeAccessibleSkillIds: (handoffAgent) => + resolveAgentScopedSkillIds({ + agent: handoffAgent, + accessibleSkillIds, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }), + computeSkillAuthoringAvailable: (handoffAgent) => + canAuthorSkillFiles({ + agent: handoffAgent, + scopedEditableSkillIds: resolveAgentScopedSkillIds({ agent: handoffAgent, - accessibleSkillIds, + accessibleSkillIds: editableSkillIds, skillsCapabilityEnabled, ephemeralSkillsToggle, }), - computeSkillAuthoringAvailable: (handoffAgent) => - canAuthorSkillFiles({ - agent: handoffAgent, - scopedEditableSkillIds: resolveAgentScopedSkillIds({ - agent: handoffAgent, - accessibleSkillIds: editableSkillIds, - skillsCapabilityEnabled, - ephemeralSkillsToggle, - }), - skillCreateAllowed, - skillsCapabilityEnabled, - ephemeralSkillsToggle, - }), - skillStates, - defaultActiveOnShare, - /** @see DiscoverConnectedAgentsParams.codeEnvAvailable */ - codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code), - backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background), - toolIntentsAvailable: enabledCapabilities.has(AgentCapabilities.tool_intents), - statefulSessionsAvailable: enabledCapabilities.has( - AgentCapabilities.stateful_code_sessions, - ), + skillCreateAllowed, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }), + skillStates, + defaultActiveOnShare, + codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code), + backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background), + toolIntentsAvailable: enabledCapabilities.has(AgentCapabilities.tool_intents), + statefulSessionsAvailable: enabledCapabilities.has( + AgentCapabilities.stateful_code_sessions, + ), + allowedStatefulCodeEnvironments: agentsEConfig?.statefulCodeSessions?.allowedEnvironments, + memoryAvailable, + }; + const discoveryDeps = { + getAgent: db.getAgent, + checkPermission: async ({ userId, role, resourceId, requiredPermission }) => { + const permissions = await getRemoteAgentPermissions( + { getEffectivePermissions }, + userId, + role, + resourceId, + ); + return hasPermissions(permissions, requiredPermission); }, - { - getAgent: db.getAgent, - // Use `getRemoteAgentPermissions` so sub-agent authorization - // matches what the route's `createCheckRemoteAgentAccess` - // middleware does for the primary: AGENT owners with the SHARE - // bit are treated as remotely authorized even without an - // explicit REMOTE_AGENT grant. - checkPermission: async ({ userId, role, resourceId, requiredPermission }) => { - const permissions = await getRemoteAgentPermissions( - { getEffectivePermissions }, - userId, - role, - resourceId, - ); - return hasPermissions(permissions, requiredPermission); - }, - logViolation, - db: dbMethods, - onAgentInitialized: (agentId, handoffAgent, config) => { - agentToolContexts.set(agentId, buildAgentToolContext({ agent: handoffAgent, config })); - }, - initializeAgent, + logViolation, + db: dbMethods, + onAgentInitialized: (loadedAgentId, loadedAgent, config) => { + agentToolContexts.set( + loadedAgentId, + buildAgentToolContext({ agent: loadedAgent, config }), + ); }, - )); + initializeAgent, + }; + if (primaryConfig.edges?.length) { + ({ + agentConfigs: handoffAgentConfigs, + edges: discoveredEdges, + userMCPAuthMap: discoveredMCPAuthMap, + } = await discoverConnectedAgents(discoveryParams, discoveryDeps)); + } + if (subagentsCapabilityEnabled) { + discoveredMCPAuthMap = await resolveSubagentGraphs( + { + ...discoveryParams, + rootConfigs: [primaryConfig, ...handoffAgentConfigs.values()], + }, + discoveryDeps, + ); + } } primaryConfig.edges = discoveredEdges; + const endpointTokenConfigByAgentId = new Map(); + for (const [agentId, context] of agentToolContexts) { + endpointTokenConfigByAgentId.set(agentId, context.endpointTokenConfig); + } + const resolveEndpointTokenConfig = (usage) => + resolveAgentTokenConfig({ + agentId: usage?.agentId, + byAgentId: endpointTokenConfigByAgentId, + fallback: primaryConfig.endpointTokenConfig, + }); // Determine if streaming is enabled (check both request and agent config) const streamingDisabled = !!primaryConfig.model_parameters?.disableStreaming; @@ -467,6 +504,11 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { // Create tracker for streaming or aggregator for non-streaming const tracker = isStreaming ? createOpenAIStreamTracker() : null; const aggregator = isStreaming ? null : createOpenAIContentAggregator(); + const accumulateResponseUsage = (usage) => { + const target = isStreaming ? tracker : aggregator; + target.usage.promptTokens += usage.input_tokens ?? 0; + target.usage.completionTokens += usage.output_tokens ?? 0; + }; // Set up response for streaming if (isStreaming) { @@ -723,9 +765,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { if (usage) { const taggedUsage = markSummarizationUsage(usage, metadata); collectedUsage.push(taggedUsage); - const target = isStreaming ? tracker : aggregator; - target.usage.promptTokens += taggedUsage.input_tokens ?? 0; - target.usage.completionTokens += taggedUsage.output_tokens ?? 0; + accumulateResponseUsage(taggedUsage); } }, }, @@ -751,11 +791,50 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { const userMCPAuthMap = discoveredMCPAuthMap ?? primaryConfig.userMCPAuthMap; const runAgents = [primaryConfig, ...handoffAgentConfigs.values()]; + const contextAgentsById = new Map(runAgents.map((runAgent) => [runAgent.id, runAgent])); + for (const runAgent of runAgents) { + for (const graph of runAgent.subagentGraphConfigs ?? []) { + for (const memberConfig of graph.memberConfigs) { + contextAgentsById.set(memberConfig.id, memberConfig); + } + } + } + const contextAgents = [...contextAgentsById.values()]; + const agentScopedContext = await buildAgentScopedContext({ + agentIds: contextAgents.map(({ id }) => id), + attachmentsByAgentId: buildAgentContextAttachmentsByAgentId(contextAgents), + req, + }); + const mcpManager = getMCPManager(); + const configServers = await resolveConfigServers(req); + await Promise.all( + contextAgents.map(async (runAgent) => { + const memoryContext = await buildInlineMemoryContext({ + agent: runAgent, + req, + userId, + memoryAvailable, + getFormattedMemories: db.getFormattedMemories, + }); + return applyContextToAgent({ + agent: runAgent, + agentId: runAgent.id, + logger, + mcpManager, + configServers, + sharedRunContext: [memoryContext, agentScopedContext.get(runAgent.id)] + .filter(Boolean) + .join('\n\n'), + }); + }), + ); + const initialSessions = buildInitialToolSessions({ agents: runAgents }); const run = await createRun({ agents: runAgents, messages: formattedMessages, indexTokenCountMap, + initialSessions, initialSummary, runId: responseId, summarizationConfig, @@ -770,7 +849,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { tenantId: principal.tenantId, /** Bills subagent child-run model calls (reported outside the * streamEvents loop) into the same collectedUsage array. */ - subagentUsageSink: createSubagentUsageSink(collectedUsage), + subagentUsageSink: createSubagentUsageSink(collectedUsage, accumulateResponseUsage), }); if (!run) { @@ -822,6 +901,8 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { balance: balanceConfig, transactions: transactionsConfig, model: primaryConfig.model || agent.model_parameters?.model, + endpointTokenConfig: primaryConfig.endpointTokenConfig, + resolveEndpointTokenConfig, }, ).catch((err) => { logger.error('[OpenAI API] Error recording usage:', err); diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index a8e6301c39..9949259c88 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -12,10 +12,12 @@ const { const { createRun, applyContextToAgent, + buildInitialToolSessions, buildToolSet, AgentRunEnvelopeError, createAgentRunEnvelope, buildAgentScopedContext, + buildInlineMemoryContext, buildAgentContextAttachmentsByAgentId, createSafeUser, initializeAgent, @@ -26,8 +28,10 @@ const { recordCollectedUsage, createSubagentUsageSink, getTransactionsConfig, + resolveAgentTokenConfig, findPiiMatchInMessages, discoverConnectedAgents, + resolveSubagentGraphs, createToolExecuteHandler, getRemoteAgentPermissions, resolveAgentScopedSkillIds, @@ -73,6 +77,7 @@ const { canAuthorSkillFiles, withDeploymentSkillIds, buildAgentToolContext, + resolveMemoryAvailability, enrichLoadedToolsWithAgentContext, } = require('~/server/services/Endpoints/agents/skillDeps'); const { getModelsConfig } = require('~/server/controllers/ModelController'); @@ -370,11 +375,10 @@ const executeResponse = async (envelope, { req, res }) => { const conversationId = request.previous_response_id ?? uuidv4(); const parentMessageId = null; + const agentsEConfig = appConfig?.endpoints?.[EModelEndpoint.agents]; // Build allowed providers set - const allowedProviders = new Set( - appConfig?.endpoints?.[EModelEndpoint.agents]?.allowedProviders, - ); + const allowedProviders = new Set(agentsEConfig?.allowedProviders); // Create tool loader const loadTools = createToolLoader(abortController.signal); @@ -403,9 +407,13 @@ const executeResponse = async (envelope, { req, res }) => { getSkillByName: skillDbMethods.getSkillByName, }; - const enabledCapabilities = new Set( - appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities, - ); + const enabledCapabilities = new Set(agentsEConfig?.capabilities); + const memoryAvailable = await resolveMemoryAvailability({ + enabledCapabilities, + memoryConfig: appConfig?.memory, + user: req.user, + getRoleByName: db.getRoleByName, + }); const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills); const ephemeralSkillsToggle = request.ephemeralAgent?.skills === true; const accessibleSkillIds = skillsCapabilityEnabled @@ -478,6 +486,8 @@ const executeResponse = async (envelope, { req, res }) => { statefulSessionsAvailable: enabledCapabilities.has( AgentCapabilities.stateful_code_sessions, ), + allowedStatefulCodeEnvironments: agentsEConfig?.statefulCodeSessions?.allowedEnvironments, + memoryAvailable, skillStates, defaultActiveOnShare, manualSkills, @@ -504,96 +514,125 @@ const executeResponse = async (envelope, { req, res }) => { buildAgentToolContext({ agent, config: primaryConfig }), ); - // Only run BFS discovery (and pay `getModelsConfig` upfront) when the - // primary has edges to follow — the common API case is single-agent. let handoffAgentConfigs = new Map(); let discoveredEdges = []; let discoveredMCPAuthMap; - if (primaryConfig.edges?.length) { + const subagentsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.subagents); + const primaryHasGraphSubagents = + subagentsCapabilityEnabled && + primaryConfig.subagents?.enabled === true && + (primaryConfig.subagents.graphs?.length ?? 0) > 0; + if (primaryConfig.edges?.length || primaryHasGraphSubagents) { const modelsConfig = await getModelsConfig(req); - ({ - agentConfigs: handoffAgentConfigs, - edges: discoveredEdges, - userMCPAuthMap: discoveredMCPAuthMap, - } = await discoverConnectedAgents( - { - req, - res, - primaryConfig, - endpointOption, - allowedProviders, - modelsConfig, - loadTools, - requestFiles: [], - conversationId, - parentMessageId, - // The route enforces REMOTE_AGENT on the primary; every discovered - // sub-agent must clear the same sharing boundary, not the looser - // in-app AGENT one. - resourceType: ResourceType.REMOTE_AGENT, - computeAccessibleSkillIds: (handoffAgent) => - resolveAgentScopedSkillIds({ + const discoveryParams = { + req, + res, + primaryConfig, + endpointOption, + allowedProviders, + modelsConfig, + loadTools, + requestFiles: [], + conversationId, + parentMessageId, + resourceType: ResourceType.REMOTE_AGENT, + computeAccessibleSkillIds: (handoffAgent) => + resolveAgentScopedSkillIds({ + agent: handoffAgent, + accessibleSkillIds, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }), + computeSkillAuthoringAvailable: (handoffAgent) => + canAuthorSkillFiles({ + agent: handoffAgent, + scopedEditableSkillIds: resolveAgentScopedSkillIds({ agent: handoffAgent, - accessibleSkillIds, + accessibleSkillIds: editableSkillIds, skillsCapabilityEnabled, ephemeralSkillsToggle, }), - computeSkillAuthoringAvailable: (handoffAgent) => - canAuthorSkillFiles({ - agent: handoffAgent, - scopedEditableSkillIds: resolveAgentScopedSkillIds({ - agent: handoffAgent, - accessibleSkillIds: editableSkillIds, - skillsCapabilityEnabled, - ephemeralSkillsToggle, - }), - skillCreateAllowed, - skillsCapabilityEnabled, - ephemeralSkillsToggle, - }), - skillStates, - defaultActiveOnShare, - /** @see DiscoverConnectedAgentsParams.codeEnvAvailable */ - codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code), - backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background), - toolIntentsAvailable: enabledCapabilities.has(AgentCapabilities.tool_intents), - statefulSessionsAvailable: enabledCapabilities.has( - AgentCapabilities.stateful_code_sessions, - ), + skillCreateAllowed, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }), + skillStates, + defaultActiveOnShare, + codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code), + backgroundToolsAvailable: enabledCapabilities.has(AgentCapabilities.run_in_background), + toolIntentsAvailable: enabledCapabilities.has(AgentCapabilities.tool_intents), + statefulSessionsAvailable: enabledCapabilities.has( + AgentCapabilities.stateful_code_sessions, + ), + allowedStatefulCodeEnvironments: agentsEConfig?.statefulCodeSessions?.allowedEnvironments, + memoryAvailable, + }; + const discoveryDeps = { + getAgent: db.getAgent, + checkPermission: async ({ userId, role, resourceId, requiredPermission }) => { + const permissions = await getRemoteAgentPermissions( + { getEffectivePermissions }, + userId, + role, + resourceId, + ); + return hasPermissions(permissions, requiredPermission); }, - { - getAgent: db.getAgent, - // Use `getRemoteAgentPermissions` so sub-agent authorization - // matches what the route's `createCheckRemoteAgentAccess` - // middleware does for the primary: AGENT owners with the SHARE - // bit are treated as remotely authorized even without an - // explicit REMOTE_AGENT grant. - checkPermission: async ({ userId, role, resourceId, requiredPermission }) => { - const permissions = await getRemoteAgentPermissions( - { getEffectivePermissions }, - userId, - role, - resourceId, - ); - return hasPermissions(permissions, requiredPermission); - }, - logViolation, - db: dbMethods, - onAgentInitialized: (agentId, handoffAgent, config) => { - agentToolContexts.set(agentId, buildAgentToolContext({ agent: handoffAgent, config })); - }, - initializeAgent, + logViolation, + db: dbMethods, + onAgentInitialized: (loadedAgentId, loadedAgent, config) => { + agentToolContexts.set( + loadedAgentId, + buildAgentToolContext({ agent: loadedAgent, config }), + ); }, - )); + initializeAgent, + }; + if (primaryConfig.edges?.length) { + ({ + agentConfigs: handoffAgentConfigs, + edges: discoveredEdges, + userMCPAuthMap: discoveredMCPAuthMap, + } = await discoverConnectedAgents(discoveryParams, discoveryDeps)); + } + if (subagentsCapabilityEnabled) { + discoveredMCPAuthMap = await resolveSubagentGraphs( + { + ...discoveryParams, + rootConfigs: [primaryConfig, ...handoffAgentConfigs.values()], + }, + discoveryDeps, + ); + } } primaryConfig.edges = discoveredEdges; + const endpointTokenConfigByAgentId = new Map(); + for (const [agentId, context] of agentToolContexts) { + endpointTokenConfigByAgentId.set(agentId, context.endpointTokenConfig); + } + const resolveEndpointTokenConfig = (usage) => + resolveAgentTokenConfig({ + agentId: usage?.agentId, + byAgentId: endpointTokenConfigByAgentId, + fallback: primaryConfig.endpointTokenConfig, + }); const runAgents = [primaryConfig, ...handoffAgentConfigs.values()]; + const initialSessions = buildInitialToolSessions({ agents: runAgents }); + const contextAgentsById = new Map(runAgents.map((runAgent) => [runAgent.id, runAgent])); + for (const runAgent of runAgents) { + for (const graph of runAgent.subagentGraphConfigs ?? []) { + for (const memberConfig of graph.memberConfigs) { + contextAgentsById.set(memberConfig.id, memberConfig); + } + } + } + const contextAgents = [...contextAgentsById.values()]; const mergedMCPAuthMap = discoveredMCPAuthMap ?? primaryConfig.userMCPAuthMap; - const agentContextAttachmentsByAgentId = buildAgentContextAttachmentsByAgentId(runAgents); + const agentContextAttachmentsByAgentId = buildAgentContextAttachmentsByAgentId(contextAgents); const agentScopedContext = await buildAgentScopedContext({ - agentIds: runAgents.map(({ id }) => id), + agentIds: contextAgents.map(({ id }) => id), attachmentsByAgentId: agentContextAttachmentsByAgentId, req, }); @@ -602,16 +641,25 @@ const executeResponse = async (envelope, { req, res }) => { const configServers = await resolveConfigServers(req); await Promise.all( - runAgents.map((runAgent) => - applyContextToAgent({ + contextAgents.map(async (runAgent) => { + const memoryContext = await buildInlineMemoryContext({ + agent: runAgent, + req, + userId: principal.userId, + memoryAvailable, + getFormattedMemories: db.getFormattedMemories, + }); + return applyContextToAgent({ agent: runAgent, agentId: runAgent.id, logger, mcpManager, configServers, - sharedRunContext: agentScopedContext.get(runAgent.id) ?? '', - }), - ), + sharedRunContext: [memoryContext, agentScopedContext.get(runAgent.id)] + .filter(Boolean) + .join('\n\n'), + }); + }), ); // Determine if streaming is enabled (check both request and agent config) @@ -804,6 +852,7 @@ const executeResponse = async (envelope, { req, res }) => { appConfig, signal: abortController.signal, customHandlers: handlers, + initialSessions, requestBody: { messageId: responseId, conversationId, @@ -812,7 +861,11 @@ const executeResponse = async (envelope, { req, res }) => { tenantId: principal.tenantId, /** Bills subagent child-run model calls (reported outside the * streamEvents loop) into the same collectedUsage array. */ - subagentUsageSink: createSubagentUsageSink(collectedUsage), + subagentUsageSink: createSubagentUsageSink(collectedUsage, (usage) => { + responsesHandlers.on_chat_model_end.handle('on_chat_model_end', { + output: { usage_metadata: usage }, + }); + }), }); if (!run) { @@ -864,6 +917,8 @@ const executeResponse = async (envelope, { req, res }) => { balance: balanceConfig, transactions: transactionsConfig, model: primaryConfig.model || agent.model_parameters?.model, + endpointTokenConfig: primaryConfig.endpointTokenConfig, + resolveEndpointTokenConfig, }, ).catch((err) => { logger.error('[Responses API] Error recording usage:', err); @@ -987,6 +1042,7 @@ const executeResponse = async (envelope, { req, res }) => { appConfig, signal: abortController.signal, customHandlers: handlers, + initialSessions, requestBody: { messageId: responseId, conversationId, @@ -995,7 +1051,11 @@ const executeResponse = async (envelope, { req, res }) => { tenantId: principal.tenantId, /** Bills subagent child-run model calls (reported outside the * streamEvents loop) into the same collectedUsage array. */ - subagentUsageSink: createSubagentUsageSink(collectedUsage), + subagentUsageSink: createSubagentUsageSink(collectedUsage, (usage) => { + aggregatorHandlers.on_chat_model_end.handle('on_chat_model_end', { + output: { usage_metadata: usage }, + }); + }), }); if (!run) { @@ -1046,6 +1106,8 @@ const executeResponse = async (envelope, { req, res }) => { balance: balanceConfig, transactions: transactionsConfig, model: primaryConfig.model || agent.model_parameters?.model, + endpointTokenConfig: primaryConfig.endpointTokenConfig, + resolveEndpointTokenConfig, }, ).catch((err) => { logger.error('[Responses API] Error recording usage:', err); diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index 6fc7450d61..296a36f908 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -9,6 +9,7 @@ const { findShadowedServerNames, agentCreateSchema, agentUpdateSchema, + agentSubagentsSchema, refreshListAvatars, collectEdgeAgentIds, replaceEdgeSourceId, @@ -175,8 +176,62 @@ const validateEdgeAgentReferences = async ( }; /** - * Validates `subagents.agent_ids` more strictly than edges: both - * missing AND unauthorized ids are errors. `subagents.agent_ids` + * Collects every saved agent referenced by a spawn target. Graph edge + * endpoints are included defensively even though request validation requires + * them to be declared in the graph's `agent_ids` list. + * @param {import('librechat-data-provider').AgentSubagentsConfig | undefined} subagents + * @returns {string[]} + */ +const collectSubagentAgentIds = (subagents) => { + const ids = new Set(subagents?.agent_ids ?? []); + for (const graph of subagents?.graphs ?? []) { + for (const agentId of graph.agent_ids ?? []) { + ids.add(agentId); + } + for (const edge of graph.edges ?? []) { + for (const agentId of collectEdgeAgentIds([edge])) { + ids.add(agentId); + } + } + } + return [...ids]; +}; + +/** + * Rewrites a duplicated agent's self-references inside saved graph spawn + * targets so the clone remains self-contained. + * @param {import('librechat-data-provider').AgentSubagentsConfig | undefined} subagents + * @param {string} sourceAgentId + * @param {string} targetAgentId + */ +const replaceSubagentGraphAgentId = (subagents, sourceAgentId, targetAgentId) => { + if (!Array.isArray(subagents?.graphs)) { + return subagents; + } + + const replaceId = (agentId) => (agentId === sourceAgentId ? targetAgentId : agentId); + return { + ...subagents, + graphs: subagents.graphs.map((graph) => ({ + ...graph, + agent_ids: graph.agent_ids?.map(replaceId), + edges: graph.edges?.map((edge) => ({ + ...edge, + from: Array.isArray(edge.from) ? edge.from.map(replaceId) : replaceId(edge.from), + to: Array.isArray(edge.to) ? edge.to.map(replaceId) : replaceId(edge.to), + })), + entry_agent_id: replaceId(graph.entry_agent_id), + result_agent_id: replaceId(graph.result_agent_id), + })), + }; +}; + +const replaceAndValidateSubagentGraphAgentId = (subagents, sourceAgentId, targetAgentId) => + agentSubagentsSchema.parse(replaceSubagentGraphAgentId(subagents, sourceAgentId, targetAgentId)); + +/** + * Validates saved-agent spawn targets more strictly than top-level edges: both + * missing AND unauthorized ids are errors. Spawn targets * can't self-reference (subagents spawn *other* agents), so a * missing id is always a typo or a reference to a deleted agent — * `initializeClient` would silently drop it at runtime, leaving the @@ -184,8 +239,22 @@ const validateEdgeAgentReferences = async ( * Returning the split lets the caller report each bucket with the * appropriate status. */ -const validateSubagentReferences = (subagents, userId, userRole) => - classifyAgentReferences(subagents?.agent_ids ?? [], userId, userRole); +const validateSubagentReferences = async ( + subagents, + userId, + userRole, + allowedMissingIds = new Set(), +) => { + const { missing, unauthorized } = await classifyAgentReferences( + collectSubagentAgentIds(subagents), + userId, + userRole, + ); + return { + missing: missing.filter((id) => !allowedMissingIds.has(id)), + unauthorized, + }; +}; /** * Returns true when the agents-endpoint `subagents` capability is @@ -224,6 +293,47 @@ const validateStatefulCodeEnvironment = (req, res, enabled, environment) => { return false; }; +/** + * @param {import('librechat-data-provider').AgentSubagentsConfig | undefined} subagents + * @param {Express.Request} req + * @returns {Promise<{ status: number, body: { error: string, agent_ids: string[] } } | null>} + */ +const getSubagentReferenceError = async (subagents, req, allowedMissingIds = new Set()) => { + if ( + !isSubagentsCapabilityEnabled(req) || + subagents?.enabled !== true || + collectSubagentAgentIds(subagents).length === 0 + ) { + return null; + } + + const { missing, unauthorized } = await validateSubagentReferences( + subagents, + req.user.id, + req.user.role, + allowedMissingIds, + ); + if (missing.length > 0) { + return { + status: 400, + body: { + error: 'One or more agents referenced in subagents do not exist', + agent_ids: missing, + }, + }; + } + if (unauthorized.length > 0) { + return { + status: 403, + body: { + error: 'You do not have access to one or more agents referenced in subagents', + agent_ids: unauthorized, + }, + }; + } + return null; +}; + /** * Filters tools to only include those the user is authorized to use. * MCP tools must match the exact format `{toolName}_mcp_{serverName}` (exactly 2 segments). @@ -444,6 +554,11 @@ const createAgentHandler = async (req, res) => { const { id: userId, role: userRole } = req.user; agentData.id = `agent_${nanoid()}`; agentData.edges = replaceEdgeSourceId(agentData.edges, '', agentData.id); + agentData.subagents = replaceAndValidateSubagentGraphAgentId( + agentData.subagents, + '', + agentData.id, + ); if (agentData.tool_resources) { await pruneToolResourceFileIdsForAgent({ @@ -488,28 +603,13 @@ const createAgentHandler = async (req, res) => { * gate, so a user who lost VIEW on a child can still save the * disable edit. */ - if ( - isSubagentsCapabilityEnabled(req) && - agentData.subagents?.enabled === true && - agentData.subagents?.agent_ids?.length - ) { - const { missing, unauthorized } = await validateSubagentReferences( - agentData.subagents, - userId, - userRole, - ); - if (missing.length > 0) { - return res.status(400).json({ - error: 'One or more agents referenced in subagents do not exist', - agent_ids: missing, - }); - } - if (unauthorized.length > 0) { - return res.status(403).json({ - error: 'You do not have access to one or more agents referenced in subagents', - agent_ids: unauthorized, - }); - } + const subagentReferenceError = await getSubagentReferenceError( + agentData.subagents, + req, + new Set([agentData.id]), + ); + if (subagentReferenceError) { + return res.status(subagentReferenceError.status).json(subagentReferenceError.body); } agentData.author = userId; @@ -767,6 +867,9 @@ const updateAgentHandler = async (req, res) => { if (updateData.edges !== undefined) { updateData.edges = replaceEdgeSourceId(updateData.edges, '', id); } + if (updateData.subagents !== undefined) { + updateData.subagents = replaceAndValidateSubagentGraphAgentId(updateData.subagents, '', id); + } if (updateData.edges?.length) { const { id: userId, role: userRole } = req.user; @@ -796,29 +899,9 @@ const updateAgentHandler = async (req, res) => { * disabled payloads always pass the gate — that preserves the * "can always save a disable edit" behavior a user might need * after losing VIEW on a referenced child. */ - if ( - isSubagentsCapabilityEnabled(req) && - updateData.subagents?.enabled === true && - updateData.subagents?.agent_ids?.length - ) { - const { id: userId, role: userRole } = req.user; - const { missing, unauthorized } = await validateSubagentReferences( - updateData.subagents, - userId, - userRole, - ); - if (missing.length > 0) { - return res.status(400).json({ - error: 'One or more agents referenced in subagents do not exist', - agent_ids: missing, - }); - } - if (unauthorized.length > 0) { - return res.status(403).json({ - error: 'You do not have access to one or more agents referenced in subagents', - agent_ids: unauthorized, - }); - } + const subagentReferenceError = await getSubagentReferenceError(updateData.subagents, req); + if (subagentReferenceError) { + return res.status(subagentReferenceError.status).json(subagentReferenceError.body); } // Convert OCR to context in incoming updateData @@ -1050,6 +1133,16 @@ const duplicateAgentHandler = async (req, res) => { } newAgentData.edges = replaceEdgeSourceId(newAgentData.edges, id, newAgentId); newAgentData.edges = replaceEdgeSourceId(newAgentData.edges, '', newAgentId); + newAgentData.subagents = replaceAndValidateSubagentGraphAgentId( + newAgentData.subagents, + id, + newAgentId, + ); + newAgentData.subagents = replaceAndValidateSubagentGraphAgentId( + newAgentData.subagents, + '', + newAgentId, + ); if (newAgentData.edges?.length) { const { missing, unauthorized } = await validateEdgeAgentReferences( @@ -1072,6 +1165,15 @@ const duplicateAgentHandler = async (req, res) => { } } + const subagentReferenceError = await getSubagentReferenceError( + newAgentData.subagents, + req, + new Set([newAgentId]), + ); + if (subagentReferenceError) { + return res.status(subagentReferenceError.status).json(subagentReferenceError.body); + } + const newActionsList = []; const originalActions = (await db.getActions({ agent_id: id }, true)) ?? []; const promises = []; @@ -1618,6 +1720,11 @@ const revertAgentVersionHandler = async (req, res) => { } } + const subagentReferenceError = await getSubagentReferenceError(revertVersion?.subagents, req); + if (subagentReferenceError) { + return res.status(subagentReferenceError.status).json(subagentReferenceError.body); + } + // Permissions are enforced via route middleware (ACL EDIT) let updatedAgent = await db.revertAgentVersion({ id }, version_index); diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js index cec5412f69..435b0da43f 100644 --- a/api/server/controllers/agents/v1.spec.js +++ b/api/server/controllers/agents/v1.spec.js @@ -901,6 +901,38 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(agentInDb.tools).not.toContain(Tools.execute_code); }); + test('rejects graph topology that becomes invalid after self-placeholder rewrite', async () => { + mockReq.user.id = existingAgentAuthorId.toString(); + mockReq.params.id = existingAgentId; + mockReq.config = { + endpoints: { agents: { capabilities: ['subagents'] } }, + }; + mockReq.body = { + subagents: { + enabled: true, + allowSelf: false, + graphs: [ + { + type: 'collapsed_team', + name: 'Collapsed team', + description: 'Becomes invalid after placeholder replacement', + agent_ids: ['', existingAgentId], + edges: [{ from: '', to: existingAgentId, edgeType: 'direct' }], + entry_agent_id: '', + result_agent_id: existingAgentId, + }, + ], + }, + }; + + await updateAgentHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'Invalid request data' }), + ); + }); + test('should sanitize corrupt numeric model_parameters on update', async () => { mockReq.user.id = existingAgentAuthorId.toString(); mockReq.params.id = existingAgentId; @@ -2819,6 +2851,76 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(response.agent_ids).toContain(targetAgent.id); }); + test('createAgentHandler should reject a missing graph-subagent member', async () => { + const missingMemberId = 'agent_missing_graph_member'; + mockReq.config = { + endpoints: { agents: { capabilities: ['subagents'] } }, + }; + mockReq.body = { + name: 'Graph Parent', + provider: 'openai', + model: 'gpt-4', + subagents: { + enabled: true, + allowSelf: false, + graphs: [ + { + type: 'research_team', + name: 'Research team', + description: 'Researches before answering', + agent_ids: [targetAgent.id, missingMemberId], + edges: [{ from: targetAgent.id, to: missingMemberId, edgeType: 'direct' }], + entry_agent_id: targetAgent.id, + result_agent_id: missingMemberId, + }, + ], + }, + }; + + await createAgentHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'One or more agents referenced in subagents do not exist', + agent_ids: [missingMemberId], + }); + }); + + test('createAgentHandler should rewrite a graph self placeholder to the generated ID', async () => { + mockReq.config = { + endpoints: { agents: { capabilities: ['subagents'] } }, + }; + mockReq.body = { + name: 'Self Graph Parent', + provider: 'openai', + model: 'gpt-4', + subagents: { + enabled: true, + graphs: [ + { + type: 'self_review', + name: 'Self review', + description: 'Runs the new agent in an isolated context', + agent_ids: [''], + edges: [], + entry_agent_id: '', + result_agent_id: '', + }, + ], + }, + }; + + await createAgentHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(201); + const createdAgent = mockRes.json.mock.calls[0][0]; + expect(createdAgent.subagents.graphs[0]).toMatchObject({ + agent_ids: [createdAgent.id], + entry_agent_id: createdAgent.id, + result_agent_id: createdAgent.id, + }); + }); + test('createAgentHandler should succeed when user has VIEW on all edge-referenced agents', async () => { const permMap = new Map([[targetAgent._id.toString(), 1]]); getResourcePermissionsMap.mockResolvedValueOnce(permMap); @@ -2980,6 +3082,50 @@ describe('Agent Controllers - Mass Assignment Protection', () => { ]); }); + test('duplicateAgentHandler should rewrite and allow a graph-team self member', async () => { + const sourceAgentId = `agent_${nanoid()}`; + await Agent.create({ + id: sourceAgentId, + author: mockReq.user.id, + name: 'Self Graph Clone Source', + provider: 'openai', + model: 'gpt-4', + tools: [], + subagents: { + enabled: true, + allowSelf: false, + graphs: [ + { + type: 'self_team', + name: 'Self team', + description: 'Contains the parent and a worker', + agent_ids: [sourceAgentId, targetAgent.id], + edges: [{ from: sourceAgentId, to: targetAgent.id, edgeType: 'direct' }], + entry_agent_id: sourceAgentId, + result_agent_id: targetAgent.id, + }, + ], + }, + }); + getResourcePermissionsMap.mockResolvedValueOnce( + new Map([[targetAgent._id.toString(), PermissionBits.VIEW]]), + ); + jest.spyOn(require('~/models'), 'getActions').mockResolvedValueOnce([]); + mockReq.config = { endpoints: { agents: { capabilities: ['subagents'] } } }; + mockReq.params = { id: sourceAgentId }; + + await duplicateAgentHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(201); + const { agent } = mockRes.json.mock.calls[0][0]; + expect(agent.subagents.graphs[0]).toMatchObject({ + agent_ids: [agent.id, targetAgent.id], + edges: [{ from: agent.id, to: targetAgent.id, edgeType: 'direct' }], + entry_agent_id: agent.id, + result_agent_id: targetAgent.id, + }); + }); + test('duplicateAgentHandler should return 400 for a missing handoff target', async () => { const missingTargetId = `agent_${nanoid()}`; const sourceAgent = await Agent.create({ @@ -3029,6 +3175,44 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(await Agent.countDocuments()).toBe(2); }); + test('duplicateAgentHandler should return 403 without VIEW access to a graph-subagent member', async () => { + const sourceAgent = await Agent.create({ + id: `agent_${nanoid()}`, + author: mockReq.user.id, + name: 'Restricted Graph Clone Source', + provider: 'openai', + model: 'gpt-4', + tools: [], + subagents: { + enabled: true, + allowSelf: false, + graphs: [ + { + type: 'restricted_team', + name: 'Restricted team', + description: 'Contains a restricted member', + agent_ids: [targetAgent.id], + edges: [], + entry_agent_id: targetAgent.id, + result_agent_id: targetAgent.id, + }, + ], + }, + }); + getResourcePermissionsMap.mockResolvedValueOnce(new Map()); + mockReq.config = { endpoints: { agents: { capabilities: ['subagents'] } } }; + mockReq.params = { id: sourceAgent.id }; + + await duplicateAgentHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(403); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'You do not have access to one or more agents referenced in subagents', + agent_ids: [targetAgent.id], + }); + expect(await Agent.countDocuments()).toBe(2); + }); + test('revertAgentVersionHandler should clear handoffs when the historical version has none', async () => { const agentId = `agent_${nanoid()}`; await Agent.create({ @@ -3134,6 +3318,55 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(persisted.name).toBe('Current Router'); }); + test('revertAgentVersionHandler should return 400 before restoring a missing graph-subagent member', async () => { + const agentId = `agent_${nanoid()}`; + const missingMemberId = `agent_${nanoid()}`; + await Agent.create({ + id: agentId, + author: mockReq.user.id, + name: 'Current Graph Parent', + provider: 'openai', + model: 'gpt-4', + tools: [], + versions: [ + { + name: 'Historical Graph Parent', + provider: 'openai', + model: 'gpt-4', + tools: [], + subagents: { + enabled: true, + allowSelf: false, + graphs: [ + { + type: 'missing_team', + name: 'Missing team', + description: 'Contains a deleted member', + agent_ids: [missingMemberId], + edges: [], + entry_agent_id: missingMemberId, + result_agent_id: missingMemberId, + }, + ], + }, + }, + ], + }); + mockReq.config = { endpoints: { agents: { capabilities: ['subagents'] } } }; + mockReq.params = { id: agentId }; + mockReq.body = { version_index: 0 }; + + await revertAgentVersionHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + error: 'One or more agents referenced in subagents do not exist', + agent_ids: [missingMemberId], + }); + const persisted = await Agent.findOne({ id: agentId }).lean(); + expect(persisted.name).toBe('Current Graph Parent'); + }); + test('revertAgentVersionHandler should return 403 before restoring a restricted handoff target', async () => { const agentId = `agent_${nanoid()}`; const sourceAgent = await Agent.create({ diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 5110a8b7bd..93b248aa41 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -1,11 +1,10 @@ const { logger } = require('@librechat/data-schemas'); const { createContentAggregator, GraphNodeKeys } = require('@librechat/agents'); const { - checkAccess, resolveSender, + createConcurrencyLimiter, loadSkillStates, initializeAgent, - isMemoryEnabled, primeInvokedSkillsForProfiles, validateAgentModel, extractManualSkills, @@ -23,11 +22,9 @@ const { createStatefulCodeEnvironmentPolicyError, } = require('@librechat/api'); const { - Permissions, ResourceType, EModelEndpoint, PermissionBits, - PermissionTypes, MAX_SUBAGENT_DEPTH, isAgentsEndpoint, AgentCapabilities, @@ -56,6 +53,7 @@ const { canAuthorSkillFiles, withDeploymentSkillIds, buildAgentToolContext, + resolveMemoryAvailability, enrichLoadedToolsWithAgentContext, } = require('./skillDeps'); const { getModelsConfig } = require('~/server/controllers/ModelController'); @@ -65,6 +63,8 @@ const { processAddedConvo } = require('./addedConvo'); const { logViolation } = require('~/cache'); const db = require('~/models'); +const SUBAGENT_GRAPH_LOAD_CONCURRENCY = 4; + /** * Creates a tool loader function for the agent. * @param {AbortSignal} signal - The abort signal @@ -213,16 +213,12 @@ const initializeClient = async ({ * read-only-memory roles that the runtime loader would then refuse to build. * Agents (or the ephemeral memory badge) opt in per-agent via the `memory` * marker on `tools`. */ - const memoryAvailablePromise = - enabledCapabilities.has(AgentCapabilities.memory) && - isMemoryEnabled(appConfig?.memory) && - req.user?.personalization?.memories !== false && - checkAccess({ - user: req.user, - permissionType: PermissionTypes.MEMORIES, - permissions: [Permissions.USE, Permissions.CREATE, Permissions.UPDATE], - getRoleByName: db.getRoleByName, - }); + const memoryAvailablePromise = resolveMemoryAvailability({ + enabledCapabilities, + memoryConfig: appConfig?.memory, + user: req.user, + getRoleByName: db.getRoleByName, + }); const accessibleSkillIdsPromise = skillsCapabilityEnabled ? findAccessibleResources({ @@ -517,6 +513,7 @@ const initializeClient = async ({ backgroundToolsAvailable, toolIntentsAvailable, statefulSessionsAvailable, + allowedStatefulCodeEnvironments, memoryAvailable, skillStates, defaultActiveOnShare, @@ -597,6 +594,7 @@ const initializeClient = async ({ backgroundToolsAvailable, toolIntentsAvailable, statefulSessionsAvailable, + allowedStatefulCodeEnvironments, memoryAvailable, }, { @@ -679,7 +677,7 @@ const initializeClient = async ({ if (updatedMCPAuthMap) { userMCPAuthMap = updatedMCPAuthMap; } - + userMCPAuthMap ??= {}; for (const [agentId, config] of agentConfigs) { if (agentToolContexts.has(agentId)) { continue; @@ -866,20 +864,53 @@ const initializeClient = async ({ } }; + const loadGraphMemberCapabilityMetadata = async (agent) => { + if (!agent.subagents?.enabled) return []; + const memberIds = Array.from( + new Set((agent.subagents.graphs ?? []).flatMap((graph) => graph.agent_ids ?? [])), + ).filter( + (memberId) => + memberId !== agent.id && memberId !== primaryConfig.id && !agentConfigs.has(memberId), + ); + const stagedMemberIds = memberIds.filter((memberId) => !subagentGraphIds.has(memberId)); + if (subagentGraphIds.size + stagedMemberIds.length > MAX_SUBAGENT_GRAPH_NODES) { + logger.warn('[initializeClient] Subagent graph node limit exceeded', { + agentId: stagedMemberIds[0], + primaryAgentId: primaryConfig.id, + loadedSubagentCount: subagentGraphIds.size, + stagedSubagentCount: stagedMemberIds.length, + maxSubagentGraphNodes: MAX_SUBAGENT_GRAPH_NODES, + }); + throw new Error( + `Subagent graph exceeds the maximum of ${MAX_SUBAGENT_GRAPH_NODES} unique agents.`, + ); + } + for (const memberId of stagedMemberIds) { + subagentGraphIds.add(memberId); + } + const memberMetadata = await Promise.all(memberIds.map(loadSubagentMetadata)); + return memberMetadata.filter(Boolean); + }; + /** * Resolves the selected descriptor inside the foreground request. The * legacy initializer requires request/response objects for tool and MCP * setup, so this intentionally remains request-scoped until AI-1597 gives * child execution a durable runtime context. */ - const initializeLazySubagent = async ({ agentId, configId, context, lazyChildren }) => { - throwIfAborted(context.signal); - const agent = await waitForAbort(db.getAgentWithVersionCount({ id: agentId }), context.signal); + const initializeLoadedSubagent = async ({ + agent, + agentId, + configId, + context, + lazyChildren, + viewAccessChecked = false, + }) => { throwIfAborted(context.signal); if (!agent || getLazySubagentConfigId(agent) !== configId) { throw new Error(`Subagent ${agentId} changed before it could be initialized.`); } - if (!(await hasSubagentViewAccess(agent, agentId, context.signal))) { + if (!viewAccessChecked && !(await hasSubagentViewAccess(agent, agentId, context.signal))) { throw new Error(`You no longer have access to subagent ${agentId}.`); } const validation = await waitForAbort( @@ -923,7 +954,10 @@ const initializeClient = async ({ ephemeralSkillsToggle, }), codeEnvAvailable, + backgroundToolsAvailable, + toolIntentsAvailable, statefulSessionsAvailable, + allowedStatefulCodeEnvironments, memoryAvailable, skillStates, defaultActiveOnShare, @@ -949,10 +983,18 @@ const initializeClient = async ({ ); throwIfAborted(context.signal); config.lazySubagentConfigs = lazyChildren; + if (config.userMCPAuthMap) { + Object.assign(userMCPAuthMap, config.userMCPAuthMap); + } agentToolContexts.set(agentId, buildAgentToolContext({ agent, config })); endpointTokenConfigByAgentId.set(agentId, config.endpointTokenConfig); return config; }; + const initializeLazySubagent = async ({ agentId, configId, context, lazyChildren }) => { + throwIfAborted(context.signal); + const agent = await waitForAbort(db.getAgentWithVersionCount({ id: agentId }), context.signal); + return initializeLoadedSubagent({ agent, agentId, configId, context, lazyChildren }); + }; const buildLazySubagentDescriptors = async (agent, depth = 0, ancestors = new Set()) => { if (!subagentsCapabilityEnabled || !agent.subagents?.enabled) { @@ -1009,6 +1051,7 @@ const initializeClient = async ({ ); const lazyChildren = childDescriptors.filter((child) => child.configId); const eagerChildren = childDescriptors.filter((child) => !child.configId); + const subagentGraphMemberMetadata = await loadGraphMemberCapabilityMetadata(metadata); descriptors.push({ id: metadata.id, name: metadata.name, @@ -1025,14 +1068,17 @@ const initializeClient = async ({ includeReasoningHistory: metadata.includeReasoningHistory, lazySubagentConfigs: lazyChildren, subagentAgentConfigs: eagerChildren, - resolve: (context) => + subagentGraphMemberMetadata, + resolve: async (context) => initializeLazySubagent({ agentId: metadata.id, configId: metadata.configId, context, lazyChildren, - }).then((config) => { + }).then(async (config) => { config.subagentAgentConfigs = eagerChildren; + graphMemberConfigsById.set(config.id, config); + await resolveGraphSubagentsFor(config, context.signal); return config; }), }); @@ -1052,7 +1098,119 @@ const initializeClient = async ({ } }; - await resolveSubagentTrees([primaryConfig, ...agentConfigs.values()]); + const rootSubagentConfigs = [primaryConfig, ...agentConfigs.values()]; + await resolveSubagentTrees(rootSubagentConfigs); + + const graphMemberConfigsById = new Map( + rootSubagentConfigs.filter((config) => config?.id).map((config) => [config.id, config]), + ); + const graphMemberLoadsById = new Map(); + const initializeGraphMember = createConcurrencyLimiter(SUBAGENT_GRAPH_LOAD_CONCURRENCY); + const loadGraphMemberOnce = async (memberId) => { + throwIfAborted(signal); + const cached = graphMemberConfigsById.get(memberId); + if (cached) return cached; + if (skippedAgentIds.has(memberId)) return null; + assertSubagentGraphRoom(memberId); + subagentGraphIds.add(memberId); + const agent = await waitForAbort(db.getAgentWithVersionCount({ id: memberId }), signal); + if (!agent || !(await hasSubagentViewAccess(agent, memberId, signal))) { + skippedAgentIds.add(memberId); + return null; + } + try { + const config = await initializeLoadedSubagent({ + agent, + agentId: memberId, + configId: getLazySubagentConfigId(agent), + context: { signal }, + lazyChildren: [], + viewAccessChecked: true, + }); + graphMemberConfigsById.set(memberId, config); + return config; + } catch (error) { + if (isFatalAgentInitializationError(error)) { + throw error; + } + logger.error(`[initializeClient] Error initializing graph member ${memberId}:`, error); + skippedAgentIds.add(memberId); + return null; + } + }; + const loadGraphMember = async (memberId, graphSignal = signal) => { + throwIfAborted(graphSignal); + const cached = graphMemberConfigsById.get(memberId); + if (cached) return cached; + let pending = graphMemberLoadsById.get(memberId); + if (!pending) { + pending = loadGraphMemberOnce(memberId); + graphMemberLoadsById.set(memberId, pending); + pending.then( + () => { + if (graphMemberLoadsById.get(memberId) === pending) { + graphMemberLoadsById.delete(memberId); + } + }, + () => { + if (graphMemberLoadsById.get(memberId) === pending) { + graphMemberLoadsById.delete(memberId); + } + }, + ); + } + return waitForAbort(pending, graphSignal); + }; + + async function resolveGraphSubagentsFor(config, graphSignal = signal) { + throwIfAborted(graphSignal); + const definitions = + subagentsCapabilityEnabled && config.subagents?.enabled === true + ? (config.subagents.graphs ?? []) + : []; + const resolvedGraphs = []; + for (const definition of definitions) { + const memberIds = [...new Set(definition.agent_ids ?? [])]; + const unloadedMemberIds = memberIds.filter( + (memberId) => !graphMemberConfigsById.has(memberId) && !subagentGraphIds.has(memberId), + ); + if (subagentGraphIds.size + unloadedMemberIds.length > MAX_SUBAGENT_GRAPH_NODES) { + const overflowIndex = MAX_SUBAGENT_GRAPH_NODES - subagentGraphIds.size; + logger.warn('[initializeClient] Subagent graph node limit exceeded', { + agentId: unloadedMemberIds[Math.max(overflowIndex, 0)], + primaryAgentId: primaryConfig.id, + loadedSubagentCount: subagentGraphIds.size, + stagedSubagentCount: unloadedMemberIds.length, + maxSubagentGraphNodes: MAX_SUBAGENT_GRAPH_NODES, + }); + continue; + } + for (const memberId of unloadedMemberIds) { + subagentGraphIds.add(memberId); + } + const memberConfigs = await Promise.all( + memberIds.map((memberId) => + initializeGraphMember(() => loadGraphMember(memberId, graphSignal)), + ), + ); + throwIfAborted(graphSignal); + if (memberConfigs.some((member) => member == null)) { + logger.warn('[initializeClient] Skipping incomplete graph subagent', { + parentAgentId: config.id, + graphType: definition.type, + expectedMemberCount: memberIds.length, + resolvedMemberCount: memberConfigs.filter(Boolean).length, + }); + continue; + } + resolvedGraphs.push({ definition, memberConfigs }); + } + config.subagentGraphConfigs = resolvedGraphs; + } + + for (const config of rootSubagentConfigs) { + await resolveGraphSubagentsFor(config); + } primaryConfig.subagents = subagentsCapabilityEnabled ? primaryConfig.subagents : undefined; @@ -1064,10 +1222,12 @@ const initializeClient = async ({ * has disabled the capability globally. */ if (!subagentsCapabilityEnabled) { primaryConfig.lazySubagentConfigs = undefined; + primaryConfig.subagentGraphConfigs = undefined; for (const config of agentConfigs.values()) { config.subagents = undefined; config.subagentAgentConfigs = undefined; config.lazySubagentConfigs = undefined; + config.subagentGraphConfigs = undefined; } } diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index 2152892fe8..31753a9924 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -90,6 +90,7 @@ jest.mock('~/cache', () => ({ })); const { initializeClient } = require('./initialize'); +const { processAddedConvo } = require('./addedConvo'); const { getSkillDbMethods, getSkillToolDeps } = require('./skillDeps'); const { loadAgentTools } = require('~/server/services/ToolService'); const { getModelsConfig } = require('~/server/controllers/ModelController'); @@ -1179,6 +1180,463 @@ describe('initializeClient — subagent loading', () => { initialization.resolve(makeSubagentConfig(SUBAGENT_ID)); }); + it('resolves a graph subagent as an isolated all-member team', async () => { + const memberIds = ['agent_graph_researcher', 'agent_graph_writer']; + for (const memberId of memberIds) { + await createViewableAgent(memberId); + } + const definition = { + type: 'research_team', + name: 'Research team', + description: 'Researches and writes a final answer', + agent_ids: memberIds, + edges: [{ from: memberIds[0], to: memberIds[1], edgeType: 'direct' }], + entry_agent_id: memberIds[0], + result_agent_id: memberIds[1], + }; + const primaryConfig = makePrimaryConfig({ + subagents: { enabled: true, allowSelf: false, graphs: [definition] }, + }); + const memberConfigs = new Map( + memberIds.map((id, index) => [ + id, + { + ...makeSubagentConfig(id), + userMCPAuthMap: { [`server_${index}`]: { token: `token_${index}` } }, + }, + ]), + ); + mockInitializeAgent.mockImplementation(({ agent }) => + Promise.resolve(agent.id === PRIMARY_ID ? primaryConfig : memberConfigs.get(agent.id)), + ); + + const { userMCPAuthMap } = await initializeClient({ + req: makeSubagentReq(), + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + expect(agentClientArgs.agent.subagentGraphConfigs).toEqual([ + { + definition, + memberConfigs: memberIds.map((id) => memberConfigs.get(id)), + }, + ]); + expect(memberIds.every((id) => !agentClientArgs.agentConfigs.has(id))).toBe(true); + expect(userMCPAuthMap).toEqual({ + server_0: { token: 'token_0' }, + server_1: { token: 'token_1' }, + }); + }); + + it('resolves graph teams only after their lazy parent is selected', async () => { + const childId = 'agent_lazy_graph_parent'; + const memberId = 'agent_lazy_graph_member'; + const definition = { + type: 'lazy_team', + name: 'Lazy team', + description: 'Loads with its selected parent', + agent_ids: [childId, memberId], + edges: [{ from: childId, to: memberId, edgeType: 'direct' }], + entry_agent_id: childId, + result_agent_id: memberId, + }; + await createViewableAgent(childId, { + enabled: true, + allowSelf: false, + graphs: [definition], + }); + const memberAgent = await createAgent({ + id: memberId, + name: memberId, + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: ['execute_code'], + stateful_code_sessions: true, + }); + await grantView(memberAgent); + const primaryConfig = makePrimaryConfig({ + subagents: { enabled: true, allowSelf: false, agent_ids: [childId] }, + }); + const memberMCPAuthMap = { late_server: { token: 'late_token' } }; + mockInitializeAgent.mockImplementation(({ agent }) => + Promise.resolve( + agent.id === PRIMARY_ID + ? primaryConfig + : { + ...makeSubagentConfig(agent.id), + subagents: agent.subagents, + ...(agent.id === memberId ? { userMCPAuthMap: memberMCPAuthMap } : {}), + }, + ), + ); + + const req = makeSubagentReq(); + req.config.endpoints.agents.capabilities.push( + 'execute_code', + 'run_in_background', + 'tool_intents', + 'stateful_code_sessions', + ); + const { userMCPAuthMap } = await initializeClient({ + req, + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + expect(mockInitializeAgent).toHaveBeenCalledTimes(1); + const descriptor = agentClientArgs.agent.lazySubagentConfigs[0]; + expect(descriptor.subagentGraphMemberMetadata).toEqual([ + expect.objectContaining({ + id: memberId, + codeEnvAvailable: true, + statefulCodeSessions: true, + }), + ]); + expect(userMCPAuthMap).toEqual({}); + const resolvedChild = await descriptor.resolve({ + signal: new AbortController().signal, + }); + expect(mockInitializeAgent).toHaveBeenCalledTimes(3); + expect(mockInitializeAgent).toHaveBeenCalledWith( + expect.objectContaining({ + agent: expect.objectContaining({ id: memberId }), + backgroundToolsAvailable: true, + toolIntentsAvailable: true, + }), + expect.anything(), + ); + expect(resolvedChild.subagentGraphConfigs).toEqual([ + { + definition, + memberConfigs: [resolvedChild, expect.objectContaining({ id: memberId })], + }, + ]); + expect(userMCPAuthMap).toEqual(memberMCPAuthMap); + }); + + it('aborts lazy graph member initialization with the descriptor signal', async () => { + const childId = 'agent_lazy_cancel_parent'; + const memberId = 'agent_lazy_cancel_member'; + const definition = { + type: 'lazy_cancel_team', + name: 'Lazy cancel team', + description: 'Stops member initialization with its selected parent', + agent_ids: [memberId], + edges: [], + entry_agent_id: memberId, + result_agent_id: memberId, + }; + await createViewableAgent(childId, { + enabled: true, + allowSelf: false, + graphs: [definition], + }); + await createViewableAgent(memberId); + const primaryConfig = makePrimaryConfig({ + subagents: { enabled: true, allowSelf: false, agent_ids: [childId] }, + }); + const memberInitialization = deferred(); + const memberStarted = deferred(); + mockInitializeAgent.mockImplementation(({ agent }) => { + if (agent.id === PRIMARY_ID) return Promise.resolve(primaryConfig); + if (agent.id === childId) { + return Promise.resolve({ ...makeSubagentConfig(childId), subagents: agent.subagents }); + } + memberStarted.resolve(); + return memberInitialization.promise; + }); + + await initializeClient({ + req: makeSubagentReq(), + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + const controller = new AbortController(); + const resolution = agentClientArgs.agent.lazySubagentConfigs[0].resolve({ + signal: controller.signal, + }); + await memberStarted.promise; + controller.abort(new Error('cancelled graph resolution')); + + await expect(resolution).rejects.toThrow('cancelled graph resolution'); + memberInitialization.resolve(makeSubagentConfig(memberId)); + }); + + it('coalesces shared graph member initialization across parallel lazy resolutions', async () => { + const childIds = ['agent_lazy_shared_parent_a', 'agent_lazy_shared_parent_b']; + const memberId = 'agent_lazy_shared_member'; + for (const childId of childIds) { + await createViewableAgent(childId, { + enabled: true, + allowSelf: false, + graphs: [ + { + type: `shared_team_${childId}`, + name: `Shared team ${childId}`, + description: 'Loads one shared member', + agent_ids: [memberId], + edges: [], + entry_agent_id: memberId, + result_agent_id: memberId, + }, + ], + }); + } + await createViewableAgent(memberId); + const primaryConfig = makePrimaryConfig({ + subagents: { enabled: true, allowSelf: false, agent_ids: childIds }, + }); + const memberStarted = deferred(); + const memberRelease = deferred(); + mockInitializeAgent.mockImplementation(async ({ agent }) => { + if (agent.id === PRIMARY_ID) return primaryConfig; + if (childIds.includes(agent.id)) { + return { ...makeSubagentConfig(agent.id), subagents: agent.subagents }; + } + memberStarted.resolve(); + await memberRelease.promise; + return makeSubagentConfig(memberId); + }); + + await initializeClient({ + req: makeSubagentReq(), + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + const resolutions = agentClientArgs.agent.lazySubagentConfigs.map((descriptor) => + descriptor.resolve({ signal: new AbortController().signal }), + ); + await memberStarted.promise; + memberRelease.resolve(); + const resolvedChildren = await Promise.all(resolutions); + + expect( + mockInitializeAgent.mock.calls.filter(([{ agent }]) => agent.id === memberId), + ).toHaveLength(1); + expect(resolvedChildren).toHaveLength(2); + expect( + resolvedChildren.every( + (child) => child.subagentGraphConfigs[0].memberConfigs[0].id === memberId, + ), + ).toBe(true); + }); + + it('loads independent graph members concurrently', async () => { + const memberIds = ['agent_graph_parallel_a', 'agent_graph_parallel_b']; + for (const memberId of memberIds) { + await createViewableAgent(memberId); + } + const definition = { + type: 'parallel_team', + name: 'Parallel team', + description: 'Loads independent members concurrently', + agent_ids: memberIds, + edges: [{ from: memberIds[0], to: memberIds[1], edgeType: 'direct' }], + entry_agent_id: memberIds[0], + result_agent_id: memberIds[1], + }; + const primaryConfig = makePrimaryConfig({ + subagents: { enabled: true, allowSelf: false, graphs: [definition] }, + }); + const memberStarts = new Map(memberIds.map((id) => [id, deferred()])); + const memberReleases = new Map(memberIds.map((id) => [id, deferred()])); + mockInitializeAgent.mockImplementation(async ({ agent }) => { + if (agent.id === PRIMARY_ID) { + return primaryConfig; + } + memberStarts.get(agent.id)?.resolve(); + await memberReleases.get(agent.id)?.promise; + return makeSubagentConfig(agent.id); + }); + const getAgentWithVersionCount = jest.spyOn(db, 'getAgentWithVersionCount'); + + const initialization = initializeClient({ + req: makeSubagentReq(), + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + await Promise.all(memberIds.map((id) => memberStarts.get(id)?.promise)); + for (const release of memberReleases.values()) { + release.resolve(); + } + await initialization; + + expect(agentClientArgs.agent.subagentGraphConfigs[0].memberConfigs).toHaveLength(2); + for (const memberId of memberIds) { + expect( + getAgentWithVersionCount.mock.calls.filter(([query]) => query.id === memberId), + ).toHaveLength(1); + } + }); + + it('reuses the primary config when the parent is a graph member', async () => { + const memberId = 'agent_graph_self_worker'; + await createViewableAgent(memberId); + const definition = { + type: 'self_team', + name: 'Self team', + description: 'Uses the parent as the entry member', + agent_ids: [PRIMARY_ID, memberId], + edges: [{ from: PRIMARY_ID, to: memberId, edgeType: 'direct' }], + entry_agent_id: PRIMARY_ID, + result_agent_id: memberId, + }; + const primaryConfig = makePrimaryConfig({ + subagents: { enabled: true, allowSelf: false, graphs: [definition] }, + }); + const memberConfig = makeSubagentConfig(memberId); + mockInitializeAgent.mockImplementation(({ agent }) => + Promise.resolve(agent.id === PRIMARY_ID ? primaryConfig : memberConfig), + ); + + await initializeClient({ + req: makeSubagentReq(), + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + expect(mockInitializeAgent).toHaveBeenCalledTimes(2); + expect(agentClientArgs.agent.subagentGraphConfigs).toEqual([ + { definition, memberConfigs: [primaryConfig, memberConfig] }, + ]); + expect(agentClientArgs.agentConfigs.has(PRIMARY_ID)).toBe(false); + }); + + it('keeps an added-conversation agent that is also a graph member', async () => { + const addedAgentId = 'agent_added_graph_member'; + const definition = { + type: 'added_member_team', + name: 'Added member team', + description: 'Runs the selected parallel agent as a graph member', + agent_ids: [addedAgentId], + edges: [], + entry_agent_id: addedAgentId, + result_agent_id: addedAgentId, + }; + const primaryConfig = makePrimaryConfig({ + subagents: { enabled: true, allowSelf: false, graphs: [definition] }, + }); + const addedConfig = makeSubagentConfig(addedAgentId); + mockInitializeAgent.mockResolvedValue(primaryConfig); + processAddedConvo.mockImplementationOnce(async ({ agentConfigs }) => { + agentConfigs.set(addedAgentId, addedConfig); + return { userMCPAuthMap: undefined }; + }); + + await initializeClient({ + req: makeSubagentReq(), + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + expect(agentClientArgs.agentConfigs.get(addedAgentId)).toBe(addedConfig); + expect(agentClientArgs.agent.subagentGraphConfigs).toEqual([ + { definition, memberConfigs: [addedConfig] }, + ]); + }); + + it('skips the whole graph subagent when one persisted member is missing', async () => { + const existingId = 'agent_graph_existing'; + const missingId = 'agent_graph_missing'; + await createViewableAgent(existingId); + const definition = { + type: 'incomplete_team', + name: 'Incomplete team', + description: 'Legacy graph with a deleted member', + agent_ids: [existingId, missingId], + edges: [{ from: existingId, to: missingId, edgeType: 'direct' }], + entry_agent_id: existingId, + result_agent_id: missingId, + }; + const primaryConfig = makePrimaryConfig({ + subagents: { enabled: true, allowSelf: false, graphs: [definition] }, + }); + mockInitializeAgent.mockImplementation(({ agent }) => + Promise.resolve(agent.id === PRIMARY_ID ? primaryConfig : makeSubagentConfig(agent.id)), + ); + + await initializeClient({ + req: makeSubagentReq(), + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + expect(agentClientArgs.agent.subagentGraphConfigs).toEqual([]); + expect(agentClientArgs.agentConfigs.has(existingId)).toBe(false); + expect(logger.warn).toHaveBeenCalledWith( + '[initializeClient] Skipping incomplete graph subagent', + expect.objectContaining({ graphType: 'incomplete_team', resolvedMemberCount: 1 }), + ); + }); + + it('counts initialized members from incomplete graph teams against the global node limit', async () => { + const retainedMemberIds = Array.from( + { length: 31 }, + (_, index) => `agent_incomplete_retained_${index}`, + ); + const overflowMemberIds = Array.from( + { length: 20 }, + (_, index) => `agent_incomplete_overflow_${index}`, + ); + await Promise.all( + [...retainedMemberIds, ...overflowMemberIds].map((id) => createViewableAgent(id)), + ); + const definitions = [ + { + type: 'incomplete_retained_team', + name: 'Incomplete retained team', + description: 'Loads members before discovering a missing result', + agent_ids: [...retainedMemberIds, 'agent_incomplete_missing_result'], + edges: [], + entry_agent_id: retainedMemberIds[0], + result_agent_id: 'agent_incomplete_missing_result', + }, + { + type: 'incomplete_overflow_team', + name: 'Incomplete overflow team', + description: 'Must be rejected before loading any more members', + agent_ids: [...overflowMemberIds, 'agent_incomplete_missing_overflow'], + edges: [], + entry_agent_id: overflowMemberIds[0], + result_agent_id: 'agent_incomplete_missing_overflow', + }, + ]; + const primaryConfig = makePrimaryConfig({ + subagents: { enabled: true, allowSelf: false, graphs: definitions }, + }); + mockInitializeAgent.mockImplementation(({ agent }) => + Promise.resolve(agent.id === PRIMARY_ID ? primaryConfig : makeSubagentConfig(agent.id)), + ); + + await initializeClient({ + req: makeSubagentReq(), + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + expect(mockInitializeAgent).toHaveBeenCalledTimes(1 + retainedMemberIds.length); + expect(logger.warn).toHaveBeenCalledWith( + '[initializeClient] Subagent graph node limit exceeded', + expect.objectContaining({ + loadedSubagentCount: retainedMemberIds.length + 1, + stagedSubagentCount: overflowMemberIds.length + 1, + maxSubagentGraphNodes: MAX_SUBAGENT_GRAPH_NODES, + }), + ); + }); + it('rejects nested subagent chains deeper than MAX_SUBAGENT_DEPTH', async () => { const ids = Array.from( { length: MAX_SUBAGENT_DEPTH + 1 }, @@ -1299,6 +1757,65 @@ describe('initializeClient — subagent loading', () => { expect(agentClientArgs).toBeUndefined(); }); + it('bounds capability metadata reads across lazy graph teams', async () => { + const firstChildId = 'agent_lazy_graph_cap_first'; + const secondChildId = 'agent_lazy_graph_cap_second'; + const firstMemberIds = Array.from({ length: 32 }, (_, index) => `agent_cap_first_${index}`); + const secondMemberIds = Array.from({ length: 17 }, (_, index) => `agent_cap_second_${index}`); + const makeGraph = (type, memberIds) => ({ + type, + name: type, + description: type, + agent_ids: memberIds, + edges: memberIds.slice(0, -1).map((memberId, index) => ({ + from: memberId, + to: memberIds[index + 1], + edgeType: 'direct', + })), + entry_agent_id: memberIds[0], + result_agent_id: memberIds.at(-1), + }); + await createViewableAgent(firstChildId, { + enabled: true, + allowSelf: false, + graphs: [makeGraph('first_capability_team', firstMemberIds)], + }); + await createViewableAgent(secondChildId, { + enabled: true, + allowSelf: false, + graphs: [makeGraph('second_capability_team', secondMemberIds)], + }); + await Promise.all( + [...firstMemberIds, ...secondMemberIds].map((memberId) => createViewableAgent(memberId)), + ); + const primaryConfig = makePrimaryConfig({ + subagents: { + enabled: true, + allowSelf: false, + agent_ids: [firstChildId, secondChildId], + }, + }); + mockInitializeAgent.mockResolvedValue(primaryConfig); + + await expect( + initializeClient({ + req: makeSubagentReq(), + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }), + ).rejects.toThrow(`maximum of ${MAX_SUBAGENT_GRAPH_NODES} unique agents`); + expect(mockInitializeAgent).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + '[initializeClient] Subagent graph node limit exceeded', + expect.objectContaining({ + loadedSubagentCount: 34, + stagedSubagentCount: secondMemberIds.length, + maxSubagentGraphNodes: MAX_SUBAGENT_GRAPH_NODES, + }), + ); + }); + it('rejects a branching DAG that exceeds expanded descriptor capacity', async () => { const width = 3; const layers = Array.from({ length: MAX_SUBAGENT_DEPTH }, (_, level) => diff --git a/api/server/services/Endpoints/agents/skillDeps.js b/api/server/services/Endpoints/agents/skillDeps.js index 2d68113892..485e37f198 100644 --- a/api/server/services/Endpoints/agents/skillDeps.js +++ b/api/server/services/Endpoints/agents/skillDeps.js @@ -10,6 +10,7 @@ const { } = require('~/server/services/Files/Code/process'); const { checkAccess, + isMemoryEnabled, getStorageMetadata, resolveRequestTenantId, enrichWithSkillConfigurable, @@ -26,6 +27,7 @@ const { AccessRoleIds, PrincipalType, PermissionTypes, + AgentCapabilities, isEphemeralAgentId, } = require('librechat-data-provider'); const { checkPermission, grantPermission } = require('~/server/services/PermissionService'); @@ -298,6 +300,23 @@ function buildAgentToolContext({ agent, config }) { }; } +/** Resolves the full run-level gate used to expose inline memory tools. */ +function resolveMemoryAvailability({ enabledCapabilities, memoryConfig, user, getRoleByName }) { + if ( + !enabledCapabilities.has(AgentCapabilities.memory) || + !isMemoryEnabled(memoryConfig) || + user?.personalization?.memories === false + ) { + return false; + } + return checkAccess({ + user, + permissionType: PermissionTypes.MEMORIES, + permissions: [Permissions.USE, Permissions.CREATE, Permissions.UPDATE], + getRoleByName, + }); +} + function hasOwn(value, key) { return Object.prototype.hasOwnProperty.call(value ?? {}, key); } @@ -385,5 +404,6 @@ module.exports = { enrichWithSkillConfigurable, buildSkillPrimedIdsByName, buildAgentToolContext, + resolveMemoryAvailability, enrichLoadedToolsWithAgentContext, }; diff --git a/client/src/components/SidePanel/Agents/Advanced/AgentSubagents.tsx b/client/src/components/SidePanel/Agents/Advanced/AgentSubagents.tsx index 6ed69cf1c5..5409b44c75 100644 --- a/client/src/components/SidePanel/Agents/Advanced/AgentSubagents.tsx +++ b/client/src/components/SidePanel/Agents/Advanced/AgentSubagents.tsx @@ -23,6 +23,7 @@ const AgentSubagents: React.FC = ({ field, currentAgentId } 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 { options, getAgent } = useSelectableAgents({ currentAgentId, exclude: agentIds }); @@ -36,12 +37,13 @@ const AgentSubagents: React.FC = ({ field, currentAgentId } * `enabled: false` flows through as a real update. */ field.onChange({ + ...value, enabled: next, allowSelf: value.allowSelf ?? true, agent_ids: value.agent_ids ?? [], }); }, - [field, value.allowSelf, value.agent_ids], + [field, value], ); const setAllowSelf = useCallback( @@ -77,7 +79,7 @@ const AgentSubagents: React.FC = ({ field, currentAgentId } }; const selfId = 'subagents-self-toggle'; - const nothingToSpawn = enabled && !allowSelf && agentIds.length === 0; + const nothingToSpawn = enabled && !allowSelf && agentIds.length === 0 && graphCount === 0; return ( SDK graph-subagent bridge. + * + * Run from `packages/api` with credentials loaded into the environment: + * `RUN_GRAPH_SUBAGENT_LIVE_TESTS=1 npx jest graph-subagent.e2e --runInBand` + */ +import { GraphEvents, Providers, formatAgentMessages } from '@librechat/agents'; +import type { EventHandler, SubagentUpdateEvent, SubagentUsageEvent } from '@librechat/agents'; +import { createRun } from '~/agents/run'; + +const shouldRun = process.env.RUN_GRAPH_SUBAGENT_LIVE_TESTS === '1'; +const liveDescribe = shouldRun ? describe : describe.skip; + +type LiveProvider = { + provider: string; + model: string; + apiKey: string | undefined; +}; + +const providers: LiveProvider[] = [ + { + provider: Providers.OPENAI, + model: process.env.GRAPH_SUBAGENT_OPENAI_MODEL ?? 'gpt-4.1-mini', + apiKey: process.env.OPENAI_API_KEY, + }, + { + provider: Providers.ANTHROPIC, + model: process.env.GRAPH_SUBAGENT_ANTHROPIC_MODEL ?? 'claude-haiku-4-5-20251001', + apiKey: process.env.ANTHROPIC_API_KEY, + }, +]; +const requestedProvider = process.env.GRAPH_SUBAGENT_LIVE_PROVIDER; +const selectedProviders = requestedProvider + ? providers.filter(({ provider }) => provider === requestedProvider) + : providers; + +function makeAgent(provider: string, model: string, id: string, instructions: string) { + return { + id, + name: id, + provider, + endpoint: provider, + instructions, + tools: [], + maxContextTokens: 4096, + recursion_limit: 9, + model_parameters: { + model, + temperature: 0, + max_tokens: 64, + streaming: false, + }, + }; +} + +liveDescribe('Graph subagent E2E (LibreChat)', () => { + jest.setTimeout(180_000); + + beforeAll(() => { + if (selectedProviders.length === 0) { + throw new Error(`Unknown live graph-subagent provider: ${requestedProvider}`); + } + if (!selectedProviders.every(({ apiKey }) => Boolean(apiKey))) { + throw new Error('The selected live graph-subagent providers require API credentials.'); + } + }); + + test.each(selectedProviders)( + '$provider executes a saved team with member telemetry', + async (liveProvider) => { + const { provider, model } = liveProvider; + const entry = makeAgent( + provider, + model, + 'live_entry', + 'Calculate 17 + 25 and state the result for the next team member.', + ); + const worker = makeAgent( + provider, + model, + 'live_worker', + 'Review the prior arithmetic and state the verified result for the final writer.', + ); + const result = makeAgent( + provider, + model, + 'live_result', + 'Answer the arithmetic question using only the verified number.', + ); + const definition = { + type: 'live_team', + name: 'Live team', + description: 'Runs the live provider verification team', + agent_ids: [entry.id, worker.id, result.id], + edges: [ + { from: entry.id, to: worker.id, edgeType: 'direct' as const }, + { from: worker.id, to: result.id, edgeType: 'direct' as const }, + ], + entry_agent_id: entry.id, + result_agent_id: result.id, + }; + const root = { + ...makeAgent( + provider, + model, + 'live_root', + 'You must call the live_team subagent exactly once to answer the user. After it returns, reply with only its returned text.', + ), + subagents: { enabled: true, allowSelf: false, graphs: [definition] }, + subagentGraphConfigs: [{ definition, memberConfigs: [entry, worker, result] }], + }; + const updates: SubagentUpdateEvent[] = []; + const usage: SubagentUsageEvent[] = []; + const handlers: Record = { + [GraphEvents.ON_SUBAGENT_UPDATE]: { + handle: (_event: string, data: unknown) => { + updates.push(data as SubagentUpdateEvent); + }, + }, + }; + const { messages } = formatAgentMessages( + [{ role: 'user', content: 'What is 17 + 25? Delegate this to the live team.' }] as never, + {}, + ); + const runId = `graph-subagent-live-${provider}-${Date.now()}`; + const run = await createRun({ + agents: [root] as never, + messages, + runId, + signal: new AbortController().signal, + customHandlers: handlers, + subagentUsageSink: (event) => usage.push(event), + streaming: false, + streamUsage: true, + }); + + await run.processStream( + { messages }, + { + configurable: { thread_id: runId }, + recursionLimit: 100, + streamMode: 'values', + version: 'v2', + }, + ); + + const runMessages = run.getRunMessages(); + if (!runMessages) { + throw new Error('Expected graph subagent run messages'); + } + + const output = runMessages + .map((message) => + typeof message.content === 'string' ? message.content : JSON.stringify(message.content), + ) + .join('\n'); + const envelopeMembers = new Set(updates.map((event) => event.memberAgentId).filter(Boolean)); + const payloadMembers = new Set( + updates + .map((event) => (event.data as { agentId?: string } | undefined)?.agentId) + .filter(Boolean), + ); + const billedMembers = new Set(usage.map((event) => event.memberAgentId).filter(Boolean)); + + console.info( + JSON.stringify({ + provider, + outputHas42: output.includes('42'), + envelopeMembers: [...envelopeMembers], + payloadMembers: [...payloadMembers], + billedMembers: [...billedMembers], + updatePhases: updates.map((event) => event.phase), + usageCount: usage.length, + }), + ); + + expect(output).toContain('42'); + expect(payloadMembers).toEqual(new Set([entry.id, worker.id, result.id])); + expect(billedMembers).toEqual(new Set([entry.id, worker.id, result.id])); + expect(usage.every((event) => event.subagentKind === 'graph')).toBe(true); + expect(usage.every((event) => event.depth === 1)).toBe(true); + expect(usage.every((event) => event.ancestry?.length === 1)).toBe(true); + }, + ); +}); diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index e01d73b0e2..7345d4e01b 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -6,6 +6,7 @@ import { MAX_SUBAGENT_RUN_CONFIGS, } from 'librechat-data-provider'; import type { SummarizationConfig, TEndpoint } from 'librechat-data-provider'; +import type { BaseMessage } from '@langchain/core/messages'; import type { AppConfig } from '@librechat/data-schemas'; import { createRun } from '~/agents/run'; @@ -159,6 +160,8 @@ async function callAndCapture( summarizationConfig?: SummarizationConfig; initialSummary?: { text: string; tokenCount: number }; appConfig?: AppConfig; + messages?: BaseMessage[]; + discoveredToolNames?: string[]; } = {}, ) { const agents = opts.agents ?? [makeAgent()]; @@ -170,6 +173,8 @@ async function callAndCapture( summarizationConfig: opts.summarizationConfig, initialSummary: opts.initialSummary, appConfig: opts.appConfig, + messages: opts.messages, + discoveredToolNames: opts.discoveredToolNames, streaming: true, streamUsage: true, }); @@ -1129,6 +1134,356 @@ describe('subagentConfigs', () => { expect(resolve).toHaveBeenCalledTimes(2); }); + it('uses pristine top-level inputs for a graph resolved by a lazy child', async () => { + const topLevelMember = makeAgent({ + id: 'agent_top_level_member', + hasDeferredTools: true, + toolDefinitions: [{ name: 'tool_search' }], + toolRegistry: new Map([['deep_tool', { name: 'deep_tool', defer_loading: true }]]), + }); + const definition = { + type: 'late_team', + name: 'Late team', + description: 'Resolves after the parent input is built', + agent_ids: [topLevelMember.id], + edges: [], + entry_agent_id: topLevelMember.id, + result_agent_id: topLevelMember.id, + }; + const resolve = jest.fn().mockResolvedValue( + makeAgent({ + id: 'agent_lazy_parent', + subagents: { enabled: true, allowSelf: false, graphs: [definition] }, + subagentGraphConfigs: [{ definition, memberConfigs: [topLevelMember] }], + }), + ); + const parent = makeAgent({ + id: 'agent_parent', + subagents: { enabled: true, allowSelf: false, agent_ids: ['agent_lazy_parent'] }, + lazySubagentConfigs: [ + { + id: 'agent_lazy_parent', + name: 'Lazy parent', + description: 'Lazy graph owner', + configId: 'agent_lazy_parent:1:fingerprint', + resolve, + }, + ], + }); + + const agents = await callAndCapture({ + agents: [topLevelMember, parent], + messages: [], + discoveredToolNames: ['deep_tool'], + }); + const lazyConfig = (agents[1].subagentConfigs as Array>)[0]; + const resolvedInputs = await ( + lazyConfig.resolveAgentInputs as (context: never) => Promise> + )({ signal: new AbortController().signal } as never); + const graphConfig = (resolvedInputs.subagentConfigs as Array>)[0]; + const memberInput = (graphConfig.agents as Array>)[0]; + const memberRegistry = memberInput.toolRegistry as Map; + + expect( + (agents[0].toolRegistry as Map).get('deep_tool'), + ).toMatchObject({ defer_loading: false }); + expect(memberRegistry.get('deep_tool')).toMatchObject({ defer_loading: true }); + expect(memberInput.toolDefinitions).toEqual([{ name: 'tool_search' }]); + }); + + it('builds lazy graph inputs from initialized members instead of capability metadata', async () => { + const childId = 'agent_lazy_capability_parent'; + const memberId = 'agent_lazy_capability_member'; + const metadata = makeAgent({ id: memberId, codeEnvAvailable: true }); + const initializedMember = makeAgent({ + id: memberId, + codeEnvAvailable: true, + toolDefinitions: [{ name: 'initialized_tool' }], + toolRegistry: new Map([['initialized_tool', { name: 'initialized_tool' }]]), + }); + const definition = { + type: 'capability_team', + name: 'Capability team', + description: 'Uses the initialized member runtime', + agent_ids: [childId, memberId], + edges: [{ from: childId, to: memberId, edgeType: 'direct' as const }], + entry_agent_id: childId, + result_agent_id: memberId, + }; + const resolve = jest.fn().mockImplementation(async () => { + const initializedChild = makeAgent({ + id: childId, + toolDefinitions: [{ name: 'child_tool' }], + toolRegistry: new Map([['child_tool', { name: 'child_tool' }]]), + subagents: { enabled: true, allowSelf: false, graphs: [definition] }, + }); + initializedChild.subagentGraphConfigs = [ + { definition, memberConfigs: [initializedChild, initializedMember] }, + ]; + return initializedChild; + }); + const agents = await callAndCapture({ + agents: [ + makeAgent({ + id: 'agent_parent', + subagents: { + enabled: true, + allowSelf: false, + agent_ids: [childId], + }, + lazySubagentConfigs: [ + { + id: childId, + name: 'Lazy capability parent', + description: 'Resolves its team on selection', + configId: `${childId}:1:fingerprint`, + subagentGraphMemberMetadata: [metadata], + resolve, + }, + ], + }), + ], + }); + const lazyConfig = (agents[0].subagentConfigs as Array>)[0]; + const resolvedInputs = await ( + lazyConfig.resolveAgentInputs as (context: never) => Promise> + )({ signal: new AbortController().signal } as never); + const graphConfig = (resolvedInputs.subagentConfigs as Array>)[0]; + const memberInputs = graphConfig.agents as Array>; + + expect(memberInputs[0].toolDefinitions).toEqual([{ name: 'child_tool' }]); + expect(memberInputs[0].toolRegistry).toEqual(new Map([['child_tool', { name: 'child_tool' }]])); + expect(memberInputs[1].toolDefinitions).toEqual([{ name: 'initialized_tool' }]); + expect(memberInputs[1].toolRegistry).toEqual( + new Map([['initialized_tool', { name: 'initialized_tool' }]]), + ); + }); + + it('builds an explicit saved-agent team as one graph subagent config', async () => { + const researcher = makeAgent({ + id: 'agent_researcher', + name: 'Researcher', + recursion_limit: 30, + }); + const writer = makeAgent({ + id: 'agent_writer', + name: 'Writer', + recursion_limit: 24, + subagents: { enabled: true, agent_ids: ['agent_nested'] }, + subagentAgentConfigs: [makeAgent({ id: 'agent_nested' })], + }); + const definition = { + type: 'research_team', + name: 'Research team', + description: 'Researches and writes a final answer', + agent_ids: ['agent_researcher', 'agent_writer'], + edges: [{ from: 'agent_researcher', to: 'agent_writer', edgeType: 'direct' as const }], + entry_agent_id: 'agent_researcher', + result_agent_id: 'agent_writer', + }; + const agents = await callAndCapture({ + agents: [ + makeAgent({ + subagents: { enabled: true, allowSelf: false, graphs: [definition] }, + subagentGraphConfigs: [{ definition, memberConfigs: [researcher, writer] }], + }), + ], + }); + + const configs = agents[0].subagentConfigs as Array>; + expect(configs).toHaveLength(1); + expect(configs[0]).toMatchObject({ + kind: 'graph', + type: 'research_team', + name: 'Research team', + description: 'Researches and writes a final answer', + edges: definition.edges, + entryAgentId: 'agent_researcher', + resultAgentId: 'agent_writer', + maxTurns: 8, + }); + const memberInputs = configs[0].agents as Array>; + expect(memberInputs.map((member) => member.agentId)).toEqual([ + 'agent_researcher', + 'agent_writer', + ]); + expect(memberInputs.every((member) => member.subagentConfigs == null)).toBe(true); + }); + + it('builds a one-member graph subagent without edges', async () => { + const member = makeAgent({ id: 'agent_solo', name: 'Solo' }); + const definition = { + type: 'solo_team', + name: 'Solo team', + description: 'Runs one isolated graph member', + agent_ids: ['agent_solo'], + edges: [], + entry_agent_id: 'agent_solo', + result_agent_id: 'agent_solo', + }; + const agents = await callAndCapture({ + agents: [ + makeAgent({ + subagents: { enabled: true, allowSelf: false, graphs: [definition] }, + subagentGraphConfigs: [{ definition, memberConfigs: [member] }], + }), + ], + }); + + expect(agents[0].subagentConfigs).toEqual([ + expect.objectContaining({ + kind: 'graph', + type: 'solo_team', + agents: [expect.objectContaining({ agentId: 'agent_solo' })], + edges: [], + entryAgentId: 'agent_solo', + resultAgentId: 'agent_solo', + }), + ]); + }); + + it('normalizes an explicit false excludeResults value before SDK validation', async () => { + const researcher = makeAgent({ id: 'agent_researcher' }); + const writer = makeAgent({ id: 'agent_writer' }); + const definition = { + type: 'default_results_team', + name: 'Default results team', + description: 'Uses the default edge result behavior', + agent_ids: ['agent_researcher', 'agent_writer'], + edges: [ + { + from: 'agent_researcher', + to: 'agent_writer', + edgeType: 'direct' as const, + excludeResults: false, + }, + ], + entry_agent_id: 'agent_researcher', + result_agent_id: 'agent_writer', + }; + const agents = await callAndCapture({ + agents: [ + makeAgent({ + subagents: { enabled: true, allowSelf: false, graphs: [definition] }, + subagentGraphConfigs: [{ definition, memberConfigs: [researcher, writer] }], + }), + ], + }); + + const [config] = agents[0].subagentConfigs as Array>; + expect(config.edges).toEqual([ + { from: 'agent_researcher', to: 'agent_writer', edgeType: 'direct' }, + ]); + }); + + it("adds each graph member's always-apply skills to its isolated context", async () => { + const member = makeAgent({ + id: 'agent_skilled_member', + additional_instructions: 'Keep the response concise.', + alwaysApplySkillPrimes: [ + { name: 'member-workflow', body: 'Follow the member-specific workflow.' }, + ], + }); + const definition = { + type: 'skilled_team', + name: 'Skilled team', + description: 'Runs a member with its own always-apply skill', + agent_ids: ['agent_skilled_member'], + edges: [], + entry_agent_id: 'agent_skilled_member', + result_agent_id: 'agent_skilled_member', + }; + const agents = await callAndCapture({ + agents: [ + makeAgent({ + subagents: { enabled: true, allowSelf: false, graphs: [definition] }, + subagentGraphConfigs: [{ definition, memberConfigs: [member] }], + }), + ], + }); + + const [config] = agents[0].subagentConfigs as Array>; + const [memberInput] = config.agents as Array>; + expect(memberInput.additional_instructions).toBe( + 'Keep the response concise.\n\n' + + '# Always-apply skill: member-workflow\nFollow the member-specific workflow.', + ); + }); + + it('isolates a parent graph member before discovered tools mutate the parent registry', async () => { + const agent = makeAgent({ + id: 'agent_parent', + name: 'Parent', + hasDeferredTools: true, + toolDefinitions: [{ name: 'tool_search' }], + toolRegistry: new Map([['deep_tool', { name: 'deep_tool', defer_loading: true }]]), + }); + const definition = { + type: 'self_team', + name: 'Self team', + description: 'Runs the parent as an isolated graph member', + agent_ids: ['agent_parent'], + edges: [], + entry_agent_id: 'agent_parent', + result_agent_id: 'agent_parent', + }; + agent.subagents = { enabled: true, allowSelf: false, graphs: [definition] }; + agent.subagentGraphConfigs = [{ definition, memberConfigs: [agent] }]; + + const agents = await callAndCapture({ + agents: [agent], + messages: [], + discoveredToolNames: ['deep_tool'], + }); + + const parentRegistry = agents[0].toolRegistry as Map; + const graphConfig = (agents[0].subagentConfigs as Array>)[0]; + const memberInputs = graphConfig.agents as Array>; + const memberRegistry = memberInputs[0].toolRegistry as Map; + expect(parentRegistry.get('deep_tool')?.defer_loading).toBe(false); + expect(memberRegistry.get('deep_tool')?.defer_loading).toBe(true); + expect(memberInputs[0].toolDefinitions).toEqual([{ name: 'tool_search' }]); + }); + + it('snapshots graph members before an earlier top-level input mutates them', async () => { + const earlierAgent = makeAgent({ + id: 'agent_earlier', + name: 'Earlier', + hasDeferredTools: true, + toolDefinitions: [{ name: 'tool_search' }], + toolRegistry: new Map([['deep_tool', { name: 'deep_tool', defer_loading: true }]]), + }); + const definition = { + type: 'cross_root_team', + name: 'Cross-root team', + description: 'Uses an earlier top-level agent as an isolated member', + agent_ids: ['agent_earlier'], + edges: [], + entry_agent_id: 'agent_earlier', + result_agent_id: 'agent_earlier', + }; + const laterAgent = makeAgent({ + id: 'agent_later', + name: 'Later', + subagents: { enabled: true, allowSelf: false, graphs: [definition] }, + subagentGraphConfigs: [{ definition, memberConfigs: [earlierAgent] }], + }); + + const agents = await callAndCapture({ + agents: [earlierAgent, laterAgent], + messages: [], + discoveredToolNames: ['deep_tool'], + }); + + const earlierRegistry = agents[0].toolRegistry as Map; + const laterGraph = (agents[1].subagentConfigs as Array>)[0]; + const memberInputs = laterGraph.agents as Array>; + const memberRegistry = memberInputs[0].toolRegistry as Map; + expect(earlierRegistry.get('deep_tool')?.defer_loading).toBe(false); + expect(memberRegistry.get('deep_tool')?.defer_loading).toBe(true); + expect(memberInputs[0].toolDefinitions).toEqual([{ name: 'tool_search' }]); + }); + it('preserves explicit nested subagents across the SDK child graph boundary', async () => { const grandchild = makeAgent({ id: 'agent_grandchild', name: 'Grandchild' }); const child = makeAgent({ @@ -1932,6 +2287,44 @@ describe('toolOutputReferences gating', () => { expect(callArgs.toolOutputReferences).toEqual({ enabled: true }); }); + it('enables tool output references from a lazy graph member metadata descriptor', async () => { + const signal = new AbortController().signal; + const graphMember = makeAgent({ + id: 'agent_lazy_graph_member', + codeEnvAvailable: true, + statefulCodeSessions: true, + }); + const lazyChild = { + ...makeAgent({ id: 'agent_lazy_child', codeEnvAvailable: false }), + configId: 'agent_lazy_child:v1', + subagentGraphMemberMetadata: [graphMember], + resolve: jest.fn(), + }; + await createRun({ + agents: [ + makeAgent({ + id: 'agent_parent', + codeEnvAvailable: false, + subagents: { enabled: true, allowSelf: false, agent_ids: ['agent_lazy_child'] }, + lazySubagentConfigs: [lazyChild], + }), + ] as never, + signal, + streaming: true, + streamUsage: true, + }); + + const createMock = Run.create as jest.Mock; + const callArgs = createMock.mock.calls[0][0] as Record; + expect(callArgs.toolOutputReferences).toEqual({ enabled: true }); + /** + * Stateful routing is intentionally agent-scoped. A lazy graph member must + * not promote its execution profile into run-global SDK configuration. + */ + expect(callArgs.toolExecution).toBeUndefined(); + expect(lazyChild.resolve).not.toHaveBeenCalled(); + }); + it('terminates and omits toolOutputReferences for a cyclic agent tree with no codeenv', async () => { /** * Cycle safety: `A → B → A`, neither has `codeEnvAvailable`. The diff --git a/packages/api/src/agents/attachments.test.ts b/packages/api/src/agents/attachments.test.ts index 88bc4d1105..9c2bfe639c 100644 --- a/packages/api/src/agents/attachments.test.ts +++ b/packages/api/src/agents/attachments.test.ts @@ -42,6 +42,22 @@ describe('agent attachment helpers', () => { expect(attachmentsByAgentId.get('agent-a')).toEqual([file]); }); + it('collects attachments from nested graph members', () => { + const memberFile = makeTextFile('member-file', 'member.txt', 'member context'); + const attachmentsByAgentId = buildAgentContextAttachmentsByAgentId([ + { + id: 'parent', + subagentGraphConfigs: [ + { + memberConfigs: [{ id: 'graph-member', agentContextAttachments: [memberFile] }], + }, + ], + }, + ]); + + expect(attachmentsByAgentId.get('graph-member')).toEqual([memberFile]); + }); + it('filters shared request files out of scoped context attachments', () => { const shared = makeTextFile('shared-file', 'shared.txt', 'shared'); const scoped = makeTextFile('scoped-file', 'scoped.txt', 'scoped'); diff --git a/packages/api/src/agents/attachments.ts b/packages/api/src/agents/attachments.ts index 4513d5b6e0..4065757af1 100644 --- a/packages/api/src/agents/attachments.ts +++ b/packages/api/src/agents/attachments.ts @@ -1,6 +1,6 @@ import type { IMongoFile } from '@librechat/data-schemas'; -import type { ServerRequest } from '~/types'; import type { TokenCountFn } from '~/utils/text'; +import type { ServerRequest } from '~/types'; import { countTokens } from '~/utils/tokenizer'; import { extractFileContext } from '~/files'; @@ -11,6 +11,10 @@ type FileWithId = { export type AgentContextAttachmentCarrier = { id?: string | null; agentContextAttachments?: TFile[] | null; + subagentAgentConfigs?: AgentContextAttachmentCarrier[] | null; + subagentGraphConfigs?: Array<{ + memberConfigs?: AgentContextAttachmentCarrier[] | null; + }> | null; }; export type AgentContextAttachmentsByAgentId = @@ -35,15 +39,22 @@ export function buildAgentContextAttachmentsByAgentId( configs: Iterable | null | undefined>, ): Map { const attachmentsByAgentId = new Map(); + const visited = new Set(); + const pending = [...configs]; - for (const config of configs) { - if (!config?.id || !Array.isArray(config.agentContextAttachments)) { + for (let index = 0; index < pending.length; index++) { + const config = pending[index]; + if (!config?.id || visited.has(config.id)) { continue; } - if (config.agentContextAttachments.length === 0) { - continue; + visited.add(config.id); + if (config.agentContextAttachments?.length) { + attachmentsByAgentId.set(config.id, config.agentContextAttachments); + } + pending.push(...(config.subagentAgentConfigs ?? [])); + for (const graph of config.subagentGraphConfigs ?? []) { + pending.push(...(graph.memberConfigs ?? [])); } - attachmentsByAgentId.set(config.id, config.agentContextAttachments); } return attachmentsByAgentId; diff --git a/packages/api/src/agents/codeFilesSession.spec.ts b/packages/api/src/agents/codeFilesSession.spec.ts index d1c63981ae..b37f44f873 100644 --- a/packages/api/src/agents/codeFilesSession.spec.ts +++ b/packages/api/src/agents/codeFilesSession.spec.ts @@ -279,6 +279,17 @@ describe('buildInitialToolSessions', () => { expect(names).toEqual(['mid.txt', 'nested.txt', 'top.txt']); }); + it('includes graph-subagent members pruned from the top-level agent map', () => { + const member = agent('graph-member', [file('g1', 'sess-G', 'team.txt')]); + const primary = agent('primary'); + primary.subagentGraphConfigs = [{ memberConfigs: [member] }]; + + const result = buildInitialToolSessions({ agents: [primary] }); + + const entry = result!.get(Constants.EXECUTE_CODE) as CodeSessionContext; + expect(entry.files!.map((item) => item.name)).toEqual(['team.txt']); + }); + it('preserves the skill side representative session_id when merging', () => { const skillSessions: ToolSessionMap = new Map(); skillSessions.set(Constants.EXECUTE_CODE, { @@ -522,6 +533,38 @@ describe('collectCodeExecutionProfileRoutes', () => { ]); }); + it('includes execution routes used only by graph-subagent members', () => { + const graphKey = 'execute_code:stateful:v2:user:graph-member'; + const graphContext = { + baseUrl: 'https://stateful.example.com/v1', + codeSessionKey: graphKey, + executionProfile: 'stateful' as const, + runtimeSessionHint: 'v2:user:graph-member', + statefulSessions: true, + }; + + const routes = collectCodeExecutionProfileRoutes([ + { + id: 'parent', + codeEnvAvailable: false, + subagentGraphConfigs: [ + { + memberConfigs: [ + { + id: 'graph-member', + codeEnvAvailable: true, + codeExecutionContext: graphContext, + codeSessionKey: graphKey, + }, + ], + }, + ], + }, + ]); + + expect(routes).toEqual([{ codeExecutionContext: graphContext, codeSessionKeys: [graphKey] }]); + }); + it('derives and includes the trusted profile for a lazy subagent descriptor', () => { process.env.LIBRECHAT_CODE_BASEURL_STATEFUL = 'https://stateful.example.com/v1'; const routes = collectCodeExecutionProfileRoutes( diff --git a/packages/api/src/agents/codeFilesSession.ts b/packages/api/src/agents/codeFilesSession.ts index 8ffcaeaa13..f932b3d03b 100644 --- a/packages/api/src/agents/codeFilesSession.ts +++ b/packages/api/src/agents/codeFilesSession.ts @@ -7,8 +7,8 @@ import { resolveCodeExecutionContext, type CodeExecutionContext } from './execut * Minimal shape for an agent that may contribute primed code files to its * execution-profile partition. Both `InitializedAgent` and `RunAgent` satisfy it, * and the recursive walk in {@link buildInitialToolSessions} traverses - * `subagentAgentConfigs` so nested subagents (which aren't in the top-level - * `agentConfigs` map after pure-subagent pruning) still contribute. + * legacy child configs and graph-member configs so agents pruned from the + * top-level `agentConfigs` map still contribute. */ export interface CodeFilesAgent { id?: string; @@ -20,6 +20,7 @@ export interface CodeFilesAgent { statefulCodeEnvironment?: StatefulCodeEnvironment; subagentAgentConfigs?: CodeFilesAgent[]; lazySubagentConfigs?: CodeFilesAgent[]; + subagentGraphConfigs?: Array<{ memberConfigs: CodeFilesAgent[] }>; } export interface CodeExecutionProfileRoute { @@ -27,6 +28,24 @@ export interface CodeExecutionProfileRoute { codeSessionKeys: string[]; } +function enqueueCodeFilesChildren( + agent: CodeFilesAgent, + queue: CodeFilesAgent[], + visited: Set, +): void { + for (const child of [ + ...(agent.subagentAgentConfigs ?? []), + ...(agent.lazySubagentConfigs ?? []), + ]) { + if (child && !visited.has(child)) queue.push(child); + } + for (const graph of agent.subagentGraphConfigs ?? []) { + for (const member of graph.memberConfigs) { + if (member && !visited.has(member)) queue.push(member); + } + } +} + /** Collects the distinct Code API deployments used by a run and every * trusted session partition that must receive that deployment's immutable * skill-file seed. */ @@ -66,12 +85,7 @@ export function collectCodeExecutionProfileRoutes( route.codeSessionKeys.add(agent.codeSessionKey ?? context.codeSessionKey); routes.set(context.executionProfile, route); } - for (const child of [ - ...(agent.subagentAgentConfigs ?? []), - ...(agent.lazySubagentConfigs ?? []), - ]) { - if (child && !visited.has(child)) queue.push(child); - } + enqueueCodeFilesChildren(agent, queue, visited); } return Array.from(routes.values(), (route) => ({ codeExecutionContext: route.codeExecutionContext, @@ -195,8 +209,8 @@ export function buildAgentInitialToolSessions( * profile partition; this helper never copies a pointer across partitions. * * **Walk order:** primary first, then `agentConfigs` (handoff/addedConvo) - * in iteration order, then recurse into each config's - * `subagentAgentConfigs` breadth-first. Order matters because when no + * in iteration order, then recurse through legacy children and graph members + * breadth-first. Order matters because when no * skill sessions exist, the FIRST agent's first file supplies the * representative `session_id` written to `Graph.sessions[EXECUTE_CODE]`. * `ToolNode` ultimately uses per-file `session_id`s for injection so @@ -214,7 +228,7 @@ export function buildAgentInitialToolSessions( * from the skill side is preserved). * @param agents - The complete set of code-execution-capable agents in * the run. Caller passes `[primaryConfig, ...agentConfigs.values()]`; - * this function recurses into each one's `subagentAgentConfigs`. + * this function recurses into every reachable subagent configuration. */ export function buildInitialToolSessions(params: { skillSessions?: ToolSessionMap; @@ -244,11 +258,7 @@ export function buildInitialToolSessions(params: { if (agent.primedCodeFiles && agent.primedCodeFiles.length > 0) { sessions = seedCodeFilesIntoSessions(agent.primedCodeFiles, sessions, sessionKey); } - if (agent.subagentAgentConfigs && agent.subagentAgentConfigs.length > 0) { - for (const child of agent.subagentAgentConfigs) { - if (child && !visited.has(child)) queue.push(child); - } - } + enqueueCodeFilesChildren(agent, queue, visited); } return sessions; } diff --git a/packages/api/src/agents/discovery.spec.ts b/packages/api/src/agents/discovery.spec.ts index a80833f7c4..10ee3a5190 100644 --- a/packages/api/src/agents/discovery.spec.ts +++ b/packages/api/src/agents/discovery.spec.ts @@ -1,6 +1,7 @@ -import { ErrorTypes, EModelEndpoint } from 'librechat-data-provider'; +import { ErrorTypes, EModelEndpoint, MAX_SUBAGENT_GRAPH_NODES } from 'librechat-data-provider'; import type { Agent, GraphEdge } from 'librechat-data-provider'; import type { Response } from 'express'; +import type { GraphSubagentHostConfig } from './discovery'; import type { InitializedAgent } from './initialize'; import type { ServerRequest } from '~/types'; @@ -23,7 +24,7 @@ jest.mock('./validation', () => ({ validateAgentModel: (...args: unknown[]) => mockValidateAgentModel(...args), })); -import { discoverConnectedAgents } from './discovery'; +import { discoverConnectedAgents, resolveSubagentGraphs } from './discovery'; const makeReq = (userId = 'u1', role = 'USER'): ServerRequest => ({ @@ -423,7 +424,7 @@ describe('discoverConnectedAgents', () => { expect(result.edges[0].to).toBe('C'); }); - it('advances through a multi-source edge on ANY reachable source (SDK OR semantics)', async () => { + it('reduces a multi-source barrier to its surviving reachable sources', async () => { // Primary A has a single edge `{from: ['A','B'], to: 'C'}`. B loads // successfully but has no incoming path from A. The agents SDK adds // one LangGraph edge per `from` source (see @@ -1132,3 +1133,327 @@ describe('discoverConnectedAgents', () => { expect(result.edges).toHaveLength(0); }); }); + +describe('resolveSubagentGraphs', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockValidateAgentModel.mockResolvedValue({ isValid: true }); + mockInitializeAgent.mockImplementation(async ({ agent }: { agent: Agent }) => + makeConfig(agent.id), + ); + }); + + it('resolves a complete graph team while reusing the primary config', async () => { + const primaryConfig = makeConfig('A') as GraphSubagentHostConfig; + primaryConfig.userMCPAuthMap = { primary: { token: 'primary-token' } }; + primaryConfig.subagents = { + enabled: true, + graphs: [ + { + type: 'team', + name: 'Team', + description: 'A remote graph team', + agent_ids: ['A', 'B'], + edges: [{ from: 'A', to: 'B', edgeType: 'direct' }], + entry_agent_id: 'A', + result_agent_id: 'B', + }, + ], + }; + const getAgent = jest.fn(async ({ id }: { id: string }) => makeAgent(id)); + const onAgentInitialized = jest.fn(); + mockInitializeAgent.mockImplementationOnce(async ({ agent }: { agent: Agent }) => ({ + ...makeConfig(agent.id), + userMCPAuthMap: { graph: { token: 'graph-token' } }, + })); + + const userMCPAuthMap = await resolveSubagentGraphs( + { + req: makeReq(), + res: makeRes(), + primaryConfig, + rootConfigs: [primaryConfig], + allowedProviders: new Set(), + modelsConfig: { openai: ['gpt-4o'] }, + loadTools: jest.fn(), + resourceType: 'remote_agent', + statefulSessionsAvailable: true, + allowedStatefulCodeEnvironments: ['user'], + }, + { + getAgent, + checkPermission: jest.fn().mockResolvedValue(true), + logViolation: jest.fn(), + db: {} as never, + onAgentInitialized, + }, + ); + + expect(getAgent).toHaveBeenCalledTimes(1); + expect(getAgent).toHaveBeenCalledWith({ id: 'B' }); + expect(mockInitializeAgent).toHaveBeenCalledWith( + expect.objectContaining({ + statefulSessionsAvailable: true, + allowedStatefulCodeEnvironments: ['user'], + }), + expect.anything(), + ); + expect(onAgentInitialized).toHaveBeenCalledWith('B', expect.anything(), expect.anything()); + expect(primaryConfig.subagentGraphConfigs).toEqual([ + expect.objectContaining({ + memberConfigs: [primaryConfig, expect.objectContaining({ id: 'B' })], + }), + ]); + expect(userMCPAuthMap).toEqual({ + primary: { token: 'primary-token' }, + graph: { token: 'graph-token' }, + }); + }); + + it('does not charge initialized root members against the graph load budget', async () => { + const rootConfigs = Array.from({ length: MAX_SUBAGENT_GRAPH_NODES }, (_, index) => { + const config = makeConfig(`root_${index}`) as GraphSubagentHostConfig; + config.subagents = { + enabled: true, + graphs: [ + { + type: `self_team_${index}`, + name: `Self team ${index}`, + description: 'Reuses an initialized root', + agent_ids: [config.id], + edges: [], + entry_agent_id: config.id, + result_agent_id: config.id, + }, + ], + }; + return config; + }); + const finalRoot = rootConfigs[rootConfigs.length - 1]; + finalRoot.subagents?.graphs?.push({ + type: 'external_team', + name: 'External team', + description: 'Still has room for a real member load', + agent_ids: ['external_member'], + edges: [], + entry_agent_id: 'external_member', + result_agent_id: 'external_member', + }); + const getAgent = jest.fn(async ({ id }: { id: string }) => makeAgent(id)); + + await resolveSubagentGraphs( + { + req: makeReq(), + res: makeRes(), + primaryConfig: rootConfigs[0], + rootConfigs, + allowedProviders: new Set(), + modelsConfig: { openai: ['gpt-4o'] }, + loadTools: jest.fn(), + resourceType: 'remote_agent', + }, + { + getAgent, + checkPermission: jest.fn().mockResolvedValue(true), + logViolation: jest.fn(), + db: {} as never, + }, + ); + + expect(getAgent).toHaveBeenCalledTimes(1); + expect(getAgent).toHaveBeenCalledWith({ id: 'external_member' }); + expect(finalRoot.subagentGraphConfigs).toHaveLength(2); + }); + + it('omits the whole graph when a member lacks remote VIEW access', async () => { + const primaryConfig = makeConfig('A') as GraphSubagentHostConfig; + primaryConfig.subagents = { + enabled: true, + graphs: [ + { + type: 'team', + name: 'Team', + description: 'A remote graph team', + agent_ids: ['A', 'B'], + edges: [{ from: 'A', to: 'B', edgeType: 'direct' }], + entry_agent_id: 'A', + result_agent_id: 'B', + }, + ], + }; + + await resolveSubagentGraphs( + { + req: makeReq(), + res: makeRes(), + primaryConfig, + rootConfigs: [primaryConfig], + allowedProviders: new Set(), + modelsConfig: { openai: ['gpt-4o'] }, + loadTools: jest.fn(), + resourceType: 'remote_agent', + }, + { + getAgent: jest.fn(async ({ id }: { id: string }) => makeAgent(id)), + checkPermission: jest.fn().mockResolvedValue(false), + logViolation: jest.fn(), + db: {} as never, + }, + ); + + expect(primaryConfig.subagentGraphConfigs).toEqual([]); + expect(mockInitializeAgent).not.toHaveBeenCalled(); + }); + + it('caches successful members from an incomplete team for later teams', async () => { + const primaryConfig = makeConfig('A') as GraphSubagentHostConfig; + primaryConfig.subagents = { + enabled: true, + graphs: [ + { + type: 'incomplete', + name: 'Incomplete team', + description: 'Contains a missing member', + agent_ids: ['B', 'missing'], + edges: [{ from: 'B', to: 'missing', edgeType: 'direct' }], + entry_agent_id: 'B', + result_agent_id: 'missing', + }, + { + type: 'complete', + name: 'Complete team', + description: 'Reuses the successful member', + agent_ids: ['B'], + edges: [], + entry_agent_id: 'B', + result_agent_id: 'B', + }, + ], + }; + const getAgent = jest.fn(async ({ id }: { id: string }) => + id === 'missing' ? null : makeAgent(id), + ); + const onAgentInitialized = jest.fn(); + + await resolveSubagentGraphs( + { + req: makeReq(), + res: makeRes(), + primaryConfig, + rootConfigs: [primaryConfig], + allowedProviders: new Set(), + modelsConfig: { openai: ['gpt-4o'] }, + loadTools: jest.fn(), + resourceType: 'remote_agent', + }, + { + getAgent, + checkPermission: jest.fn().mockResolvedValue(true), + logViolation: jest.fn(), + db: {} as never, + onAgentInitialized, + }, + ); + + expect(getAgent).toHaveBeenCalledTimes(2); + expect(mockInitializeAgent).toHaveBeenCalledTimes(1); + expect(onAgentInitialized).toHaveBeenCalledTimes(1); + expect(primaryConfig.subagentGraphConfigs).toEqual([ + expect.objectContaining({ memberConfigs: [expect.objectContaining({ id: 'B' })] }), + ]); + }); + + it('caches failed members across incomplete teams', async () => { + const primaryConfig = makeConfig('A') as GraphSubagentHostConfig; + primaryConfig.subagents = { + enabled: true, + graphs: ['first', 'second'].map((suffix) => ({ + type: `incomplete_${suffix}`, + name: `Incomplete ${suffix}`, + description: 'Reuses the same missing member', + agent_ids: ['missing'], + edges: [], + entry_agent_id: 'missing', + result_agent_id: 'missing', + })), + }; + const getAgent = jest.fn().mockResolvedValue(null); + const onAgentSkipped = jest.fn(); + + await resolveSubagentGraphs( + { + req: makeReq(), + res: makeRes(), + primaryConfig, + rootConfigs: [primaryConfig], + allowedProviders: new Set(), + modelsConfig: { openai: ['gpt-4o'] }, + loadTools: jest.fn(), + resourceType: 'remote_agent', + }, + { + getAgent, + checkPermission: jest.fn(), + logViolation: jest.fn(), + db: {} as never, + onAgentSkipped, + }, + ); + + expect(getAgent).toHaveBeenCalledTimes(1); + expect(onAgentSkipped).toHaveBeenCalledTimes(1); + expect(primaryConfig.subagentGraphConfigs).toEqual([]); + }); + + it('counts distinct failed attempts against the request-wide member limit', async () => { + const firstMemberIds = Array.from({ length: 32 }, (_, index) => `missing_first_${index}`); + const overflowMemberIds = Array.from({ length: 20 }, (_, index) => `missing_overflow_${index}`); + const primaryConfig = makeConfig('A') as GraphSubagentHostConfig; + primaryConfig.subagents = { + enabled: true, + graphs: [ + { + type: 'first_missing_team', + name: 'First missing team', + description: 'Consumes the attempted-member budget', + agent_ids: firstMemberIds, + edges: [], + entry_agent_id: firstMemberIds[0], + result_agent_id: firstMemberIds[firstMemberIds.length - 1], + }, + { + type: 'overflow_missing_team', + name: 'Overflow missing team', + description: 'Must be skipped before member lookup', + agent_ids: overflowMemberIds, + edges: [], + entry_agent_id: overflowMemberIds[0], + result_agent_id: overflowMemberIds[overflowMemberIds.length - 1], + }, + ], + }; + const getAgent = jest.fn().mockResolvedValue(null); + + await resolveSubagentGraphs( + { + req: makeReq(), + res: makeRes(), + primaryConfig, + rootConfigs: [primaryConfig], + allowedProviders: new Set(), + modelsConfig: { openai: ['gpt-4o'] }, + loadTools: jest.fn(), + resourceType: 'remote_agent', + }, + { + getAgent, + checkPermission: jest.fn(), + logViolation: jest.fn(), + db: {} as never, + }, + ); + + expect(getAgent).toHaveBeenCalledTimes(firstMemberIds.length); + expect(primaryConfig.subagentGraphConfigs).toEqual([]); + }); +}); diff --git a/packages/api/src/agents/discovery.ts b/packages/api/src/agents/discovery.ts index b12b8b8d24..2fb107940e 100644 --- a/packages/api/src/agents/discovery.ts +++ b/packages/api/src/agents/discovery.ts @@ -1,6 +1,17 @@ import { logger } from '@librechat/data-schemas'; -import { ResourceType, PermissionBits, EModelEndpoint } from 'librechat-data-provider'; -import type { Agent, GraphEdge, TModelsConfig, TEndpointOption } from 'librechat-data-provider'; +import { + ResourceType, + PermissionBits, + EModelEndpoint, + MAX_SUBAGENT_GRAPH_NODES, +} from 'librechat-data-provider'; +import type { + Agent, + GraphEdge, + TModelsConfig, + TEndpointOption, + AgentSubagentGraph, +} from 'librechat-data-provider'; import type { Response as ServerResponse } from 'express'; import type { InitializedAgent, @@ -13,8 +24,11 @@ import { validateAgentModel as defaultValidateAgentModel } from './validation'; import { initializeAgent as defaultInitializeAgent } from './initialize'; import { createEdgeCollector, filterOrphanedEdges } from './edges'; import { isFatalAgentInitializationError } from './errors'; +import { createConcurrencyLimiter } from '~/utils/promise'; import { createSequentialChainEdges } from './chain'; +const SUBAGENT_GRAPH_LOAD_CONCURRENCY = 4; + /** * Callback invoked after a sub-agent is successfully initialized. * Used by callers that need to track per-agent tool context (e.g., for @@ -91,6 +105,8 @@ export interface DiscoverConnectedAgentsParams { codeEnvAvailable?: InitializeAgentParams['codeEnvAvailable']; /** Sibling of `codeEnvAvailable` — the `stateful_code_sessions` capability flag, forwarded to every handoff `initializeAgent`. */ statefulSessionsAvailable?: InitializeAgentParams['statefulSessionsAvailable']; + /** Deployment policy for stateful workspace scopes, forwarded unchanged to every referenced agent. */ + allowedStatefulCodeEnvironments?: InitializeAgentParams['allowedStatefulCodeEnvironments']; /** * Run-level inline memory availability gate. Forwarded verbatim to every * handoff agent so sub-agents that list the `memory` capability expand the @@ -147,6 +163,211 @@ export interface DiscoverConnectedAgentsResult { userMCPAuthMap?: Record>; } +export type GraphSubagentHostConfig = InitializedAgent & { + subagentGraphConfigs?: Array<{ + definition: AgentSubagentGraph; + memberConfigs: InitializedAgent[]; + }>; +}; + +export interface ResolveSubagentGraphsParams extends DiscoverConnectedAgentsParams { + /** Top-level primary/handoff configs whose saved graph spawn targets should be resolved. */ + rootConfigs: GraphSubagentHostConfig[]; +} + +async function initializeReferencedAgent( + agentId: string, + params: DiscoverConnectedAgentsParams, + deps: DiscoverConnectedAgentsDeps, +): Promise<{ agent: Agent; config: InitializedAgent } | null> { + const agent = await deps.getAgent({ id: agentId }); + if (!agent) { + logger.warn(`[initializeReferencedAgent] Agent ${agentId} not found, skipping`); + deps.onAgentSkipped?.(agentId); + return null; + } + + const userId = params.req.user?.id; + if (!userId) { + logger.warn(`[initializeReferencedAgent] No authenticated user, skipping agent ${agentId}`); + deps.onAgentSkipped?.(agentId); + return null; + } + + const hasAccess = await deps.checkPermission({ + userId, + role: params.req.user?.role, + resourceType: params.resourceType ?? ResourceType.AGENT, + resourceId: agent._id, + requiredPermission: PermissionBits.VIEW, + }); + if (!hasAccess) { + logger.warn(`[initializeReferencedAgent] User ${userId} lacks VIEW access to agent ${agentId}`); + deps.onAgentSkipped?.(agentId); + return null; + } + + const validateAgentModel = deps.validateAgentModel ?? defaultValidateAgentModel; + const validation = await validateAgentModel({ + req: params.req, + res: params.res, + agent, + modelsConfig: params.modelsConfig, + logViolation: deps.logViolation, + }); + if (!validation.isValid) { + throw new Error(validation.error?.message); + } + + const scopedSkillIds = params.computeAccessibleSkillIds?.(agent); + const initializeAgent = deps.initializeAgent ?? defaultInitializeAgent; + const config = await initializeAgent( + { + req: params.req, + res: params.res, + agent, + loadTools: params.loadTools, + requestFiles: params.requestFiles, + conversationId: params.conversationId, + parentMessageId: params.parentMessageId, + endpointOption: { + ...(params.endpointOption ?? {}), + endpoint: EModelEndpoint.agents, + }, + allowedProviders: params.allowedProviders, + accessibleSkillIds: scopedSkillIds, + skillAuthoringAvailable: params.computeSkillAuthoringAvailable?.(agent, scopedSkillIds), + skillStates: params.skillStates, + defaultActiveOnShare: params.defaultActiveOnShare, + codeEnvAvailable: params.codeEnvAvailable, + backgroundToolsAvailable: params.backgroundToolsAvailable, + toolIntentsAvailable: params.toolIntentsAvailable, + statefulSessionsAvailable: params.statefulSessionsAvailable, + allowedStatefulCodeEnvironments: params.allowedStatefulCodeEnvironments, + memoryAvailable: params.memoryAvailable, + }, + deps.db, + ); + deps.onAgentInitialized?.(agentId, agent, config); + return { agent, config }; +} + +/** Resolves saved graph spawn targets without promoting graph-only members to top-level nodes. */ +export async function resolveSubagentGraphs( + params: ResolveSubagentGraphsParams, + deps: DiscoverConnectedAgentsDeps, +): Promise> | undefined> { + const configById = new Map(params.rootConfigs.map((config) => [config.id, config])); + const attemptedGraphMemberIds = new Set(); + const failedMemberIds = new Set(); + const loadGraphMember = createConcurrencyLimiter(SUBAGENT_GRAPH_LOAD_CONCURRENCY); + let userMCPAuthMap: Record> | undefined; + for (const config of params.rootConfigs) { + if (config.userMCPAuthMap) { + userMCPAuthMap = { ...userMCPAuthMap, ...config.userMCPAuthMap }; + } + } + + for (const rootConfig of params.rootConfigs) { + const resolvedGraphs: NonNullable = []; + for (const definition of rootConfig.subagents?.enabled === true + ? (rootConfig.subagents.graphs ?? []) + : []) { + const memberIds = [...new Set(definition.agent_ids)]; + const newMemberIds = memberIds.filter( + (memberId) => !configById.has(memberId) && !attemptedGraphMemberIds.has(memberId), + ); + if (attemptedGraphMemberIds.size + newMemberIds.length > MAX_SUBAGENT_GRAPH_NODES) { + logger.warn('[resolveSubagentGraphs] Subagent graph node limit exceeded', { + parentAgentId: rootConfig.id, + graphType: definition.type, + loadedSubagentCount: attemptedGraphMemberIds.size, + stagedSubagentCount: newMemberIds.length, + maxSubagentGraphNodes: MAX_SUBAGENT_GRAPH_NODES, + }); + continue; + } + for (const memberId of newMemberIds) { + attemptedGraphMemberIds.add(memberId); + } + + const resolvedMembers = await Promise.all( + memberIds.map((memberId) => { + const existing = configById.get(memberId); + if (existing) { + return Promise.resolve({ config: existing }); + } + if (failedMemberIds.has(memberId)) { + return Promise.resolve(null); + } + return loadGraphMember(async () => { + try { + const resolved = await initializeReferencedAgent(memberId, params, { + ...deps, + onAgentInitialized: undefined, + }); + if (!resolved) { + failedMemberIds.add(memberId); + } + return resolved; + } catch (error) { + if (isFatalAgentInitializationError(error)) { + throw error; + } + failedMemberIds.add(memberId); + logger.error( + `[resolveSubagentGraphs] Error processing graph member ${memberId}:`, + error, + ); + deps.onAgentSkipped?.(memberId); + return null; + } + }); + }), + ); + for (let index = 0; index < memberIds.length; index++) { + const resolvedMember = resolvedMembers[index]; + if (!resolvedMember) { + continue; + } + const memberId = memberIds[index]; + configById.set(memberId, resolvedMember.config); + if (resolvedMember.config.userMCPAuthMap) { + userMCPAuthMap = { + ...userMCPAuthMap, + ...resolvedMember.config.userMCPAuthMap, + }; + } + if ('agent' in resolvedMember) { + deps.onAgentInitialized?.(memberId, resolvedMember.agent, resolvedMember.config); + } + } + if (resolvedMembers.some((member) => member == null)) { + logger.warn('[resolveSubagentGraphs] Skipping incomplete graph subagent', { + parentAgentId: rootConfig.id, + graphType: definition.type, + expectedMemberCount: memberIds.length, + resolvedMemberCount: resolvedMembers.filter(Boolean).length, + }); + continue; + } + const memberConfigs: InitializedAgent[] = []; + for (let index = 0; index < memberIds.length; index++) { + const resolvedMember = resolvedMembers[index] as { + config: InitializedAgent; + }; + memberConfigs.push(resolvedMember.config); + } + resolvedGraphs.push({ + definition, + memberConfigs, + }); + } + rootConfig.subagentGraphConfigs = resolvedGraphs; + } + return userMCPAuthMap; +} + /** * Discovers and initializes all agents reachable from `primaryConfig.edges` * via BFS. This is the shared graph-topology discovery logic that enables @@ -162,40 +383,8 @@ export async function discoverConnectedAgents( params: DiscoverConnectedAgentsParams, deps: DiscoverConnectedAgentsDeps, ): Promise { - const { - req, - res, - primaryConfig, - agent_ids, - endpointOption, - allowedProviders, - modelsConfig, - loadTools, - requestFiles, - conversationId, - parentMessageId, - resourceType = ResourceType.AGENT, - computeAccessibleSkillIds, - computeSkillAuthoringAvailable, - skillStates, - defaultActiveOnShare, - codeEnvAvailable, - backgroundToolsAvailable, - toolIntentsAvailable, - statefulSessionsAvailable, - memoryAvailable, - } = params; - - const { - getAgent, - checkPermission, - logViolation, - db, - onAgentInitialized, - onAgentSkipped, - initializeAgent = defaultInitializeAgent, - validateAgentModel = defaultValidateAgentModel, - } = deps; + const { primaryConfig, agent_ids } = params; + const { onAgentSkipped } = deps; const agentConfigs = new Map(); const skippedAgentIds = new Set(); @@ -210,90 +399,14 @@ export async function discoverConnectedAgents( }; const processAgent = async (agentId: string): Promise => { - const agent = await getAgent({ id: agentId }); - if (!agent) { - logger.warn( - `[discoverConnectedAgents] Handoff agent ${agentId} not found, skipping (orphaned reference)`, - ); - markSkipped(agentId); - return null; - } - - const userId = req.user?.id; - if (!userId) { - logger.warn( - `[discoverConnectedAgents] No authenticated user on request, skipping handoff agent ${agentId}`, - ); - markSkipped(agentId); - return null; - } - - const hasAccess = await checkPermission({ - userId, - role: req.user?.role, - resourceType, - resourceId: agent._id, - requiredPermission: PermissionBits.VIEW, + const loaded = await initializeReferencedAgent(agentId, params, { + ...deps, + onAgentSkipped: markSkipped, }); - - if (!hasAccess) { - logger.warn( - `[discoverConnectedAgents] User ${userId} lacks VIEW access to handoff agent ${agentId}, skipping`, - ); - markSkipped(agentId); + if (!loaded) { return null; } - - const validation = await validateAgentModel({ - req, - res, - agent, - modelsConfig, - logViolation, - }); - - if (!validation.isValid) { - throw new Error(validation.error?.message); - } - - /** - * Force `endpoint: agents` on the per-sub-agent init call so - * `initializeAgent`'s `isAgentsEndpoint`-gated `allowedProviders` - * check always fires for handoff sub-agents, regardless of which - * endpoint the caller entered through. Without this, the OpenAI- - * compat routes (whose `endpointOption.endpoint` is the primary - * provider, not `agents`) would silently bypass the provider - * allowlist configured under `endpoints.agents.allowedProviders`. - */ - const subAgentEndpointOption: Partial = { - ...(endpointOption ?? {}), - endpoint: EModelEndpoint.agents, - }; - - const scopedSkillIds = computeAccessibleSkillIds?.(agent); - const config = await initializeAgent( - { - req, - res, - agent, - loadTools, - requestFiles, - conversationId, - parentMessageId, - endpointOption: subAgentEndpointOption, - allowedProviders, - accessibleSkillIds: scopedSkillIds, - skillAuthoringAvailable: computeSkillAuthoringAvailable?.(agent, scopedSkillIds), - skillStates, - defaultActiveOnShare, - codeEnvAvailable, - backgroundToolsAvailable, - toolIntentsAvailable, - statefulSessionsAvailable, - memoryAvailable, - }, - db, - ); + const { agent, config } = loaded; if (userMCPAuthMap != null) { Object.assign(userMCPAuthMap, config.userMCPAuthMap ?? {}); @@ -305,7 +418,6 @@ export async function discoverConnectedAgents( } agentConfigs.set(agentId, config); - onAgentInitialized?.(agentId, agent, config); return agent; }; @@ -368,12 +480,11 @@ export async function discoverConnectedAgents( const filteredEdges = filterOrphanedEdges(preFilterEdges, skippedAgentIds); /** - * Keep discovery's reachability model aligned with the agents SDK's - * runtime semantics. `MultiAgentGraph.createWorkflow` adds one - * LangGraph edge per `from` source, so a multi-source edge - * `{ from: ['A', 'B'], to: 'C' }` is really `A -> C` OR `B -> C` — - * either source firing routes to `C`. Reachability therefore advances - * through an edge whenever ANY of its sources is already reachable. + * Discovery computes structural reachability before compiling the SDK + * graph. A multi-source direct edge is an all-source runtime barrier, but + * discovery deliberately advances when any surviving source is reachable: + * inaccessible/orphaned sources are removed below, reducing the barrier to + * the branches the caller can actually run. * * Two semantics to reconcile when pruning after orphan-filter: * @@ -414,7 +525,7 @@ export async function discoverConnectedAgents( * - Agents referenced as an endpoint in a surviving edge are always * kept (a multi-source edge co-source like B in * `{ from: ['A','B'], to: 'C' }` where nothing reaches B still - * needs B present for the SDK's per-source `addEdge` to compile). + * needs B present for the SDK waiting barrier to compile). */ const anyReachable = (value: string | string[], reachableSet: Set): boolean => { const ids = Array.isArray(value) ? value : [value]; @@ -480,13 +591,12 @@ export async function discoverConnectedAgents( * crash `StateGraph.compile` with `Found edge ending at unknown * node`). * - For kept edges with an array `from`, strip out unreachable - * co-sources. The SDK's per-source `addEdge` fires independently - * (each source becomes its own `addEdge(source, dest)` call), so - * losing an unreachable co-source doesn't invalidate the routes - * through the surviving ones. Leaving the dead co-source in the - * array was propping up agents that `reachable` had already - * excluded — in `MultiAgentGraph.analyzeGraph` they'd then show up - * as incoming-less nodes and execute as unintended parallel roots. + * co-sources. The SDK represents a multi-source direct edge as a + * synchronization barrier, so retaining a source that was pruned + * would leave the destination waiting forever. Removing dead sources + * preserves the barrier across the remaining reachable branches and + * prevents pruned agents from reappearing as unintended parallel roots + * during `MultiAgentGraph.analyzeGraph`. * * After sanitization every endpoint in every surviving edge is * guaranteed to be in `reachable`, which lets the agent prune below diff --git a/packages/api/src/agents/memory.spec.ts b/packages/api/src/agents/memory.spec.ts index be7843fdf4..77260bb260 100644 --- a/packages/api/src/agents/memory.spec.ts +++ b/packages/api/src/agents/memory.spec.ts @@ -12,6 +12,7 @@ import { createDeleteMemoryTool, invalidateRequestMemories, agentHasInlineMemoryTools, + buildInlineMemoryContext, } from './memory'; import { GenerationJobManager } from '~/stream/GenerationJobManager'; @@ -754,6 +755,47 @@ describe('agentHasInlineMemoryTools', () => { }); }); +describe('buildInlineMemoryContext', () => { + it('loads keyed memories for an initialized inline-memory agent', async () => { + const getFormattedMemories = jest.fn().mockResolvedValue({ + withKeys: 'preferred_name: Danny', + withoutKeys: 'Danny', + totalTokens: 4, + }); + const context = await buildInlineMemoryContext({ + agent: { + id: 'agent_memory', + memory_scope: MemoryScope.agent, + memoryToolsRegistered: true, + }, + req: {} as never, + userId: 'user-1', + memoryAvailable: true, + getFormattedMemories, + }); + + expect(context).toContain('# Existing memory about the user:\npreferred_name: Danny'); + expect(getFormattedMemories).toHaveBeenCalledWith({ + userId: 'user-1', + agentId: 'agent_memory', + }); + }); + + it('does not load memories when inline tools are unavailable', async () => { + const getFormattedMemories = jest.fn(); + await expect( + buildInlineMemoryContext({ + agent: { id: 'agent_without_memory', memoryToolsRegistered: false }, + req: {} as never, + userId: 'user-1', + memoryAvailable: true, + getFormattedMemories, + }), + ).resolves.toBe(''); + expect(getFormattedMemories).not.toHaveBeenCalled(); + }); +}); + describe('getRequestMemories caching', () => { it('memoizes per request, then re-fetches after invalidation', async () => { const getFormattedMemories = jest diff --git a/packages/api/src/agents/memory.ts b/packages/api/src/agents/memory.ts index 0d70928848..bef2acc334 100644 --- a/packages/api/src/agents/memory.ts +++ b/packages/api/src/agents/memory.ts @@ -489,6 +489,39 @@ export function agentHasInlineMemoryTools(agent: InlineMemoryAgent): boolean { ); } +/** Builds the existing-memory system context for an inline-memory agent. */ +export async function buildInlineMemoryContext({ + agent, + req, + userId, + memoryAvailable, + getFormattedMemories, +}: { + agent: InlineMemoryAgent; + req: ServerRequest; + userId: string | ObjectId; + memoryAvailable: boolean; + getFormattedMemories: MemoryMethods['getFormattedMemories']; +}): Promise { + if (!memoryAvailable || !agentHasInlineMemoryTools(agent)) { + return ''; + } + try { + const memories = await getRequestMemories({ + req, + userId, + agentId: getMemoryAgentId(agent), + getFormattedMemories, + }); + return memories.withKeys + ? `${memoryInstructions}\n\n# Existing memory about the user:\n${memories.withKeys}` + : ''; + } catch (error) { + logger.error('[memory] Error loading inline agent memory context', error); + return ''; + } +} + /** * Request-scoped cache so that multiple memory-enabled agents in one run (and * the run's memory context load) share a single `getFormattedMemories` call diff --git a/packages/api/src/agents/responses/__tests__/service.test.ts b/packages/api/src/agents/responses/__tests__/service.test.ts index b9b64d21ee..eb3513fe37 100644 --- a/packages/api/src/agents/responses/__tests__/service.test.ts +++ b/packages/api/src/agents/responses/__tests__/service.test.ts @@ -1,5 +1,42 @@ -import { convertInputToMessages } from '../service'; import type { InputItem } from '../types'; +import { + convertInputToMessages, + createAggregatorEventHandlers, + createResponseAggregator, +} from '../service'; + +describe('response usage aggregation', () => { + it('accumulates usage across parent and subagent model calls', () => { + const aggregator = createResponseAggregator(); + const handlers = createAggregatorEventHandlers(aggregator); + + handlers.on_chat_model_end.handle('on_chat_model_end', { + output: { + usage_metadata: { + input_tokens: 100, + output_tokens: 40, + input_token_details: { cache_read: 10 }, + }, + }, + }); + handlers.on_chat_model_end.handle('on_chat_model_end', { + output: { + usage_metadata: { + input_tokens: 25, + output_tokens: 15, + cache_read_input_tokens: 5, + }, + }, + }); + + expect(aggregator.usage).toEqual({ + inputTokens: 125, + outputTokens: 55, + reasoningTokens: 0, + cachedTokens: 15, + }); + }); +}); describe('convertInputToMessages', () => { // ── String input shorthand ───────────────────────────────────────── diff --git a/packages/api/src/agents/responses/service.ts b/packages/api/src/agents/responses/service.ts index 575606123c..8c9d355f87 100644 --- a/packages/api/src/agents/responses/service.ts +++ b/packages/api/src/agents/responses/service.ts @@ -34,10 +34,36 @@ import { emitReasoningDone, emitReasoningContentPartDone, emitReasoningItemDone, - updateTrackerUsage, type StreamHandlerConfig, } from './handlers'; +interface ResponseUsageAccumulator { + inputTokens: number; + outputTokens: number; + cachedTokens: number; +} + +interface ModelUsageMetadata { + input_tokens?: number; + output_tokens?: number; + input_token_details?: { + cache_creation?: number; + cache_read?: number; + }; + cache_creation_input_tokens?: number; + cache_read_input_tokens?: number; +} + +function accumulateResponseUsage( + target: ResponseUsageAccumulator, + usage: ModelUsageMetadata, +): void { + target.inputTokens += usage.input_tokens ?? 0; + target.outputTokens += usage.output_tokens ?? 0; + target.cachedTokens += + (usage.input_token_details?.cache_read ?? 0) + (usage.cache_read_input_tokens ?? 0); +} + /* ============================================================================= * REQUEST VALIDATION * ============================================================================= */ @@ -546,32 +572,13 @@ export function createResponsesEventHandlers(config: StreamHandlerConfig): { handle: (_event: string, data: unknown): void => { const endData = data as { output?: { - usage_metadata?: { - input_tokens?: number; - output_tokens?: number; - // OpenAI format - input_token_details?: { - cache_creation?: number; - cache_read?: number; - }; - // Anthropic format - cache_creation_input_tokens?: number; - cache_read_input_tokens?: number; - }; + usage_metadata?: ModelUsageMetadata; }; }; const usage = endData?.output?.usage_metadata; if (usage) { - // Extract cached tokens from either OpenAI or Anthropic format - const cachedTokens = - (usage.input_token_details?.cache_read ?? 0) + (usage.cache_read_input_tokens ?? 0); - - updateTrackerUsage(config.tracker, { - promptTokens: usage.input_tokens, - completionTokens: usage.output_tokens, - cachedTokens, - }); + accumulateResponseUsage(config.tracker.usage, usage); } }, }, @@ -857,29 +864,13 @@ export function createAggregatorEventHandlers(aggregator: ResponseAggregator): R handle: (_event: string, data: unknown): void => { const endData = data as { output?: { - usage_metadata?: { - input_tokens?: number; - output_tokens?: number; - // OpenAI format - input_token_details?: { - cache_creation?: number; - cache_read?: number; - }; - // Anthropic format - cache_creation_input_tokens?: number; - cache_read_input_tokens?: number; - }; + usage_metadata?: ModelUsageMetadata; }; }; const usage = endData?.output?.usage_metadata; if (usage) { - aggregator.usage.inputTokens = usage.input_tokens ?? 0; - aggregator.usage.outputTokens = usage.output_tokens ?? 0; - - // Extract cached tokens from either OpenAI or Anthropic format - aggregator.usage.cachedTokens = - (usage.input_token_details?.cache_read ?? 0) + (usage.cache_read_input_tokens ?? 0); + accumulateResponseUsage(aggregator.usage, usage); } }, }, diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index e9f7f19c51..27634dad32 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -19,6 +19,7 @@ import type { LCToolRegistry, SubagentConfig, SubagentResolveContext, + SubagentConfigEntry, HookCallback, AgentInputs, GenericTool, @@ -31,12 +32,14 @@ import type { TAgentsEndpoint, AgentModelParameters, AgentSubagentsConfig, + AgentSubagentGraph, ReasoningResponseKey, SummarizationConfig, } from 'librechat-data-provider'; import type { BaseMessage } from '@librechat/agents/langchain/messages'; import type { AppConfig, IUser } from '@librechat/data-schemas'; import type { ToolInputValidationError } from '~/agents/toolValidation'; +import type { ResolvedAlwaysApplySkill } from '~/agents/skills'; import type { SubagentUsageEvent } from '~/agents/usage'; import type * as t from '~/types'; import { @@ -410,6 +413,13 @@ type RunAgent = Omit & { * may use the active request's authorization and tool-loading context. */ lazySubagentConfigs?: LazySubagentAgent[]; + /** All-or-nothing saved-agent teams resolved by initialize.js. */ + subagentGraphConfigs?: Array<{ + definition: AgentSubagentGraph; + memberConfigs: RunAgent[]; + }>; + /** Member-scoped always-apply skills resolved during agent initialization. */ + alwaysApplySkillPrimes?: ResolvedAlwaysApplySkill[]; /** Source subagent spawning configuration (enabled / allowSelf / agent_ids). */ subagents?: AgentSubagentsConfig; }; @@ -433,6 +443,8 @@ type LazySubagentAgent = Pick< configId: string; subagentAgentConfigs?: RunAgent[]; lazySubagentConfigs?: LazySubagentAgent[]; + /** Lightweight graph-member metadata used only by run-wide capability gates. */ + subagentGraphMemberMetadata?: SubagentTreeNode[]; resolve: (context: SubagentResolveContext) => Promise; }; @@ -450,6 +462,8 @@ type SubagentTreeNode = Pick< > & { subagentAgentConfigs?: SubagentTreeNode[]; lazySubagentConfigs?: SubagentTreeNode[]; + subagentGraphMemberMetadata?: SubagentTreeNode[]; + subagentGraphConfigs?: Array<{ memberConfigs: SubagentTreeNode[] }>; }; function isNonEmptyString(value: unknown): value is string { @@ -794,40 +808,13 @@ function assertSubagentDepth(depth: number, agentId: string): void { } } -function buildIsolatedSubagentInputs( - child: RunAgent, - toInput: (child: RunAgent, opts?: { isSubagent?: boolean }) => AgentInputs, -): AgentInputs { - const childInputs = toInput(child, { isSubagent: true }); - if ((child.backgroundToolNames?.length ?? 0) > 0) { - childInputs.toolDefinitions = stripBackgroundFromToolDefinitions( - childInputs.toolDefinitions, - child.backgroundToolNames, - ); - childInputs.toolRegistry = stripBackgroundFromToolRegistry( - childInputs.toolRegistry, - child.backgroundToolNames, - ); - } - if ((child.intentToolNames?.length ?? 0) > 0) { - childInputs.toolDefinitions = stripIntentFromToolDefinitions( - childInputs.toolDefinitions, - child.intentToolNames, - ); - childInputs.toolRegistry = stripIntentFromToolRegistry( - childInputs.toolRegistry, - child.intentToolNames, - ); - } - return childInputs; -} - function createLazySubagentConfig( child: LazySubagentAgent, toInput: (child: RunAgent, opts?: { isSubagent?: boolean }) => AgentInputs, agentsEConfig: Partial | undefined, ancestors: Set, depth: number, + prebuiltGraphInputs?: ReadonlyMap, ): SubagentConfig { return { type: child.id, @@ -846,7 +833,7 @@ function createLazySubagentConfig( if (context.signal.aborted) { throw context.signal.reason ?? new Error('Subagent resolution was aborted.'); } - const childInputs = buildIsolatedSubagentInputs(resolvedChild, toInput); + const childInputs = buildIsolatedAgentInputs(resolvedChild, toInput); const resolutionState: SubagentBuildState = { configCount: 1, rootAgentIds: [resolvedChild.id], @@ -859,6 +846,7 @@ function createLazySubagentConfig( agentsEConfig, ancestors, depth, + prebuiltGraphInputs, ); if (grandchildConfigs.length > 0) { childInputs.subagentConfigs = grandchildConfigs; @@ -868,6 +856,41 @@ function createLazySubagentConfig( }; } +function enqueueSubagentChildren( + agent: SubagentTreeNode, + pending: Array, + visited: ReadonlySet, + includeLazyDescriptors = true, + includeCapabilityMetadata = true, +): void { + for (const child of agent.subagentAgentConfigs ?? []) { + if (child != null && !visited.has(child.id)) { + pending.push(child); + } + } + if (includeLazyDescriptors) { + for (const child of agent.lazySubagentConfigs ?? []) { + if (!visited.has(child.id)) { + pending.push(child); + } + } + } + if (includeCapabilityMetadata) { + for (const member of agent.subagentGraphMemberMetadata ?? []) { + if (!visited.has(member.id)) { + pending.push(member); + } + } + } + for (const graph of agent.subagentGraphConfigs ?? []) { + for (const member of graph.memberConfigs) { + if (member != null && !visited.has(member.id)) { + pending.push(member); + } + } + } +} + /** * Recursive any-true check across the agent tree: returns `true` if this * agent or any subagent (transitively) has the per-agent codeenv gate @@ -898,16 +921,7 @@ function anyAgentHasCodeEnv(agents: RunAgent[]): boolean { if (agent.codeEnvAvailable === true) { return true; } - for (const child of agent.subagentAgentConfigs ?? []) { - if (!visited.has(child.id)) { - pending.push(child); - } - } - for (const child of agent.lazySubagentConfigs ?? []) { - if (!visited.has(child.id)) { - pending.push(child); - } - } + enqueueSubagentChildren(agent, pending, visited); } return false; } @@ -975,16 +989,7 @@ export function anyAgentReplaysReasoningContent( if (shouldReplayReasoningContent(agent)) { return true; } - for (const child of agent.subagentAgentConfigs ?? []) { - if (!visited.has(child.id)) { - pending.push(child); - } - } - for (const child of agent.lazySubagentConfigs ?? []) { - if (!visited.has(child.id)) { - pending.push(child); - } - } + enqueueSubagentChildren(agent, pending, visited); } return false; } @@ -994,6 +999,43 @@ export function anyAgentReplaysReasoningContent( * explicit eager children and inert lazy descriptors. Returns an empty array * when subagents are disabled or no spawn targets are available. */ +function buildIsolatedAgentInputs( + child: RunAgent, + toInput: (agent: RunAgent, opts?: { isSubagent?: boolean }) => AgentInputs, +): AgentInputs { + const childInputs = toInput(child, { isSubagent: true }); + const alwaysApplySkillPrimes = child.alwaysApplySkillPrimes; + if (alwaysApplySkillPrimes && alwaysApplySkillPrimes.length > 0) { + const skillInstructions = alwaysApplySkillPrimes + .map((prime) => `# Always-apply skill: ${prime.name}\n${prime.body}`) + .join('\n\n'); + childInputs.additional_instructions = [childInputs.additional_instructions, skillInstructions] + .filter((value): value is string => typeof value === 'string' && value.length > 0) + .join('\n\n'); + } + if ((child.backgroundToolNames?.length ?? 0) > 0) { + childInputs.toolDefinitions = stripBackgroundFromToolDefinitions( + childInputs.toolDefinitions, + child.backgroundToolNames, + ); + childInputs.toolRegistry = stripBackgroundFromToolRegistry( + childInputs.toolRegistry, + child.backgroundToolNames, + ); + } + if ((child.intentToolNames?.length ?? 0) > 0) { + childInputs.toolDefinitions = stripIntentFromToolDefinitions( + childInputs.toolDefinitions, + child.intentToolNames, + ); + childInputs.toolRegistry = stripIntentFromToolRegistry( + childInputs.toolRegistry, + child.intentToolNames, + ); + } + return childInputs; +} + function buildSubagentConfigs( agent: RunAgent, agentInput: AgentInputs, @@ -1002,12 +1044,13 @@ function buildSubagentConfigs( agentsEConfig: Partial | undefined, ancestors: Set = new Set(), depth = 0, -): SubagentConfig[] { + prebuiltGraphInputs?: ReadonlyMap, +): SubagentConfigEntry[] { if (!agent.subagents?.enabled) { return []; } - const configs: SubagentConfig[] = []; + const configs: SubagentConfigEntry[] = []; const allowSelf = agent.subagents.allowSelf !== false; if (allowSelf) { @@ -1070,7 +1113,7 @@ function buildSubagentConfigs( const childDepth = depth + 1; assertSubagentDepth(childDepth, child.id); countSubagentConfig(state); - const childInputs = buildIsolatedSubagentInputs(child, toInput); + const childInputs = buildIsolatedAgentInputs(child, toInput); /** * Recursively resolve the child's own spawn targets so multi-level * delegation (A → B → C) works. Without this, a child whose own @@ -1087,6 +1130,7 @@ function buildSubagentConfigs( agentsEConfig, nextAncestors, childDepth, + prebuiltGraphInputs, ); if (grandchildConfigs.length > 0) { childInputs.subagentConfigs = grandchildConfigs; @@ -1113,10 +1157,53 @@ function buildSubagentConfigs( assertSubagentDepth(childDepth, child.id); countSubagentConfig(state); configs.push( - createLazySubagentConfig(child, toInput, agentsEConfig, nextAncestors, childDepth), + createLazySubagentConfig( + child, + toInput, + agentsEConfig, + nextAncestors, + childDepth, + prebuiltGraphInputs, + ), ); } + for (const { definition, memberConfigs } of agent.subagentGraphConfigs ?? []) { + if (memberConfigs.length === 0) { + continue; + } + countSubagentConfig(state); + const maxTurns = Math.min( + ...memberConfigs.map((member) => resolveSubagentMaxTurns(agentsEConfig, member)), + ); + configs.push({ + kind: 'graph', + type: definition.type, + name: definition.name, + description: definition.description, + agents: memberConfigs.map( + (member) => + prebuiltGraphInputs?.get(member.id) ?? buildIsolatedAgentInputs(member, toInput), + ), + /** + * The persisted API accepts `excludeResults: false` as the explicit + * form of the default. The SDK reserves this field for prompted edges + * and rejects any defined value when no prompt exists, so erase the + * no-op false value at the host boundary. + */ + edges: definition.edges.map((edge) => { + if (edge.excludeResults !== false) { + return edge; + } + const { excludeResults: _excludeResults, ...normalizedEdge } = edge; + return normalizedEdge; + }), + entryAgentId: definition.entry_agent_id, + resultAgentId: definition.result_agent_id, + maxTurns, + }); + } + return configs; } @@ -1489,6 +1576,27 @@ export async function createRun({ configCount: 0, rootAgentIds: agents.map((agent) => agent.id), }; + const prebuiltGraphInputs = new Map(); + const visitedConfigIds = new Set(); + const pendingConfigs: Array = [...agents]; + for (let index = 0; index < pendingConfigs.length; index++) { + const config = pendingConfigs[index]; + if (!config?.id || visitedConfigIds.has(config.id)) { + continue; + } + visitedConfigIds.add(config.id); + if (!prebuiltGraphInputs.has(config.id)) { + prebuiltGraphInputs.set(config.id, buildIsolatedAgentInputs(config, buildAgentInput)); + } + for (const graph of config.subagentGraphConfigs ?? []) { + for (const member of graph.memberConfigs) { + if (!prebuiltGraphInputs.has(member.id)) { + prebuiltGraphInputs.set(member.id, buildIsolatedAgentInputs(member, buildAgentInput)); + } + } + } + enqueueSubagentChildren(config, pendingConfigs, visitedConfigIds, false, false); + } for (const agent of agents) { const agentInput = buildAgentInput(agent); const subagentConfigs = buildSubagentConfigs( @@ -1497,6 +1605,9 @@ export async function createRun({ buildAgentInput, subagentBuildState, agentsEndpointConfig, + undefined, + 0, + prebuiltGraphInputs, ); if (subagentConfigs.length > 0) { agentInput.subagentConfigs = subagentConfigs; diff --git a/packages/api/src/agents/usage.spec.ts b/packages/api/src/agents/usage.spec.ts index 8ea185ed6f..4c0992e27e 100644 --- a/packages/api/src/agents/usage.spec.ts +++ b/packages/api/src/agents/usage.spec.ts @@ -1482,6 +1482,30 @@ describe('createSubagentUsageSink', () => { expect(emitted[0].agentId).toBe('agent_xyz'); }); + it('prices graph usage with the member agent instead of the synthetic execution subject', () => { + const collectedUsage: UsageMetadata[] = []; + const sink = createSubagentUsageSink(collectedUsage); + + sink( + makeEvent({ + subagentKind: 'graph', + subagentAgentId: 'graph:research_team', + memberAgentId: 'agent_writer', + }), + ); + + expect(collectedUsage[0].agentId).toBe('agent_writer'); + }); + + it('falls back to the execution subject when the member agent id is empty', () => { + const collectedUsage: UsageMetadata[] = []; + const sink = createSubagentUsageSink(collectedUsage); + + sink(makeEvent({ subagentAgentId: 'agent_researcher', memberAgentId: '' })); + + expect(collectedUsage[0].agentId).toBe('agent_researcher'); + }); + it('preserves cache token details from the child call', () => { const collectedUsage: UsageMetadata[] = []; const sink = createSubagentUsageSink(collectedUsage); diff --git a/packages/api/src/agents/usage.ts b/packages/api/src/agents/usage.ts index be0eecf049..935c356a0a 100644 --- a/packages/api/src/agents/usage.ts +++ b/packages/api/src/agents/usage.ts @@ -11,6 +11,7 @@ import type { TContextUsageEvent, TTransactionsConfig, } from 'librechat-data-provider'; +import type { SubagentUsageEvent as AgentsSubagentUsageEvent } from '@librechat/agents'; import type { StructuredTokenUsage, BulkWriteDeps, @@ -689,29 +690,8 @@ export async function recordCollectedUsage( }; } -/** - * Structural mirror of the agents SDK's `SubagentUsageEvent` (added after - * `@librechat/agents` 3.2.33). Defined locally so type-checking does not - * depend on the unreleased SDK — replace with - * `import type { SubagentUsageEvent } from '@librechat/agents'` once the - * dependency is bumped. - */ -export interface SubagentUsageEvent { - /** Usage metadata reported by the child's model call. */ - usage: UsageMetadata; - /** Model that produced this usage (per-call, falls back to the child config's model). */ - model?: string; - /** Provider enum value of the subagent's configured agent. */ - provider?: string; - /** Subagent `type` identifier from the SubagentConfig. */ - subagentType: string; - /** Child run ID (unique per subagent execution). */ - subagentRunId: string; - /** Child agent ID assigned to this subagent execution. */ - subagentAgentId: string; - /** Parent run ID under which the subagent was spawned. */ - runId: string; -} +/** SDK-owned usage envelope re-exported for host billing consumers. */ +export type SubagentUsageEvent = AgentsSubagentUsageEvent; /** * Builds the host-side `subagentUsageSink` for `Run.create`. Subagent child @@ -741,8 +721,12 @@ export function createSubagentUsageSink( /** Tag the child's agent id so the host can price this usage with the * subagent's own endpoint token config (its endpoint may differ from the * parent's). The same tagged object is pushed AND handed to `onUsage`. */ - if (event.subagentAgentId != null && event.subagentAgentId !== '') { - usage.agentId = event.subagentAgentId; + const billingAgentId = + event.memberAgentId != null && event.memberAgentId !== '' + ? event.memberAgentId + : event.subagentAgentId; + if (billingAgentId != null && billingAgentId !== '') { + usage.agentId = billingAgentId; } collectedUsage.push(usage); /** Lets the host stream the billed child usage to the client (tagged diff --git a/packages/api/src/agents/validation.spec.ts b/packages/api/src/agents/validation.spec.ts index 7edfef482b..914534e04e 100644 --- a/packages/api/src/agents/validation.spec.ts +++ b/packages/api/src/agents/validation.spec.ts @@ -1,7 +1,21 @@ -import { MAX_SUBAGENTS } from 'librechat-data-provider'; +import { + MAX_SUBAGENTS, + MAX_SUBAGENT_GRAPH_NODES, + MAX_GRAPH_SUBAGENT_MEMBERS, +} from 'librechat-data-provider'; import { agentCreateSchema, agentUpdateSchema, agentSubagentsSchema } from './validation'; describe('agentSubagentsSchema', () => { + const graph = { + type: 'research_team', + name: 'Research team', + description: 'Researches and writes a final answer', + agent_ids: ['agent_researcher', 'agent_writer'], + edges: [{ from: 'agent_researcher', to: 'agent_writer', edgeType: 'direct' as const }], + entry_agent_id: 'agent_researcher', + result_agent_id: 'agent_writer', + }; + it('accepts enabled:true with a list within the cap', () => { const result = agentSubagentsSchema.safeParse({ enabled: true, @@ -33,6 +47,131 @@ describe('agentSubagentsSchema', () => { }); expect(result.success).toBe(true); }); + + it('accepts an explicit bounded graph subagent', () => { + expect( + agentSubagentsSchema.safeParse({ enabled: true, allowSelf: false, graphs: [graph] }).success, + ).toBe(true); + }); + + it('accepts a one-member graph with no edges', () => { + expect( + agentSubagentsSchema.safeParse({ + enabled: true, + allowSelf: false, + graphs: [ + { + ...graph, + agent_ids: ['agent_solo'], + edges: [], + entry_agent_id: 'agent_solo', + result_agent_id: 'agent_solo', + }, + ], + }).success, + ).toBe(true); + }); + + it('accepts excludeResults:false without an edge prompt', () => { + expect( + agentSubagentsSchema.safeParse({ + enabled: true, + allowSelf: false, + graphs: [ + { + ...graph, + edges: [{ ...graph.edges[0], excludeResults: false }], + }, + ], + }).success, + ).toBe(true); + }); + + it('rejects configurations above the aggregate unique-agent cap', () => { + const firstAgentIds = Array.from( + { length: MAX_GRAPH_SUBAGENT_MEMBERS }, + (_, index) => `first_${index}`, + ); + const secondAgentIds = Array.from( + { length: MAX_SUBAGENT_GRAPH_NODES - MAX_GRAPH_SUBAGENT_MEMBERS + 1 }, + (_, index) => `second_${index}`, + ); + const toChain = (agentIds: string[]) => + agentIds.slice(1).map((agentId, index) => ({ + from: agentIds[index], + to: agentId, + edgeType: 'direct' as const, + })); + + expect( + agentSubagentsSchema.safeParse({ + enabled: true, + allowSelf: false, + graphs: [ + { + ...graph, + type: 'first_team', + agent_ids: firstAgentIds, + edges: toChain(firstAgentIds), + entry_agent_id: firstAgentIds[0], + result_agent_id: firstAgentIds[firstAgentIds.length - 1], + }, + { + ...graph, + type: 'second_team', + agent_ids: secondAgentIds, + edges: toChain(secondAgentIds), + entry_agent_id: secondAgentIds[0], + result_agent_id: secondAgentIds[secondAgentIds.length - 1], + }, + ], + }).success, + ).toBe(false); + }); + + it('rejects graph members outside the cap and edges outside the member set', () => { + const oversizedAgentIds = Array.from( + { length: MAX_GRAPH_SUBAGENT_MEMBERS + 1 }, + (_, index) => `agent_${index}`, + ); + expect( + agentSubagentsSchema.safeParse({ + enabled: true, + allowSelf: false, + graphs: [{ ...graph, agent_ids: oversizedAgentIds }], + }).success, + ).toBe(false); + expect( + agentSubagentsSchema.safeParse({ + enabled: true, + allowSelf: false, + graphs: [ + { + ...graph, + edges: [{ from: 'agent_researcher', to: 'agent_unknown', edgeType: 'direct' }], + }, + ], + }).success, + ).toBe(false); + }); + + it('rejects handoff edges and spawn-type collisions', () => { + expect( + agentSubagentsSchema.safeParse({ + enabled: true, + allowSelf: false, + graphs: [{ ...graph, edges: [{ ...graph.edges[0], edgeType: 'handoff' }] }], + }).success, + ).toBe(false); + expect( + agentSubagentsSchema.safeParse({ + enabled: true, + allowSelf: false, + agent_ids: ['research_team'], + graphs: [graph], + }).success, + ).toBe(false); + }); }); describe('agentCreateSchema with subagents', () => { @@ -55,6 +194,28 @@ describe('agentCreateSchema with subagents', () => { expect(result.success).toBe(true); }); + it('accepts the current-agent placeholder in a graph subagent', () => { + const result = agentCreateSchema.safeParse({ + ...base, + subagents: { + enabled: true, + graphs: [ + { + type: 'self_review', + name: 'Self review', + description: 'Runs the new agent in an isolated context', + agent_ids: [''], + edges: [], + entry_agent_id: '', + result_agent_id: '', + }, + ], + }, + }); + + expect(result.success).toBe(true); + }); + it('rejects when subagents.agent_ids exceeds the cap', () => { const oversized = Array.from({ length: MAX_SUBAGENTS + 1 }, (_, i) => `agent_${i}`); const result = agentCreateSchema.safeParse({ diff --git a/packages/api/src/agents/validation.ts b/packages/api/src/agents/validation.ts index 8f05615487..4a3c198491 100644 --- a/packages/api/src/agents/validation.ts +++ b/packages/api/src/agents/validation.ts @@ -1,6 +1,13 @@ import { z } from 'zod'; -import { MemoryScope, MAX_SUBAGENTS, ViolationTypes, ErrorTypes } from 'librechat-data-provider'; -import type { Agent, TModelsConfig } from 'librechat-data-provider'; +import { + MemoryScope, + MAX_SUBAGENTS, + ViolationTypes, + ErrorTypes, + MAX_SUBAGENT_GRAPH_NODES, + MAX_GRAPH_SUBAGENT_MEMBERS, +} from 'librechat-data-provider'; +import type { Agent, TModelsConfig, AgentSubagentsConfig } from 'librechat-data-provider'; import type { Request, Response } from 'express'; /** @@ -179,31 +186,198 @@ export const agentToolOptionsSchema: z.ZodOptional< * of `processAgent` calls (DB lookup + permission check + tool loading). * The UI enforces the same cap, so legitimate payloads never hit the bound. */ -export const agentSubagentsSchema: z.ZodOptional< - z.ZodObject< - { - enabled: z.ZodOptional; - allowSelf: z.ZodOptional; - agent_ids: z.ZodOptional>; - }, - 'strip', - z.ZodTypeAny, - { - enabled?: boolean | undefined; - agent_ids?: string[] | undefined; - allowSelf?: boolean | undefined; - }, - { - enabled?: boolean | undefined; - agent_ids?: string[] | undefined; - allowSelf?: boolean | undefined; +const graphSubagentEdgeSchema = z + .object({ + from: z.union([z.string(), z.array(z.string()).min(1)]), + to: z.union([z.string(), z.array(z.string()).min(1)]), + description: z.string().optional(), + edgeType: z.literal('direct'), + prompt: z.string().optional(), + excludeResults: z.boolean().optional(), + }) + .strict(); + +function validateGraphSubagentTopology(graph: { + type: string; + agent_ids: string[]; + edges: Array<{ + from: string | string[]; + to: string | string[]; + prompt?: string; + excludeResults?: boolean; + }>; + entry_agent_id: string; + result_agent_id: string; +}): string | undefined { + const memberIds = new Set(graph.agent_ids); + if (memberIds.size !== graph.agent_ids.length) { + return `Graph subagent "${graph.type}" contains duplicate member IDs.`; + } + const reservedMemberIds = new Set([ + '__start__', + '__end__', + 'messages', + 'agentMessages', + 'subagentResult', + ]); + const invalidMemberId = graph.agent_ids.find( + (agentId) => reservedMemberIds.has(agentId) || agentId.includes('|') || agentId.includes(':'), + ); + if (invalidMemberId) { + return `Graph subagent "${graph.type}" member "${invalidMemberId}" is reserved by the graph runtime.`; + } + if (!memberIds.has(graph.entry_agent_id) || !memberIds.has(graph.result_agent_id)) { + return `Graph subagent "${graph.type}" entry and result must reference configured members.`; + } + const adjacency = new Map(graph.agent_ids.map((agentId) => [agentId, new Set()])); + const reverse = new Map(graph.agent_ids.map((agentId) => [agentId, new Set()])); + const incomingGroups = new Map(); + const directedEdges = new Set(); + for (const edge of graph.edges) { + const sources = Array.isArray(edge.from) ? edge.from : [edge.from]; + const destinations = Array.isArray(edge.to) ? edge.to : [edge.to]; + if ( + new Set(sources).size !== sources.length || + new Set(destinations).size !== destinations.length + ) { + return `Graph subagent "${graph.type}" edge endpoints must be unique.`; } - > -> = z + if (edge.excludeResults === true && !edge.prompt) { + return `Graph subagent "${graph.type}" cannot exclude results without an edge prompt.`; + } + if (edge.prompt && destinations.length !== 1) { + return `Graph subagent "${graph.type}" prompted edges must have one destination.`; + } + for (const agentId of [...sources, ...destinations]) { + if (!memberIds.has(agentId)) { + return `Graph subagent "${graph.type}" references unknown member "${agentId}".`; + } + } + for (const destination of destinations) { + const groups = incomingGroups.get(destination) ?? []; + groups.push(sources); + incomingGroups.set(destination, groups); + for (const source of sources) { + if (source === destination) { + return `Graph subagent "${graph.type}" cannot contain self-edges.`; + } + const edgeKey = `${source}\0${destination}`; + if (directedEdges.has(edgeKey)) { + return `Graph subagent "${graph.type}" contains duplicate edges.`; + } + directedEdges.add(edgeKey); + adjacency.get(source)?.add(destination); + reverse.get(destination)?.add(source); + } + } + } + for (const [destination, groups] of incomingGroups) { + const sources = reverse.get(destination); + if (sources && sources.size > 1 && (groups.length !== 1 || groups[0].length !== sources.size)) { + return `Graph subagent "${graph.type}" fan-in to "${destination}" must use one array-valued source edge.`; + } + } + const roots = graph.agent_ids.filter((agentId) => reverse.get(agentId)?.size === 0); + const sinks = graph.agent_ids.filter((agentId) => adjacency.get(agentId)?.size === 0); + if (roots.length !== 1 || roots[0] !== graph.entry_agent_id) { + return `Graph subagent "${graph.type}" must use entry_agent_id as its only root.`; + } + if (sinks.length !== 1 || sinks[0] !== graph.result_agent_id) { + return `Graph subagent "${graph.type}" must use result_agent_id as its only sink.`; + } + const remainingIncoming = new Map( + graph.agent_ids.map((agentId) => [agentId, reverse.get(agentId)?.size ?? 0]), + ); + const ready = roots.slice(); + let visitedCount = 0; + while (ready.length > 0) { + const source = ready.pop(); + if (source === undefined) { + continue; + } + visitedCount++; + for (const destination of adjacency.get(source) ?? []) { + const count = (remainingIncoming.get(destination) ?? 0) - 1; + remainingIncoming.set(destination, count); + if (count === 0) { + ready.push(destination); + } + } + } + if (visitedCount !== memberIds.size) { + return `Graph subagent "${graph.type}" must be acyclic and fully connected.`; + } + const isSimpleChain = graph.agent_ids.every( + (agentId) => (adjacency.get(agentId)?.size ?? 0) <= 1 && (reverse.get(agentId)?.size ?? 0) <= 1, + ); + if ( + !isSimpleChain && + graph.edges.some( + (edge) => + edge.prompt && (Array.isArray(edge.to) ? edge.to[0] : edge.to) !== graph.result_agent_id, + ) + ) { + return `Graph subagent "${graph.type}" prompts in a branched graph must target result_agent_id.`; + } + return undefined; +} + +const graphSubagentSchema = z + .object({ + type: z.string().trim().min(1), + name: z.string().trim().min(1), + description: z.string().trim().min(1), + agent_ids: z.array(z.string()).min(1).max(MAX_GRAPH_SUBAGENT_MEMBERS), + edges: z.array(graphSubagentEdgeSchema), + entry_agent_id: z.string(), + result_agent_id: z.string(), + }) + .superRefine((graph, ctx) => { + const error = validateGraphSubagentTopology(graph); + if (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: error, + }); + } + }); + +export const agentSubagentsSchema: z.ZodOptional> = z .object({ enabled: z.boolean().optional(), allowSelf: z.boolean().optional(), agent_ids: z.array(z.string()).max(MAX_SUBAGENTS).optional(), + graphs: z.array(graphSubagentSchema).max(MAX_SUBAGENTS).optional(), + }) + .superRefine((subagents, ctx) => { + const reservedTypes = new Set(subagents.agent_ids ?? []); + const configuredAgentIds = new Set(subagents.agent_ids ?? []); + if (subagents.allowSelf !== false) { + reservedTypes.add('self'); + } + for (let graphIndex = 0; graphIndex < (subagents.graphs?.length ?? 0); graphIndex++) { + const graph = subagents.graphs?.[graphIndex]; + if (!graph) { + continue; + } + if (reservedTypes.has(graph.type)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ['graphs', graphIndex, 'type'], + message: 'Graph subagent types must be unique across all spawn targets', + }); + } + reservedTypes.add(graph.type); + for (const agentId of graph.agent_ids) { + configuredAgentIds.add(agentId); + } + } + if (configuredAgentIds.size > MAX_SUBAGENT_GRAPH_NODES) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Subagent configuration exceeds the maximum of ${MAX_SUBAGENT_GRAPH_NODES} unique agents`, + }); + } }) .optional(); @@ -327,27 +501,7 @@ export const agentBaseSchema: z.ZodObject< > > >; - subagents: z.ZodOptional< - z.ZodObject< - { - enabled: z.ZodOptional; - allowSelf: z.ZodOptional; - agent_ids: z.ZodOptional>; - }, - 'strip', - z.ZodTypeAny, - { - enabled?: boolean | undefined; - agent_ids?: string[] | undefined; - allowSelf?: boolean | undefined; - }, - { - enabled?: boolean | undefined; - agent_ids?: string[] | undefined; - allowSelf?: boolean | undefined; - } - > - >; + subagents: typeof agentSubagentsSchema; support_contact: z.ZodOptional< z.ZodObject< { @@ -514,27 +668,7 @@ export const agentCreateSchema: z.ZodObject< > > >; - subagents: z.ZodOptional< - z.ZodObject< - { - enabled: z.ZodOptional; - allowSelf: z.ZodOptional; - agent_ids: z.ZodOptional>; - }, - 'strip', - z.ZodTypeAny, - { - enabled?: boolean | undefined; - agent_ids?: string[] | undefined; - allowSelf?: boolean | undefined; - }, - { - enabled?: boolean | undefined; - agent_ids?: string[] | undefined; - allowSelf?: boolean | undefined; - } - > - >; + subagents: typeof agentSubagentsSchema; support_contact: z.ZodOptional< z.ZodObject< { @@ -665,27 +799,7 @@ export const agentUpdateSchema: z.ZodObject< > > >; - subagents: z.ZodOptional< - z.ZodObject< - { - enabled: z.ZodOptional; - allowSelf: z.ZodOptional; - agent_ids: z.ZodOptional>; - }, - 'strip', - z.ZodTypeAny, - { - enabled?: boolean | undefined; - agent_ids?: string[] | undefined; - allowSelf?: boolean | undefined; - }, - { - enabled?: boolean | undefined; - agent_ids?: string[] | undefined; - allowSelf?: boolean | undefined; - } - > - >; + subagents: typeof agentSubagentsSchema; support_contact: z.ZodOptional< z.ZodObject< { diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 96a23396a3..5c2a785e8b 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -18,6 +18,7 @@ import { FileSources } from './types/files'; import { MCPServersSchema } from './mcp'; export { MAX_SUBAGENTS, + MAX_GRAPH_SUBAGENT_MEMBERS, MAX_CHAT_PROJECT_NAME_LENGTH, MAX_CHAT_PROJECT_DESCRIPTION_LENGTH, } from './limits'; diff --git a/packages/data-provider/src/limits.ts b/packages/data-provider/src/limits.ts index db29ddac86..1392e6881c 100644 --- a/packages/data-provider/src/limits.ts +++ b/packages/data-provider/src/limits.ts @@ -5,3 +5,6 @@ export const MAX_SUBAGENTS = 10; * so the inputs stop at the same point the server would otherwise truncate. */ export const MAX_CHAT_PROJECT_NAME_LENGTH = 100; export const MAX_CHAT_PROJECT_DESCRIPTION_LENGTH = 1000; + +/** Mirrors the bounded graph-child member limit in `@librechat/agents`. */ +export const MAX_GRAPH_SUBAGENT_MEMBERS = 32; diff --git a/packages/data-provider/src/models.ts b/packages/data-provider/src/models.ts index 36cf7e4261..e0ca18545b 100644 --- a/packages/data-provider/src/models.ts +++ b/packages/data-provider/src/models.ts @@ -10,6 +10,8 @@ import { } from './schemas'; import { MAX_SUBAGENTS } from './limits'; +type ModelSpecSubagentsConfig = Omit; + export type TModelSpec = { name: string; label: string; @@ -79,7 +81,7 @@ export type TModelSpec = { artifacts?: string | boolean; mcpServers?: string[]; skills?: boolean | string[]; - subagents?: AgentSubagentsConfig; + subagents?: ModelSpecSubagentsConfig; }; export const modelSpecSubagentsSchema = z.object({ diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 3e8d73dad9..a8ff1f4dae 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -1,5 +1,10 @@ import { z } from 'zod'; -import type { TMessageContentParts, FunctionTool, FunctionToolCall } from './types/assistants'; +import type { + TMessageContentParts, + AgentSubagentGraph, + FunctionToolCall, + FunctionTool, +} from './types/assistants'; import type { SearchResultData } from './types/web'; import type { TFile } from './types/files'; import { TFeedback, feedbackSchema } from './feedback'; @@ -361,7 +366,12 @@ export const defaultAgentFormValues = { skills_enabled: undefined as boolean | undefined, /** `undefined` = feature disabled by default (no subagent tool injected). */ subagents: undefined as - | { enabled?: boolean; allowSelf?: boolean; agent_ids?: string[] } + | { + enabled?: boolean; + allowSelf?: boolean; + agent_ids?: string[]; + graphs?: AgentSubagentGraph[]; + } | undefined, /** Memory partition: 'agent' isolates memories per (user, agent); default shared pool */ memory_scope: undefined as MemoryScope | undefined, diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts index c6da929e30..5488229e67 100644 --- a/packages/data-provider/src/types/assistants.ts +++ b/packages/data-provider/src/types/assistants.ts @@ -270,14 +270,41 @@ export type AgentToolOptions = Record; /** * Configuration for spawning subagents (isolated-context child agents) from an agent. * When `enabled` is true, the agent gets a subagent-spawn tool that can delegate work - * to either itself (when `allowSelf` is true) and/or the listed `agent_ids`. + * to itself, listed single-agent targets, and/or explicit saved-agent teams. */ +export type AgentSubagentGraphEdge = Omit< + GraphEdge, + 'edgeType' | 'condition' | 'prompt' | 'promptKey' +> & { + edgeType: 'direct'; + condition?: never; + prompt?: string; + promptKey?: never; +}; + +/** A bounded saved-agent team that can be spawned as one isolated child graph. */ +export type AgentSubagentGraph = { + /** Stable spawn-tool enum value for the team. */ + type: string; + name: string; + description: string; + /** Member IDs. In create/update payloads, an empty ID refers to the current agent. */ + agent_ids: string[]; + edges: AgentSubagentGraphEdge[]; + /** Entry member ID. In create/update payloads, an empty ID refers to the current agent. */ + entry_agent_id: string; + /** Result member ID. In create/update payloads, an empty ID refers to the current agent. */ + result_agent_id: string; +}; + export type AgentSubagentsConfig = { enabled?: boolean; /** When true (default), the agent may spawn itself in an isolated context. */ allowSelf?: boolean; /** Specific agents that may be spawned as subagents. */ agent_ids?: string[]; + /** Explicit saved-agent teams that may be spawned as bounded child graphs. */ + graphs?: AgentSubagentGraph[]; }; export type Agent = { diff --git a/packages/data-provider/src/types/runs.ts b/packages/data-provider/src/types/runs.ts index 9cd08cb994..24ec03d119 100644 --- a/packages/data-provider/src/types/runs.ts +++ b/packages/data-provider/src/types/runs.ts @@ -361,16 +361,34 @@ export type SubagentUpdatePhase = | 'stop' | 'error'; +/** Structured root-to-leaf identity for one nested subagent execution. */ +export interface SubagentAncestryEntry { + readonly subagentRunId: string; + readonly subagentType: string; + readonly subagentKind: 'agent' | 'graph'; + /** Execution subject ID; synthetic for graph subagents. */ + readonly subagentAgentId: string; + readonly parentRunId: string; + readonly parentAgentId?: string; + readonly parentToolCallId?: string; +} + /** Single streamed subagent update forwarded by the SDK's SubagentExecutor. */ export interface SubagentUpdateEvent { runId: string; + parentRunId?: string; subagentRunId: string; /** Parent-side `tool_call_id` for the `subagent` tool invocation that * triggered this run. Surfaces from the SDK (`3.1.67-dev.2`+) so hosts * can correlate child progress to the parent tool call deterministically. */ parentToolCallId?: string; subagentType: string; + subagentKind?: 'agent' | 'graph'; + /** Execution subject ID; synthetic for graph subagents. */ subagentAgentId: string; + memberAgentId?: string; + depth?: number; + ancestry?: readonly SubagentAncestryEntry[]; parentAgentId?: string; phase: SubagentUpdatePhase; data?: unknown;