diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 8375e40d42..00e43956de 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -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} */ + 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} */ - 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} */ - const endpointTokenConfigByAgentId = new Map(); for (const [agentId, ctx] of agentToolContexts) { endpointTokenConfigByAgentId.set(agentId, ctx?.endpointTokenConfig); } diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index 144d27ff22..9d846be38e 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -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 diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index e59cee3a94..8f7729d0e7 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -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>; + 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>)[0] + .resolveAgentInputs as (context: never) => Promise; + 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({ diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 2074539372..8e1b408073 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -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'; diff --git a/packages/api/src/agents/lazySubagents.spec.ts b/packages/api/src/agents/lazySubagents.spec.ts new file mode 100644 index 0000000000..ac2ba42ccc --- /dev/null +++ b/packages/api/src/agents/lazySubagents.spec.ts @@ -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), + ); + }); +}); diff --git a/packages/api/src/agents/lazySubagents.ts b/packages/api/src/agents/lazySubagents.ts new file mode 100644 index 0000000000..0679ec76df --- /dev/null +++ b/packages/api/src/agents/lazySubagents.ts @@ -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 { + 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}`; +} diff --git a/packages/api/src/agents/run.spec.ts b/packages/api/src/agents/run.spec.ts index d6577d275b..dd1c3c48e4 100644 --- a/packages/api/src/agents/run.spec.ts +++ b/packages/api/src/agents/run.spec.ts @@ -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')], diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 94acfba396..a41fe79aee 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -18,6 +18,7 @@ import type { StreamPreemption, LCToolRegistry, SubagentConfig, + SubagentResolveContext, HookCallback, AgentInputs, GenericTool, @@ -397,10 +398,50 @@ type RunAgent = Omit & { 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; +}; + +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 | undefined, + ancestors: Set, + 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(); - 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): boolean { const visited = new Set(); - const pending = [...agents]; + const pending: Array = [...agents]; for (let index = 0; index < pending.length; index++) { const agent = pending[index]; @@ -853,6 +973,11 @@ export function anyAgentHasStatefulSessions(agents: Array, ): boolean { const visited = new Set(); - const pending = [...agents]; + const pending: Array = [...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; } diff --git a/packages/api/src/agents/statefulCodeSessions.spec.ts b/packages/api/src/agents/statefulCodeSessions.spec.ts index 70ac3e65e9..a299922cea 100644 --- a/packages/api/src/agents/statefulCodeSessions.spec.ts +++ b/packages/api/src/agents/statefulCodeSessions.spec.ts @@ -6,6 +6,7 @@ interface TestAgent { id: string; statefulCodeSessions?: boolean; subagentAgentConfigs?: Array; + lazySubagentConfigs?: Array; } 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); diff --git a/packages/api/src/agents/usage.spec.ts b/packages/api/src/agents/usage.spec.ts index f7a921df76..8ea185ed6f 100644 --- a/packages/api/src/agents/usage.spec.ts +++ b/packages/api/src/agents/usage.spec.ts @@ -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. */