🪶 feat: Resolve Explicit Subagents Lazily (#14714)

* feat: resolve explicit subagents lazily

* fix: satisfy lazy subagent type checks

* test: persist lazy subagent mutation through model API

* style: format lazy subagent persistence test

* fix: log lazy subagent depth limit failures

* fix: harden lazy subagent resolution

* fix: Yield during lazy cancellation test

* test: Synchronize lazy cancellation setup

* style: Format lazy cancellation test
This commit is contained in:
Danny Avila 2026-08-09 19:23:45 -04:00 committed by GitHub
parent 6bff5ba148
commit 54d7f04d71
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 999 additions and 359 deletions

View file

@ -10,12 +10,14 @@ const {
extractManualSkills,
GenerationJobManager,
getCustomEndpointConfig,
getProviderConfig,
discoverConnectedAgents,
resolveAgentTokenConfig,
resolveAgentScopedSkillIds,
resolveModelSpecSkillIds,
getAgentStartupTelemetry,
buildAgentContextAttachmentsByAgentId,
getLazySubagentConfigId,
} = require('@librechat/api');
const {
Permissions,
@ -27,7 +29,9 @@ const {
isAgentsEndpoint,
getResponseSender,
AgentCapabilities,
Tools,
MAX_SUBAGENT_GRAPH_NODES,
MAX_SUBAGENT_RUN_CONFIGS,
isEphemeralAgentId,
} = require('librechat-data-provider');
const {
@ -275,6 +279,8 @@ const initializeClient = async ({
* }>}
*/
const agentToolContexts = new Map();
/** @type {Map<string, import('@librechat/api').EndpointTokenConfig | undefined>} */
const endpointTokenConfigByAgentId = new Map();
const toolExecuteOptions = {
loadTools: async (toolNames, agentId) => {
@ -652,105 +658,213 @@ const initializeClient = async ({
// further normalization is needed before handing this to `createRun`.
primaryConfig.edges = edges;
// Subagents: load any explicit subagent configs. Subagents run in isolated
// context windows and are invoked via a dedicated spawn tool (not handoff
// edges). An agent that is ONLY referenced as a subagent is dropped from
// `agentConfigs` so the LangGraph pipeline doesn't treat it as a
// parallel/handoff node, but it is KEPT in `agentToolContexts` — the child's
// `ON_TOOL_EXECUTE` dispatches resolve tool execution context (agent,
// tool_resources, skill ACLs, ...) from that map, so removing it would leave
// action tools skipped and resource-scoped tools running without their
// configured resources.
// Subagents run in isolated context windows and are invoked via a dedicated
// spawn tool, not handoff edges. Explicit children are advertised as inert,
// VIEW-checked descriptors; model, tool, MCP, file, and skill initialization
// happens only when the SDK selects one.
const subagentsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.subagents);
/** Track skipped ids locally so repeated failures short-circuit within
* the subagent loading loop. Seeded from the discovery helper's skip
* list so agents that already failed handoff loading don't get retried. */
const skippedAgentIds = new Set(discoveredSkippedIds ?? []);
/** All agent ids referenced on any edge (source OR target). Used by
* `loadSubagentsFor` to decide whether an agent that's only a subagent
* can be safely dropped from `agentConfigs` LangGraph doesn't treat
* pure subagents as parallel/handoff nodes. */
const edgeAgentIds = new Set([primaryConfig.id]);
for (const edge of edges ?? []) {
const sources = Array.isArray(edge.from) ? edge.from : [edge.from];
const targets = Array.isArray(edge.to) ? edge.to : [edge.to];
for (const id of sources) {
if (typeof id === 'string') edgeAgentIds.add(id);
}
for (const id of targets) {
if (typeof id === 'string') edgeAgentIds.add(id);
}
}
const lazyMetadataByAgentId = new Map();
const subagentGraphIds = new Set();
const expandedSubagentDescriptorState = { configCount: 0, rootAgentIds: [] };
/** Lazy per-id agent loader used for subagents that weren't reachable
* via the handoff edge graph (so `discoverConnectedAgents` didn't
* initialize them). Mirrors the helper's internal `processAgent`:
* DB lookup + VIEW check + `initializeAgent`, then inserts into
* `agentConfigs` and `agentToolContexts`. Returns `null` on any
* failure so the caller can skip gracefully. */
const loadAgentById = async (agentId) => {
if (skippedAgentIds.has(agentId)) return null;
const existing = agentConfigs.get(agentId);
if (existing) return existing;
const assertSubagentGraphRoom = (agentId) => {
if (subagentGraphIds.has(agentId)) {
return;
}
if (subagentGraphIds.size >= MAX_SUBAGENT_GRAPH_NODES) {
logger.warn('[initializeClient] Subagent graph node limit exceeded', {
agentId,
primaryAgentId: primaryConfig.id,
loadedSubagentCount: subagentGraphIds.size,
maxSubagentGraphNodes: MAX_SUBAGENT_GRAPH_NODES,
});
throw new Error(
`Subagent graph exceeds the maximum of ${MAX_SUBAGENT_GRAPH_NODES} unique agents.`,
);
}
};
try {
const agent = await db.getAgent({ id: agentId });
if (!agent) {
skippedAgentIds.add(agentId);
return null;
const countExpandedSubagentDescriptor = (agentId) => {
expandedSubagentDescriptorState.configCount += 1;
if (expandedSubagentDescriptorState.configCount <= MAX_SUBAGENT_RUN_CONFIGS) {
return;
}
logger.warn('[initializeClient] Subagent run configuration limit exceeded', {
agentId,
expandedConfigCount: expandedSubagentDescriptorState.configCount,
maxSubagentRunConfigs: MAX_SUBAGENT_RUN_CONFIGS,
rootAgentIds: expandedSubagentDescriptorState.rootAgentIds,
});
throw new Error(
`Subagent run configuration exceeds the maximum of ${MAX_SUBAGENT_RUN_CONFIGS} expanded entries.`,
);
};
const userId = req.user?.id;
const userRole = req.user?.role;
const throwIfAborted = (abortSignal) => {
if (!abortSignal?.aborted) return;
throw abortSignal.reason instanceof Error
? abortSignal.reason
: new Error('Subagent resolution was aborted.');
};
const waitForAbort = (promise, abortSignal) => {
throwIfAborted(abortSignal);
if (!abortSignal) return promise;
return new Promise((resolve, reject) => {
const onAbort = () => {
reject(
abortSignal.reason instanceof Error
? abortSignal.reason
: new Error('Subagent resolution was aborted.'),
);
};
abortSignal.addEventListener('abort', onAbort, { once: true });
if (abortSignal.aborted) {
onAbort();
}
const userId = req.user?.id;
if (!userId) {
skippedAgentIds.add(agentId);
return null;
}
const hasAccess = await checkPermission({
promise.then(resolve, reject).finally(() => {
abortSignal.removeEventListener('abort', onAbort);
});
});
};
const hasSubagentViewAccess = async (agent, agentId, abortSignal) => {
throwIfAborted(abortSignal);
if (!userId) return false;
const hasAccess = await waitForAbort(
checkPermission({
userId,
role: req.user?.role,
role: userRole,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
requiredPermission: PermissionBits.VIEW,
});
if (!hasAccess) {
logger.warn(
`[processAgent] User ${userId} lacks VIEW access to subagent ${agentId}, skipping`,
);
}),
abortSignal,
);
throwIfAborted(abortSignal);
if (!hasAccess) {
logger.warn(
`[processAgent] User ${userId} lacks VIEW access to subagent ${agentId}, skipping`,
);
}
return hasAccess;
};
const getIncludeReasoningHistory = (agent) => {
if (!agent.provider) return undefined;
try {
return getProviderConfig({ provider: agent.provider, appConfig }).customEndpointConfig
?.customParams?.includeReasoningHistory;
} catch {
return undefined;
}
};
const getExplicitSubagentIds = (agent) =>
Array.from(
new Set(
Array.isArray(agent.subagents?.agent_ids)
? agent.subagents.agent_ids.filter(
(id) => typeof id === 'string' && id && id !== agent.id,
)
: [],
),
);
const toLazySubagentMetadata = (agent) => ({
id: agent.id,
name: agent.name,
description: agent.description,
provider: agent.provider,
model: agent.model,
model_parameters: { model: agent.model_parameters?.model },
recursion_limit: agent.recursion_limit,
subagents: agent.subagents,
configId: getLazySubagentConfigId(agent),
codeEnvAvailable:
codeEnvAvailable === true && agent.tools?.includes(Tools.execute_code) === true,
statefulCodeSessions:
statefulSessionsAvailable === true &&
codeEnvAvailable === true &&
agent.stateful_code_sessions === true &&
agent.tools?.includes(Tools.execute_code) === true,
includeReasoningHistory: getIncludeReasoningHistory(agent),
});
const loadSubagentMetadata = async (agentId) => {
if (skippedAgentIds.has(agentId)) return null;
const cached = lazyMetadataByAgentId.get(agentId);
if (cached) return cached;
try {
const agent = await db.getAgentWithVersionCount({ id: agentId });
if (!agent || !(await hasSubagentViewAccess(agent, agentId))) {
skippedAgentIds.add(agentId);
return null;
}
const validation = await validateAgentModel({
req,
res,
agent,
modelsConfig,
logViolation,
});
if (!validation.isValid) {
logger.warn(
`[processAgent] Subagent ${agentId} failed model validation: ${validation.error?.message}`,
);
skippedAgentIds.add(agentId);
return null;
const metadata = toLazySubagentMetadata(agent);
lazyMetadataByAgentId.set(agentId, metadata);
return metadata;
} catch (error) {
if (isFatalAgentInitializationError(error)) {
throw error;
}
const scopedSkillIds = resolveAgentScopedSkillIds({
agent,
accessibleSkillIds,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
});
const scopedEditableSkillIds = resolveAgentScopedSkillIds({
agent,
accessibleSkillIds: editableSkillIds,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
});
const config = await initializeAgent(
logger.error(`[initializeClient] Error loading subagent metadata ${agentId}:`, error);
skippedAgentIds.add(agentId);
return null;
}
};
/**
* 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);
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))) {
throw new Error(`You no longer have access to subagent ${agentId}.`);
}
const validation = await waitForAbort(
validateAgentModel({ req, res, agent, modelsConfig, logViolation }),
context.signal,
);
throwIfAborted(context.signal);
if (!validation.isValid) {
throw new Error(validation.error?.message ?? `Subagent ${agentId} failed model validation.`);
}
const scopedSkillIds = resolveAgentScopedSkillIds({
agent,
accessibleSkillIds,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
});
const scopedEditableSkillIds = resolveAgentScopedSkillIds({
agent,
accessibleSkillIds: editableSkillIds,
skillsCapabilityEnabled,
ephemeralSkillsToggle,
});
const config = await waitForAbort(
initializeAgent(
{
req,
res,
agent,
loadTools,
loadTools: createToolLoader(context.signal, streamId, true, jobCreatedAt),
requestFiles,
conversationId,
parentMessageId,
@ -764,20 +878,7 @@ const initializeClient = async ({
skillsCapabilityEnabled,
ephemeralSkillsToggle,
}),
/** Match the primary / handoff / addedConvo paths: forward the
* endpoint-level admin flag so `initializeAgent` can compute the
* per-agent narrowing (admin AND agent.tools includes
* execute_code) into `InitializedAgent.codeEnvAvailable`. Without
* this, a code-enabled subagent loaded only through
* `subagentAgentConfigs` initializes with `codeEnvAvailable:
* false`, so `bash_tool` / `read_file` sandbox fallback are
* silently gated off even though the seed walk found it. */
codeEnvAvailable,
/* Background tool calls are intentionally NOT enabled for pure
* subagents (spawn-tool child graphs): their tools don't reach the
* host ON_TOOL_EXECUTE background interceptor, so injecting the
* schema would advertise a poll tool the host can't honor. Backgrounding
* subagent work is the durable follow-up. */
statefulSessionsAvailable,
memoryAvailable,
skillStates,
@ -799,169 +900,115 @@ const initializeClient = async ({
listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills,
getSkillByName: skillDbMethods.getSkillByName,
},
);
agentConfigs.set(agentId, config);
agentToolContexts.set(agentId, buildAgentToolContext({ agent, config }));
return config;
} catch (err) {
if (isFatalAgentInitializationError(err)) {
throw err;
}
logger.error(`[processAgent] Error processing subagent ${agentId}:`, err);
skippedAgentIds.add(agentId);
return null;
}
};
/** Collected during resolution; applied to `agentConfigs` only after
* every config has had its subagents resolved. Eager pruning would
* hide pure-subagent ids from the subsequent `loadSubagentsFor`
* loop, which would leave *their* `subagentAgentConfigs` empty and
* silently break nested delegation like A B C where B is only
* a subagent of A. */
const pureSubagentIds = new Set();
const subagentGraphIds = new Set();
const loadedSubagentConfigIds = new Set();
const assertSubagentGraphRoom = (agentId) => {
if (subagentGraphIds.has(agentId)) {
return;
}
if (subagentGraphIds.size >= MAX_SUBAGENT_GRAPH_NODES) {
logger.warn('[initializeClient] Subagent graph node limit exceeded', {
agentId,
primaryAgentId: primaryConfig.id,
loadedSubagentCount: subagentGraphIds.size,
maxSubagentGraphNodes: MAX_SUBAGENT_GRAPH_NODES,
});
throw new Error(
`Subagent graph exceeds the maximum of ${MAX_SUBAGENT_GRAPH_NODES} unique agents.`,
);
}
};
/**
* Loads `subagentAgentConfigs` for a single agent config. Shared
* between the primary agent and handoff-target agents (and pure
* subagents, transitively) so an agent used via handoff or
* nested-subagent that has its own explicit `subagents.agent_ids`
* gets them honored at runtime. Self-spawn works regardless (no DB
* lookup needed). Pruning decisions are deferred to `pureSubagentIds`.
*/
const loadSubagentsFor = async (config, depth = 0) => {
const sub = config.subagents;
if (!subagentsCapabilityEnabled || !sub?.enabled) {
config.subagentAgentConfigs = [];
return;
}
if (loadedSubagentConfigIds.has(config.id)) {
if ((config.subagentAgentConfigs?.length ?? 0) > 0 && depth >= MAX_SUBAGENT_DEPTH) {
logger.warn('[initializeClient] Subagent graph depth limit exceeded', {
agentId: config.id,
primaryAgentId: primaryConfig.id,
depth,
maxSubagentDepth: MAX_SUBAGENT_DEPTH,
childCount: config.subagentAgentConfigs.length,
});
throw new Error(
`Subagent graph exceeds the maximum depth of ${MAX_SUBAGENT_DEPTH} at agent ${config.id}.`,
);
}
return;
}
/** Dedupe and filter in one pass a crafted payload could
* legitimately include the same ID twice; the backend shouldn't
* create duplicate SubagentConfig entries for the LLM to see as
* separate spawn targets. */
const explicitSubagentIds = Array.from(
new Set(
Array.isArray(sub.agent_ids)
? sub.agent_ids.filter((id) => typeof id === 'string' && id && id !== config.id)
: [],
),
context.signal,
);
throwIfAborted(context.signal);
config.lazySubagentConfigs = lazyChildren;
agentToolContexts.set(agentId, buildAgentToolContext({ agent, config }));
endpointTokenConfigByAgentId.set(agentId, config.endpointTokenConfig);
return config;
};
if (explicitSubagentIds.length > 0 && depth >= MAX_SUBAGENT_DEPTH) {
const buildLazySubagentDescriptors = async (agent, depth = 0, ancestors = new Set()) => {
if (!subagentsCapabilityEnabled || !agent.subagents?.enabled) {
return [];
}
if (agent.subagents.allowSelf !== false) {
countExpandedSubagentDescriptor(agent.id);
}
const subagentIds = getExplicitSubagentIds(agent);
if (subagentIds.length > 0 && depth >= MAX_SUBAGENT_DEPTH) {
logger.warn('[initializeClient] Subagent graph depth limit exceeded', {
agentId: config.id,
agentId: agent.id,
primaryAgentId: primaryConfig.id,
depth,
maxSubagentDepth: MAX_SUBAGENT_DEPTH,
childCount: explicitSubagentIds.length,
});
throw new Error(
`Subagent graph exceeds the maximum depth of ${MAX_SUBAGENT_DEPTH} at agent ${config.id}.`,
`Subagent graph exceeds the maximum depth of ${MAX_SUBAGENT_DEPTH} at agent ${agent.id}.`,
);
}
loadedSubagentConfigIds.add(config.id);
/** @type {Array<Object>} */
const resolved = [];
for (const subagentId of explicitSubagentIds) {
if (skippedAgentIds.has(subagentId)) continue;
/** Cycle guard: a configuration like A B (B lists A as its
* subagent) would otherwise trigger `loadAgentById` on the
* primary inserting a second config for the same primary id,
* which downstream duplicates in the agent array. Reuse the
* existing primary config when a subagent ref points back at it. */
if (subagentId === primaryConfig.id) {
resolved.push(primaryConfig);
const nextAncestors = new Set(ancestors);
nextAncestors.add(agent.id);
const descriptors = [];
for (const subagentId of subagentIds) {
if (skippedAgentIds.has(subagentId) || nextAncestors.has(subagentId)) continue;
if (subagentId !== primaryConfig.id) {
assertSubagentGraphRoom(subagentId);
}
const existing =
subagentId === primaryConfig.id ? primaryConfig : agentConfigs.get(subagentId);
if (existing) {
countExpandedSubagentDescriptor(subagentId);
if (subagentId !== primaryConfig.id) {
subagentGraphIds.add(subagentId);
}
const existingChildren = await buildLazySubagentDescriptors(
existing,
depth + 1,
nextAncestors,
);
existing.lazySubagentConfigs = existingChildren.filter((child) => child.configId);
existing.subagentAgentConfigs = existingChildren.filter((child) => !child.configId);
descriptors.push(existing);
continue;
}
assertSubagentGraphRoom(subagentId);
const subagentConfig = await loadAgentById(subagentId);
if (!subagentConfig) continue;
subagentGraphIds.add(subagentConfig.id ?? subagentId);
resolved.push(subagentConfig);
if (!edgeAgentIds.has(subagentId)) {
pureSubagentIds.add(subagentId);
}
const metadata = await loadSubagentMetadata(subagentId);
if (!metadata) continue;
countExpandedSubagentDescriptor(subagentId);
subagentGraphIds.add(subagentId);
const childDescriptors = await buildLazySubagentDescriptors(
metadata,
depth + 1,
nextAncestors,
);
const lazyChildren = childDescriptors.filter((child) => child.configId);
const eagerChildren = childDescriptors.filter((child) => !child.configId);
descriptors.push({
id: metadata.id,
name: metadata.name,
description: metadata.description,
provider: metadata.provider,
model: metadata.model,
model_parameters: metadata.model_parameters,
recursion_limit: metadata.recursion_limit,
subagents: metadata.subagents,
configId: metadata.configId,
codeEnvAvailable: metadata.codeEnvAvailable,
statefulCodeSessions: metadata.statefulCodeSessions,
includeReasoningHistory: metadata.includeReasoningHistory,
lazySubagentConfigs: lazyChildren,
subagentAgentConfigs: eagerChildren,
resolve: (context) =>
initializeLazySubagent({
agentId: metadata.id,
configId: metadata.configId,
context,
lazyChildren,
}).then((config) => {
config.subagentAgentConfigs = eagerChildren;
return config;
}),
});
}
config.subagentAgentConfigs = resolved;
return descriptors;
};
const maxResolvedDepthByConfigId = new Map();
/** BFS across subagent trees so nested chains like A B C get
* resolved before any pruning. Agent configs are loaded once, but
* overlapping roots can still be revisited at deeper path depths so
* the depth guard observes the deepest reachable subagent path. */
const resolveSubagentTrees = async (rootConfigs) => {
const pending = rootConfigs.map((cfg) => ({ cfg, depth: 0 }));
for (let index = 0; index < pending.length; index++) {
const { cfg, depth } = pending[index];
if (!cfg?.id) continue;
const previousDepth = maxResolvedDepthByConfigId.get(cfg.id);
if (previousDepth != null && previousDepth >= depth) continue;
maxResolvedDepthByConfigId.set(cfg.id, depth);
await loadSubagentsFor(cfg, depth);
for (const child of cfg.subagentAgentConfigs ?? []) {
const childDepth = depth + 1;
const previousChildDepth = child?.id ? maxResolvedDepthByConfigId.get(child.id) : undefined;
if (child?.id && (previousChildDepth == null || previousChildDepth < childDepth)) {
pending.push({ cfg: child, depth: childDepth });
}
}
expandedSubagentDescriptorState.rootAgentIds = rootConfigs
.filter((config) => config?.id)
.map((config) => config.id);
for (const config of rootConfigs) {
if (!config?.id) continue;
const descriptors = await buildLazySubagentDescriptors(config);
config.lazySubagentConfigs = descriptors.filter((child) => child.configId);
config.subagentAgentConfigs = descriptors.filter((child) => !child.configId);
}
};
await resolveSubagentTrees([primaryConfig, ...agentConfigs.values()]);
/** Drop pure-subagent entries now that every reachable config has
* had its subagents resolved. They stay in `agentToolContexts` so
* their tools still execute with the right scoping. */
for (const id of pureSubagentIds) {
agentConfigs.delete(id);
}
primaryConfig.subagents = subagentsCapabilityEnabled ? primaryConfig.subagents : undefined;
/** If the capability is off at the endpoint level, strip `subagents` on
@ -971,9 +1018,11 @@ const initializeClient = async ({
* otherwise still expose self-spawn at runtime even though the admin
* has disabled the capability globally. */
if (!subagentsCapabilityEnabled) {
primaryConfig.lazySubagentConfigs = undefined;
for (const config of agentConfigs.values()) {
config.subagents = undefined;
config.subagentAgentConfigs = undefined;
config.lazySubagentConfigs = undefined;
}
}
@ -1031,7 +1080,6 @@ const initializeClient = async ({
* known non-custom agent (built-in pricing) from an untagged/unknown one
* (primary fallback).
* @type {Map<string, import('@librechat/api').EndpointTokenConfig | undefined>} */
const endpointTokenConfigByAgentId = new Map();
for (const [agentId, ctx] of agentToolContexts) {
endpointTokenConfigByAgentId.set(agentId, ctx?.endpointTokenConfig);
}

View file

@ -6,6 +6,7 @@ const {
PrincipalModel,
MAX_SUBAGENT_DEPTH,
MAX_SUBAGENT_GRAPH_NODES,
MAX_SUBAGENT_RUN_CONFIGS,
Constants,
ErrorTypes,
} = require('librechat-data-provider');
@ -89,7 +90,8 @@ 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 } = require('~/models');
const { createAgent, createSkill, updateAgent } = require('~/models');
const db = require('~/models');
jest.spyOn(logger, 'warn').mockImplementation(() => {});
@ -656,7 +658,7 @@ describe('initializeClient — subagent loading', () => {
subagents: { enabled: true, allowSelf: false, agent_ids: childIds },
});
const createViewableAgent = async (id) => {
const createViewableAgent = async (id, subagents) => {
const agent = await createAgent({
id,
name: id,
@ -664,12 +666,13 @@ describe('initializeClient — subagent loading', () => {
model: 'gpt-4',
author: new mongoose.Types.ObjectId(),
tools: [],
subagents,
});
await grantView(agent);
return agent;
};
it('aborts the run when a pure subagent resolves none of its expected MCP tools', async () => {
it('defers pure-subagent MCP initialization until the descriptor is selected', async () => {
const subAgent = await createAgent({
id: SUBAGENT_ID,
name: 'Data Subagent',
@ -702,17 +705,22 @@ describe('initializeClient — subagent loading', () => {
});
});
await initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
expect(mockInitializeAgent).toHaveBeenCalledTimes(1);
await expect(
initializeClient({
req: makeSubagentReq(),
res: {},
agentClientArgs.agent.lazySubagentConfigs[0].resolve({
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
}),
).rejects.toBe(toolError);
});
it('aborts the run when a pure subagent requires CodeAPI resource recovery', async () => {
it('defers pure-subagent resource recovery until the descriptor is selected', async () => {
const subAgent = await createAgent({
id: SUBAGENT_ID,
name: 'Code Subagent',
@ -736,17 +744,21 @@ describe('initializeClient — subagent loading', () => {
)
.mockRejectedValueOnce(resourceRecoveryError);
await initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
await expect(
initializeClient({
req: makeSubagentReq(),
res: {},
agentClientArgs.agent.lazySubagentConfigs[0].resolve({
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
}),
).rejects.toBe(resourceRecoveryError);
});
it('loads a configured subagent, populates `subagentAgentConfigs`, and keeps it out of `agentConfigs`', async () => {
it('advertises a configured subagent without initializing it, then initializes it on selection', async () => {
const subAgent = await createAgent({
id: SUBAGENT_ID,
name: 'Explicit Subagent',
@ -780,23 +792,129 @@ describe('initializeClient — subagent loading', () => {
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) }),
);
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([]);
/** 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('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
@ -831,6 +949,9 @@ describe('initializeClient — subagent loading', () => {
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`
@ -910,13 +1031,7 @@ describe('initializeClient — subagent loading', () => {
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);
});
mockInitializeAgent.mockResolvedValue(primaryConfig);
await initializeClient({
req: makeSubagentReq(),
@ -925,9 +1040,61 @@ describe('initializeClient — subagent loading', () => {
endpointOption: makeEndpointOption(),
});
/** One call for primary, one for the subagent — not four. */
expect(mockInitializeAgent).toHaveBeenCalledTimes(2);
expect(agentClientArgs.agent.subagentAgentConfigs).toHaveLength(1);
/** 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 () => {
@ -935,23 +1102,18 @@ describe('initializeClient — subagent loading', () => {
{ length: MAX_SUBAGENT_DEPTH + 1 },
(_, index) => `agent_depth_${index}`,
);
for (const id of ids) {
await createViewableAgent(id);
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]] },
});
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)),
);
mockInitializeAgent.mockResolvedValue(primaryConfig);
await expect(
initializeClient({
@ -964,11 +1126,10 @@ describe('initializeClient — subagent loading', () => {
expect(logger.warn).toHaveBeenCalledWith(
'[initializeClient] Subagent graph depth limit exceeded',
expect.objectContaining({
agentId: `agent_depth_${MAX_SUBAGENT_DEPTH - 1}`,
agentId: ids[MAX_SUBAGENT_DEPTH - 1],
primaryAgentId: PRIMARY_ID,
depth: MAX_SUBAGENT_DEPTH,
maxSubagentDepth: MAX_SUBAGENT_DEPTH,
childCount: 1,
}),
);
expect(agentClientArgs).toBeUndefined();
@ -979,9 +1140,17 @@ describe('initializeClient — subagent loading', () => {
{ length: MAX_SUBAGENT_DEPTH },
(_, index) => `agent_overlap_depth_${index}`,
);
const allIds = [HANDOFF_AND_SUB_ID, ...chainIds];
for (const id of allIds) {
await createViewableAgent(id);
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' }];
@ -989,16 +1158,9 @@ describe('initializeClient — subagent loading', () => {
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]] : []),
]),
]);
const sharedConfig = makeNestedSubagentConfig(HANDOFF_AND_SUB_ID, [chainIds[0]]);
mockInitializeAgent.mockImplementation(({ agent }) =>
Promise.resolve(agent.id === PRIMARY_ID ? primaryConfig : nestedConfigs.get(agent.id)),
Promise.resolve(agent.id === PRIMARY_ID ? primaryConfig : sharedConfig),
);
await expect(
@ -1009,15 +1171,6 @@ describe('initializeClient — subagent loading', () => {
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();
});
@ -1029,25 +1182,21 @@ describe('initializeClient — subagent loading', () => {
Array.from({ length: 5 }, (_, index) => `${id}_child_${index}`),
]),
);
const allIds = [...firstLevelIds, ...Array.from(secondLevelIdsByParent.values()).flat()];
for (const id of allIds) {
await createViewableAgent(id);
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 },
});
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)),
);
mockInitializeAgent.mockResolvedValue(primaryConfig);
await expect(
initializeClient({
@ -1068,6 +1217,42 @@ describe('initializeClient — subagent loading', () => {
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