mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🕸️ 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:
parent
7ee9e4e363
commit
7d62be2ad3
36 changed files with 3903 additions and 636 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
185
packages/api/src/agents/__tests__/graph-subagent.e2e.test.ts
Normal file
185
packages/api/src/agents/__tests__/graph-subagent.e2e.test.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue