🪶 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

View file

@ -1046,6 +1046,88 @@ describe('subagentConfigs', () => {
expect(configs[0].self).toBeUndefined();
});
it('adds explicit lazy subagent descriptors without eager agent inputs', async () => {
const resolve = jest
.fn()
.mockResolvedValue(
makeAgent({ id: 'agent_child', name: 'Researcher', description: 'Deep web research' }),
);
const agents = await callAndCapture({
agents: [
makeAgent({
subagents: { enabled: true, allowSelf: false, agent_ids: ['agent_child'] },
lazySubagentConfigs: [
{
id: 'agent_child',
name: 'Researcher',
description: 'Deep web research',
configId: 'agent_child:3:fingerprint',
resolve,
},
],
}),
],
});
const configs = agents[0].subagentConfigs as Array<Record<string, unknown>>;
expect(configs).toHaveLength(1);
expect(configs[0]).toMatchObject({
type: 'agent_child',
configId: 'agent_child:3:fingerprint',
allowNested: true,
});
expect(configs[0].agentInputs).toBeUndefined();
expect(configs[0].resolveAgentInputs).toBeInstanceOf(Function);
expect(resolve).not.toHaveBeenCalled();
const childInputs = await (
configs[0].resolveAgentInputs as (context: never) => Promise<{
name?: string;
}>
)({ signal: new AbortController().signal } as never);
expect(resolve).toHaveBeenCalledTimes(1);
expect(childInputs.name).toBe('Researcher');
});
it('uses a fresh expansion budget for each lazy descriptor resolution', async () => {
const nestedDescriptors = Array.from({ length: 99 }, (_, index) => ({
id: `agent_nested_${index}`,
name: `Nested ${index}`,
description: 'Nested lazy child',
configId: `agent_nested_${index}:1:fingerprint`,
resolve: jest.fn(),
}));
const resolve = jest.fn().mockResolvedValue(
makeAgent({
id: 'agent_child',
subagents: { enabled: true, allowSelf: false },
lazySubagentConfigs: nestedDescriptors,
}),
);
const agents = await callAndCapture({
agents: [
makeAgent({
subagents: { enabled: true, allowSelf: false, agent_ids: ['agent_child'] },
lazySubagentConfigs: [
{
id: 'agent_child',
name: 'Child',
description: 'Lazy child',
configId: 'agent_child:1:fingerprint',
resolve,
},
],
}),
],
});
const resolveAgentInputs = (agents[0].subagentConfigs as Array<Record<string, unknown>>)[0]
.resolveAgentInputs as (context: never) => Promise<unknown>;
const context = { signal: new AbortController().signal } as never;
await expect(resolveAgentInputs(context)).resolves.toBeDefined();
await expect(resolveAgentInputs(context)).resolves.toBeDefined();
expect(resolve).toHaveBeenCalledTimes(2);
});
it('preserves explicit nested subagents across the SDK child graph boundary', async () => {
const grandchild = makeAgent({ id: 'agent_grandchild', name: 'Grandchild' });
const child = makeAgent({

View file

@ -15,6 +15,7 @@ export * from './handlers';
export * from './harvest';
export * from './initialize';
export * from './legacy';
export * from './lazySubagents';
export * from './memory';
export * from './orphans';
export * from './migration';

View file

@ -0,0 +1,62 @@
import { getLazySubagentConfigId } from './lazySubagents';
const agent = {
id: 'child-agent',
name: 'Child',
description: 'Delegated work',
provider: 'openAI',
model: 'gpt-5',
model_parameters: {
temperature: 0,
maxContextTokens: 128000,
max_context_tokens: null,
max_output_tokens: null,
top_p: null,
frequency_penalty: null,
presence_penalty: null,
},
version: 4,
};
describe('getLazySubagentConfigId', () => {
it('changes when initializer-relevant config changes', () => {
const original = getLazySubagentConfigId(agent);
const changed = getLazySubagentConfigId({
...agent,
instructions: 'Use concise answers.',
});
expect(changed).not.toBe(original);
});
it('changes when model-advertised identity changes', () => {
expect(getLazySubagentConfigId({ ...agent, name: 'Renamed child' })).not.toBe(
getLazySubagentConfigId(agent),
);
});
it('is stable across key order and excludes secret values', () => {
const first = getLazySubagentConfigId({
...agent,
tool_kwargs: { retry: 2, access_token: 'first-secret' },
});
const second = getLazySubagentConfigId({
...agent,
tool_kwargs: { access_token: 'second-secret', retry: 2 },
});
expect(second).toBe(first);
});
it('includes token-budget settings in the descriptor identity', () => {
expect(getLazySubagentConfigId({ ...agent, tool_kwargs: { max_tokens: 1024 } })).not.toBe(
getLazySubagentConfigId({ ...agent, tool_kwargs: { max_tokens: 2048 } }),
);
});
it('includes the persisted version in the descriptor identity', () => {
expect(getLazySubagentConfigId({ ...agent, version: 5 })).not.toBe(
getLazySubagentConfigId(agent),
);
});
});

View file

@ -0,0 +1,141 @@
import { createHash } from 'crypto';
import type { Agent } from 'librechat-data-provider';
type VersionedAgent = Pick<
Agent,
| 'id'
| 'name'
| 'description'
| 'instructions'
| 'additional_instructions'
| 'endpoint'
| 'provider'
| 'model'
| 'model_parameters'
| 'tools'
| 'tool_kwargs'
| 'tool_options'
| 'tool_resources'
| 'skills'
| 'skills_enabled'
| 'stateful_code_sessions'
| 'artifacts'
| 'recursion_limit'
| 'agent_ids'
| 'edges'
| 'end_after_tools'
| 'hide_sequential_outputs'
| 'subagents'
| 'memory_scope'
> & {
version?: number;
actions?: string[];
mcpServerNames?: string[];
};
const sensitiveKeyPattern =
/(?:^|[_-])(?:api[_-]?key|authorization|credentials?|password|secret|(?:access|refresh|id|auth)?[_-]?token)(?:$|[_-])/i;
function isSensitiveKey(key: string): boolean {
return sensitiveKeyPattern.test(key);
}
function canonicalize(value: unknown): string {
if (value === null) {
return 'null';
}
if (typeof value === 'string') {
return JSON.stringify(value);
}
if (typeof value === 'number' || typeof value === 'boolean') {
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return `[${value.map(canonicalize).join(',')}]`;
}
if (typeof value !== 'object') {
return 'null';
}
const entries = Object.entries(value)
.filter(([key, entry]) => !isSensitiveKey(key) && entry !== undefined)
.sort(([left], [right]) => left.localeCompare(right));
return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${canonicalize(entry)}`).join(',')}}`;
}
/**
* Returns only persisted fields that can change the initialized child graph.
* `name` and `description` are intentionally included because they are
* advertised to the parent model; request state, ACL-only display fields, and
* secret values are excluded.
*/
export function selectLazySubagentConfig(agent: VersionedAgent): Omit<VersionedAgent, 'version'> {
const {
id,
name,
description,
instructions,
additional_instructions,
endpoint,
provider,
model,
model_parameters,
tools,
tool_kwargs,
tool_options,
tool_resources,
skills,
skills_enabled,
stateful_code_sessions,
artifacts,
recursion_limit,
agent_ids,
edges,
end_after_tools,
hide_sequential_outputs,
subagents,
memory_scope,
actions,
mcpServerNames,
} = agent;
return {
id,
name,
description,
instructions,
additional_instructions,
endpoint,
provider,
model,
model_parameters,
tools,
tool_kwargs,
tool_options,
tool_resources,
skills,
skills_enabled,
stateful_code_sessions,
artifacts,
recursion_limit,
agent_ids,
edges,
end_after_tools,
hide_sequential_outputs,
subagents,
memory_scope,
actions,
mcpServerNames,
};
}
/** Deterministic descriptor identity for a persisted lazy subagent. */
export function getLazySubagentConfigId(agent: VersionedAgent): string {
const version =
agent.version != null && Number.isInteger(agent.version) && agent.version >= 0
? agent.version
: 0;
const fingerprint = createHash('sha256')
.update(canonicalize(selectLazySubagentConfig(agent)))
.digest('hex');
return `${agent.id}:${version}:${fingerprint}`;
}

View file

@ -381,6 +381,13 @@ describe('anyAgentReplaysReasoningContent', () => {
expect(anyAgentReplaysReasoningContent([primary])).toBe(true);
});
it('returns true when an inert lazy descriptor opts in', () => {
const primary = plainAgent('root', {
lazySubagentConfigs: [plainAgent('lazy-child', { includeReasoningHistory: true })],
});
expect(anyAgentReplaysReasoningContent([primary])).toBe(true);
});
it('returns false when no reachable agent opts in', () => {
const primary = plainAgent('root', {
subagentAgentConfigs: [plainAgent('child')],

View file

@ -18,6 +18,7 @@ import type {
StreamPreemption,
LCToolRegistry,
SubagentConfig,
SubagentResolveContext,
HookCallback,
AgentInputs,
GenericTool,
@ -397,10 +398,50 @@ type RunAgent = Omit<Agent, 'tools'> & {
maxToolResultChars?: number;
/** Initialized subagent configs (loaded by initialize.js from agent.subagents.agent_ids). */
subagentAgentConfigs?: RunAgent[];
/**
* Inert, VIEW-checked descriptors for explicit children that are initialized
* only after the SDK selects them. These resolvers are request-scoped: they
* may use the active request's authorization and tool-loading context.
*/
lazySubagentConfigs?: LazySubagentAgent[];
/** Source subagent spawning configuration (enabled / allowSelf / agent_ids). */
subagents?: AgentSubagentsConfig;
};
type LazySubagentAgent = Pick<
RunAgent,
| 'id'
| 'name'
| 'description'
| 'provider'
| 'model'
| 'model_parameters'
| 'recursion_limit'
| 'subagents'
| 'codeEnvAvailable'
| 'statefulCodeSessions'
| 'includeReasoningHistory'
> & {
configId: string;
subagentAgentConfigs?: RunAgent[];
lazySubagentConfigs?: LazySubagentAgent[];
resolve: (context: SubagentResolveContext) => Promise<RunAgent>;
};
type SubagentTreeNode = Pick<
RunAgent,
| 'id'
| 'provider'
| 'model'
| 'model_parameters'
| 'codeEnvAvailable'
| 'statefulCodeSessions'
| 'includeReasoningHistory'
> & {
subagentAgentConfigs?: SubagentTreeNode[];
lazySubagentConfigs?: SubagentTreeNode[];
};
function isNonEmptyString(value: unknown): value is string {
return typeof value === 'string' && value.trim().length > 0;
}
@ -743,6 +784,80 @@ function assertSubagentDepth(depth: number, agentId: string): void {
}
}
function buildIsolatedSubagentInputs(
child: RunAgent,
toInput: (child: RunAgent, opts?: { isSubagent?: boolean }) => AgentInputs,
): AgentInputs {
const childInputs = toInput(child, { isSubagent: true });
if ((child.backgroundToolNames?.length ?? 0) > 0) {
childInputs.toolDefinitions = stripBackgroundFromToolDefinitions(
childInputs.toolDefinitions,
child.backgroundToolNames,
);
childInputs.toolRegistry = stripBackgroundFromToolRegistry(
childInputs.toolRegistry,
child.backgroundToolNames,
);
}
if ((child.intentToolNames?.length ?? 0) > 0) {
childInputs.toolDefinitions = stripIntentFromToolDefinitions(
childInputs.toolDefinitions,
child.intentToolNames,
);
childInputs.toolRegistry = stripIntentFromToolRegistry(
childInputs.toolRegistry,
child.intentToolNames,
);
}
return childInputs;
}
function createLazySubagentConfig(
child: LazySubagentAgent,
toInput: (child: RunAgent, opts?: { isSubagent?: boolean }) => AgentInputs,
agentsEConfig: Partial<TAgentsEndpoint> | undefined,
ancestors: Set<string>,
depth: number,
): SubagentConfig {
return {
type: child.id,
name: child.name ?? child.id,
description:
child.description ??
`Delegate a subtask to the ${child.name ?? child.id} agent in an isolated context.`,
configId: child.configId,
allowNested: true,
maxTurns: resolveSubagentMaxTurns(agentsEConfig, child),
resolveAgentInputs: async (context) => {
if (context.signal.aborted) {
throw context.signal.reason ?? new Error('Subagent resolution was aborted.');
}
const resolvedChild = await child.resolve(context);
if (context.signal.aborted) {
throw context.signal.reason ?? new Error('Subagent resolution was aborted.');
}
const childInputs = buildIsolatedSubagentInputs(resolvedChild, toInput);
const resolutionState: SubagentBuildState = {
configCount: 1,
rootAgentIds: [resolvedChild.id],
};
const grandchildConfigs = buildSubagentConfigs(
resolvedChild,
childInputs,
toInput,
resolutionState,
agentsEConfig,
ancestors,
depth,
);
if (grandchildConfigs.length > 0) {
childInputs.subagentConfigs = grandchildConfigs;
}
return childInputs;
},
};
}
/**
* Recursive any-true check across the agent tree: returns `true` if this
* agent or any subagent (transitively) has the per-agent codeenv gate
@ -762,7 +877,7 @@ function assertSubagentDepth(depth: number, agentId: string): void {
*/
function anyAgentHasCodeEnv(agents: RunAgent[]): boolean {
const visited = new Set<string>();
const pending = [...agents];
const pending: SubagentTreeNode[] = [...agents];
for (let index = 0; index < pending.length; index++) {
const agent = pending[index];
@ -778,6 +893,11 @@ function anyAgentHasCodeEnv(agents: RunAgent[]): boolean {
pending.push(child);
}
}
for (const child of agent.lazySubagentConfigs ?? []) {
if (!visited.has(child.id)) {
pending.push(child);
}
}
}
return false;
}
@ -837,7 +957,7 @@ function isAskUserQuestionAdminDisabled(appConfig?: AppConfig): boolean {
*/
export function anyAgentHasStatefulSessions(agents: Array<RunAgent | null | undefined>): boolean {
const visited = new Set<string>();
const pending = [...agents];
const pending: Array<SubagentTreeNode | null | undefined> = [...agents];
for (let index = 0; index < pending.length; index++) {
const agent = pending[index];
@ -853,6 +973,11 @@ export function anyAgentHasStatefulSessions(agents: Array<RunAgent | null | unde
pending.push(child);
}
}
for (const child of agent.lazySubagentConfigs ?? []) {
if (!visited.has(child.id)) {
pending.push(child);
}
}
}
return false;
}
@ -867,7 +992,7 @@ export function anyAgentReplaysReasoningContent(
agents: Array<RunAgent | null | undefined>,
): boolean {
const visited = new Set<string>();
const pending = [...agents];
const pending: Array<SubagentTreeNode | null | undefined> = [...agents];
for (let index = 0; index < pending.length; index++) {
const agent = pending[index];
@ -883,14 +1008,19 @@ export function anyAgentReplaysReasoningContent(
pending.push(child);
}
}
for (const child of agent.lazySubagentConfigs ?? []) {
if (!visited.has(child.id)) {
pending.push(child);
}
}
}
return false;
}
/**
* Builds SubagentConfig entries for an agent: optional self-spawn plus any
* explicit child agents loaded in `agent.subagentAgentConfigs`. Returns an empty
* array when subagents are disabled or no spawn targets are available.
* explicit eager children and inert lazy descriptors. Returns an empty array
* when subagents are disabled or no spawn targets are available.
*/
function buildSubagentConfigs(
agent: RunAgent,
@ -968,50 +1098,7 @@ function buildSubagentConfigs(
const childDepth = depth + 1;
assertSubagentDepth(childDepth, child.id);
countSubagentConfig(state);
/**
* `buildAgentInput` applies parent-run context (initialSummary +
* discoveredTools) to the returned AgentInputs *and* to the
* passed-in agent's `toolRegistry` / `toolDefinitions` flipping
* `defer_loading: true → false` on tools the parent had previously
* searched for, and injecting those tools' definitions into the
* child's `toolDefinitions`. Clearing fields on the returned
* object post-hoc would leave those side-effects in place, leaking
* the parent's tool-search state into an "isolated" subagent and
* inflating the child's prompt/token budget. The `isSubagent` flag
* skips both the field stamping and the registry mutation at the
* source so children truly start fresh.
*/
const childInputs = toInput(child, { isSubagent: true });
/**
* A child reachable as a top-level/handoff agent is initialized WITH the
* background capability, then reused here as a subagent. Isolated child
* graphs run subagent tools without the host background dispatch/poll
* behavior, so strip the injected `run_in_background` param + the
* `check_background_task` def (defs AND registry) so the child doesn't
* advertise a background contract it can't honor. Mirrors the self-spawn path.
*/
if ((child.backgroundToolNames?.length ?? 0) > 0) {
childInputs.toolDefinitions = stripBackgroundFromToolDefinitions(
childInputs.toolDefinitions,
child.backgroundToolNames,
);
childInputs.toolRegistry = stripBackgroundFromToolRegistry(
childInputs.toolRegistry,
child.backgroundToolNames,
);
}
/** Same sanitization for the host-injected `intent` param (see the
* self-spawn path above). */
if ((child.intentToolNames?.length ?? 0) > 0) {
childInputs.toolDefinitions = stripIntentFromToolDefinitions(
childInputs.toolDefinitions,
child.intentToolNames,
);
childInputs.toolRegistry = stripIntentFromToolRegistry(
childInputs.toolRegistry,
child.intentToolNames,
);
}
const childInputs = buildIsolatedSubagentInputs(child, toInput);
/**
* Recursively resolve the child's own spawn targets so multi-level
* delegation (A B C) works. Without this, a child whose own
@ -1046,6 +1133,18 @@ function buildSubagentConfigs(
});
}
for (const child of agent.lazySubagentConfigs ?? []) {
if (!child.id || child.id === agent.id || ancestors.has(child.id)) {
continue;
}
const childDepth = depth + 1;
assertSubagentDepth(childDepth, child.id);
countSubagentConfig(state);
configs.push(
createLazySubagentConfig(child, toInput, agentsEConfig, nextAncestors, childDepth),
);
}
return configs;
}

View file

@ -6,6 +6,7 @@ interface TestAgent {
id: string;
statefulCodeSessions?: boolean;
subagentAgentConfigs?: Array<TestAgent | null>;
lazySubagentConfigs?: Array<TestAgent | null>;
}
function agent(
@ -38,6 +39,10 @@ describe('anyAgentHasStatefulSessions', () => {
expect(walk([agent('a', false, [child])])).toBe(true);
});
it('includes inert lazy descriptors when deciding whether to prewarm', () => {
expect(walk([{ id: 'a', lazySubagentConfigs: [agent('lazy-child', true)] }])).toBe(true);
});
it('tolerates null entries and cycles in the subagent graph', () => {
const a = agent('a', false);
const b = agent('b', false);

View file

@ -2096,6 +2096,16 @@ describe('resolveAgentTokenConfig', () => {
);
});
it('observes a selected lazy subagent added after the resolver is created', () => {
const byAgentId = new Map([['primary', primary]]);
const resolveForUsage = (agentId: string) =>
resolveAgentTokenConfig({ agentId, byAgentId, fallback: primary });
byAgentId.set('lazy-subagent', subagent);
expect(resolveForUsage('lazy-subagent')).toBe(subagent);
});
it('returns undefined for a known agent with no configured rates (built-in pricing)', () => {
/** A known non-custom agent (e.g. a normal OpenAI agent) is recorded with an
* undefined config; it must NOT inherit the custom-primary rates. */