mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-05-14 16:38:40 +00:00
761 lines
24 KiB
JavaScript
761 lines
24 KiB
JavaScript
const mongoose = require('mongoose');
|
|
const {
|
|
ResourceType,
|
|
PermissionBits,
|
|
PrincipalType,
|
|
PrincipalModel,
|
|
MAX_SUBAGENT_DEPTH,
|
|
MAX_SUBAGENT_GRAPH_NODES,
|
|
} = require('librechat-data-provider');
|
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
|
|
|
const mockInitializeAgent = jest.fn();
|
|
const mockValidateAgentModel = jest.fn();
|
|
|
|
jest.mock('@librechat/agents', () => ({
|
|
...jest.requireActual('@librechat/agents'),
|
|
createContentAggregator: jest.fn(() => ({
|
|
contentParts: [],
|
|
aggregateContent: jest.fn(),
|
|
})),
|
|
}));
|
|
|
|
jest.mock('@librechat/api', () => ({
|
|
...jest.requireActual('@librechat/api'),
|
|
initializeAgent: (...args) => mockInitializeAgent(...args),
|
|
validateAgentModel: (...args) => mockValidateAgentModel(...args),
|
|
GenerationJobManager: { setCollectedUsage: jest.fn() },
|
|
getCustomEndpointConfig: jest.fn(),
|
|
createSequentialChainEdges: jest.fn(),
|
|
}));
|
|
|
|
/** Captured by the `getDefaultHandlers` mock so tests can drive the
|
|
* `ON_TOOL_EXECUTE` pipeline with a real subagent id and observe whether
|
|
* the tool context (agent, tool_resources, skill ACLs) was preserved. */
|
|
let capturedToolExecuteOptions;
|
|
jest.mock('~/server/controllers/agents/callbacks', () => ({
|
|
createToolEndCallback: jest.fn(() => jest.fn()),
|
|
getDefaultHandlers: jest.fn((opts) => {
|
|
capturedToolExecuteOptions = opts?.toolExecuteOptions;
|
|
return {};
|
|
}),
|
|
}));
|
|
|
|
const mockLoadToolsForExecution = jest.fn();
|
|
jest.mock('~/server/services/ToolService', () => ({
|
|
loadAgentTools: jest.fn(),
|
|
loadToolsForExecution: (...args) => mockLoadToolsForExecution(...args),
|
|
}));
|
|
|
|
jest.mock('~/server/controllers/ModelController', () => ({
|
|
getModelsConfig: jest.fn().mockResolvedValue({}),
|
|
}));
|
|
|
|
let agentClientArgs;
|
|
jest.mock('~/server/controllers/agents/client', () => {
|
|
return jest.fn().mockImplementation((args) => {
|
|
agentClientArgs = args;
|
|
return {};
|
|
});
|
|
});
|
|
|
|
jest.mock('./addedConvo', () => ({
|
|
processAddedConvo: jest.fn().mockResolvedValue({ userMCPAuthMap: undefined }),
|
|
}));
|
|
|
|
jest.mock('~/cache', () => ({
|
|
logViolation: jest.fn(),
|
|
}));
|
|
|
|
const { initializeClient } = require('./initialize');
|
|
const { logger } = require('@librechat/data-schemas');
|
|
const { User, AclEntry } = require('~/db/models');
|
|
const { createAgent } = require('~/models');
|
|
|
|
jest.spyOn(logger, 'warn').mockImplementation(() => {});
|
|
|
|
const PRIMARY_ID = 'agent_primary';
|
|
const TARGET_ID = 'agent_target';
|
|
const AUTHORIZED_ID = 'agent_authorized';
|
|
|
|
describe('initializeClient — processAgent ACL gate', () => {
|
|
let mongoServer;
|
|
let testUser;
|
|
|
|
beforeAll(async () => {
|
|
mongoServer = await MongoMemoryServer.create();
|
|
await mongoose.connect(mongoServer.getUri());
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await mongoose.disconnect();
|
|
await mongoServer.stop();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await mongoose.connection.dropDatabase();
|
|
jest.clearAllMocks();
|
|
agentClientArgs = undefined;
|
|
|
|
testUser = await User.create({
|
|
email: 'test@example.com',
|
|
name: 'Test User',
|
|
username: 'testuser',
|
|
role: 'USER',
|
|
});
|
|
|
|
mockValidateAgentModel.mockResolvedValue({ isValid: true });
|
|
});
|
|
|
|
const makeReq = () => ({
|
|
user: { id: testUser._id.toString(), role: 'USER' },
|
|
body: { conversationId: 'conv_1', files: [] },
|
|
config: { endpoints: {} },
|
|
_resumableStreamId: null,
|
|
});
|
|
|
|
const makeEndpointOption = () => ({
|
|
agent: Promise.resolve({
|
|
id: PRIMARY_ID,
|
|
name: 'Primary',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
tools: [],
|
|
}),
|
|
model_parameters: { model: 'gpt-4' },
|
|
endpoint: 'agents',
|
|
});
|
|
|
|
const makePrimaryConfig = (edges) => ({
|
|
id: PRIMARY_ID,
|
|
endpoint: 'agents',
|
|
edges,
|
|
toolDefinitions: [],
|
|
toolRegistry: new Map(),
|
|
userMCPAuthMap: null,
|
|
tool_resources: {},
|
|
resendFiles: true,
|
|
maxContextTokens: 4096,
|
|
});
|
|
|
|
it('should skip handoff agent and filter its edge when user lacks VIEW access', async () => {
|
|
await createAgent({
|
|
id: TARGET_ID,
|
|
name: 'Target Agent',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: new mongoose.Types.ObjectId(),
|
|
tools: [],
|
|
});
|
|
|
|
const edges = [{ from: PRIMARY_ID, to: TARGET_ID, edgeType: 'handoff' }];
|
|
mockInitializeAgent.mockResolvedValue(makePrimaryConfig(edges));
|
|
|
|
await initializeClient({
|
|
req: makeReq(),
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
});
|
|
|
|
expect(mockInitializeAgent).toHaveBeenCalledTimes(1);
|
|
expect(agentClientArgs.agent.edges).toEqual([]);
|
|
});
|
|
|
|
it('should initialize handoff agent and keep its edge when user has VIEW access', async () => {
|
|
const authorizedAgent = await createAgent({
|
|
id: AUTHORIZED_ID,
|
|
name: 'Authorized Agent',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: new mongoose.Types.ObjectId(),
|
|
tools: [],
|
|
});
|
|
|
|
await AclEntry.create({
|
|
principalType: PrincipalType.USER,
|
|
principalId: testUser._id,
|
|
principalModel: PrincipalModel.USER,
|
|
resourceType: ResourceType.AGENT,
|
|
resourceId: authorizedAgent._id,
|
|
permBits: PermissionBits.VIEW,
|
|
grantedBy: testUser._id,
|
|
});
|
|
|
|
const edges = [{ from: PRIMARY_ID, to: AUTHORIZED_ID, edgeType: 'handoff' }];
|
|
const handoffConfig = {
|
|
id: AUTHORIZED_ID,
|
|
edges: [],
|
|
toolDefinitions: [],
|
|
toolRegistry: new Map(),
|
|
userMCPAuthMap: null,
|
|
tool_resources: {},
|
|
};
|
|
|
|
let callCount = 0;
|
|
mockInitializeAgent.mockImplementation(() => {
|
|
callCount++;
|
|
return callCount === 1
|
|
? Promise.resolve(makePrimaryConfig(edges))
|
|
: Promise.resolve(handoffConfig);
|
|
});
|
|
|
|
await initializeClient({
|
|
req: makeReq(),
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
});
|
|
|
|
expect(mockInitializeAgent).toHaveBeenCalledTimes(2);
|
|
expect(agentClientArgs.agent.edges).toHaveLength(1);
|
|
expect(agentClientArgs.agent.edges[0].to).toBe(AUTHORIZED_ID);
|
|
});
|
|
});
|
|
|
|
describe('initializeClient — subagent loading', () => {
|
|
const SUBAGENT_ID = 'agent_subagent_1';
|
|
const DUPLICATE_SUBAGENT_ID = 'agent_subagent_dup';
|
|
const HANDOFF_AND_SUB_ID = 'agent_handoff_and_sub';
|
|
|
|
let mongoServer;
|
|
let testUser;
|
|
|
|
beforeAll(async () => {
|
|
mongoServer = await MongoMemoryServer.create();
|
|
await mongoose.connect(mongoServer.getUri());
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await mongoose.disconnect();
|
|
await mongoServer.stop();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await mongoose.connection.dropDatabase();
|
|
jest.clearAllMocks();
|
|
agentClientArgs = undefined;
|
|
capturedToolExecuteOptions = undefined;
|
|
mockLoadToolsForExecution.mockReset();
|
|
mockLoadToolsForExecution.mockResolvedValue({ loadedTools: [] });
|
|
|
|
testUser = await User.create({
|
|
email: 'subagent@example.com',
|
|
name: 'Subagent User',
|
|
username: 'subuser',
|
|
role: 'USER',
|
|
});
|
|
|
|
mockValidateAgentModel.mockResolvedValue({ isValid: true });
|
|
});
|
|
|
|
/** Grant the test user VIEW on an agent so processAgent loads it. */
|
|
const grantView = async (agentDoc) => {
|
|
await AclEntry.create({
|
|
principalType: PrincipalType.USER,
|
|
principalId: testUser._id,
|
|
principalModel: PrincipalModel.USER,
|
|
resourceType: ResourceType.AGENT,
|
|
resourceId: agentDoc._id,
|
|
permBits: PermissionBits.VIEW,
|
|
grantedBy: testUser._id,
|
|
});
|
|
};
|
|
|
|
/** Build a request with the `subagents` capability enabled. */
|
|
const makeSubagentReq = () => ({
|
|
user: { id: testUser._id.toString(), role: 'USER' },
|
|
body: { conversationId: 'conv_sub', files: [] },
|
|
config: {
|
|
endpoints: {
|
|
agents: {
|
|
capabilities: ['subagents'],
|
|
},
|
|
},
|
|
},
|
|
_resumableStreamId: null,
|
|
});
|
|
|
|
const makeEndpointOption = () => ({
|
|
agent: Promise.resolve({
|
|
id: PRIMARY_ID,
|
|
name: 'Primary',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
tools: [],
|
|
}),
|
|
model_parameters: { model: 'gpt-4' },
|
|
endpoint: 'agents',
|
|
});
|
|
|
|
const makePrimaryConfig = ({ edges = [], subagents, agent_ids }) => ({
|
|
id: PRIMARY_ID,
|
|
endpoint: 'agents',
|
|
edges,
|
|
toolDefinitions: [],
|
|
toolRegistry: new Map(),
|
|
userMCPAuthMap: null,
|
|
tool_resources: {},
|
|
resendFiles: true,
|
|
maxContextTokens: 4096,
|
|
subagents,
|
|
agent_ids,
|
|
});
|
|
|
|
const makeSubagentConfig = (id) => ({
|
|
id,
|
|
endpoint: 'agents',
|
|
edges: [],
|
|
toolDefinitions: [{ name: 'web', description: 'web', parameters: {} }],
|
|
toolRegistry: new Map([['web', { name: 'web' }]]),
|
|
userMCPAuthMap: null,
|
|
tool_resources: { file_search: { file_ids: ['file_1'] } },
|
|
accessibleSkillIds: ['skill_1'],
|
|
actionsEnabled: true,
|
|
resendFiles: false,
|
|
maxContextTokens: 4096,
|
|
});
|
|
|
|
const makeNestedSubagentConfig = (id, childIds = []) => ({
|
|
...makeSubagentConfig(id),
|
|
subagents: { enabled: true, allowSelf: false, agent_ids: childIds },
|
|
});
|
|
|
|
const createViewableAgent = async (id) => {
|
|
const agent = await createAgent({
|
|
id,
|
|
name: id,
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: new mongoose.Types.ObjectId(),
|
|
tools: [],
|
|
});
|
|
await grantView(agent);
|
|
return agent;
|
|
};
|
|
|
|
it('loads a configured subagent, populates `subagentAgentConfigs`, and keeps it out of `agentConfigs`', async () => {
|
|
const subAgent = await createAgent({
|
|
id: SUBAGENT_ID,
|
|
name: 'Explicit Subagent',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: new mongoose.Types.ObjectId(),
|
|
tools: ['web'],
|
|
});
|
|
await grantView(subAgent);
|
|
|
|
const primaryConfig = makePrimaryConfig({
|
|
subagents: { enabled: true, allowSelf: true, agent_ids: [SUBAGENT_ID] },
|
|
});
|
|
const subagentConfig = makeSubagentConfig(SUBAGENT_ID);
|
|
|
|
let call = 0;
|
|
let subagentConvoFileIds;
|
|
mockInitializeAgent.mockImplementation(async (params, dbDeps) => {
|
|
call += 1;
|
|
if (call === 1) {
|
|
return primaryConfig;
|
|
}
|
|
subagentConvoFileIds = await dbDeps.getConvoFiles(params.conversationId);
|
|
return subagentConfig;
|
|
});
|
|
|
|
await initializeClient({
|
|
req: makeSubagentReq(),
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
});
|
|
|
|
expect(mockInitializeAgent).toHaveBeenCalledTimes(2);
|
|
const subagentDbDeps = mockInitializeAgent.mock.calls[1][1];
|
|
expect(subagentDbDeps.getConvoFiles).toBeInstanceOf(Function);
|
|
expect(subagentDbDeps.db).toBeUndefined();
|
|
expect(subagentConvoFileIds).toEqual([]);
|
|
|
|
/** The subagent's AgentConfig is attached to the primary for run.ts to
|
|
* turn into `SubagentConfig[]` on the parent's `AgentInputs`. */
|
|
expect(agentClientArgs.agent.subagentAgentConfigs).toHaveLength(1);
|
|
expect(agentClientArgs.agent.subagentAgentConfigs[0].id).toBe(SUBAGENT_ID);
|
|
|
|
/** Subagent-only agents must NOT appear in `agentConfigs` — otherwise the
|
|
* graph would treat them as a parallel/handoff node. */
|
|
expect(agentClientArgs.agentConfigs).toBeDefined();
|
|
expect(agentClientArgs.agentConfigs.has(SUBAGENT_ID)).toBe(false);
|
|
});
|
|
|
|
it('preserves subagent tool context for ON_TOOL_EXECUTE (Codex P1 regression guard)', async () => {
|
|
/** Verifies the Codex P1 fix: `agentToolContexts.delete(subagentId)` is
|
|
* NOT called for subagent-only agents, so when the child dispatches
|
|
* `ON_TOOL_EXECUTE` the parent can still resolve its tool context
|
|
* (agent, tool_resources, skill ACLs, actionsEnabled) to run tools
|
|
* with the right scope. We drive the real `loadTools` closure that
|
|
* `initializeClient` wires into `toolExecuteOptions`. */
|
|
const subAgent = await createAgent({
|
|
id: SUBAGENT_ID,
|
|
name: 'Explicit Subagent',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: new mongoose.Types.ObjectId(),
|
|
tools: ['web'],
|
|
});
|
|
await grantView(subAgent);
|
|
|
|
const primaryConfig = makePrimaryConfig({
|
|
subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID] },
|
|
});
|
|
const subagentConfig = makeSubagentConfig(SUBAGENT_ID);
|
|
|
|
let call = 0;
|
|
mockInitializeAgent.mockImplementation(() =>
|
|
Promise.resolve(++call === 1 ? primaryConfig : subagentConfig),
|
|
);
|
|
|
|
await initializeClient({
|
|
req: makeSubagentReq(),
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
});
|
|
|
|
expect(capturedToolExecuteOptions?.loadTools).toBeInstanceOf(Function);
|
|
|
|
/** Invoke the real closure with the subagent's id. If `agentToolContexts`
|
|
* had been deleted (as in the pre-fix code), this would call
|
|
* `loadToolsForExecution` with `agent: undefined` — actions/resource-
|
|
* scoped tools would silently drop. */
|
|
await capturedToolExecuteOptions.loadTools(['web'], SUBAGENT_ID);
|
|
|
|
expect(mockLoadToolsForExecution).toHaveBeenCalledTimes(1);
|
|
const arg = mockLoadToolsForExecution.mock.calls[0][0];
|
|
expect(arg.agent).toBeDefined();
|
|
expect(arg.agent.id).toBe(SUBAGENT_ID);
|
|
expect(arg.toolRegistry).toBeInstanceOf(Map);
|
|
expect(arg.tool_resources).toEqual({ file_search: { file_ids: ['file_1'] } });
|
|
expect(arg.actionsEnabled).toBe(true);
|
|
});
|
|
|
|
it('deduplicates repeated ids in subagents.agent_ids', async () => {
|
|
const subAgent = await createAgent({
|
|
id: DUPLICATE_SUBAGENT_ID,
|
|
name: 'Dup Subagent',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: new mongoose.Types.ObjectId(),
|
|
tools: [],
|
|
});
|
|
await grantView(subAgent);
|
|
|
|
const primaryConfig = makePrimaryConfig({
|
|
subagents: {
|
|
enabled: true,
|
|
allowSelf: false,
|
|
/** Same id three times — the backend must not load the agent
|
|
* repeatedly and must not emit three SubagentConfig entries. */
|
|
agent_ids: [DUPLICATE_SUBAGENT_ID, DUPLICATE_SUBAGENT_ID, DUPLICATE_SUBAGENT_ID],
|
|
},
|
|
});
|
|
const subagentConfig = makeSubagentConfig(DUPLICATE_SUBAGENT_ID);
|
|
|
|
let initCalls = 0;
|
|
mockInitializeAgent.mockImplementation(() => {
|
|
initCalls += 1;
|
|
return Promise.resolve(initCalls === 1 ? primaryConfig : subagentConfig);
|
|
});
|
|
|
|
await initializeClient({
|
|
req: makeSubagentReq(),
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
});
|
|
|
|
/** One call for primary, one for the subagent — not four. */
|
|
expect(mockInitializeAgent).toHaveBeenCalledTimes(2);
|
|
expect(agentClientArgs.agent.subagentAgentConfigs).toHaveLength(1);
|
|
});
|
|
|
|
it('rejects nested subagent chains deeper than MAX_SUBAGENT_DEPTH', async () => {
|
|
const ids = Array.from(
|
|
{ length: MAX_SUBAGENT_DEPTH + 1 },
|
|
(_, index) => `agent_depth_${index}`,
|
|
);
|
|
for (const id of ids) {
|
|
await createViewableAgent(id);
|
|
}
|
|
|
|
const primaryConfig = makePrimaryConfig({
|
|
subagents: { enabled: true, allowSelf: false, agent_ids: [ids[0]] },
|
|
});
|
|
const nestedConfigs = new Map(
|
|
ids.map((id, index) => [
|
|
id,
|
|
makeNestedSubagentConfig(id, index < ids.length - 1 ? [ids[index + 1]] : []),
|
|
]),
|
|
);
|
|
|
|
mockInitializeAgent.mockImplementation(({ agent }) =>
|
|
Promise.resolve(agent.id === PRIMARY_ID ? primaryConfig : nestedConfigs.get(agent.id)),
|
|
);
|
|
|
|
await expect(
|
|
initializeClient({
|
|
req: makeSubagentReq(),
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
}),
|
|
).rejects.toThrow(`maximum depth of ${MAX_SUBAGENT_DEPTH}`);
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'[initializeClient] Subagent graph depth limit exceeded',
|
|
expect.objectContaining({
|
|
agentId: `agent_depth_${MAX_SUBAGENT_DEPTH - 1}`,
|
|
primaryAgentId: PRIMARY_ID,
|
|
depth: MAX_SUBAGENT_DEPTH,
|
|
maxSubagentDepth: MAX_SUBAGENT_DEPTH,
|
|
childCount: 1,
|
|
}),
|
|
);
|
|
expect(agentClientArgs).toBeUndefined();
|
|
});
|
|
|
|
it('preserves deeper path depth when a handoff root is also a nested subagent', async () => {
|
|
const chainIds = Array.from(
|
|
{ length: MAX_SUBAGENT_DEPTH },
|
|
(_, index) => `agent_overlap_depth_${index}`,
|
|
);
|
|
const allIds = [HANDOFF_AND_SUB_ID, ...chainIds];
|
|
for (const id of allIds) {
|
|
await createViewableAgent(id);
|
|
}
|
|
|
|
const edges = [{ from: PRIMARY_ID, to: HANDOFF_AND_SUB_ID, edgeType: 'handoff' }];
|
|
const primaryConfig = makePrimaryConfig({
|
|
edges,
|
|
subagents: { enabled: true, allowSelf: false, agent_ids: [HANDOFF_AND_SUB_ID] },
|
|
});
|
|
const nestedConfigs = new Map([
|
|
[HANDOFF_AND_SUB_ID, makeNestedSubagentConfig(HANDOFF_AND_SUB_ID, [chainIds[0]])],
|
|
...chainIds.map((id, index) => [
|
|
id,
|
|
makeNestedSubagentConfig(id, index < chainIds.length - 1 ? [chainIds[index + 1]] : []),
|
|
]),
|
|
]);
|
|
|
|
mockInitializeAgent.mockImplementation(({ agent }) =>
|
|
Promise.resolve(agent.id === PRIMARY_ID ? primaryConfig : nestedConfigs.get(agent.id)),
|
|
);
|
|
|
|
await expect(
|
|
initializeClient({
|
|
req: makeSubagentReq(),
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
}),
|
|
).rejects.toThrow(`maximum depth of ${MAX_SUBAGENT_DEPTH}`);
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'[initializeClient] Subagent graph depth limit exceeded',
|
|
expect.objectContaining({
|
|
primaryAgentId: PRIMARY_ID,
|
|
depth: MAX_SUBAGENT_DEPTH,
|
|
maxSubagentDepth: MAX_SUBAGENT_DEPTH,
|
|
childCount: 1,
|
|
}),
|
|
);
|
|
expect(agentClientArgs).toBeUndefined();
|
|
});
|
|
|
|
it('rejects subagent graphs that exceed MAX_SUBAGENT_GRAPH_NODES unique agents', async () => {
|
|
const firstLevelIds = Array.from({ length: 10 }, (_, index) => `agent_graph_${index}`);
|
|
const secondLevelIdsByParent = new Map(
|
|
firstLevelIds.map((id) => [
|
|
id,
|
|
Array.from({ length: 5 }, (_, index) => `${id}_child_${index}`),
|
|
]),
|
|
);
|
|
const allIds = [...firstLevelIds, ...Array.from(secondLevelIdsByParent.values()).flat()];
|
|
|
|
for (const id of allIds) {
|
|
await createViewableAgent(id);
|
|
}
|
|
|
|
const primaryConfig = makePrimaryConfig({
|
|
subagents: { enabled: true, allowSelf: false, agent_ids: firstLevelIds },
|
|
});
|
|
const nestedConfigs = new Map(
|
|
firstLevelIds.map((id) => [id, makeNestedSubagentConfig(id, secondLevelIdsByParent.get(id))]),
|
|
);
|
|
for (const id of Array.from(secondLevelIdsByParent.values()).flat()) {
|
|
nestedConfigs.set(id, makeNestedSubagentConfig(id));
|
|
}
|
|
|
|
mockInitializeAgent.mockImplementation(({ agent }) =>
|
|
Promise.resolve(agent.id === PRIMARY_ID ? primaryConfig : nestedConfigs.get(agent.id)),
|
|
);
|
|
|
|
await expect(
|
|
initializeClient({
|
|
req: makeSubagentReq(),
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
}),
|
|
).rejects.toThrow(`maximum of ${MAX_SUBAGENT_GRAPH_NODES} unique agents`);
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'[initializeClient] Subagent graph node limit exceeded',
|
|
expect.objectContaining({
|
|
primaryAgentId: PRIMARY_ID,
|
|
loadedSubagentCount: MAX_SUBAGENT_GRAPH_NODES,
|
|
maxSubagentGraphNodes: MAX_SUBAGENT_GRAPH_NODES,
|
|
}),
|
|
);
|
|
expect(agentClientArgs).toBeUndefined();
|
|
});
|
|
|
|
it('keeps an agent in `agentConfigs` when it is BOTH a handoff target and a subagent', async () => {
|
|
/** Overlap case: the same child is used both via handoff edges (needs to
|
|
* be in agentConfigs) and as a subagent (needs to be in
|
|
* subagentAgentConfigs, and its tool context preserved). The pipeline
|
|
* shouldn't silently drop it from the handoff map. */
|
|
const shared = await createAgent({
|
|
id: HANDOFF_AND_SUB_ID,
|
|
name: 'Shared Agent',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: new mongoose.Types.ObjectId(),
|
|
tools: [],
|
|
});
|
|
await grantView(shared);
|
|
|
|
const edges = [{ from: PRIMARY_ID, to: HANDOFF_AND_SUB_ID, edgeType: 'handoff' }];
|
|
const primaryConfig = makePrimaryConfig({
|
|
edges,
|
|
subagents: {
|
|
enabled: true,
|
|
allowSelf: false,
|
|
agent_ids: [HANDOFF_AND_SUB_ID],
|
|
},
|
|
});
|
|
const sharedConfig = makeSubagentConfig(HANDOFF_AND_SUB_ID);
|
|
|
|
let call = 0;
|
|
mockInitializeAgent.mockImplementation(() =>
|
|
Promise.resolve(++call === 1 ? primaryConfig : sharedConfig),
|
|
);
|
|
|
|
await initializeClient({
|
|
req: makeSubagentReq(),
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
});
|
|
|
|
expect(agentClientArgs.agent.subagentAgentConfigs).toHaveLength(1);
|
|
/** Shared agent must stay in agentConfigs — it's still the handoff target. */
|
|
expect(agentClientArgs.agentConfigs.has(HANDOFF_AND_SUB_ID)).toBe(true);
|
|
});
|
|
|
|
it('clears subagents config on primary when the capability is disabled', async () => {
|
|
/** Admin can turn subagents off at the endpoint level even if an agent was
|
|
* configured for them. The primary's `subagents` field should be
|
|
* suppressed so run.ts never builds a SubagentConfig. */
|
|
const primaryConfig = makePrimaryConfig({
|
|
subagents: { enabled: true, allowSelf: true, agent_ids: [] },
|
|
});
|
|
mockInitializeAgent.mockResolvedValue(primaryConfig);
|
|
|
|
const req = makeSubagentReq();
|
|
/** Remove the capability from the admin config. */
|
|
req.config.endpoints.agents.capabilities = [];
|
|
|
|
await initializeClient({
|
|
req,
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
});
|
|
|
|
expect(agentClientArgs.agent.subagents).toBeUndefined();
|
|
expect(agentClientArgs.agent.subagentAgentConfigs).toEqual([]);
|
|
});
|
|
|
|
it('clears subagents on handoff agents too when capability is disabled (Codex P2 regression)', async () => {
|
|
/** Codex P2: the capability gate must suppress `subagents` on EVERY
|
|
* loaded config, not just the primary. `run.ts` iterates all agents
|
|
* and calls `buildSubagentConfigs` per agent, so a handoff target
|
|
* with `subagents.enabled: true` persisted on its document would
|
|
* otherwise still expose self-spawn even when the admin has disabled
|
|
* the capability globally. */
|
|
const authorized = await createAgent({
|
|
id: AUTHORIZED_ID,
|
|
name: 'Handoff Agent',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: new mongoose.Types.ObjectId(),
|
|
tools: [],
|
|
});
|
|
await grantView(authorized);
|
|
|
|
const edges = [{ from: PRIMARY_ID, to: AUTHORIZED_ID, edgeType: 'handoff' }];
|
|
const primaryConfig = makePrimaryConfig({
|
|
edges,
|
|
subagents: { enabled: true, allowSelf: true, agent_ids: [] },
|
|
});
|
|
const handoffConfig = {
|
|
id: AUTHORIZED_ID,
|
|
endpoint: 'agents',
|
|
edges: [],
|
|
toolDefinitions: [],
|
|
toolRegistry: new Map(),
|
|
userMCPAuthMap: null,
|
|
tool_resources: {},
|
|
/** Handoff agent document has subagents enabled of its own accord. */
|
|
subagents: { enabled: true, allowSelf: true, agent_ids: [] },
|
|
};
|
|
|
|
let callCount = 0;
|
|
mockInitializeAgent.mockImplementation(() =>
|
|
Promise.resolve(++callCount === 1 ? primaryConfig : handoffConfig),
|
|
);
|
|
|
|
const req = makeSubagentReq();
|
|
/** Capability OFF at endpoint level. */
|
|
req.config.endpoints.agents.capabilities = [];
|
|
|
|
await initializeClient({
|
|
req,
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
});
|
|
|
|
/** Primary cleared. */
|
|
expect(agentClientArgs.agent.subagents).toBeUndefined();
|
|
/** Handoff target must ALSO be cleared — otherwise its self-spawn
|
|
* would still fire when run.ts iterates agentConfigs. */
|
|
const handoffLoaded = agentClientArgs.agentConfigs.get(AUTHORIZED_ID);
|
|
expect(handoffLoaded).toBeDefined();
|
|
expect(handoffLoaded.subagents).toBeUndefined();
|
|
expect(handoffLoaded.subagentAgentConfigs).toBeUndefined();
|
|
});
|
|
|
|
it('skips subagent loading entirely when the feature is disabled on the agent', async () => {
|
|
const primaryConfig = makePrimaryConfig({
|
|
subagents: { enabled: false, allowSelf: true, agent_ids: [SUBAGENT_ID] },
|
|
});
|
|
mockInitializeAgent.mockResolvedValue(primaryConfig);
|
|
|
|
await initializeClient({
|
|
req: makeSubagentReq(),
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
});
|
|
|
|
/** Only one initializeAgent call — for the primary. No subagent loaded. */
|
|
expect(mockInitializeAgent).toHaveBeenCalledTimes(1);
|
|
expect(agentClientArgs.agent.subagentAgentConfigs).toEqual([]);
|
|
});
|
|
});
|