mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-31 17:03:24 +00:00
* feat: let admins restrict stateful workspace scopes * fix: enforce stateful scope policy across agent paths * fix: close stateful scope policy activation gaps
1482 lines
48 KiB
JavaScript
1482 lines
48 KiB
JavaScript
const mongoose = require('mongoose');
|
|
const {
|
|
ResourceType,
|
|
PermissionBits,
|
|
PrincipalType,
|
|
PrincipalModel,
|
|
MAX_SUBAGENT_DEPTH,
|
|
MAX_SUBAGENT_GRAPH_NODES,
|
|
MAX_SUBAGENT_RUN_CONFIGS,
|
|
Constants,
|
|
ErrorTypes,
|
|
} = require('librechat-data-provider');
|
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
|
|
|
const mockInitializeAgent = jest.fn();
|
|
const mockValidateAgentModel = jest.fn();
|
|
|
|
function deferred() {
|
|
let resolve;
|
|
const promise = new Promise((resolvePromise) => {
|
|
resolve = resolvePromise;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
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;
|
|
let capturedDefaultHandlerOptions;
|
|
const mockArtifactToolEndCallback = jest.fn();
|
|
jest.mock('~/server/controllers/agents/callbacks', () => ({
|
|
createToolEndCallback: jest.fn(() => mockArtifactToolEndCallback),
|
|
createAttachmentEmitter: jest.fn(() => jest.fn()),
|
|
createBackgroundCodeResultHandler: jest.fn(() => jest.fn()),
|
|
getDefaultHandlers: jest.fn((opts) => {
|
|
capturedDefaultHandlerOptions = opts;
|
|
capturedToolExecuteOptions = opts?.toolExecuteOptions;
|
|
return {};
|
|
}),
|
|
}));
|
|
|
|
const mockLoadToolsForExecution = jest.fn();
|
|
jest.mock('~/server/services/ToolService', () => ({
|
|
loadAgentTools: jest.fn(),
|
|
loadToolsForExecution: (...args) => mockLoadToolsForExecution(...args),
|
|
isFatalAgentInitializationError: (error) =>
|
|
[
|
|
'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
|
|
'resource_recovery_required',
|
|
'stateful_code_environment_not_allowed',
|
|
].includes(error?.code),
|
|
}));
|
|
|
|
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 { getSkillDbMethods, getSkillToolDeps } = require('./skillDeps');
|
|
const { loadAgentTools } = require('~/server/services/ToolService');
|
|
const { getModelsConfig } = require('~/server/controllers/ModelController');
|
|
const { logger } = require('@librechat/data-schemas');
|
|
const { User, AclEntry } = require('~/db/models');
|
|
const { createAgent, createSkill, updateAgent } = require('~/models');
|
|
const db = 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;
|
|
capturedDefaultHandlerOptions = 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,
|
|
codeExecutionContext: {
|
|
baseUrl: 'https://code-default.example.com',
|
|
codeSessionKey: 'execute_code',
|
|
executionProfile: 'default',
|
|
statefulSessions: false,
|
|
},
|
|
});
|
|
|
|
it('replaces untrusted artifact route metadata with the executing agent context', async () => {
|
|
mockInitializeAgent.mockResolvedValue(makePrimaryConfig([]));
|
|
|
|
await initializeClient({
|
|
req: makeReq(),
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
});
|
|
|
|
const data = { output: { name: 'execute_code', artifact: { files: [] } } };
|
|
await capturedDefaultHandlerOptions.toolEndCallback(data, {
|
|
langgraph_node: `tools=${PRIMARY_ID}`,
|
|
codeExecutionContext: {
|
|
baseUrl: 'https://attacker.invalid',
|
|
executionProfile: 'stateful',
|
|
},
|
|
});
|
|
|
|
expect(mockArtifactToolEndCallback).toHaveBeenLastCalledWith(
|
|
data,
|
|
expect.objectContaining({
|
|
codeExecutionContext: expect.objectContaining({
|
|
baseUrl: 'https://code-default.example.com',
|
|
executionProfile: 'default',
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('threads the owning job epoch into resumable event handlers', async () => {
|
|
const {
|
|
createAttachmentEmitter,
|
|
createToolEndCallback,
|
|
} = require('~/server/controllers/agents/callbacks');
|
|
mockInitializeAgent.mockResolvedValue(makePrimaryConfig([]));
|
|
const req = makeReq();
|
|
req._resumableStreamId = 'conv_1';
|
|
|
|
await initializeClient({
|
|
req,
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
jobCreatedAt: 1234,
|
|
});
|
|
|
|
expect(capturedDefaultHandlerOptions).toEqual(
|
|
expect.objectContaining({
|
|
streamId: 'conv_1',
|
|
jobCreatedAt: 1234,
|
|
}),
|
|
);
|
|
expect(createToolEndCallback).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
streamId: 'conv_1',
|
|
jobCreatedAt: 1234,
|
|
}),
|
|
);
|
|
expect(createAttachmentEmitter).toHaveBeenCalledWith({
|
|
res: {},
|
|
streamId: 'conv_1',
|
|
jobCreatedAt: 1234,
|
|
});
|
|
|
|
mockLoadToolsForExecution.mockResolvedValue({ loadedTools: [], configurable: {} });
|
|
await capturedToolExecuteOptions.loadTools([], PRIMARY_ID);
|
|
expect(mockLoadToolsForExecution).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
streamId: 'conv_1',
|
|
jobCreatedAt: 1234,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('propagates an expected-MCP-tools failure from the runtime tool loader', async () => {
|
|
const toolError = Object.assign(new Error('Expected MCP tools are unavailable'), {
|
|
code: 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
|
|
statusCode: 503,
|
|
});
|
|
loadAgentTools.mockRejectedValueOnce(toolError);
|
|
mockInitializeAgent.mockImplementationOnce(async ({ req, res, loadTools, agent }) => {
|
|
await loadTools({
|
|
req,
|
|
res,
|
|
tools: ['run_query_mcp_warehouse'],
|
|
model: agent.model,
|
|
agentId: agent.id,
|
|
provider: agent.provider,
|
|
});
|
|
return makePrimaryConfig([]);
|
|
});
|
|
|
|
await expect(
|
|
initializeClient({
|
|
req: makeReq(),
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
}),
|
|
).rejects.toBe(toolError);
|
|
});
|
|
|
|
it('aborts the run when a handoff target resolves none of its expected MCP tools', async () => {
|
|
const target = await createAgent({
|
|
id: AUTHORIZED_ID,
|
|
name: 'Target Agent',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: new mongoose.Types.ObjectId(),
|
|
tools: ['run_query_mcp_warehouse'],
|
|
});
|
|
await AclEntry.create({
|
|
principalType: PrincipalType.USER,
|
|
principalId: testUser._id,
|
|
principalModel: PrincipalModel.USER,
|
|
resourceType: ResourceType.AGENT,
|
|
resourceId: target._id,
|
|
permBits: PermissionBits.VIEW,
|
|
grantedBy: testUser._id,
|
|
});
|
|
|
|
const toolError = Object.assign(new Error('Target Agent expected MCP tools are unavailable'), {
|
|
code: 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
|
|
statusCode: 503,
|
|
});
|
|
loadAgentTools.mockRejectedValueOnce(toolError);
|
|
mockInitializeAgent
|
|
.mockResolvedValueOnce(
|
|
makePrimaryConfig([{ from: PRIMARY_ID, to: AUTHORIZED_ID, edgeType: 'handoff' }]),
|
|
)
|
|
.mockImplementationOnce(async ({ req, res, loadTools, agent }) => {
|
|
await loadTools({
|
|
req,
|
|
res,
|
|
tools: agent.tools,
|
|
model: agent.model,
|
|
agentId: agent.id,
|
|
provider: agent.provider,
|
|
});
|
|
});
|
|
|
|
await expect(
|
|
initializeClient({
|
|
req: makeReq(),
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
}),
|
|
).rejects.toBe(toolError);
|
|
});
|
|
|
|
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 requestAttachment = { file_id: 'request_file', filename: 'request.txt' };
|
|
const primaryContextAttachment = { file_id: 'primary_context', filename: 'primary.txt' };
|
|
const handoffContextAttachment = { file_id: 'handoff_context', filename: 'handoff.txt' };
|
|
const primaryConfig = {
|
|
...makePrimaryConfig(edges),
|
|
attachments: [primaryContextAttachment, requestAttachment],
|
|
requestAttachments: [requestAttachment],
|
|
agentContextAttachments: [primaryContextAttachment],
|
|
};
|
|
const handoffConfig = {
|
|
id: AUTHORIZED_ID,
|
|
edges: [],
|
|
toolDefinitions: [],
|
|
toolRegistry: new Map(),
|
|
userMCPAuthMap: null,
|
|
tool_resources: {},
|
|
agentContextAttachments: [handoffContextAttachment],
|
|
};
|
|
|
|
let callCount = 0;
|
|
mockInitializeAgent.mockImplementation(() => {
|
|
callCount++;
|
|
return callCount === 1 ? Promise.resolve(primaryConfig) : 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);
|
|
expect(agentClientArgs.attachments).toEqual([requestAttachment]);
|
|
expect(agentClientArgs.agentContextAttachmentsByAgentId.get(PRIMARY_ID)).toEqual([
|
|
primaryContextAttachment,
|
|
]);
|
|
expect(agentClientArgs.agentContextAttachmentsByAgentId.get(AUTHORIZED_ID)).toEqual([
|
|
handoffContextAttachment,
|
|
]);
|
|
});
|
|
|
|
it('does not enable skill authoring for VIEW-only shared skills', async () => {
|
|
const { skill } = await createSkill({
|
|
name: 'shared-view-only',
|
|
description: 'Use for read-only sharing.',
|
|
body: '# Shared view-only skill\n',
|
|
author: new mongoose.Types.ObjectId(),
|
|
authorName: 'Skill Owner',
|
|
});
|
|
await AclEntry.create({
|
|
principalType: PrincipalType.USER,
|
|
principalId: testUser._id,
|
|
principalModel: PrincipalModel.USER,
|
|
resourceType: ResourceType.SKILL,
|
|
resourceId: skill._id,
|
|
permBits: PermissionBits.VIEW,
|
|
grantedBy: testUser._id,
|
|
});
|
|
|
|
const endpointOption = makeEndpointOption();
|
|
endpointOption.agent = Promise.resolve({
|
|
id: PRIMARY_ID,
|
|
name: 'Primary',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
tools: [],
|
|
skills_enabled: true,
|
|
});
|
|
mockInitializeAgent.mockResolvedValue(makePrimaryConfig([]));
|
|
const req = makeReq();
|
|
req.config.endpoints.agents = { capabilities: ['skills'] };
|
|
const canCreateSkillSpy = jest
|
|
.spyOn(getSkillToolDeps(), 'canCreateSkill')
|
|
.mockResolvedValue(false);
|
|
|
|
try {
|
|
await initializeClient({
|
|
req,
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption,
|
|
});
|
|
} finally {
|
|
canCreateSkillSpy.mockRestore();
|
|
}
|
|
|
|
const initializeParams = mockInitializeAgent.mock.calls[0][0];
|
|
expect(initializeParams.accessibleSkillIds.map(String)).toContain(skill._id.toString());
|
|
expect(initializeParams.skillAuthoringAvailable).toBe(false);
|
|
});
|
|
|
|
it('enables skill authoring when model specs enable skills for an ephemeral agent', async () => {
|
|
const endpointOption = makeEndpointOption();
|
|
endpointOption.spec = 'spec-skills';
|
|
endpointOption.agent = Promise.resolve({
|
|
id: Constants.EPHEMERAL_AGENT_ID,
|
|
name: 'Ephemeral Primary',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
tools: [],
|
|
});
|
|
mockInitializeAgent.mockResolvedValue(makePrimaryConfig([]));
|
|
const req = makeReq();
|
|
req.config.endpoints.agents = { capabilities: ['skills'] };
|
|
req.config.modelSpecs = {
|
|
list: [{ name: 'spec-skills', skills: true }],
|
|
};
|
|
const canCreateSkillSpy = jest
|
|
.spyOn(getSkillToolDeps(), 'canCreateSkill')
|
|
.mockResolvedValue(true);
|
|
|
|
try {
|
|
await initializeClient({
|
|
req,
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption,
|
|
});
|
|
} finally {
|
|
canCreateSkillSpy.mockRestore();
|
|
}
|
|
|
|
const initializeParams = mockInitializeAgent.mock.calls[0][0];
|
|
expect(initializeParams.agent.skills_enabled).toBe(true);
|
|
expect(initializeParams.skillAuthoringAvailable).toBe(true);
|
|
});
|
|
|
|
it('loads model validation and skill permissions without serial waits', async () => {
|
|
const models = deferred();
|
|
const createPermission = deferred();
|
|
const req = makeReq();
|
|
req.config.endpoints.agents = { capabilities: ['skills'] };
|
|
mockInitializeAgent.mockResolvedValue(makePrimaryConfig([]));
|
|
getModelsConfig.mockReturnValueOnce(models.promise);
|
|
const canCreateSkillSpy = jest
|
|
.spyOn(getSkillToolDeps(), 'canCreateSkill')
|
|
.mockReturnValue(createPermission.promise);
|
|
|
|
try {
|
|
const initialization = initializeClient({
|
|
req,
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
});
|
|
|
|
expect(getModelsConfig).toHaveBeenCalledWith(req);
|
|
expect(canCreateSkillSpy).toHaveBeenCalledWith({ req });
|
|
|
|
models.resolve({});
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
expect(mockValidateAgentModel).toHaveBeenCalledTimes(1);
|
|
expect(mockInitializeAgent).not.toHaveBeenCalled();
|
|
|
|
createPermission.resolve(false);
|
|
await initialization;
|
|
expect(mockInitializeAgent).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
canCreateSkillSpy.mockRestore();
|
|
}
|
|
});
|
|
|
|
it('resolves model-spec skill names through deployment-aware skill methods', async () => {
|
|
const deploymentSkillId = new mongoose.Types.ObjectId();
|
|
await AclEntry.create({
|
|
principalType: PrincipalType.USER,
|
|
principalId: testUser._id,
|
|
principalModel: PrincipalModel.USER,
|
|
resourceType: ResourceType.SKILL,
|
|
resourceId: new mongoose.Types.ObjectId(),
|
|
permBits: PermissionBits.VIEW,
|
|
grantedBy: testUser._id,
|
|
});
|
|
const endpointOption = makeEndpointOption();
|
|
endpointOption.spec = 'spec-deployment-skill';
|
|
endpointOption.agent = Promise.resolve({
|
|
id: Constants.EPHEMERAL_AGENT_ID,
|
|
name: 'Ephemeral Primary',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
tools: [],
|
|
});
|
|
mockInitializeAgent.mockResolvedValue(makePrimaryConfig([]));
|
|
const req = makeReq();
|
|
req.config.endpoints.agents = { capabilities: ['skills'] };
|
|
req.config.modelSpecs = {
|
|
list: [{ name: 'spec-deployment-skill', skills: ['deployment-skill'] }],
|
|
};
|
|
const getSkillByNameSpy = jest.spyOn(getSkillDbMethods(), 'getSkillByName').mockResolvedValue({
|
|
_id: deploymentSkillId,
|
|
name: 'deployment-skill',
|
|
source: 'deployment',
|
|
});
|
|
const canCreateSkillSpy = jest
|
|
.spyOn(getSkillToolDeps(), 'canCreateSkill')
|
|
.mockResolvedValue(false);
|
|
|
|
try {
|
|
await initializeClient({
|
|
req,
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption,
|
|
});
|
|
|
|
expect(getSkillByNameSpy).toHaveBeenCalledWith(
|
|
'deployment-skill',
|
|
expect.any(Array),
|
|
expect.any(Object),
|
|
);
|
|
const initializeParams = mockInitializeAgent.mock.calls[0][0];
|
|
expect(initializeParams.agent.skills_enabled).toBe(true);
|
|
expect(initializeParams.agent.skills).toEqual([deploymentSkillId.toString()]);
|
|
} finally {
|
|
getSkillByNameSpy.mockRestore();
|
|
canCreateSkillSpy.mockRestore();
|
|
}
|
|
});
|
|
});
|
|
|
|
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, subagents) => {
|
|
const agent = await createAgent({
|
|
id,
|
|
name: id,
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: new mongoose.Types.ObjectId(),
|
|
tools: [],
|
|
subagents,
|
|
});
|
|
await grantView(agent);
|
|
return agent;
|
|
};
|
|
|
|
it('defers pure-subagent MCP initialization until the descriptor is selected', async () => {
|
|
const subAgent = await createAgent({
|
|
id: SUBAGENT_ID,
|
|
name: 'Data Subagent',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: new mongoose.Types.ObjectId(),
|
|
tools: ['run_query_mcp_warehouse'],
|
|
});
|
|
await grantView(subAgent);
|
|
|
|
const toolError = Object.assign(new Error('Subagent expected MCP tools are unavailable'), {
|
|
code: 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
|
|
statusCode: 503,
|
|
});
|
|
loadAgentTools.mockRejectedValueOnce(toolError);
|
|
mockInitializeAgent
|
|
.mockResolvedValueOnce(
|
|
makePrimaryConfig({
|
|
subagents: { enabled: true, allowSelf: true, agent_ids: [SUBAGENT_ID] },
|
|
}),
|
|
)
|
|
.mockImplementationOnce(async ({ req, res, loadTools, agent }) => {
|
|
await loadTools({
|
|
req,
|
|
res,
|
|
tools: agent.tools,
|
|
model: agent.model,
|
|
agentId: agent.id,
|
|
provider: agent.provider,
|
|
});
|
|
});
|
|
|
|
await initializeClient({
|
|
req: makeSubagentReq(),
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
});
|
|
|
|
expect(mockInitializeAgent).toHaveBeenCalledTimes(1);
|
|
await expect(
|
|
agentClientArgs.agent.lazySubagentConfigs[0].resolve({
|
|
signal: new AbortController().signal,
|
|
}),
|
|
).rejects.toBe(toolError);
|
|
});
|
|
|
|
it('defers pure-subagent resource recovery until the descriptor is selected', async () => {
|
|
const subAgent = await createAgent({
|
|
id: SUBAGENT_ID,
|
|
name: 'Code Subagent',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: new mongoose.Types.ObjectId(),
|
|
tools: ['execute_code'],
|
|
});
|
|
await grantView(subAgent);
|
|
|
|
const resourceRecoveryError = Object.assign(new Error('resource recovery required'), {
|
|
code: ErrorTypes.RESOURCE_RECOVERY_REQUIRED,
|
|
status: 409,
|
|
statusCode: 409,
|
|
});
|
|
mockInitializeAgent
|
|
.mockResolvedValueOnce(
|
|
makePrimaryConfig({
|
|
subagents: { enabled: true, allowSelf: true, agent_ids: [SUBAGENT_ID] },
|
|
}),
|
|
)
|
|
.mockRejectedValueOnce(resourceRecoveryError);
|
|
|
|
await initializeClient({
|
|
req: makeSubagentReq(),
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
});
|
|
|
|
await expect(
|
|
agentClientArgs.agent.lazySubagentConfigs[0].resolve({
|
|
signal: new AbortController().signal,
|
|
}),
|
|
).rejects.toBe(resourceRecoveryError);
|
|
});
|
|
|
|
it('advertises a configured subagent without initializing it, then initializes it on selection', async () => {
|
|
const subAgent = await createAgent({
|
|
id: SUBAGENT_ID,
|
|
name: 'Explicit Subagent',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: new mongoose.Types.ObjectId(),
|
|
tools: ['web'],
|
|
stateful_code_environment: 'agent-user',
|
|
});
|
|
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(1);
|
|
expect(agentClientArgs.agent.lazySubagentConfigs).toHaveLength(1);
|
|
expect(agentClientArgs.agent.lazySubagentConfigs[0]).toEqual(
|
|
expect.objectContaining({
|
|
id: SUBAGENT_ID,
|
|
configId: expect.any(String),
|
|
statefulCodeEnvironment: 'agent-user',
|
|
}),
|
|
);
|
|
expect(agentClientArgs.agent.lazySubagentConfigs[0]).not.toHaveProperty('tools');
|
|
expect(agentClientArgs.agent.lazySubagentConfigs[0]).not.toHaveProperty('tool_resources');
|
|
expect(agentClientArgs.agent.lazySubagentConfigs[0]).not.toHaveProperty('_id');
|
|
|
|
await agentClientArgs.agent.lazySubagentConfigs[0].resolve({
|
|
signal: new AbortController().signal,
|
|
});
|
|
|
|
expect(mockInitializeAgent).toHaveBeenCalledTimes(2);
|
|
const subagentDbDeps = mockInitializeAgent.mock.calls[1][1];
|
|
expect(subagentDbDeps.getConvoFiles).toBeInstanceOf(Function);
|
|
expect(subagentDbDeps.db).toBeUndefined();
|
|
expect(subagentConvoFileIds).toEqual([]);
|
|
|
|
/** 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('rejects a disallowed lazy subagent scope before exposing it for prewarm', async () => {
|
|
const subAgent = await createAgent({
|
|
id: SUBAGENT_ID,
|
|
name: 'Disallowed Stateful Subagent',
|
|
provider: 'openai',
|
|
model: 'gpt-4',
|
|
author: new mongoose.Types.ObjectId(),
|
|
tools: ['execute_code'],
|
|
stateful_code_sessions: true,
|
|
stateful_code_environment: 'conversation',
|
|
});
|
|
await grantView(subAgent);
|
|
mockInitializeAgent.mockResolvedValue(
|
|
makePrimaryConfig({
|
|
subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID] },
|
|
}),
|
|
);
|
|
const req = makeSubagentReq();
|
|
req.config.endpoints.agents.capabilities.push('execute_code', 'stateful_code_sessions');
|
|
req.config.endpoints.agents.statefulCodeSessions = { allowedEnvironments: ['user'] };
|
|
|
|
await expect(
|
|
initializeClient({
|
|
req,
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
}),
|
|
).rejects.toMatchObject({
|
|
code: ErrorTypes.STATEFUL_CODE_ENVIRONMENT_NOT_ALLOWED,
|
|
});
|
|
|
|
expect(agentClientArgs).toBeUndefined();
|
|
expect(mockInitializeAgent).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('omits a descriptor when its metadata lookup fails without aborting the primary run', async () => {
|
|
const primaryConfig = makePrimaryConfig({
|
|
subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID] },
|
|
});
|
|
mockInitializeAgent.mockResolvedValue(primaryConfig);
|
|
jest.spyOn(db, 'getAgentWithVersionCount').mockRejectedValueOnce(new Error('transient read'));
|
|
|
|
await initializeClient({
|
|
req: makeSubagentReq(),
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
});
|
|
|
|
expect(agentClientArgs.agent.lazySubagentConfigs).toEqual([]);
|
|
expect(logger.error).toHaveBeenCalledWith(
|
|
expect.stringContaining(`Error loading subagent metadata ${SUBAGENT_ID}`),
|
|
expect.any(Error),
|
|
);
|
|
});
|
|
|
|
it('uses exact custom-endpoint identity for descriptor reasoning history', async () => {
|
|
const subAgent = await createAgent({
|
|
id: SUBAGENT_ID,
|
|
name: 'Custom Subagent',
|
|
provider: 'caseprovider',
|
|
model: 'custom-model',
|
|
author: new mongoose.Types.ObjectId(),
|
|
tools: [],
|
|
});
|
|
await grantView(subAgent);
|
|
mockInitializeAgent.mockResolvedValue(
|
|
makePrimaryConfig({
|
|
subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID] },
|
|
}),
|
|
);
|
|
const req = makeSubagentReq();
|
|
req.config.endpoints.custom = [
|
|
{ name: 'CaseProvider', customParams: { includeReasoningHistory: true } },
|
|
{ name: 'caseprovider', customParams: { includeReasoningHistory: false } },
|
|
];
|
|
|
|
await initializeClient({
|
|
req,
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
});
|
|
|
|
expect(agentClientArgs.agent.lazySubagentConfigs[0].includeReasoningHistory).toBe(false);
|
|
});
|
|
|
|
it('fails closed when a selected subagent configuration changes after advertisement', async () => {
|
|
await createViewableAgent(SUBAGENT_ID);
|
|
const primaryConfig = makePrimaryConfig({
|
|
subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID] },
|
|
});
|
|
mockInitializeAgent.mockResolvedValue(primaryConfig);
|
|
|
|
await initializeClient({
|
|
req: makeSubagentReq(),
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
});
|
|
await updateAgent({ id: SUBAGENT_ID }, { instructions: 'Changed after descriptor creation.' });
|
|
|
|
await expect(
|
|
agentClientArgs.agent.lazySubagentConfigs[0].resolve({
|
|
signal: new AbortController().signal,
|
|
}),
|
|
).rejects.toThrow(`Subagent ${SUBAGENT_ID} changed`);
|
|
expect(mockInitializeAgent).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('rechecks VIEW permission when a descriptor is selected', async () => {
|
|
const subAgent = await createViewableAgent(SUBAGENT_ID);
|
|
const primaryConfig = makePrimaryConfig({
|
|
subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID] },
|
|
});
|
|
mockInitializeAgent.mockResolvedValue(primaryConfig);
|
|
|
|
await initializeClient({
|
|
req: makeSubagentReq(),
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
});
|
|
await AclEntry.deleteMany({ resourceId: subAgent._id });
|
|
|
|
await expect(
|
|
agentClientArgs.agent.lazySubagentConfigs[0].resolve({
|
|
signal: new AbortController().signal,
|
|
}),
|
|
).rejects.toThrow(`no longer have access to subagent ${SUBAGENT_ID}`);
|
|
expect(mockInitializeAgent).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
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(),
|
|
});
|
|
|
|
await agentClientArgs.agent.lazySubagentConfigs[0].resolve({
|
|
signal: new AbortController().signal,
|
|
});
|
|
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('threads run-scoped MCP tool definitions into ON_TOOL_EXECUTE loading', async () => {
|
|
/** Regression guard for the request-scoped MCP/PTC handoff: the
|
|
* `mcpAvailableTools` discovered at run start must survive
|
|
* `buildAgentToolContext` and reach `loadToolsForExecution`, otherwise
|
|
* request-scoped servers reinitialize on every programmatic tool call
|
|
* and can trip the MCP circuit breaker under parallel calls. */
|
|
const mcpTool = 'list_tables_mcp_ClickHouse';
|
|
const mcpAvailableTools = {
|
|
ClickHouse: {
|
|
[mcpTool]: {
|
|
type: 'function',
|
|
function: {
|
|
name: mcpTool,
|
|
description: 'List tables',
|
|
parameters: { type: 'object', properties: {} },
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const primaryConfig = {
|
|
...makePrimaryConfig({}),
|
|
toolRegistry: new Map([[mcpTool, { name: mcpTool }]]),
|
|
mcpAvailableTools,
|
|
};
|
|
mockInitializeAgent.mockResolvedValue(primaryConfig);
|
|
|
|
await initializeClient({
|
|
req: makeSubagentReq(),
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
});
|
|
|
|
expect(capturedToolExecuteOptions?.loadTools).toBeInstanceOf(Function);
|
|
await capturedToolExecuteOptions.loadTools([mcpTool], PRIMARY_ID);
|
|
|
|
expect(mockLoadToolsForExecution).toHaveBeenCalledTimes(1);
|
|
expect(mockLoadToolsForExecution).toHaveBeenCalledWith(
|
|
expect.objectContaining({ mcpAvailableTools }),
|
|
);
|
|
});
|
|
|
|
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],
|
|
},
|
|
});
|
|
mockInitializeAgent.mockResolvedValue(primaryConfig);
|
|
|
|
await initializeClient({
|
|
req: makeSubagentReq(),
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
});
|
|
|
|
/** Repeated ids produce one lightweight descriptor and no eager child initialization. */
|
|
expect(mockInitializeAgent).toHaveBeenCalledTimes(1);
|
|
expect(agentClientArgs.agent.lazySubagentConfigs).toHaveLength(1);
|
|
});
|
|
|
|
it('does not initialize a descriptor after its SDK cancellation signal aborts', async () => {
|
|
await createViewableAgent(SUBAGENT_ID);
|
|
const primaryConfig = makePrimaryConfig({
|
|
subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID] },
|
|
});
|
|
mockInitializeAgent.mockResolvedValue(primaryConfig);
|
|
|
|
await initializeClient({
|
|
req: makeSubagentReq(),
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
});
|
|
const controller = new AbortController();
|
|
controller.abort(new Error('cancelled'));
|
|
|
|
await expect(
|
|
agentClientArgs.agent.lazySubagentConfigs[0].resolve({ signal: controller.signal }),
|
|
).rejects.toThrow('cancelled');
|
|
expect(mockInitializeAgent).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('rejects promptly when cancellation occurs during lazy initialization', async () => {
|
|
await createViewableAgent(SUBAGENT_ID);
|
|
const primaryConfig = makePrimaryConfig({
|
|
subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID] },
|
|
});
|
|
const initialization = deferred();
|
|
const initializationStarted = deferred();
|
|
mockInitializeAgent.mockResolvedValueOnce(primaryConfig).mockImplementationOnce((params) => {
|
|
initializationStarted.resolve(params);
|
|
return initialization.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,
|
|
});
|
|
const selectedInitParams = await initializationStarted.promise;
|
|
controller.abort(new Error('cancelled in flight'));
|
|
|
|
await expect(resolution).rejects.toThrow('cancelled in flight');
|
|
expect(selectedInitParams.loadTools).not.toBe(mockInitializeAgent.mock.calls[0][0].loadTools);
|
|
initialization.resolve(makeSubagentConfig(SUBAGENT_ID));
|
|
});
|
|
|
|
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 [index, id] of ids.entries()) {
|
|
await createViewableAgent(id, {
|
|
enabled: true,
|
|
allowSelf: false,
|
|
agent_ids: index < ids.length - 1 ? [ids[index + 1]] : [],
|
|
});
|
|
}
|
|
|
|
const primaryConfig = makePrimaryConfig({
|
|
subagents: { enabled: true, allowSelf: false, agent_ids: [ids[0]] },
|
|
});
|
|
mockInitializeAgent.mockResolvedValue(primaryConfig);
|
|
|
|
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: ids[MAX_SUBAGENT_DEPTH - 1],
|
|
primaryAgentId: PRIMARY_ID,
|
|
depth: MAX_SUBAGENT_DEPTH,
|
|
maxSubagentDepth: MAX_SUBAGENT_DEPTH,
|
|
}),
|
|
);
|
|
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}`,
|
|
);
|
|
await createViewableAgent(HANDOFF_AND_SUB_ID, {
|
|
enabled: true,
|
|
allowSelf: false,
|
|
agent_ids: [chainIds[0]],
|
|
});
|
|
for (const [index, id] of chainIds.entries()) {
|
|
await createViewableAgent(id, {
|
|
enabled: true,
|
|
allowSelf: false,
|
|
agent_ids: index < chainIds.length - 1 ? [chainIds[index + 1]] : [],
|
|
});
|
|
}
|
|
|
|
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 = makeNestedSubagentConfig(HANDOFF_AND_SUB_ID, [chainIds[0]]);
|
|
mockInitializeAgent.mockImplementation(({ agent }) =>
|
|
Promise.resolve(agent.id === PRIMARY_ID ? primaryConfig : sharedConfig),
|
|
);
|
|
|
|
await expect(
|
|
initializeClient({
|
|
req: makeSubagentReq(),
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
}),
|
|
).rejects.toThrow(`maximum depth of ${MAX_SUBAGENT_DEPTH}`);
|
|
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}`),
|
|
]),
|
|
);
|
|
for (const id of firstLevelIds) {
|
|
await createViewableAgent(id, {
|
|
enabled: true,
|
|
allowSelf: false,
|
|
agent_ids: secondLevelIdsByParent.get(id),
|
|
});
|
|
}
|
|
for (const id of Array.from(secondLevelIdsByParent.values()).flat()) {
|
|
await createViewableAgent(id, { enabled: true, allowSelf: false, agent_ids: [] });
|
|
}
|
|
|
|
const primaryConfig = makePrimaryConfig({
|
|
subagents: { enabled: true, allowSelf: false, agent_ids: firstLevelIds },
|
|
});
|
|
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(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('rejects a branching DAG that exceeds expanded descriptor capacity', async () => {
|
|
const width = 3;
|
|
const layers = Array.from({ length: MAX_SUBAGENT_DEPTH }, (_, level) =>
|
|
Array.from({ length: width }, (_, index) => `agent_descriptor_${level}_${index}`),
|
|
);
|
|
for (let level = 0; level < layers.length; level++) {
|
|
const childIds = level < layers.length - 1 ? layers[level + 1] : [];
|
|
for (const id of layers[level]) {
|
|
await createViewableAgent(id, { enabled: true, allowSelf: false, agent_ids: childIds });
|
|
}
|
|
}
|
|
|
|
const primaryConfig = makePrimaryConfig({
|
|
subagents: { enabled: true, allowSelf: false, agent_ids: layers[0] },
|
|
});
|
|
mockInitializeAgent.mockResolvedValue(primaryConfig);
|
|
|
|
await expect(
|
|
initializeClient({
|
|
req: makeSubagentReq(),
|
|
res: {},
|
|
signal: new AbortController().signal,
|
|
endpointOption: makeEndpointOption(),
|
|
}),
|
|
).rejects.toThrow(`maximum of ${MAX_SUBAGENT_RUN_CONFIGS} expanded entries`);
|
|
expect(logger.warn).toHaveBeenCalledWith(
|
|
'[initializeClient] Subagent run configuration limit exceeded',
|
|
expect.objectContaining({
|
|
expandedConfigCount: MAX_SUBAGENT_RUN_CONFIGS + 1,
|
|
maxSubagentRunConfigs: MAX_SUBAGENT_RUN_CONFIGS,
|
|
rootAgentIds: [PRIMARY_ID],
|
|
}),
|
|
);
|
|
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([]);
|
|
});
|
|
});
|