🕸️ feat: Run Saved Agent Teams as Subagents (#14944)

* 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
This commit is contained in:
Danny Avila 2026-08-17 18:02:52 -04:00 committed by GitHub
parent 7ee9e4e363
commit 7d62be2ad3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 3903 additions and 636 deletions

View file

@ -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');

View file

@ -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', () => {

View file

@ -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);

View file

@ -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;
}

View file

@ -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

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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({

View file

@ -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;
}
}

View file

@ -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) =>

View file

@ -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,
};

View file

@ -23,6 +23,7 @@ const AgentSubagents: React.FC<AgentSubagentsProps> = ({ 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<AgentSubagentsProps> = ({ 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<AgentSubagentsProps> = ({ field, currentAgentId }
};
const selfId = 'subagents-self-toggle';
const nothingToSpawn = enabled && !allowSelf && agentIds.length === 0;
const nothingToSpawn = enabled && !allowSelf && agentIds.length === 0 && graphCount === 0;
return (
<OrchestrationPattern

View file

@ -0,0 +1,185 @@
/**
* Live-provider verification for LibreChat's saved-team -> 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<string, EventHandler> = {
[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);
},
);
});

View file

@ -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<Record<string, unknown>>)[0];
const resolvedInputs = await (
lazyConfig.resolveAgentInputs as (context: never) => Promise<Record<string, unknown>>
)({ signal: new AbortController().signal } as never);
const graphConfig = (resolvedInputs.subagentConfigs as Array<Record<string, unknown>>)[0];
const memberInput = (graphConfig.agents as Array<Record<string, unknown>>)[0];
const memberRegistry = memberInput.toolRegistry as Map<string, { defer_loading?: boolean }>;
expect(
(agents[0].toolRegistry as Map<string, { defer_loading?: boolean }>).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<Record<string, unknown>>)[0];
const resolvedInputs = await (
lazyConfig.resolveAgentInputs as (context: never) => Promise<Record<string, unknown>>
)({ signal: new AbortController().signal } as never);
const graphConfig = (resolvedInputs.subagentConfigs as Array<Record<string, unknown>>)[0];
const memberInputs = graphConfig.agents as Array<Record<string, unknown>>;
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<Record<string, unknown>>;
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<Record<string, unknown>>;
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<Record<string, unknown>>;
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<Record<string, unknown>>;
const [memberInput] = config.agents as Array<Record<string, unknown>>;
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<string, { defer_loading?: boolean }>;
const graphConfig = (agents[0].subagentConfigs as Array<Record<string, unknown>>)[0];
const memberInputs = graphConfig.agents as Array<Record<string, unknown>>;
const memberRegistry = memberInputs[0].toolRegistry as Map<string, { defer_loading?: boolean }>;
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<string, { defer_loading?: boolean }>;
const laterGraph = (agents[1].subagentConfigs as Array<Record<string, unknown>>)[0];
const memberInputs = laterGraph.agents as Array<Record<string, unknown>>;
const memberRegistry = memberInputs[0].toolRegistry as Map<string, { defer_loading?: boolean }>;
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<string, unknown>;
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

View file

@ -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');

View file

@ -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<TFile extends FileWithId = IMongoFile> = {
id?: string | null;
agentContextAttachments?: TFile[] | null;
subagentAgentConfigs?: AgentContextAttachmentCarrier<TFile>[] | null;
subagentGraphConfigs?: Array<{
memberConfigs?: AgentContextAttachmentCarrier<TFile>[] | null;
}> | null;
};
export type AgentContextAttachmentsByAgentId<TFile extends FileWithId = IMongoFile> =
@ -35,15 +39,22 @@ export function buildAgentContextAttachmentsByAgentId<TFile extends FileWithId>(
configs: Iterable<AgentContextAttachmentCarrier<TFile> | null | undefined>,
): Map<string, TFile[]> {
const attachmentsByAgentId = new Map<string, TFile[]>();
const visited = new Set<string>();
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;

View file

@ -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(

View file

@ -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<CodeFilesAgent>,
): 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;
}

View file

@ -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([]);
});
});

View file

@ -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<string, Record<string, string>>;
}
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<Record<string, Record<string, string>> | undefined> {
const configById = new Map(params.rootConfigs.map((config) => [config.id, config]));
const attemptedGraphMemberIds = new Set<string>();
const failedMemberIds = new Set<string>();
const loadGraphMember = createConcurrencyLimiter(SUBAGENT_GRAPH_LOAD_CONCURRENCY);
let userMCPAuthMap: Record<string, Record<string, string>> | undefined;
for (const config of params.rootConfigs) {
if (config.userMCPAuthMap) {
userMCPAuthMap = { ...userMCPAuthMap, ...config.userMCPAuthMap };
}
}
for (const rootConfig of params.rootConfigs) {
const resolvedGraphs: NonNullable<GraphSubagentHostConfig['subagentGraphConfigs']> = [];
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<DiscoverConnectedAgentsResult> {
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<string, InitializedAgent>();
const skippedAgentIds = new Set<string>();
@ -210,90 +399,14 @@ export async function discoverConnectedAgents(
};
const processAgent = async (agentId: string): Promise<Agent | null> => {
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<TEndpointOption> = {
...(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<string>): 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

View file

@ -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

View file

@ -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<string> {
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

View file

@ -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 ─────────────────────────────────────────

View file

@ -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);
}
},
},

View file

@ -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<Agent, 'tools'> & {
* 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<RunAgent>;
};
@ -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<TAgentsEndpoint> | undefined,
ancestors: Set<string>,
depth: number,
prebuiltGraphInputs?: ReadonlyMap<string, AgentInputs>,
): 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<SubagentTreeNode | null | undefined>,
visited: ReadonlySet<string>,
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<TAgentsEndpoint> | undefined,
ancestors: Set<string> = new Set(),
depth = 0,
): SubagentConfig[] {
prebuiltGraphInputs?: ReadonlyMap<string, AgentInputs>,
): 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<string, AgentInputs>();
const visitedConfigIds = new Set<string>();
const pendingConfigs: Array<RunAgent | null | undefined> = [...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;

View file

@ -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);

View file

@ -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

View file

@ -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({

View file

@ -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<z.ZodBoolean>;
allowSelf: z.ZodOptional<z.ZodBoolean>;
agent_ids: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
},
'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<string>()]));
const reverse = new Map(graph.agent_ids.map((agentId) => [agentId, new Set<string>()]));
const incomingGroups = new Map<string, string[][]>();
const directedEdges = new Set<string>();
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.ZodType<AgentSubagentsConfig>> = 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<z.ZodBoolean>;
allowSelf: z.ZodOptional<z.ZodBoolean>;
agent_ids: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
},
'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<z.ZodBoolean>;
allowSelf: z.ZodOptional<z.ZodBoolean>;
agent_ids: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
},
'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<z.ZodBoolean>;
allowSelf: z.ZodOptional<z.ZodBoolean>;
agent_ids: z.ZodOptional<z.ZodArray<z.ZodString, 'many'>>;
},
'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<
{

View file

@ -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';

View file

@ -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;

View file

@ -10,6 +10,8 @@ import {
} from './schemas';
import { MAX_SUBAGENTS } from './limits';
type ModelSpecSubagentsConfig = Omit<AgentSubagentsConfig, 'graphs'>;
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({

View file

@ -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,

View file

@ -270,14 +270,41 @@ export type AgentToolOptions = Record<string, ToolOptions>;
/**
* 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 = {

View file

@ -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;