mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🪶 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:
parent
6bff5ba148
commit
54d7f04d71
10 changed files with 999 additions and 359 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue