diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index 160df4ea95..c54293f9f6 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -6,6 +6,7 @@ const { GraphNodeKeys, ToolEndHandler, CODE_EXECUTION_TOOLS, + createContentAggregator, } = require('@librechat/agents'); const { sendEvent, @@ -114,6 +115,48 @@ async function emitEvent(res, streamId, eventData) { } } +/** + * Maps a {@link SubagentUpdateEvent} phase to the corresponding + * {@link GraphEvents} name that the SDK's `createContentAggregator` + * knows how to consume. Phases that don't carry content (`start`, `stop`, + * `error`) or whose payload doesn't match a handled event (`run_step` + * with an `ON_TOOL_EXECUTE`-shaped batch request rather than a RunStep) + * return `null` so the caller skips them. + * @param {SubagentUpdateEvent} event + * @returns {string | null} + */ +function subagentPhaseToGraphEvent(event) { + switch (event?.phase) { + case 'run_step': + /** `ON_RUN_STEP` and `ON_TOOL_EXECUTE` both forward with phase + * `run_step`; only the former matches the aggregator's RunStep + * schema. Detect by presence of `stepDetails`. */ + return event.data?.stepDetails ? GraphEvents.ON_RUN_STEP : null; + case 'run_step_delta': + return GraphEvents.ON_RUN_STEP_DELTA; + case 'run_step_completed': + return GraphEvents.ON_RUN_STEP_COMPLETED; + case 'message_delta': + return GraphEvents.ON_MESSAGE_DELTA; + case 'reasoning_delta': + return GraphEvents.ON_REASONING_DELTA; + default: + return null; + } +} + +/** + * Folds a single {@link SubagentUpdateEvent} into the given content + * aggregator. Silent no-op for phases outside the aggregator's domain. + * @param {{ aggregateContent: Function }} aggregator + * @param {SubagentUpdateEvent} event + */ +function feedSubagentAggregator(aggregator, event) { + const graphEvent = subagentPhaseToGraphEvent(event); + if (!graphEvent) return; + aggregator.aggregateContent({ event: graphEvent, data: event.data }); +} + /** * @typedef {Object} ToolExecuteOptions * @property {(toolNames: string[]) => Promise<{loadedTools: StructuredTool[]}>} loadTools - Function to load tools by name @@ -140,6 +183,7 @@ function getDefaultHandlers({ streamId = null, toolExecuteOptions = null, summarizationOptions = null, + subagentAggregatorsByToolCallId = null, }) { if (!res || !aggregateContent) { throw new Error( @@ -252,6 +296,55 @@ function getDefaultHandlers({ handlers[GraphEvents.ON_TOOL_EXECUTE] = createToolExecuteHandler(toolExecuteOptions); } + handlers[GraphEvents.ON_SUBAGENT_UPDATE] = { + /** + * Forwards subagent progress envelopes to the client stream, and + * (when a caller-owned aggregator map is provided) also folds each + * event into a per-tool-call `createContentAggregator`. The + * resulting `contentParts` are attached to the parent's `subagent` + * tool_call at message-save time so the child's reasoning / tool + * calls / final text survive a page refresh — in-memory Recoil + * atoms alone wouldn't persist that. + * + * Aggregation runs regardless of stream visibility (persistence + + * dialog depend on it), but the SSE forward respects + * `hide_sequential_outputs` the same way `ON_RUN_STEP`, + * `ON_MESSAGE_DELTA`, etc. do — so intermediate agents in a + * sequential chain don't leak their subagent activity when the + * chain is configured to suppress intermediates. + */ + handle: async (event, data, metadata) => { + const isLastAgent = checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node); + const visible = isLastAgent || !metadata?.hide_sequential_outputs; + /** + * Gate BOTH aggregation (persistence) AND streaming on the same + * visibility rule. If we aggregated for a hidden intermediate + * agent, `finalizeSubagentContent` would still attach its + * child's reasoning / tool output to the saved message — so a + * page refresh would reveal activity that was intentionally + * suppressed live. Treat hide_sequential_outputs as a + * consistent "don't record" rule for subagent traces. + */ + if (!visible) return; + if (subagentAggregatorsByToolCallId && data?.parentToolCallId) { + const key = data.parentToolCallId; + let aggregator = subagentAggregatorsByToolCallId.get(key); + if (!aggregator) { + aggregator = createContentAggregator(); + subagentAggregatorsByToolCallId.set(key, aggregator); + } + try { + feedSubagentAggregator(aggregator, data); + } catch (err) { + logger.warn( + `[ON_SUBAGENT_UPDATE] Failed to aggregate phase "${data?.phase}" for tool_call ${key}: ${err?.message ?? err}`, + ); + } + } + await emitEvent(res, streamId, { event, data }); + }, + }; + if (summarizationOptions?.enabled !== false) { handlers[GraphEvents.ON_SUMMARIZE_START] = { handle: async (_event, data) => { diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index cf48a8d87b..7d59bd7bec 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -82,6 +82,7 @@ class AgentClient extends BaseClient { collectedUsage, artifactPromises, maxContextTokens, + subagentAggregatorsByToolCallId, ...clientOptions } = options; @@ -93,6 +94,12 @@ class AgentClient extends BaseClient { this.collectedUsage = collectedUsage; /** @type {ArtifactPromises} */ this.artifactPromises = artifactPromises; + /** Per-request map of `createContentAggregator` instances keyed by + * the parent's `tool_call_id`. `ON_SUBAGENT_UPDATE` events stream + * into each aggregator as they arrive; `finalizeSubagentContent` + * harvests `contentParts` onto the matching `subagent` tool_call + * so the child's full activity survives a page refresh. */ + this.subagentAggregatorsByToolCallId = subagentAggregatorsByToolCallId ?? new Map(); /** @type {AgentClientOptions} */ this.options = Object.assign({ endpoint: options.endpoint }, clientOptions); /** @type {string} */ @@ -118,6 +125,45 @@ class AgentClient extends BaseClient { return this.contentParts; } + /** + * Harvest the `contentParts` from each per-subagent `createContentAggregator` + * instance and attach them onto the matching parent `subagent` tool_call + * as `subagent_content`. Runs once per message save (from + * `sendCompletion`'s `finally`) so the child's full reasoning / tool + * calls / final text survive a page refresh — the client-side Recoil + * atom is session-only. Aggregators keyed by a tool_call_id that never + * appeared in `contentParts` are discarded (no home to attach to). + */ + finalizeSubagentContent() { + const buffer = this.subagentAggregatorsByToolCallId; + if (!buffer || buffer.size === 0 || !Array.isArray(this.contentParts)) { + return; + } + for (const part of this.contentParts) { + if (part?.type !== ContentTypes.TOOL_CALL) continue; + const toolCall = part[ContentTypes.TOOL_CALL]; + if (!toolCall || toolCall.name !== Constants.SUBAGENT || !toolCall.id) continue; + const aggregator = buffer.get(toolCall.id); + if (!aggregator) continue; + try { + /** `createContentAggregator` returns a sparse array (undefined + * slots for indices that never received content). Strip those + * so the persisted shape is a clean `TMessageContentParts[]`. */ + const parts = Array.isArray(aggregator.contentParts) + ? aggregator.contentParts.filter((p) => p != null) + : []; + if (parts.length > 0) { + toolCall.subagent_content = parts; + } + } catch (err) { + logger.warn( + `[AgentClient] Failed to attach subagent content for tool_call ${toolCall.id}: ${err?.message ?? err}`, + ); + } + } + buffer.clear(); + } + setOptions(_options) {} /** @@ -1017,6 +1063,8 @@ class AgentClient extends BaseClient { this.contextMeta = undefined; } + this.finalizeSubagentContent(); + try { const attachments = await this.awaitMemoryWithTimeout(memoryPromise); if (attachments && attachments.length > 0) { diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 1595f652f7..69c90689ef 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -2265,3 +2265,247 @@ describe('AgentClient - titleConvo', () => { }); }); }); + +describe('AgentClient - finalizeSubagentContent', () => { + /** Verifies the backend persistence path: per-subagent + * `createContentAggregator` instances (populated by the callbacks + * ON_SUBAGENT_UPDATE handler) have their `contentParts` harvested + * onto the matching parent `subagent` tool_call at message-save time + * so a page refresh shows the same activity the user saw live. */ + const { createContentAggregator, GraphEvents } = jest.requireActual('@librechat/agents'); + const { getDefaultHandlers } = require('./callbacks'); + + const makeClient = (subagentAggregatorsByToolCallId) => { + const client = new AgentClient({ + req: { user: { id: 'u' }, body: {}, config: { endpoints: {} } }, + res: {}, + agent: { + id: 'agent', + endpoint: EModelEndpoint.openAI, + provider: EModelEndpoint.openAI, + model_parameters: { model: 'gpt-4' }, + }, + contentParts: [], + subagentAggregatorsByToolCallId, + }); + return client; + }; + + const event = (phase, data, parentToolCallId = 'call_sub') => ({ + runId: 'parent-run', + subagentRunId: 'child-run', + subagentType: 'self', + subagentAgentId: 'child', + parentToolCallId, + phase, + data, + timestamp: '2026-04-17T00:00:00Z', + }); + + /** Feeds a SubagentUpdateEvent sequence through the real + * `ON_SUBAGENT_UPDATE` handler so we exercise the same get-or-create + * aggregator logic the live request uses, rather than constructing + * aggregators directly in the test. */ + const runSubagentEvents = async (events) => { + const map = new Map(); + const handlers = getDefaultHandlers({ + res: { write: jest.fn(), writableEnded: false }, + aggregateContent: jest.fn(), + toolEndCallback: jest.fn(), + collectedUsage: [], + subagentAggregatorsByToolCallId: map, + }); + const handler = handlers[GraphEvents.ON_SUBAGENT_UPDATE]; + for (const e of events) { + await handler.handle(GraphEvents.ON_SUBAGENT_UPDATE, e); + } + return map; + }; + + it('attaches aggregated subagent_content to the matching subagent tool_call part', async () => { + const buffer = await runSubagentEvents([ + event('run_step', { + id: 'step_msg', + index: 0, + stepDetails: { type: 'message_creation' }, + }), + event('message_delta', { + id: 'step_msg', + delta: { content: [{ type: 'text', text: 'Hello ' }] }, + }), + event('message_delta', { + id: 'step_msg', + delta: { content: [{ type: 'text', text: 'world!' }] }, + }), + event('run_step', { + id: 'step_tool', + index: 1, + stepDetails: { + type: 'tool_calls', + tool_calls: [{ id: 'inner_1', name: 'calculator', args: '{}' }], + }, + }), + event('run_step_completed', { + id: 'step_tool', + index: 1, + result: { + id: 'step_tool', + type: 'tool_call', + tool_call: { + id: 'inner_1', + name: 'calculator', + output: '4', + progress: 1, + }, + }, + }), + ]); + + const client = makeClient(buffer); + client.contentParts = [ + { + type: 'tool_call', + tool_call: { + id: 'call_sub', + name: Constants.SUBAGENT, + args: '{}', + output: 'final text', + progress: 1, + }, + }, + ]; + + client.finalizeSubagentContent(); + + const attached = client.contentParts[0].tool_call.subagent_content; + expect(Array.isArray(attached)).toBe(true); + expect(attached).toHaveLength(2); + expect(attached[0].type).toBe('text'); + expect(attached[0].text).toBe('Hello world!'); + expect(attached[1].type).toBe('tool_call'); + expect(attached[1].tool_call.name).toBe('calculator'); + expect(attached[1].tool_call.output).toBe('4'); + /** Buffer drained so a second call (e.g. resumable retry) doesn't + * double-append. */ + expect(buffer.size).toBe(0); + }); + + it('ignores tool_call parts whose name is not SUBAGENT', async () => { + const buffer = await runSubagentEvents([ + event( + 'run_step', + { + id: 'step_msg', + index: 0, + stepDetails: { type: 'message_creation' }, + }, + 'call_regular', + ), + event( + 'message_delta', + { + id: 'step_msg', + delta: { content: [{ type: 'text', text: 'x' }] }, + }, + 'call_regular', + ), + ]); + const client = makeClient(buffer); + client.contentParts = [ + { + type: 'tool_call', + tool_call: { id: 'call_regular', name: 'calculator', args: '{}' }, + }, + ]; + client.finalizeSubagentContent(); + expect(client.contentParts[0].tool_call.subagent_content).toBeUndefined(); + }); + + it('is a safe no-op when the aggregator map is empty or missing', () => { + const client = makeClient(undefined); + client.contentParts = [ + { + type: 'tool_call', + tool_call: { id: 'call_sub', name: Constants.SUBAGENT, args: '{}' }, + }, + ]; + expect(() => client.finalizeSubagentContent()).not.toThrow(); + expect(client.contentParts[0].tool_call.subagent_content).toBeUndefined(); + }); + + it('discards aggregators keyed by a tool_call_id not present in contentParts', async () => { + const buffer = await runSubagentEvents([ + event( + 'run_step', + { + id: 'step_msg', + index: 0, + stepDetails: { type: 'message_creation' }, + }, + 'call_missing', + ), + event( + 'message_delta', + { + id: 'step_msg', + delta: { content: [{ type: 'text', text: 'x' }] }, + }, + 'call_missing', + ), + ]); + const client = makeClient(buffer); + client.contentParts = [ + { + type: 'tool_call', + tool_call: { id: 'call_other', name: Constants.SUBAGENT, args: '{}' }, + }, + ]; + client.finalizeSubagentContent(); + expect(client.contentParts[0].tool_call.subagent_content).toBeUndefined(); + }); + + it('keeps per-parent tool_call aggregators isolated for parallel subagents', async () => { + const buffer = await runSubagentEvents([ + event( + 'run_step', + { + id: 'step_a', + index: 0, + stepDetails: { type: 'message_creation' }, + }, + 'call_a', + ), + event( + 'message_delta', + { id: 'step_a', delta: { content: [{ type: 'text', text: 'A' }] } }, + 'call_a', + ), + event( + 'run_step', + { + id: 'step_b', + index: 0, + stepDetails: { type: 'message_creation' }, + }, + 'call_b', + ), + event( + 'message_delta', + { id: 'step_b', delta: { content: [{ type: 'text', text: 'B' }] } }, + 'call_b', + ), + ]); + const client = makeClient(buffer); + client.contentParts = [ + { type: 'tool_call', tool_call: { id: 'call_a', name: Constants.SUBAGENT, args: '{}' } }, + { type: 'tool_call', tool_call: { id: 'call_b', name: Constants.SUBAGENT, args: '{}' } }, + ]; + client.finalizeSubagentContent(); + expect(client.contentParts[0].tool_call.subagent_content).toEqual([ + expect.objectContaining({ type: 'text', text: 'A' }), + ]); + expect(client.contentParts[1].tool_call.subagent_content).toEqual([ + expect.objectContaining({ type: 'text', text: 'B' }), + ]); + }); +}); diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index 5bddb9aac3..23c8341839 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -26,6 +26,8 @@ const { EToolResources, PermissionBits, actionDelimiter, + AgentCapabilities, + EModelEndpoint, removeNullishValues, } = require('librechat-data-provider'); const { @@ -55,25 +57,28 @@ const MAX_SEARCH_LEN = 100; const escapeRegex = (str = '') => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); /** - * Validates that the requesting user has VIEW access to every agent referenced in edges. - * Agents that do not exist in the database are skipped — at create time, the `from` field - * often references the agent being built, which has no DB record yet. - * @param {import('librechat-data-provider').GraphEdge[]} edges + * Looks up each referenced agent id in Mongo, splits them into three + * buckets the caller needs for validation: ids that don't exist at all, + * ids the user lacks VIEW permission on, and ids that are fully + * accessible. Missing ids are intentionally NOT treated as unauthorized + * — for `edges`, a self-referential `from` can legitimately name the + * agent being created (no DB record yet); callers that should reject + * missing ids (like the subagent path) read the `missing` bucket + * instead. + * @param {Iterable} agentIds * @param {string} userId - * @param {string} userRole - Used for group/role principal resolution - * @returns {Promise} Agent IDs the user cannot VIEW (empty if all accessible) + * @param {string} userRole + * @returns {Promise<{ missing: string[], unauthorized: string[] }>} */ -const validateEdgeAgentAccess = async (edges, userId, userRole) => { - const edgeAgentIds = collectEdgeAgentIds(edges); - if (edgeAgentIds.size === 0) { - return []; - } +const classifyAgentReferences = async (agentIds, userId, userRole) => { + const ids = [...new Set(agentIds)]; + if (ids.length === 0) return { missing: [], unauthorized: [] }; - const agents = await db.getAgents({ id: { $in: [...edgeAgentIds] } }); + const agents = await db.getAgents({ id: { $in: ids } }); + const foundIds = new Set(agents.map((a) => a.id)); + const missing = ids.filter((id) => !foundIds.has(id)); - if (agents.length === 0) { - return []; - } + if (agents.length === 0) return { missing, unauthorized: [] }; const permissionsMap = await getResourcePermissionsMap({ userId, @@ -82,12 +87,57 @@ const validateEdgeAgentAccess = async (edges, userId, userRole) => { resourceIds: agents.map((a) => a._id), }); - return agents + const unauthorized = agents .filter((a) => { const bits = permissionsMap.get(a._id.toString()) ?? 0; return (bits & PermissionBits.VIEW) === 0; }) .map((a) => a.id); + + return { missing, unauthorized }; +}; + +/** + * Validates VIEW access for every agent referenced in `edges`. + * Missing ids are NOT errors here — at create time a self-referential + * `from` often names the agent being built, which has no DB record + * yet. Only unauthorized (existing but unviewable) ids are returned. + */ +const validateEdgeAgentAccess = async (edges, userId, userRole) => { + const { unauthorized } = await classifyAgentReferences( + collectEdgeAgentIds(edges), + userId, + userRole, + ); + return unauthorized; +}; + +/** + * Validates `subagents.agent_ids` more strictly than edges: both + * missing AND unauthorized ids are errors. `subagents.agent_ids` + * can't self-reference (subagents spawn *other* agents), so a + * missing id is always a typo or a reference to a deleted agent — + * `initializeClient` would silently drop it at runtime, leaving the + * persisted config out of sync with actual spawn targets (Codex P2). + * Returning the split lets the caller report each bucket with the + * appropriate status. + */ +const validateSubagentReferences = (subagents, userId, userRole) => + classifyAgentReferences(subagents?.agent_ids ?? [], userId, userRole); + +/** + * Returns true when the agents-endpoint `subagents` capability is + * enabled in this request's resolved app config. When disabled, + * `initializeClient` already strips the `subagents` block at runtime + * so persisted `agent_ids` are inert — gating the ACL check on this + * keeps stale references in legacy records from blocking unrelated + * edits after a capability-off rollback (Codex P2). + * @param {Express.Request} req + */ +const isSubagentsCapabilityEnabled = (req) => { + const capabilities = req.config?.endpoints?.[EModelEndpoint.agents]?.capabilities; + if (!Array.isArray(capabilities)) return false; + return capabilities.includes(AgentCapabilities.subagents); }; /** @@ -199,6 +249,44 @@ const createAgentHandler = async (req, res) => { } } + /** + * Only validate subagent ACL when the feature is actually enabled + * on BOTH the endpoint (capability flag in appConfig) AND the + * agent payload. Runtime (`initializeClient` + `run.ts`) checks + * `subagents?.enabled` as a truthy predicate — so `undefined` / + * `null` / missing `enabled` all disable the feature. The ACL + * check must match exactly: only enforce when `enabled === true`. + * Otherwise a payload that omits `enabled` (e.g. API clients, or + * legacy records that never set the field) could 403 here while + * runtime would happily no-op on the subagent tool. Disable-path + * is also untouched: toggling `enabled: false` always passes the + * gate, so a user who lost VIEW on a child can still save the + * disable edit. + */ + if ( + isSubagentsCapabilityEnabled(req) && + agentData.subagents?.enabled === true && + agentData.subagents?.agent_ids?.length + ) { + const { missing, unauthorized } = await validateSubagentReferences( + agentData.subagents, + userId, + userRole, + ); + if (missing.length > 0) { + return res.status(400).json({ + error: 'One or more agents referenced in subagents do not exist', + agent_ids: missing, + }); + } + if (unauthorized.length > 0) { + return res.status(403).json({ + error: 'You do not have access to one or more agents referenced in subagents', + agent_ids: unauthorized, + }); + } + } + agentData.id = `agent_${nanoid()}`; agentData.author = userId; agentData.tools = []; @@ -371,6 +459,38 @@ const updateAgentHandler = async (req, res) => { } } + /** Same guard as the create path: capability on the endpoint, + * AND `subagents.enabled === true` on the payload (runtime's + * truthy check treats `undefined` / `null` / `false` as + * disabled, so the ACL check must too). Missing or explicitly- + * disabled payloads always pass the gate — that preserves the + * "can always save a disable edit" behavior a user might need + * after losing VIEW on a referenced child. */ + if ( + isSubagentsCapabilityEnabled(req) && + updateData.subagents?.enabled === true && + updateData.subagents?.agent_ids?.length + ) { + const { id: userId, role: userRole } = req.user; + const { missing, unauthorized } = await validateSubagentReferences( + updateData.subagents, + userId, + userRole, + ); + if (missing.length > 0) { + return res.status(400).json({ + error: 'One or more agents referenced in subagents do not exist', + agent_ids: missing, + }); + } + if (unauthorized.length > 0) { + return res.status(403).json({ + error: 'You do not have access to one or more agents referenced in subagents', + agent_ids: unauthorized, + }); + } + } + // Convert OCR to context in incoming updateData convertOcrToContextInPlace(updateData); diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 3baf6b8554..367a8adb45 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -192,6 +192,18 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { const summarizationOptions = appConfig?.summarization?.enabled === false ? { enabled: false } : { enabled: true }; + /** + * Per-request map of per-subagent `createContentAggregator` instances + * keyed by the parent's `tool_call_id`. The handler in `callbacks.js` + * lazily creates an aggregator for each distinct `parentToolCallId` + * and folds every `ON_SUBAGENT_UPDATE` event into it as they stream + * in. `AgentClient` pulls each aggregator's `contentParts` at message + * save time and attaches them to the matching `subagent` tool_call so + * the child's reasoning / tool calls / final text survive a page + * refresh — the client-side Recoil atom is best-effort live-only. + */ + const subagentAggregatorsByToolCallId = new Map(); + const eventHandlers = getDefaultHandlers({ res, toolExecuteOptions, @@ -200,6 +212,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { toolEndCallback, collectedUsage, streamId, + subagentAggregatorsByToolCallId, }); if (!endpointOption.agent) { @@ -312,6 +325,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { agentConfigs: discoveredConfigs, edges: discoveredEdges, userMCPAuthMap: discoveredMCPAuthMap, + skippedAgentIds: discoveredSkippedIds, } = await discoverConnectedAgents( { req, @@ -438,6 +452,262 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { // 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. + 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); + } + } + + /** 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; + + try { + const agent = await db.getAgent({ id: agentId }); + if (!agent) { + skippedAgentIds.add(agentId); + return null; + } + const userId = req.user?.id; + if (!userId) { + skippedAgentIds.add(agentId); + return null; + } + const hasAccess = await checkPermission({ + userId, + role: req.user?.role, + resourceType: ResourceType.AGENT, + resourceId: agent._id, + requiredPermission: PermissionBits.VIEW, + }); + if (!hasAccess) { + logger.warn( + `[processAgent] User ${userId} lacks VIEW access to subagent ${agentId}, skipping`, + ); + 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 config = await initializeAgent( + { + req, + res, + agent, + loadTools, + requestFiles, + conversationId, + parentMessageId, + endpointOption: { ...endpointOption, endpoint: EModelEndpoint.agents }, + allowedProviders, + accessibleSkillIds: scopeSkillIds( + accessibleSkillIds, + ephemeralSkillsToggle ? undefined : agent.skills, + ), + skillStates, + defaultActiveOnShare, + }, + { + getAgent: db.getAgent, + checkPermission, + logViolation, + db: { + getFiles: db.getFiles, + getUserKey: db.getUserKey, + getMessages: db.getMessages, + getConvoFiles: db.getConvoFiles, + updateFilesUsage: db.updateFilesUsage, + getUserKeyValues: db.getUserKeyValues, + getUserCodeFiles: db.getUserCodeFiles, + getToolFilesByIds: db.getToolFilesByIds, + getCodeGeneratedFiles: db.getCodeGeneratedFiles, + filterFilesByAgentAccess, + listSkillsByAccess: db.listSkillsByAccess, + listAlwaysApplySkills: db.listAlwaysApplySkills, + getSkillByName: db.getSkillByName, + }, + }, + ); + agentConfigs.set(agentId, config); + agentToolContexts.set(agentId, { + agent, + toolRegistry: config.toolRegistry, + userMCPAuthMap: config.userMCPAuthMap, + tool_resources: config.tool_resources, + actionsEnabled: config.actionsEnabled, + accessibleSkillIds: config.accessibleSkillIds, + skillPrimedIdsByName: buildSkillPrimedIdsByName( + config.manualSkillPrimes, + config.alwaysApplySkillPrimes, + ), + }); + return config; + } catch (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(); + + /** + * 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) => { + const sub = config.subagents; + if (!subagentsCapabilityEnabled || !sub?.enabled) { + config.subagentAgentConfigs = []; + 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) + : [], + ), + ); + + /** @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); + continue; + } + + const subagentConfig = await loadAgentById(subagentId); + if (!subagentConfig) continue; + + resolved.push(subagentConfig); + + if (!edgeAgentIds.has(subagentId)) { + pureSubagentIds.add(subagentId); + } + } + + config.subagentAgentConfigs = resolved; + }; + + /** BFS across the primary's subagent tree so nested chains like + * A → B → C get resolved before any pruning. Each config is + * visited once. */ + const visitedConfigIds = new Set(); + const pending = [primaryConfig]; + while (pending.length > 0) { + const cfg = pending.shift(); + if (!cfg || visitedConfigIds.has(cfg.id)) continue; + visitedConfigIds.add(cfg.id); + await loadSubagentsFor(cfg); + for (const child of cfg.subagentAgentConfigs ?? []) { + if (child?.id && !visitedConfigIds.has(child.id)) { + pending.push(child); + } + } + } + /** Handoff targets still in the map that weren't visited via the + * primary's subagent tree also need their subagents resolved. */ + for (const [id, cfg] of agentConfigs.entries()) { + if (id === primaryConfig.id || visitedConfigIds.has(id)) continue; + visitedConfigIds.add(id); + await loadSubagentsFor(cfg); + for (const child of cfg.subagentAgentConfigs ?? []) { + if (child?.id && !visitedConfigIds.has(child.id)) { + visitedConfigIds.add(child.id); + await loadSubagentsFor(child); + } + } + } + + /** 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 + * every loaded config — not just the primary. `run.ts` calls + * `buildSubagentConfigs` for every agent in the array, so a handoff + * agent with `subagents.enabled: true` persisted on its document would + * otherwise still expose self-spawn at runtime even though the admin + * has disabled the capability globally. */ + if (!subagentsCapabilityEnabled) { + for (const config of agentConfigs.values()) { + config.subagents = undefined; + config.subagentAgentConfigs = undefined; + } + } + let endpointConfig = appConfig.endpoints?.[primaryConfig.endpoint]; if (!isAgentsEndpoint(primaryConfig.endpoint) && !endpointConfig) { try { @@ -497,6 +767,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { resendFiles: primaryConfig.resendFiles ?? true, maxContextTokens: primaryConfig.maxContextTokens, endpoint: isEphemeralAgentId(primaryConfig.id) ? primaryConfig.endpoint : EModelEndpoint.agents, + subagentAggregatorsByToolCallId, }); if (streamId) { diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index 8027744965..ca44be8122 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -27,14 +27,22 @@ jest.mock('@librechat/api', () => ({ createSequentialChainEdges: jest.fn(), })); +/** Captured by the `getDefaultHandlers` mock so tests can drive the + * `ON_TOOL_EXECUTE` pipeline with a real subagent id and observe whether + * the tool context (agent, tool_resources, skill ACLs) was preserved. */ +let capturedToolExecuteOptions; jest.mock('~/server/controllers/agents/callbacks', () => ({ createToolEndCallback: jest.fn(() => jest.fn()), - getDefaultHandlers: jest.fn(() => ({})), + getDefaultHandlers: jest.fn((opts) => { + capturedToolExecuteOptions = opts?.toolExecuteOptions; + return {}; + }), })); +const mockLoadToolsForExecution = jest.fn(); jest.mock('~/server/services/ToolService', () => ({ loadAgentTools: jest.fn(), - loadToolsForExecution: jest.fn(), + loadToolsForExecution: (...args) => mockLoadToolsForExecution(...args), })); jest.mock('~/server/controllers/ModelController', () => ({ @@ -199,3 +207,384 @@ describe('initializeClient — processAgent ACL gate', () => { expect(agentClientArgs.agent.edges[0].to).toBe(AUTHORIZED_ID); }); }); + +describe('initializeClient — subagent loading', () => { + const SUBAGENT_ID = 'agent_subagent_1'; + const DUPLICATE_SUBAGENT_ID = 'agent_subagent_dup'; + const HANDOFF_AND_SUB_ID = 'agent_handoff_and_sub'; + + let mongoServer; + let testUser; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + beforeEach(async () => { + await mongoose.connection.dropDatabase(); + jest.clearAllMocks(); + agentClientArgs = undefined; + capturedToolExecuteOptions = undefined; + mockLoadToolsForExecution.mockReset(); + mockLoadToolsForExecution.mockResolvedValue({ loadedTools: [] }); + + testUser = await User.create({ + email: 'subagent@example.com', + name: 'Subagent User', + username: 'subuser', + role: 'USER', + }); + + mockValidateAgentModel.mockResolvedValue({ isValid: true }); + }); + + /** Grant the test user VIEW on an agent so processAgent loads it. */ + const grantView = async (agentDoc) => { + await AclEntry.create({ + principalType: PrincipalType.USER, + principalId: testUser._id, + principalModel: PrincipalModel.USER, + resourceType: ResourceType.AGENT, + resourceId: agentDoc._id, + permBits: PermissionBits.VIEW, + grantedBy: testUser._id, + }); + }; + + /** Build a request with the `subagents` capability enabled. */ + const makeSubagentReq = () => ({ + user: { id: testUser._id.toString(), role: 'USER' }, + body: { conversationId: 'conv_sub', files: [] }, + config: { + endpoints: { + agents: { + capabilities: ['subagents'], + }, + }, + }, + _resumableStreamId: null, + }); + + const makeEndpointOption = () => ({ + agent: Promise.resolve({ + id: PRIMARY_ID, + name: 'Primary', + provider: 'openai', + model: 'gpt-4', + tools: [], + }), + model_parameters: { model: 'gpt-4' }, + endpoint: 'agents', + }); + + const makePrimaryConfig = ({ edges = [], subagents, agent_ids }) => ({ + id: PRIMARY_ID, + endpoint: 'agents', + edges, + toolDefinitions: [], + toolRegistry: new Map(), + userMCPAuthMap: null, + tool_resources: {}, + resendFiles: true, + maxContextTokens: 4096, + subagents, + agent_ids, + }); + + const makeSubagentConfig = (id) => ({ + id, + endpoint: 'agents', + edges: [], + toolDefinitions: [{ name: 'web', description: 'web', parameters: {} }], + toolRegistry: new Map([['web', { name: 'web' }]]), + userMCPAuthMap: null, + tool_resources: { file_search: { file_ids: ['file_1'] } }, + accessibleSkillIds: ['skill_1'], + actionsEnabled: true, + resendFiles: false, + maxContextTokens: 4096, + }); + + it('loads a configured subagent, populates `subagentAgentConfigs`, and keeps it out of `agentConfigs`', async () => { + const subAgent = await createAgent({ + id: SUBAGENT_ID, + name: 'Explicit Subagent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: ['web'], + }); + await grantView(subAgent); + + const primaryConfig = makePrimaryConfig({ + subagents: { enabled: true, allowSelf: true, agent_ids: [SUBAGENT_ID] }, + }); + const subagentConfig = makeSubagentConfig(SUBAGENT_ID); + + let call = 0; + mockInitializeAgent.mockImplementation(() => + Promise.resolve(++call === 1 ? primaryConfig : subagentConfig), + ); + + await initializeClient({ + req: makeSubagentReq(), + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + expect(mockInitializeAgent).toHaveBeenCalledTimes(2); + + /** 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('preserves subagent tool context for ON_TOOL_EXECUTE (Codex P1 regression guard)', async () => { + /** Verifies the Codex P1 fix: `agentToolContexts.delete(subagentId)` is + * NOT called for subagent-only agents, so when the child dispatches + * `ON_TOOL_EXECUTE` the parent can still resolve its tool context + * (agent, tool_resources, skill ACLs, actionsEnabled) to run tools + * with the right scope. We drive the real `loadTools` closure that + * `initializeClient` wires into `toolExecuteOptions`. */ + const subAgent = await createAgent({ + id: SUBAGENT_ID, + name: 'Explicit Subagent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: ['web'], + }); + await grantView(subAgent); + + const primaryConfig = makePrimaryConfig({ + subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID] }, + }); + const subagentConfig = makeSubagentConfig(SUBAGENT_ID); + + let call = 0; + mockInitializeAgent.mockImplementation(() => + Promise.resolve(++call === 1 ? primaryConfig : subagentConfig), + ); + + await initializeClient({ + req: makeSubagentReq(), + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + expect(capturedToolExecuteOptions?.loadTools).toBeInstanceOf(Function); + + /** Invoke the real closure with the subagent's id. If `agentToolContexts` + * had been deleted (as in the pre-fix code), this would call + * `loadToolsForExecution` with `agent: undefined` — actions/resource- + * scoped tools would silently drop. */ + await capturedToolExecuteOptions.loadTools(['web'], SUBAGENT_ID); + + expect(mockLoadToolsForExecution).toHaveBeenCalledTimes(1); + const arg = mockLoadToolsForExecution.mock.calls[0][0]; + expect(arg.agent).toBeDefined(); + expect(arg.agent.id).toBe(SUBAGENT_ID); + expect(arg.toolRegistry).toBeInstanceOf(Map); + expect(arg.tool_resources).toEqual({ file_search: { file_ids: ['file_1'] } }); + expect(arg.actionsEnabled).toBe(true); + }); + + it('deduplicates repeated ids in subagents.agent_ids', async () => { + const subAgent = await createAgent({ + id: DUPLICATE_SUBAGENT_ID, + name: 'Dup Subagent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: [], + }); + await grantView(subAgent); + + const primaryConfig = makePrimaryConfig({ + subagents: { + enabled: true, + allowSelf: false, + /** Same id three times — the backend must not load the agent + * repeatedly and must not emit three SubagentConfig entries. */ + agent_ids: [DUPLICATE_SUBAGENT_ID, DUPLICATE_SUBAGENT_ID, DUPLICATE_SUBAGENT_ID], + }, + }); + const subagentConfig = makeSubagentConfig(DUPLICATE_SUBAGENT_ID); + + let initCalls = 0; + mockInitializeAgent.mockImplementation(() => { + initCalls += 1; + return Promise.resolve(initCalls === 1 ? primaryConfig : subagentConfig); + }); + + await initializeClient({ + req: makeSubagentReq(), + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + /** One call for primary, one for the subagent — not four. */ + expect(mockInitializeAgent).toHaveBeenCalledTimes(2); + expect(agentClientArgs.agent.subagentAgentConfigs).toHaveLength(1); + }); + + it('keeps an agent in `agentConfigs` when it is BOTH a handoff target and a subagent', async () => { + /** Overlap case: the same child is used both via handoff edges (needs to + * be in agentConfigs) and as a subagent (needs to be in + * subagentAgentConfigs, and its tool context preserved). The pipeline + * shouldn't silently drop it from the handoff map. */ + const shared = await createAgent({ + id: HANDOFF_AND_SUB_ID, + name: 'Shared Agent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: [], + }); + await grantView(shared); + + const edges = [{ from: PRIMARY_ID, to: HANDOFF_AND_SUB_ID, edgeType: 'handoff' }]; + const primaryConfig = makePrimaryConfig({ + edges, + subagents: { + enabled: true, + allowSelf: false, + agent_ids: [HANDOFF_AND_SUB_ID], + }, + }); + const sharedConfig = makeSubagentConfig(HANDOFF_AND_SUB_ID); + + let call = 0; + mockInitializeAgent.mockImplementation(() => + Promise.resolve(++call === 1 ? primaryConfig : sharedConfig), + ); + + await initializeClient({ + req: makeSubagentReq(), + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + expect(agentClientArgs.agent.subagentAgentConfigs).toHaveLength(1); + /** Shared agent must stay in agentConfigs — it's still the handoff target. */ + expect(agentClientArgs.agentConfigs.has(HANDOFF_AND_SUB_ID)).toBe(true); + }); + + it('clears subagents config on primary when the capability is disabled', async () => { + /** Admin can turn subagents off at the endpoint level even if an agent was + * configured for them. The primary's `subagents` field should be + * suppressed so run.ts never builds a SubagentConfig. */ + const primaryConfig = makePrimaryConfig({ + subagents: { enabled: true, allowSelf: true, agent_ids: [] }, + }); + mockInitializeAgent.mockResolvedValue(primaryConfig); + + const req = makeSubagentReq(); + /** Remove the capability from the admin config. */ + req.config.endpoints.agents.capabilities = []; + + await initializeClient({ + req, + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + expect(agentClientArgs.agent.subagents).toBeUndefined(); + expect(agentClientArgs.agent.subagentAgentConfigs).toEqual([]); + }); + + it('clears subagents on handoff agents too when capability is disabled (Codex P2 regression)', async () => { + /** Codex P2: the capability gate must suppress `subagents` on EVERY + * loaded config, not just the primary. `run.ts` iterates all agents + * and calls `buildSubagentConfigs` per agent, so a handoff target + * with `subagents.enabled: true` persisted on its document would + * otherwise still expose self-spawn even when the admin has disabled + * the capability globally. */ + const authorized = await createAgent({ + id: AUTHORIZED_ID, + name: 'Handoff Agent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: [], + }); + await grantView(authorized); + + const edges = [{ from: PRIMARY_ID, to: AUTHORIZED_ID, edgeType: 'handoff' }]; + const primaryConfig = makePrimaryConfig({ + edges, + subagents: { enabled: true, allowSelf: true, agent_ids: [] }, + }); + const handoffConfig = { + id: AUTHORIZED_ID, + endpoint: 'agents', + edges: [], + toolDefinitions: [], + toolRegistry: new Map(), + userMCPAuthMap: null, + tool_resources: {}, + /** Handoff agent document has subagents enabled of its own accord. */ + subagents: { enabled: true, allowSelf: true, agent_ids: [] }, + }; + + let callCount = 0; + mockInitializeAgent.mockImplementation(() => + Promise.resolve(++callCount === 1 ? primaryConfig : handoffConfig), + ); + + const req = makeSubagentReq(); + /** Capability OFF at endpoint level. */ + req.config.endpoints.agents.capabilities = []; + + await initializeClient({ + req, + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + /** Primary cleared. */ + expect(agentClientArgs.agent.subagents).toBeUndefined(); + /** Handoff target must ALSO be cleared — otherwise its self-spawn + * would still fire when run.ts iterates agentConfigs. */ + const handoffLoaded = agentClientArgs.agentConfigs.get(AUTHORIZED_ID); + expect(handoffLoaded).toBeDefined(); + expect(handoffLoaded.subagents).toBeUndefined(); + expect(handoffLoaded.subagentAgentConfigs).toBeUndefined(); + }); + + it('skips subagent loading entirely when the feature is disabled on the agent', async () => { + const primaryConfig = makePrimaryConfig({ + subagents: { enabled: false, allowSelf: true, agent_ids: [SUBAGENT_ID] }, + }); + mockInitializeAgent.mockResolvedValue(primaryConfig); + + await initializeClient({ + req: makeSubagentReq(), + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + /** Only one initializeAgent call — for the primary. No subagent loaded. */ + expect(mockInitializeAgent).toHaveBeenCalledTimes(1); + expect(agentClientArgs.agent.subagentAgentConfigs).toEqual([]); + }); +}); diff --git a/client/src/common/agents-types.ts b/client/src/common/agents-types.ts index cd3e5e0f49..8a018e8fcb 100644 --- a/client/src/common/agents-types.ts +++ b/client/src/common/agents-types.ts @@ -1,6 +1,7 @@ import { AgentCapabilities, ArtifactModes } from 'librechat-data-provider'; import type { AgentModelParameters, + AgentSubagentsConfig, AgentToolOptions, SupportContact, AgentProvider, @@ -43,6 +44,7 @@ export type AgentForm = { /** @deprecated Use edges instead */ agent_ids?: string[]; edges?: GraphEdge[]; + subagents?: AgentSubagentsConfig; [AgentCapabilities.artifacts]?: ArtifactModes | string; recursion_limit?: number; support_contact?: SupportContact; diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index 7478afbd0c..1876434271 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -19,6 +19,7 @@ import { SkillCall, ReadFileCall, BashCall, + SubagentCall, } from './Parts'; import { ErrorMessage } from './MessageContent'; import RetrievalCall from './RetrievalCall'; @@ -169,6 +170,29 @@ const Part = memo(function Part({ attachments={attachments} /> ); + } else if (isToolCall && toolCall.name === Constants.SUBAGENT) { + /** `subagent_content` is the aggregated content-parts array the + * backend writes onto the tool_call at message-save time so the + * child's activity survives a page refresh. Not present on older + * runs recorded before the persistence path existed — those fall + * back to the Recoil atom (live session) or the raw tool output + * inside `SubagentCall`. */ + const persistedContent = ( + toolCall as unknown as { + subagent_content?: TMessageContentParts[]; + } + ).subagent_content; + return ( + + ); } else if (isToolCall && toolCall.name === 'read_file') { return ( ; + output?: string | null; + attachments?: TAttachment[]; + /** Aggregated content parts the backend attached to the tool_call at + * message-save time. Takes precedence over the in-memory Recoil atom + * so a page refresh shows the same history the user saw live. Older + * runs recorded before the persistence path landed will not have this + * field; those fall back to the atom (or the raw `output` string). */ + persistedContent?: TMessageContentParts[]; +} + +const TICKER_MAX_LINES = 3; +/** Trailing-edge throttle window for the live preview. Tuned down from + * the original 1.2s so the ticker feels snappy when the container is + * already full and frames are scrolling. */ +const TICKER_THROTTLE_MS = 800; +/** Below this live-buffer length we skip throttling entirely. Without + * this the user would see "Reasoning: I" for ~1s while the model + * streams the rest of the sentence — the pass-through lets early + * tokens appear right away, and throttling only kicks in once the + * preview is long enough to "fill the container". */ +const TICKER_PASSTHROUGH_CHARS = 120; +/** Distance from the dialog scroller's bottom that still counts as + * "following along". Inside this window new content auto-scrolls; past + * it we pause so the user can read. Slightly looser than the main + * messages view since the dialog is a smaller scroller. */ +const DIALOG_AT_BOTTOM_THRESHOLD_PX = 120; + +/** + * Trailing-edge throttle. Forwards `value` at most once per `intervalMs` + * when `enabled` is true; pass-through when false so the final frame + * lands without waiting out the interval. + * + * Uses refs + `useReducer` for the re-render trigger instead of + * `useState(value)`: storing the throttled value as state would drive + * an infinite update loop whenever the upstream `value` is a new + * reference each render (e.g. a `useMemo` whose deps are stable by + * content but not by identity), because `setState` with a new-reference + * input always schedules another render. + * + * Ref mutations happen during render (idempotent — same value on + * re-invoke under Strict/Concurrent rendering), but `setTimeout` is + * confined to a `useEffect` so discarded renders don't leave orphan + * timers firing against stale trees. + */ +function useThrottledValue(value: T, intervalMs: number, enabled: boolean): T { + const [, forceUpdate] = useReducer((x) => x + 1, 0); + const throttledRef = useRef(value); + const latestValueRef = useRef(value); + /** Negative-infinity sentinel so the very first render always falls + * through the "past the window" branch and the caller sees the + * initial value synchronously — no dead 1.2s while the first frame + * sits in the throttle. */ + const lastFireAtRef = useRef(Number.NEGATIVE_INFINITY); + const timerRef = useRef | null>(null); + + latestValueRef.current = value; + + /** Render-time computation: pick the value the caller should see, and + * commit refs if we're past the throttle window. Ref writes are + * idempotent under Strict Mode double-invoke. No `setTimeout` here — + * that lives in the effect below so replayed renders don't strand + * timers. */ + let effectiveValue: T; + if (!enabled) { + effectiveValue = value; + } else { + const now = performance.now(); + const sinceLast = now - lastFireAtRef.current; + if (sinceLast >= intervalMs) { + throttledRef.current = value; + lastFireAtRef.current = now; + effectiveValue = value; + } else { + effectiveValue = throttledRef.current; + } + } + + /** Schedule the trailing-edge timer after commit. Runs whenever the + * throttled frame is stale relative to the latest value; the timer + * callback fires `forceUpdate` so the next render's render-time + * check commits the now-latest value. */ + useEffect(() => { + if (!enabled) { + if (timerRef.current != null) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + return; + } + if (Object.is(throttledRef.current, latestValueRef.current)) return; + if (timerRef.current != null) return; + const sinceLast = performance.now() - lastFireAtRef.current; + const delay = Math.max(0, intervalMs - sinceLast); + timerRef.current = setTimeout(() => { + timerRef.current = null; + forceUpdate(); + }, delay); + }, [value, intervalMs, enabled]); + + /** Cleanup on unmount. */ + useEffect( + () => () => { + if (timerRef.current != null) clearTimeout(timerRef.current); + }, + [], + ); + + return effectiveValue; +} + +/** + * Renders the parent's `subagent` tool call as a compact "what the child is + * doing right now" ticker. The collapsed view shows short, user-readable + * status lines — streaming text/reasoning previews plus tool-call lifecycle + * markers — built from the `SubagentUpdateEvent` stream. Clicking opens a + * dialog that renders the child's aggregated content parts through the same + * `` pipeline the main conversation uses, so tool calls, reasoning + * blocks, and the final response all look like a regular assistant message. + * + * Progress is sourced from the `subagentProgressByToolCallId` Recoil atom + * family, populated by `useStepHandler` as `ON_SUBAGENT_UPDATE` SSE + * envelopes arrive. The atom is keyed by the parent's `tool_call_id`. + */ +export default function SubagentCall({ + toolCallId, + initialProgress, + isSubmitting = false, + args, + output, + attachments, + persistedContent, +}: SubagentCallProps) { + const localize = useLocalize(); + const progress = useRecoilValue(subagentProgressByToolCallId(toolCallId)); + const agentsMap = useAgentsMapContext(); + const [open, setOpen] = useState(false); + + const subagentType = progress?.subagentType ?? extractSubagentType(args); + const isSelfSpawn = subagentType === 'self'; + /** Avatar lookup for the header icon. We use the child's agent id when + * present (explicit subagents); self-spawn falls back to the agents + * map being unavailable → the Users SVG. The tool UI has a similar + * icon-left-of-label pattern; this reuses `MessageIcon` so the agent's + * configured avatar lands here without a separate image pipeline. */ + const subagentAgentId = progress?.subagentAgentId; + const subagentAgent = subagentAgentId ? agentsMap?.[subagentAgentId] : undefined; + /** + * Tri-state status resolution, aligned with `ToolCall.tsx`: + * + * - `finished`: the tool_call's own progress reached 1 (backend wrote a + * result) OR the subagent explicitly emitted a `stop` / `error` phase. + * - `cancelled`: the stream has ended (`!isSubmitting`) before either + * condition was met — e.g. user stop, dropped connection, backend + * crash. Without this check, an interrupted run would render as + * permanently "working…". + * - `running`: the parent is still streaming and no terminal signal has + * arrived yet. + */ + const hasError = progress?.status === 'error'; + const finished = initialProgress >= 1 || progress?.status === 'stop' || hasError; + const cancelled = !isSubmitting && !finished; + const running = !finished && !cancelled; + + /** + * Content parts for the dialog. Preference order: + * + * 1. **Persisted** `subagent_content` on the parent `tool_call` + * when available. Written by the backend at message-save time + * and refreshed on sync / reconnect — the canonical record of + * the run. After a disconnect the client's live atom may have + * missed events, so trusting `persistedContent` prevents the + * dialog from showing a stale/partial view of a completed + * subagent. + * 2. **Live atom** incrementally built by `foldSubagentEvent` as + * each `ON_SUBAGENT_UPDATE` arrives. Used while the subagent + * is mid-run (before the parent message saves, the persisted + * snapshot is empty) and as a fallback for older runs recorded + * before the persistence path landed. + */ + const liveParts = progress?.contentParts as TMessageContentParts[] | undefined; + const contentParts = useMemo(() => { + if (persistedContent && persistedContent.length > 0) return persistedContent; + if (liveParts && liveParts.length > 0) return liveParts; + return []; + }, [liveParts, persistedContent]); + + /** Last `TICKER_MAX_LINES` lines from the atom's incrementally-built + * ticker state, so history isn't lost to any event trimming. */ + const tickerLines = useMemo(() => { + const lines = progress?.tickerState?.lines ?? []; + return lines.slice(-TICKER_MAX_LINES); + }, [progress?.tickerState?.lines]); + + /** Only throttle once the running buffer is wide enough to "fill the + * container" — pre-threshold updates pass through so the user sees + * early tokens immediately, not a static "Reasoning: I" while more + * text piles up behind the throttle. */ + const shouldThrottleTicker = useMemo(() => { + if (!running) return false; + const liveBody = tickerLines.reduce((max, line) => { + if (line.kind === 'writing' || line.kind === 'reasoning') { + return Math.max(max, line.body.length); + } + return max; + }, 0); + return liveBody >= TICKER_PASSTHROUGH_CHARS; + }, [running, tickerLines]); + + const displayedTickerLines = useThrottledValue( + tickerLines, + TICKER_THROTTLE_MS, + shouldThrottleTicker, + ); + + const description = typeof args === 'string' ? tryDescription(args) : extractDescription(args); + + /** Base verb-only label ("Running agent" / "Ran agent"). The agent name + * is rendered separately as a muted sub-label so "agent" stays a + * constant visual anchor regardless of name length. */ + const headerText = hasError + ? localize('com_ui_subagent_errored') + : cancelled + ? localize('com_ui_subagent_cancelled') + : running + ? localize('com_ui_subagent_running') + : localize('com_ui_subagent_complete'); + /** Muted sub-label shown to the right of the base label: the + * configured agent name for named subagents. Self-spawns omit it + * (redundant — the header already says "agent") as do cases where + * the name isn't resolvable (agent map miss). */ + const subagentNameLabel = !isSelfSpawn && subagentAgent?.name ? subagentAgent.name : ''; + + /** + * Minimal `MessageContext` for the dialog's `` tree. Subagent + * content rendering needs the same context the main conversation uses + * (reasoning expand state, latest-message cursor, etc.) — synthesizing + * a scoped context lets us reuse the real part renderers without + * pulling the full `ChatView` / `MessagesView` tree into the dialog. + */ + const dialogMessageContext = useMemo( + () => ({ + messageId: `subagent-${toolCallId}`, + isExpanded: true, + isSubmitting: running, + isLatestMessage: running, + conversationId: null, + }), + [toolCallId, running], + ); + + const lastPartIndex = contentParts.length - 1; + + /** + * Dialog renderer used by {@link ToolCallGroup} (for grouped tool_call + * batches) and by the per-part map (for single parts). Mirrors the + * main `` dispatch table but stays scoped to the three types + * a subagent run emits — avoiding the import cycle that would come + * from routing through `Parts/index`. + */ + const renderDialogPart = useCallback( + (part: TMessageContentParts, idx: number, isLastPart: boolean): JSX.Element | null => { + return ( + + ); + }, + [toolCallId, running], + ); + + /** + * Apply the same consecutive-tool-call batching the main `ContentParts` + * uses so the dialog renders with visual parity: grouped tools collapse + * into a single `Used N tools` header, single parts wrap in `Container` + * for the same `gap-3` flex column spacing the main conversation has. + */ + const groupedParts = useMemo(() => { + const withIdx: PartWithIndex[] = contentParts.map((part, idx) => ({ part, idx })); + return groupSequentialToolCalls(withIdx); + }, [contentParts]); + + /** + * Auto-scroll the dialog's content area as new parts / delta chunks + * stream in. Same pattern as `MessagesView` but with a dialog-tuned + * threshold — the user can scroll up to read back without auto-scroll + * snatching control. Explicit "jump to bottom" button lets them resume + * following along without having to scroll all the way down. + */ + const scrollRef = useRef(null); + const contentRef = useRef(null); + const [isAtBottom, setIsAtBottom] = useState(true); + + /** React `onScroll` prop instead of manual `addEventListener` so the + * handler attaches as part of DOM commit — no race with Radix's + * portal-mount timing that would leave `scrollRef.current` null when + * the effect runs and silently skip the listener. */ + const handleScroll = useCallback((event: React.UIEvent) => { + const el = event.currentTarget; + const distance = el.scrollHeight - el.scrollTop - el.clientHeight; + setIsAtBottom(distance <= DIALOG_AT_BOTTOM_THRESHOLD_PX); + }, []); + + /** Snap to bottom every time the dialog opens so a freshly-opened + * dialog starts parked on the live cursor. */ + useEffect(() => { + if (!open) return; + const el = scrollRef.current; + if (!el) return; + el.scrollTop = el.scrollHeight; + setIsAtBottom(true); + }, [open]); + + /** Keep the view pinned to the bottom while the user is at/near it — + * including during delta streams that grow the last TEXT/THINK part + * without changing `contentParts.length`. A `ResizeObserver` on the + * inner content div catches every height change, whether structural + * (new tool call) or incremental (writing text grows in-place), so + * auto-scroll doesn't desync just because tokens are piling into an + * existing part. */ + useEffect(() => { + if (!open) return; + const scrollEl = scrollRef.current; + const contentEl = contentRef.current; + if (!scrollEl || !contentEl) return; + if (typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(() => { + if (!isAtBottom) return; + scrollEl.scrollTop = scrollEl.scrollHeight; + }); + observer.observe(contentEl); + return () => observer.disconnect(); + }, [open, isAtBottom]); + + const scrollDialogToBottom = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' }); + setIsAtBottom(true); + }, []); + + return ( + <> + + + + + + {isSelfSpawn + ? localize('com_ui_subagent_dialog_title_self') + : localize('com_ui_subagent_dialog_title', { 0: subagentType })} + + + {description || localize('com_ui_subagent_dialog_description')} + + +
+ {!isAtBottom && ( + + )} +
+
+ {contentParts.length > 0 ? ( + + {groupedParts.map((group) => { + if (group.type === 'single') { + const { part, idx } = group.part; + /** Per-type dispatch handles wrapping: TEXT goes + * through `Container`, THINK/TOOL_CALL render + * directly so their own wrappers set the width + * and spacing. */ + return renderDialogPart(part, idx, idx === lastPartIndex); + } + /** Consecutive tool_calls (2+) collapse into a + * `Used N tools` group — same behavior as the main + * message view. */ + return ( + p.idx === lastPartIndex)} + renderPart={renderDialogPart} + lastContentIdx={lastPartIndex} + /> + ); + })} + + ) : output ? ( + /** Fallback: no aggregated content parts but the backend + * wrote a final tool_call output. Happens for older + * subagent runs recorded before the event forwarder + * existed. Route through the same leaf renderer so + * markdown renders properly. */ + + + + ) : ( +
+ {running + ? localize('com_ui_subagent_no_result_yet') + : localize('com_ui_subagent_empty_result')} +
+ )} +
+
+
+
+
+ + {attachments && attachments.length > 0 && } + + ); +} + +function extractSubagentType(args: SubagentCallProps['args']): string { + if (typeof args === 'string') { + try { + const parsed = JSON.parse(args) as { subagent_type?: string }; + return parsed?.subagent_type ?? 'agent'; + } catch { + return 'agent'; + } + } + const a = args as { subagent_type?: string } | undefined; + return a?.subagent_type ?? 'agent'; +} + +function extractDescription(args: Record | undefined): string | undefined { + const d = args?.description; + return typeof d === 'string' && d.length > 0 ? d : undefined; +} + +function tryDescription(args: string): string | undefined { + try { + const parsed = JSON.parse(args) as { description?: string }; + return typeof parsed?.description === 'string' ? parsed.description : undefined; + } catch { + return undefined; + } +} + +/** Stable key for a ticker line — helps React reuse the DOM node across + * in-place updates to the same live `writing` / `reasoning` line, and + * gives tool-call lines a stable identity by tool name. */ +function tickerLineKey(line: SubagentTickerLine): string { + switch (line.kind) { + case 'writing': + case 'reasoning': + return line.kind; + case 'using_tool': + return `using:${line.toolNames.join(',')}`; + case 'tool_complete': + return `done:${line.toolName}`; + case 'error': + return `error:${line.message ?? ''}`; + } +} + +/** Inline code-style tool-name badge. Matches the monospace styling of + * the ticker itself but with a subtle background so the tool identifier + * reads as a "code" token rather than plain prose. */ +function ToolNameBadge({ name }: { name: string }): JSX.Element { + return ( + {name} + ); +} + +/** Render a single tool id as a compact JSX fragment: MCP tools split + * into ` · toolName`, native tools resolve their + * friendly name via `FRIENDLY_NAME_KEYS`, unknown ids fall back to a + * bare code badge of the raw name. */ +function ToolIdentifier({ + rawName, + localize, +}: { + rawName: string; + localize: ReturnType; +}): JSX.Element { + const parsed = parseToolName(rawName); + if (parsed.mcpServer) { + return ( + + {parsed.mcpServer} + · + + + ); + } + if (parsed.friendlyKey) { + return {localize(parsed.friendlyKey)}; + } + return ; +} + +/** + * Renderer for one ticker line. Splits a fixed label (e.g. "Writing:") + * into its own `shrink-0` span so the label is never clipped when the + * body overflows; the body then uses `dir="rtl"` + `text-align: left` + * to push tail-side ellipsis behavior (newest characters stay flush- + * right, oldest clip off the left). The rtl trick is scoped to the + * body span so trailing punctuation on non-streaming lines (e.g. the + * `…` in "Waiting for first update…") can't get flipped by bidi. + * + * Tool lines (`using_tool`, `tool_complete`) go through `ToolIdentifier` + * so MCP-hosted tools render as ` · ` badges and native + * tools use their friendly names — matching the delimiter-aware + * rendering the main tool UI already uses. + */ +function TickerLineView({ line }: { line: SubagentTickerLine }): JSX.Element { + const localize = useLocalize(); + if (line.kind === 'writing' || line.kind === 'reasoning') { + const prefix = + line.kind === 'writing' + ? localize('com_ui_subagent_ticker_writing') + : localize('com_ui_subagent_ticker_reasoning'); + return ( +
  • + {prefix}: + + {line.body} + +
  • + ); + } + if (line.kind === 'using_tool') { + const prefix = localize('com_ui_subagent_ticker_using'); + return ( +
  • + {prefix} + + {line.toolNames.map((name, i) => ( + + {i > 0 && ,} + + + ))} + {line.argsSnippet && ( + ({line.argsSnippet}) + )} + +
  • + ); + } + if (line.kind === 'tool_complete') { + return ( +
  • + + + + {line.outputSnippet ?? localize('com_ui_subagent_ticker_tool_done')} + +
  • + ); + } + /* error */ + const errorPrefix = localize('com_ui_subagent_ticker_error'); + return ( +
  • + {errorPrefix}: + {line.message ?? ''} +
  • + ); +} + +/** + * Per-part renderer for the dialog. Mirrors the wrapper choices `` + * makes in regular messages so subagent content matches the visual width + * and spacing the user already knows: TEXT wraps in `Container` (which + * provides `gap-3` column spacing and the `mt-5` sibling margin), while + * THINK and TOOL_CALL render bare — their own wrappers (`Reasoning`'s + * `mb-2 pb-2 pt-2` box, `ToolCall`'s own margins) control their layout + * and full-column width. Staying inline (vs. calling ``) avoids + * the `Parts/index.ts → SubagentCall → Part` import cycle and keeps us + * from accidentally rendering a nested subagent dialog. + */ +function SubagentDialogPart({ + part, + isSubmitting, + showCursor, + isLast, +}: { + part: TMessageContentParts; + isSubmitting: boolean; + showCursor: boolean; + isLast: boolean; +}): JSX.Element | null { + if (part.type === ContentTypes.TEXT) { + const text = (part as { text: string }).text; + return ( + + + + ); + } + if (part.type === ContentTypes.THINK) { + const think = (part as { think: string }).think; + return ; + } + if (part.type === ContentTypes.TOOL_CALL) { + const tc = ( + part as { + [ContentTypes.TOOL_CALL]?: { + args?: string | Record; + output?: string; + name?: string; + progress?: number; + }; + } + )[ContentTypes.TOOL_CALL]; + if (!tc) return null; + return ( + + ); + } + return null; +} diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx new file mode 100644 index 0000000000..14725282cf --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx @@ -0,0 +1,626 @@ +import React from 'react'; +import { RecoilRoot, useRecoilCallback } from 'recoil'; +import { render, screen, act, waitFor } from '@testing-library/react'; +import SubagentCall from '../SubagentCall'; +import { subagentProgressByToolCallId, type SubagentProgress } from '~/store/subagents'; +import { + foldSubagentEvent, + foldSubagentEventIntoTicker, + initSubagentAggregatorState, + initSubagentTickerState, + type SubagentContentPart, + type SubagentAggregatorState, + type SubagentTickerState, +} from '~/utils/subagentContent'; +import type { SubagentUpdateEvent } from 'librechat-data-provider'; + +jest.mock('~/hooks', () => ({ + useLocalize: + () => + (key: string, values?: Record): string => { + const arg0 = (values?.[0] as string | undefined) ?? ''; + const arg1 = (values?.[1] as string | undefined) ?? ''; + const translations: Record = { + com_ui_subagent_running: 'Running agent', + com_ui_subagent_complete: 'Ran agent', + com_ui_subagent_cancelled: 'Cancelled agent', + com_ui_subagent_errored: 'Agent errored', + com_ui_subagent_waiting: 'Waiting for first update…', + com_ui_subagent_dialog_title: `"${arg0}" agent`, + com_ui_subagent_dialog_title_self: 'Agent', + com_ui_subagent_dialog_description: 'Isolated child run.', + com_ui_subagent_no_result_yet: 'No result yet.', + com_ui_subagent_empty_result: 'No text.', + com_ui_subagent_ticker_writing: 'Writing', + com_ui_subagent_ticker_reasoning: 'Reasoning', + com_ui_subagent_ticker_error: 'Error', + com_ui_subagent_ticker_using: 'Using', + com_ui_subagent_ticker_tool_done: 'done', + com_ui_subagent_ticker_tool_output: `${arg0} → ${arg1}`, + }; + return translations[key] ?? key; + }, +})); + +/** Stub the leaf content-part renderers — the tests only need to confirm + * that the right TMessageContentParts flow through to them. */ +jest.mock('../Text', () => ({ + __esModule: true, + default: ({ text }: { text: string }) =>
    {text}
    , +})); + +jest.mock('../Reasoning', () => ({ + __esModule: true, + default: ({ reasoning }: { reasoning: string }) => ( +
    {reasoning}
    + ), +})); + +jest.mock('~/components/Chat/Messages/Content/ToolCall', () => ({ + __esModule: true, + default: ({ name, output }: { name: string; output: string }) => ( +
    + {output} +
    + ), +})); + +jest.mock('../Attachment', () => ({ + AttachmentGroup: ({ attachments }: { attachments: unknown }) => ( +
    {JSON.stringify(attachments)}
    + ), +})); + +jest.mock('@librechat/client', () => ({ + OGDialog: ({ children }: { children: React.ReactNode }) => <>{children}, + OGDialogContent: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ), + OGDialogTitle: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ), + OGDialogDescription: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ), +})); + +jest.mock('lucide-react', () => ({ + // eslint-disable-next-line i18next/no-literal-string + ChevronRight: () => chevron, + // eslint-disable-next-line i18next/no-literal-string + Users: () => users, +})); + +/** Stub out the agents-map provider so the header doesn't look up real + * agent data. Tests don't exercise the avatar lookup path; the default + * (no agent) renders the `Users` SVG fallback. */ +jest.mock('~/Providers', () => ({ + useAgentsMapContext: () => ({}), +})); + +/** Stub `MessageIcon` — only relevant when `useAgentsMapContext` returns + * a matching agent; with the stub above it never renders. */ +jest.mock('~/components/Share/MessageIcon', () => ({ + __esModule: true, + default: ({ agent }: { agent?: { name?: string } }) => ( + {agent?.name ?? ''} + ), +})); + +jest.mock('~/utils', () => ({ + ...jest.requireActual('~/utils/groupToolCalls'), + ...jest.requireActual('~/utils/toolLabels'), + cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '), +})); + +/** The dialog wraps single parts in `Container` and grouped tool_calls in + * `ToolCallGroup`. Stub both as transparent wrappers so the tests still + * assert on the leaf renderers (Text/Reasoning/ToolCall) without pulling + * Recoil-backed tool-call batching state into the component tree. */ +jest.mock('~/components/Chat/Messages/Content/Container', () => ({ + __esModule: true, + default: ({ children }: { children: React.ReactNode }) => ( +
    {children}
    + ), +})); + +jest.mock('~/components/Chat/Messages/Content/ToolCallGroup', () => ({ + __esModule: true, + default: ({ + parts, + renderPart, + lastContentIdx, + }: { + parts: Array<{ part: unknown; idx: number }>; + renderPart: (part: unknown, idx: number, isLast: boolean) => React.ReactNode; + lastContentIdx: number; + }) => ( +
    + {parts.map(({ part, idx }) => renderPart(part, idx, idx === lastContentIdx))} +
    + ), +})); + +/** Helper: fold an event sequence through the real incremental + * aggregators so each test seeds the atom with the same shape + * `useStepHandler` produces in live state. */ +function foldEvents(events: SubagentUpdateEvent[]): { + contentParts: SubagentContentPart[]; + aggregatorState: SubagentAggregatorState; + tickerState: SubagentTickerState; +} { + let contentParts: SubagentContentPart[] = []; + let aggregatorState = initSubagentAggregatorState(); + let tickerState = initSubagentTickerState(); + for (const event of events) { + ({ parts: contentParts, state: aggregatorState } = foldSubagentEvent( + contentParts, + aggregatorState, + event, + )); + tickerState = foldSubagentEventIntoTicker(tickerState, event); + } + return { contentParts, aggregatorState, tickerState }; +} + +/** Thin wrapper: tests pass `{status, subagentRunId, subagentType, events}` + * and get back a full-shape `SubagentProgress` with all three aggregator + * outputs filled. */ +function progressFromEvents( + base: { events: SubagentUpdateEvent[] } & Omit< + SubagentProgress, + 'contentParts' | 'aggregatorState' | 'tickerState' + >, +): SubagentProgress { + const { events, ...rest } = base; + const aggregates = foldEvents(events); + return { ...rest, ...aggregates }; +} + +/** + * Mount the component inside a RecoilRoot and expose a setter so each test + * can seed the `subagentProgressByToolCallId` atom with the state under test. + * Real Recoil, no mocks of the store — matches the hook-integration test + * style in useStepHandler.spec.ts. + */ +function renderWithState(args: { + toolCallId: string; + initialProgress: number; + isSubmitting?: boolean; + progress?: SubagentProgress | null; +}) { + const setter = { current: null as null | ((next: SubagentProgress | null) => void) }; + const SeedHelper = () => { + setter.current = useRecoilCallback( + ({ set }) => + (next: SubagentProgress | null) => { + set(subagentProgressByToolCallId(args.toolCallId), next); + }, + [], + ); + return null; + }; + const rendered = render( + + + + , + ); + act(() => { + setter.current?.(args.progress ?? null); + }); + return rendered; +} + +describe('SubagentCall — status resolution', () => { + it('renders "Running agent" while streaming and no terminal envelope has arrived', () => { + renderWithState({ + toolCallId: 'call_running', + initialProgress: 0.3, + isSubmitting: true, + progress: progressFromEvents({ + subagentRunId: 'run_a', + subagentType: 'self', + events: [], + status: 'run_step', + }), + }); + expect(screen.getByText('Running agent')).toBeInTheDocument(); + }); + + it('renders "Ran agent" when the subagent emits a `stop` phase', () => { + renderWithState({ + toolCallId: 'call_stopped', + initialProgress: 1, + isSubmitting: true, + progress: progressFromEvents({ + subagentRunId: 'run_a', + subagentType: 'self', + events: [], + status: 'stop', + }), + }); + expect(screen.getByText('Ran agent')).toBeInTheDocument(); + }); + + it('renders "Ran agent" when the tool call progress reaches 1', () => { + renderWithState({ + toolCallId: 'call_done', + initialProgress: 1, + isSubmitting: false, + progress: null, + }); + expect(screen.getByText('Ran agent')).toBeInTheDocument(); + }); + + it('renders "Cancelled agent" when the stream stops before a terminal envelope (Codex P2 regression)', () => { + /** + * Codex P2 on #12725: the old `running` computation ignored whether the + * parent run was still streaming, so a user stop or dropped connection + * would leave the ticker permanently "working…". Mirror the behavior + * of `ToolCall.tsx` — `!isSubmitting && !finished` → cancelled. + */ + renderWithState({ + toolCallId: 'call_cancelled', + initialProgress: 0.4, + isSubmitting: false, + progress: progressFromEvents({ + subagentRunId: 'run_a', + subagentType: 'self', + events: [], + status: 'run_step', + }), + }); + expect(screen.getByText('Cancelled agent')).toBeInTheDocument(); + }); + + it('renders "Agent errored" when the subagent emits an `error` phase', () => { + renderWithState({ + toolCallId: 'call_error', + initialProgress: 0.4, + isSubmitting: true, + progress: progressFromEvents({ + subagentRunId: 'run_a', + subagentType: 'self', + events: [], + status: 'error', + }), + }); + expect(screen.getByText('Agent errored')).toBeInTheDocument(); + }); + + it('uses the base "Running agent" label for non-self subagent types (name shown as sub-label elsewhere)', () => { + renderWithState({ + toolCallId: 'call_named', + initialProgress: 0.3, + isSubmitting: true, + progress: progressFromEvents({ + subagentRunId: 'run_b', + subagentType: 'researcher', + events: [], + status: 'run_step', + }), + }); + /** Header base label is constant ("Running agent"). The agent + * display name is rendered as a muted sub-label, which this test + * doesn't exercise (no agents-map context is seeded). */ + expect(screen.getByText('Running agent')).toBeInTheDocument(); + }); +}); + +describe('SubagentCall — ticker', () => { + it('renders semantic text lines instead of raw event names', async () => { + renderWithState({ + toolCallId: 'call_ticker', + initialProgress: 0.3, + isSubmitting: true, + progress: progressFromEvents({ + subagentRunId: 'run_a', + subagentType: 'self', + status: 'run_step', + events: [ + { + runId: 'p', + subagentRunId: 'run_a', + subagentType: 'self', + subagentAgentId: 'child', + phase: 'message_delta', + data: { delta: { content: [{ type: 'text', text: 'Computing result…' }] } }, + timestamp: '', + }, + { + runId: 'p', + subagentRunId: 'run_a', + subagentType: 'self', + subagentAgentId: 'child', + phase: 'run_step', + data: { + stepDetails: { + type: 'tool_calls', + tool_calls: [{ id: 'c1', name: 'calculator', args: '{"expression":"42*58"}' }], + }, + }, + timestamp: '', + }, + { + runId: 'p', + subagentRunId: 'run_a', + subagentType: 'self', + subagentAgentId: 'child', + phase: 'run_step_completed', + data: { + result: { + type: 'tool_call', + tool_call: { + id: 'c1', + name: 'calculator', + output: '42*58 = 2436', + progress: 1, + }, + }, + }, + timestamp: '', + }, + ], + }), + }); + + /** Ticker now renders a structured `using_tool` line — prefix span + * ("Using"), a code-style badge for the tool name, and a muted + * args snippet. Check the pieces individually rather than a + * combined text match. */ + await waitFor( + () => { + expect(screen.getByText('Using')).toBeInTheDocument(); + }, + { timeout: 2500 }, + ); + /** Raw event names never appear in the ticker. */ + expect(screen.queryByText(/on_run_step/)).not.toBeInTheDocument(); + expect(screen.queryByText(/on_message_delta/)).not.toBeInTheDocument(); + /** Tool name renders as a `` badge, args snippet in parens. */ + const calcBadges = screen.getAllByText('calculator'); + expect(calcBadges.some((el) => el.tagName === 'CODE')).toBe(true); + expect(screen.getByText('(expression=42*58)')).toBeInTheDocument(); + /** Completion line renders the output snippet in the body span. */ + expect(screen.getAllByText('42*58 = 2436').length).toBeGreaterThan(0); + }); + + it('collapses a streak of message_delta events into one live Writing line', async () => { + renderWithState({ + toolCallId: 'call_writing', + initialProgress: 0.3, + isSubmitting: true, + progress: progressFromEvents({ + subagentRunId: 'run_a', + subagentType: 'self', + status: 'message_delta', + events: ['Hello ', 'world', '!'].map((text) => ({ + runId: 'p', + subagentRunId: 'run_a', + subagentType: 'self', + subagentAgentId: 'child', + phase: 'message_delta' as const, + data: { delta: { content: [{ type: 'text', text }] } }, + timestamp: '', + })), + }), + }); + /** Ticker renders the "Writing:" label and the body in separate spans + * (prefix is `shrink-0`, body is a tail-truncatable sibling) so the + * label never gets clipped when the body overflows. "Hello world!" + * also appears in the dialog body (mocked OGDialog renders + * children), so `getAllByText` is needed for the body. */ + await waitFor( + () => { + expect(screen.getAllByText('Hello world!').length).toBeGreaterThan(0); + }, + { timeout: 2500 }, + ); + /** Only one "Writing:" label, not three — deltas collapse into one live line. */ + expect(screen.getAllByText('Writing:')).toHaveLength(1); + }); +}); + +describe('SubagentCall — dialog content', () => { + it('renders aggregated text, reasoning, and tool_call parts through leaf renderers', () => { + renderWithState({ + toolCallId: 'call_dialog', + initialProgress: 0.4, + isSubmitting: true, + progress: progressFromEvents({ + subagentRunId: 'run_a', + subagentType: 'self', + status: 'stop', + events: [ + { + runId: 'p', + subagentRunId: 'run_a', + subagentType: 'self', + subagentAgentId: 'child', + phase: 'reasoning_delta', + data: { delta: { content: [{ type: 'think', think: 'Let me compute.' }] } }, + timestamp: '', + }, + { + runId: 'p', + subagentRunId: 'run_a', + subagentType: 'self', + subagentAgentId: 'child', + phase: 'run_step', + data: { + stepDetails: { + type: 'tool_calls', + tool_calls: [{ id: 'c1', name: 'calculator', args: '{}' }], + }, + }, + timestamp: '', + }, + { + runId: 'p', + subagentRunId: 'run_a', + subagentType: 'self', + subagentAgentId: 'child', + phase: 'run_step_completed', + data: { + result: { + type: 'tool_call', + tool_call: { id: 'c1', name: 'calculator', output: '4', progress: 1 }, + }, + }, + timestamp: '', + }, + { + runId: 'p', + subagentRunId: 'run_a', + subagentType: 'self', + subagentAgentId: 'child', + phase: 'message_delta', + data: { delta: { content: [{ type: 'text', text: 'The answer is 4.' }] } }, + timestamp: '', + }, + ], + }), + }); + + /** The mocked `OGDialog` always renders children, so dialog content is + * inspectable without simulating a click. */ + expect(screen.getByTestId('reasoning-part')).toHaveTextContent('Let me compute.'); + expect(screen.getByTestId('tool-call-part')).toHaveAttribute('data-name', 'calculator'); + expect(screen.getByTestId('tool-call-part')).toHaveTextContent('4'); + expect(screen.getByTestId('text-part')).toHaveTextContent('The answer is 4.'); + }); + + it('falls back to the raw tool output when no content parts were recorded', () => { + renderWithState({ + toolCallId: 'call_fallback', + initialProgress: 1, + isSubmitting: false, + progress: null, + }); + /** No events → no aggregated parts. The SubagentCall should still + * render the raw final `output` that came back in the parent's + * tool_call (we pass it explicitly below). */ + const { rerender } = render( + + + , + ); + expect(screen.getByText('raw final text')).toBeInTheDocument(); + rerender(); + }); + + it('renders persistedContent parts when no live events are available (page-refresh flow)', () => { + /** + * After a refresh the Recoil atom is empty — the child's history has + * to come from the `subagent_content` array the backend attached to + * the tool_call at message-save time. Verifies that a + * `persistedContent` prop routes through the same leaf renderers + * (Text / Reasoning / ToolCall) as live aggregation so a reopened + * dialog looks identical to how the run streamed. + */ + const persistedContent = [ + { type: 'think', think: 'Prior thinking.' }, + { + type: 'tool_call', + tool_call: { + id: 'inner-1', + name: 'calculator', + args: '{"expression":"42*58"}', + output: '2436', + progress: 1, + }, + }, + { type: 'text', text: 'Final persisted answer.' }, + ] as unknown as Parameters[0]['persistedContent']; + + render( + + + , + ); + + expect(screen.getByTestId('reasoning-part')).toHaveTextContent('Prior thinking.'); + expect(screen.getByTestId('tool-call-part')).toHaveAttribute('data-name', 'calculator'); + expect(screen.getByTestId('tool-call-part')).toHaveTextContent('2436'); + expect(screen.getByTestId('text-part')).toHaveTextContent('Final persisted answer.'); + }); + + it('prefers persistedContent when both are populated (sync/reconnect canonical)', () => { + /** + * Codex P2 regression: after a disconnect/reconnect the live + * Recoil bucket can be stale or partial — it missed events + * while the socket was down. The server-written + * `persistedContent` on the `tool_call` is the canonical trace + * of the completed run, so when it's present the dialog should + * show it, not the (possibly lossy) live aggregation. + * + * This also covers the post-stream case where persistence has + * landed and both snapshots carry the same content — preferring + * persisted is still correct because it's the authoritative copy. + */ + render( + + [0]['persistedContent'] + } + /> + , + ); + expect(screen.getByText('Persisted answer.')).toBeInTheDocument(); + }); + + it('falls back to live aggregated events when persistedContent is empty (mid-stream)', () => { + /** + * Before the parent message saves, `persistedContent` is + * undefined/empty — the live atom is the only source of truth. + * Verify we render the live aggregation in that case. + */ + renderWithState({ + toolCallId: 'call_live_fallback', + initialProgress: 0.4, + isSubmitting: true, + progress: progressFromEvents({ + subagentRunId: 'run_live', + subagentType: 'self', + status: 'message_delta', + events: [ + { + runId: 'p', + subagentRunId: 'run_live', + subagentType: 'self', + subagentAgentId: 'child', + phase: 'message_delta', + data: { delta: { content: [{ type: 'text', text: 'Live answer.' }] } }, + timestamp: '', + }, + ], + }), + }); + /** Live content renders — both in the ticker preview (collapsed + * card) and inside the dialog body (mocked OGDialog renders + * children). */ + expect(screen.getAllByText('Live answer.').length).toBeGreaterThan(0); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/Parts/index.ts b/client/src/components/Chat/Messages/Content/Parts/index.ts index a37bbadd86..da495abb82 100644 --- a/client/src/components/Chat/Messages/Content/Parts/index.ts +++ b/client/src/components/Chat/Messages/Content/Parts/index.ts @@ -12,3 +12,4 @@ export { default as EditTextPart } from './EditTextPart'; export { default as SkillCall } from './SkillCall'; export { default as ReadFileCall } from './ReadFileCall'; export { default as BashCall } from './BashCall'; +export { default as SubagentCall } from './SubagentCall'; diff --git a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx index c66ecc24fa..3706182a21 100644 --- a/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCallGroup.tsx @@ -1,29 +1,15 @@ import { useState, useMemo, useEffect, useCallback } from 'react'; import { useRecoilValue } from 'recoil'; -import { ChevronDown } from 'lucide-react'; -import { ContentTypes, ToolCallTypes } from 'librechat-data-provider'; +import { ChevronDown, Users } from 'lucide-react'; +import { Constants, ContentTypes, ToolCallTypes } from 'librechat-data-provider'; import type { TMessageContentParts, Agents, FunctionToolCall } from 'librechat-data-provider'; import type { PartWithIndex } from './ParallelContent'; -import type { TranslationKeys } from '~/hooks'; -import { StackedToolIcons, getMCPServerName } from './ToolOutput'; +import { StackedToolIcons } from './ToolOutput'; import { useLocalize, useExpandCollapse } from '~/hooks'; import { useMCPIconMap } from '~/hooks/MCP'; -import { cn } from '~/utils'; +import { cn, getToolDisplayLabel } from '~/utils'; import store from '~/store'; -/** Maps tool names to translation keys — resolved via localize() at render time. */ -const FRIENDLY_NAME_KEYS: Record = { - execute_code: 'com_ui_tool_name_code', - run_tools_with_code: 'com_ui_tool_name_code', - web_search: 'com_ui_tool_name_web_search', - image_gen_oai: 'com_ui_tool_name_image_gen', - image_edit_oai: 'com_ui_tool_name_image_edit', - gemini_image_gen: 'com_ui_tool_name_image_gen', - file_search: 'com_ui_tool_name_file_search', - code_interpreter: 'com_ui_tool_name_code_analysis', - retrieval: 'com_ui_tool_name_file_search', -}; - interface ToolMeta { name: string; hasOutput: boolean; @@ -41,8 +27,13 @@ function getToolMeta(part: TMessageContentParts): ToolMeta | null { const isStandard = 'args' in toolCall && (!toolCall.type || toolCall.type === ToolCallTypes.TOOL_CALL); if (isStandard) { - const tc = toolCall as Agents.ToolCall; - return { name: tc.name ?? '', hasOutput: !!tc.output }; + const tc = toolCall as Agents.ToolCall & { progress?: number }; + /** Subagents can finish with `progress === 1` and no final output + * text (the parent saw "" / undefined back). Fall back to progress + * so the group header flips from "Running N agents" to "Ran N + * agents" on completion even when the child returned no text. */ + const completed = !!tc.output || tc.progress === 1; + return { name: tc.name ?? '', hasOutput: completed }; } if (toolCall.type === ToolCallTypes.CODE_INTERPRETER) { @@ -88,16 +79,29 @@ export default function ToolCallGroup({ ); const toolNames = useMemo(() => toolMetadata.map((m) => m?.name ?? ''), [toolMetadata]); + /** Subagent tool calls get their own label verb ("Running/Ran N agents") + * since "Used N tools" reads oddly when the "tools" are actually child + * agents. `subagentCount === count` ⇒ the group is 100% subagents. */ + const subagentCount = useMemo( + () => toolNames.filter((n) => n === Constants.SUBAGENT).length, + [toolNames], + ); + const allSubagents = subagentCount > 0 && subagentCount === count; + /** Past-tense label once the parent stream is no longer live OR every + * child has a terminal signal (output / progress === 1). Without the + * `!isSubmitting` branch, a cancelled or errored subagent that never + * reached `progress === 1` would leave the header stuck on "Running + * N agents" forever — each individual card already renders its own + * terminal state ("Cancelled agent", "Agent errored"), so the group + * summary needs to match that tense. */ + const subagentsDone = allSubagents && (allCompleted || !isSubmitting); + const toolNameSummary = useMemo(() => { const seen = new Set(); const labels: string[] = []; for (const rawName of toolNames) { - if (!rawName) { - continue; - } - const serverName = getMCPServerName(rawName); - const nameKey = FRIENDLY_NAME_KEYS[rawName]; - const label = serverName || (nameKey ? localize(nameKey) : rawName); + if (!rawName) continue; + const label = getToolDisplayLabel(rawName, localize); if (!seen.has(label)) { seen.add(label); labels.push(label); @@ -144,18 +148,47 @@ export default function ToolCallGroup({ className="inline-flex w-full items-center gap-2 py-1 text-text-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy" onClick={handleToggle} aria-expanded={isExpanded} - aria-label={localize('com_ui_used_n_tools', { 0: String(count) })} + aria-label={ + allSubagents + ? subagentsDone + ? localize('com_ui_ran_n_agents', { 0: String(count) }) + : localize('com_ui_running_n_agents', { 0: String(count) }) + : localize('com_ui_used_n_tools', { 0: String(count) }) + } > - + {allSubagents ? ( + /** Subagent groups don't have per-tool icons — StackedToolIcons + * falls back to a generic wrench that reads as "tools" rather + * than "agents". A single Users glyph matches the individual + * subagent card header and keeps the visual language consistent. */ + + ) : ( + + )} - {localize('com_ui_used_n_tools', { 0: String(count) })} + {allSubagents + ? subagentsDone + ? localize('com_ui_ran_n_agents', { 0: String(count) }) + : localize('com_ui_running_n_agents', { 0: String(count) }) + : localize('com_ui_used_n_tools', { 0: String(count) })} - {toolNameSummary && ( + {/** Hide the tool-name summary for pure-subagent groups — every + * entry deduplicates to the same "subagent" token, which adds + * noise without info. Mixed groups keep the summary. */} + {toolNameSummary && !allSubagents && ( — {toolNameSummary} )} agentsConfig?.capabilities.includes(AgentCapabilities.chain) ?? false, [agentsConfig], ); + const subagentsEnabled = useMemo( + () => agentsConfig?.capabilities.includes(AgentCapabilities.subagents) ?? false, + [agentsConfig], + ); return (
    @@ -43,6 +48,13 @@ export default function AdvancedPanel() {
    + {subagentsEnabled && ( + } + /> + )} ; + currentAgentId: string; +} + +const AgentSubagents: React.FC = ({ field, currentAgentId }) => { + const localize = useLocalize(); + const agentsMap = useAgentsMapContext(); + const [newAgentId, setNewAgentId] = useState(''); + + const fieldValue = field.value; + const value = useMemo(() => fieldValue ?? {}, [fieldValue]); + const enabled = value.enabled === true; + const allowSelf = value.allowSelf !== false; + const agentIds = useMemo(() => value.agent_ids ?? [], [value.agent_ids]); + + const setEnabled = useCallback( + (next: boolean) => { + if (!next) { + /** + * Persist `{ enabled: false }` (with the existing selections preserved) + * rather than `undefined`. The backend's `removeNullishValues` strips + * undefined fields from PATCH payloads, so setting the whole object to + * undefined would leave the server copy enabled. An explicit + * `enabled: false` flows through as a real update. + */ + field.onChange({ + enabled: false, + allowSelf: value.allowSelf ?? true, + agent_ids: value.agent_ids ?? [], + }); + return; + } + field.onChange({ + enabled: true, + allowSelf: value.allowSelf ?? true, + agent_ids: value.agent_ids ?? [], + }); + }, + [field, value.allowSelf, value.agent_ids], + ); + + const setAllowSelf = useCallback( + (next: boolean) => { + field.onChange({ + ...value, + enabled: true, + allowSelf: next, + }); + }, + [field, value], + ); + + const setAgentIds = useCallback( + (ids: string[]) => { + field.onChange({ + ...value, + enabled: true, + allowSelf: value.allowSelf ?? true, + agent_ids: ids, + }); + }, + [field, value], + ); + + const agents = useMemo(() => (agentsMap ? Object.values(agentsMap) : []), [agentsMap]); + + const selectableAgents = useMemo(() => { + const selectedSet = new Set(agentIds); + return agents + .filter((agent) => { + if (!agent?.id) return false; + if (agent.id === currentAgentId) return false; + return !selectedSet.has(agent.id); + }) + .map( + (agent) => + ({ + label: agent?.name || '', + value: agent?.id || '', + icon: ( + + ), + }) as OptionWithIcon, + ); + }, [agents, currentAgentId, agentIds]); + + const getAgentDetails = useCallback((id: string) => agentsMap?.[id], [agentsMap]); + + useEffect(() => { + if (newAgentId && agentIds.length < MAX_SUBAGENTS && !agentIds.includes(newAgentId)) { + setAgentIds([...agentIds, newAgentId]); + setNewAgentId(''); + } else if (newAgentId) { + setNewAgentId(''); + } + }, [newAgentId, agentIds, setAgentIds]); + + const removeAgentAt = (index: number) => { + setAgentIds(agentIds.filter((_, i) => i !== index)); + }; + + const enableId = 'subagents-enable-toggle'; + const selfId = 'subagents-self-toggle'; + const nothingToSpawn = enabled && !allowSelf && agentIds.length === 0; + + return ( + +
    +
    + + + + +
    +
    +
    + {localize('com_ui_beta')} +
    + +
    +
    + + {enabled && ( +
    +
    +
    + + + {localize('com_ui_agent_subagents_allow_self_info')} + +
    + +
    + +
    +
    + + {localize('com_ui_agent_subagents_agents')} + + + {agentIds.length} / {MAX_SUBAGENTS} + +
    + + {agentIds.map((agentId, idx) => { + const details = getAgentDetails(agentId); + return ( +
    +
    + +
    +
    + {details?.name ?? agentId} +
    + +
    + ); + })} + + {agentIds.length < MAX_SUBAGENTS && ( + } + /> + )} + + {agentIds.length >= MAX_SUBAGENTS && ( +

    + {localize('com_ui_agent_subagents_max', { 0: MAX_SUBAGENTS })} +

    + )} +
    + + {nothingToSpawn && ( +

    +

    + )} +
    + )} + + + +
    +

    {localize('com_ui_agent_subagents_info')}

    +

    + {localize('com_ui_agent_subagents_info_2')} +

    +
    +
    +
    +
    + ); +}; + +export default AgentSubagents; diff --git a/client/src/components/SidePanel/Agents/AgentPanel.tsx b/client/src/components/SidePanel/Agents/AgentPanel.tsx index 6f1504440b..5096551b83 100644 --- a/client/src/components/SidePanel/Agents/AgentPanel.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanel.tsx @@ -69,6 +69,7 @@ export function composeAgentUpdatePayload(data: AgentForm, agent_id?: string | n provider: _provider, agent_ids, edges, + subagents, end_after_tools, hide_sequential_outputs, recursion_limit, @@ -96,6 +97,7 @@ export function composeAgentUpdatePayload(data: AgentForm, agent_id?: string | n model_parameters, agent_ids, edges, + subagents, end_after_tools, hide_sequential_outputs, recursion_limit, diff --git a/client/src/components/SidePanel/Agents/AgentSelect.tsx b/client/src/components/SidePanel/Agents/AgentSelect.tsx index 8c6291183a..1fbaa1f989 100644 --- a/client/src/components/SidePanel/Agents/AgentSelect.tsx +++ b/client/src/components/SidePanel/Agents/AgentSelect.tsx @@ -120,6 +120,11 @@ function AgentSelect({ return; } + if (name === 'subagents' && typeof value === 'object' && value !== null) { + formValues[name] = value; + return; + } + if (name === 'tool_options' && typeof value === 'object' && value !== null) { formValues[name] = value; return; diff --git a/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts b/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts index 220d55704d..ae28a9e6f1 100644 --- a/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts +++ b/client/src/hooks/SSE/__tests__/useStepHandler.spec.ts @@ -1,5 +1,12 @@ import { renderHook, act } from '@testing-library/react'; -import { StepTypes, StepEvents, ContentTypes, ToolCallTypes } from 'librechat-data-provider'; +import { RecoilRoot, useRecoilCallback } from 'recoil'; +import { + Constants, + StepTypes, + StepEvents, + ContentTypes, + ToolCallTypes, +} from 'librechat-data-provider'; import type { TMessageContentParts, SummaryContentPart, @@ -7,9 +14,11 @@ import type { TEndpointOption, TConversation, TMessage, + SubagentUpdateEvent, Agents, } from 'librechat-data-provider'; import useStepHandler from '~/hooks/SSE/useStepHandler'; +import { subagentProgressByToolCallId } from '~/store/subagents'; type TSubmissionForTest = { userMessage: TMessage; @@ -1508,4 +1517,414 @@ describe('useStepHandler', () => { expect(mockSetMessages).not.toHaveBeenCalled(); }); }); + + describe('on_subagent_update event', () => { + /** + * These tests exercise the real Recoil `atomFamily` via a `RecoilRoot` + * wrapper and a `useRecoilCallback`-powered reader mounted alongside + * the hook under test. No mocks of the store module — only the same + * `setMessages`/`getMessages` spies the rest of this file uses. + */ + const renderStepHandlerWithReader = (): { + result: ReturnType['result']; + getProgress: (toolCallId: string) => unknown; + } => { + /** Composite hook: the step handler under test + a `useRecoilCallback` + * reader that shares the same `RecoilRoot` store. Reading via a + * top-level `snapshot_UNSTABLE()` returns a different root, so the + * writes done by the step handler wouldn't be visible. */ + const hookResult = renderHook( + () => { + const stepHandler = useStepHandler(createHookParams()); + const read = useRecoilCallback( + ({ snapshot }) => + (toolCallId: string): unknown => + snapshot.getLoadable(subagentProgressByToolCallId(toolCallId)).valueOrThrow(), + [], + ); + return { ...stepHandler, read }; + }, + { wrapper: RecoilRoot }, + ); + + const getProgress = (toolCallId: string): unknown => + (hookResult.result.current as any).read(toolCallId); + return { result: hookResult.result, getProgress }; + }; + + const buildSubagentToolCallPart = (toolCallId: string): TMessageContentParts => + ({ + type: ContentTypes.TOOL_CALL, + [ContentTypes.TOOL_CALL]: { + id: toolCallId, + name: Constants.SUBAGENT, + args: '{}', + type: ToolCallTypes.TOOL_CALL, + progress: 0.1, + }, + }) as unknown as TMessageContentParts; + + /** + * Directly seed the hook's internal `messageMap` with a response message + * whose content contains one or more `subagent` tool calls. Uses the + * real `syncStepMessage` export so we exercise the same code path the + * SSE pipeline uses for reconnects — no mocks, no patched internals. + */ + const seedResponseWithSubagentToolCalls = ( + result: ReturnType['result'], + toolCallIds: string[], + ): { response: TMessage; submission: EventSubmission } => { + const response: TMessage = { + ...createResponseMessage(), + content: toolCallIds.map(buildSubagentToolCallPart), + }; + mockGetMessages.mockReturnValue([response]); + const submission = createSubmission({ + initialResponse: createResponseMessage({ + messageId: 'initial-response-id', + }), + }); + act(() => { + (result.current as any).syncStepMessage(response); + }); + return { response, submission }; + }; + + const makeUpdate = (overrides: Partial = {}): SubagentUpdateEvent => ({ + runId: 'parent-run', + subagentRunId: 'child-run-1', + subagentType: 'self', + subagentAgentId: 'child-1', + parentAgentId: 'parent', + phase: 'start', + label: 'Subagent "self" started', + timestamp: new Date().toISOString(), + ...overrides, + }); + + it('correlates updates to a tool call via parentToolCallId (deterministic path)', () => { + const { result, getProgress } = renderStepHandlerWithReader(); + const { submission } = seedResponseWithSubagentToolCalls(result, ['call_A']); + + const start = makeUpdate({ parentToolCallId: 'call_A', phase: 'start' }); + const step = makeUpdate({ + parentToolCallId: 'call_A', + phase: 'run_step', + label: 'Using tool: calculator', + }); + const stop = makeUpdate({ + parentToolCallId: 'call_A', + phase: 'stop', + label: 'Subagent "self" finished', + }); + + act(() => { + (result.current as any).stepHandler( + { event: StepEvents.ON_SUBAGENT_UPDATE, data: start }, + submission, + ); + + (result.current as any).stepHandler( + { event: StepEvents.ON_SUBAGENT_UPDATE, data: step }, + submission, + ); + + (result.current as any).stepHandler( + { event: StepEvents.ON_SUBAGENT_UPDATE, data: stop }, + submission, + ); + }); + + const bucket = getProgress('call_A') as { + status: string; + latestLabel?: string; + subagentType: string; + }; + /** The atom no longer retains raw envelopes — they're folded + * incrementally into `contentParts`/`tickerState`. Metadata + * (status, latestLabel, subagentType) still reflects the last + * update, which is what the UI actually renders from. */ + expect(bucket.status).toBe('stop'); + expect(bucket.latestLabel).toBe('Subagent "self" finished'); + expect(bucket.subagentType).toBe('self'); + }); + + it('falls back to oldest-unclaimed tool call when parentToolCallId is absent', () => { + const { result, getProgress } = renderStepHandlerWithReader(); + /** Two subagent tool calls seeded in creation order. Without + * `parentToolCallId`, forward iteration must claim `call_old` for + * the first start and `call_new` for the second. */ + const { submission } = seedResponseWithSubagentToolCalls(result, ['call_old', 'call_new']); + + const updateOld = makeUpdate({ + subagentRunId: 'run-1', + phase: 'start', + label: 'first', + }); + const updateNew = makeUpdate({ + subagentRunId: 'run-2', + phase: 'start', + label: 'second', + }); + + act(() => { + (result.current as any).stepHandler( + { event: StepEvents.ON_SUBAGENT_UPDATE, data: updateOld }, + submission, + ); + + (result.current as any).stepHandler( + { event: StepEvents.ON_SUBAGENT_UPDATE, data: updateNew }, + submission, + ); + }); + + const first = getProgress('call_old') as { latestLabel?: string }; + const second = getProgress('call_new') as { latestLabel?: string }; + expect(first.latestLabel).toBe('first'); + expect(second.latestLabel).toBe('second'); + }); + + it('buffers early-arriving updates and replays once a tool call is claimable', () => { + const { result, getProgress } = renderStepHandlerWithReader(); + const submission = createSubmission({ + initialResponse: createResponseMessage({ + messageId: 'initial-response-id', + }), + }); + /** Deliberately: no `mockGetMessages.mockReturnValue([response])` + * and no ON_RUN_STEP yet, so the tool call isn't visible. The + * first envelope must be buffered. */ + mockGetMessages.mockReturnValue([]); + + const earlyUpdate = makeUpdate({ + phase: 'start', + label: 'arrives first', + }); + const laterUpdate = makeUpdate({ + phase: 'run_step', + label: 'arrives after correlation', + }); + + act(() => { + (result.current as any).stepHandler( + { event: StepEvents.ON_SUBAGENT_UPDATE, data: earlyUpdate }, + submission, + ); + }); + + /** Now the tool call appears. Subsequent update can claim and drain. */ + const responseWithToolCall: TMessage = { + ...createResponseMessage(), + content: [buildSubagentToolCallPart('call_late')], + }; + mockGetMessages.mockReturnValue([responseWithToolCall]); + act(() => { + (result.current as any).syncStepMessage(responseWithToolCall); + }); + + act(() => { + (result.current as any).stepHandler( + { + event: StepEvents.ON_SUBAGENT_UPDATE, + data: { ...laterUpdate, parentToolCallId: 'call_late' }, + }, + submission, + ); + }); + + const bucket = getProgress('call_late') as { + status: string; + latestLabel?: string; + }; + /** The buffered `start` event and the current `run_step` both + * get applied in order — the latest label reflects the most + * recently processed envelope. */ + expect(bucket.status).toBe('run_step'); + expect(bucket.latestLabel).toBe('arrives after correlation'); + }); + + it('keeps atom state bounded under a large burst of lifecycle-only updates', () => { + /** Previously the atom retained a 200-event rolling window so long + * runs could lose earlier tool_call records. We now fold events + * into `contentParts` + `tickerState` incrementally — no raw + * event array is stored, so memory is bounded by the structural + * output (text/reasoning runs + tool calls), not delta volume. + * A pile of pass-through phases like `run_step_delta` must not + * create lines at all. */ + const { result, getProgress } = renderStepHandlerWithReader(); + const { submission } = seedResponseWithSubagentToolCalls(result, ['call_cap']); + + act(() => { + for (let i = 0; i < 500; i++) { + (result.current as any).stepHandler( + { + event: StepEvents.ON_SUBAGENT_UPDATE, + data: makeUpdate({ + parentToolCallId: 'call_cap', + phase: 'run_step_delta', + label: `delta-${i}`, + }), + }, + submission, + ); + } + }); + + const bucket = getProgress('call_cap') as { + contentParts: unknown[]; + tickerState: { lines: unknown[] }; + latestLabel?: string; + }; + expect(bucket.contentParts).toEqual([]); + expect(bucket.tickerState.lines).toEqual([]); + expect(bucket.latestLabel).toBe('delta-499'); + }); + + it('keeps parallel subagent streams independent when events interleave', () => { + /** + * Parallel tool calls: the LLM emits two `subagent` tool calls in the + * same AIMessage, LangChain invokes them concurrently, and the SDK + * streams `ON_SUBAGENT_UPDATE` envelopes for both runs in arbitrary + * order. Each envelope carries `parentToolCallId` (SDK dev.2+), so + * correlation stays deterministic. Verify no cross-contamination + * between buckets: each tool_call_id's atom should accumulate only + * that run's events, and closing one stream must not affect the + * other. + */ + const { result, getProgress } = renderStepHandlerWithReader(); + const { submission } = seedResponseWithSubagentToolCalls(result, ['call_a', 'call_b']); + + const makeParallelUpdate = ( + runId: string, + toolCallId: string, + overrides: Partial = {}, + ): SubagentUpdateEvent => ({ + ...makeUpdate({ subagentRunId: runId, parentToolCallId: toolCallId }), + ...overrides, + }); + + act(() => { + // Interleaved order: a-start, b-start, a-step, b-step, a-stop, b-stop + (result.current as any).stepHandler( + { + event: StepEvents.ON_SUBAGENT_UPDATE, + data: makeParallelUpdate('run_a', 'call_a', { + phase: 'start', + label: 'A started', + }), + }, + submission, + ); + (result.current as any).stepHandler( + { + event: StepEvents.ON_SUBAGENT_UPDATE, + data: makeParallelUpdate('run_b', 'call_b', { + phase: 'start', + label: 'B started', + }), + }, + submission, + ); + (result.current as any).stepHandler( + { + event: StepEvents.ON_SUBAGENT_UPDATE, + data: makeParallelUpdate('run_a', 'call_a', { + phase: 'run_step', + label: 'A using calculator', + }), + }, + submission, + ); + (result.current as any).stepHandler( + { + event: StepEvents.ON_SUBAGENT_UPDATE, + data: makeParallelUpdate('run_b', 'call_b', { + phase: 'run_step', + label: 'B using web', + }), + }, + submission, + ); + (result.current as any).stepHandler( + { + event: StepEvents.ON_SUBAGENT_UPDATE, + data: makeParallelUpdate('run_a', 'call_a', { + phase: 'stop', + label: 'A done', + }), + }, + submission, + ); + (result.current as any).stepHandler( + { + event: StepEvents.ON_SUBAGENT_UPDATE, + data: makeParallelUpdate('run_b', 'call_b', { + phase: 'run_step', + label: 'B still going', + }), + }, + submission, + ); + }); + + const bucketA = getProgress('call_a') as { + subagentRunId: string; + status: string; + latestLabel?: string; + }; + const bucketB = getProgress('call_b') as { + subagentRunId: string; + status: string; + latestLabel?: string; + }; + + /** Each bucket only captures its own run's lifecycle — metadata + * (status, latestLabel, subagentRunId) stays scoped to the + * matching tool_call_id despite interleaved delivery order. */ + expect(bucketA.subagentRunId).toBe('run_a'); + expect(bucketB.subagentRunId).toBe('run_b'); + expect(bucketA.latestLabel).toBe('A done'); + expect(bucketB.latestLabel).toBe('B still going'); + + // A reached `stop`, B is still running — their statuses should reflect that + expect(bucketA.status).toBe('stop'); + expect(bucketB.status).toBe('run_step'); + }); + + it('clearStepMaps preserves subagent atoms so the dialog can be re-opened for auditability', () => { + /** + * Intentionally the inverse of the earlier behavior: the collapsed + * `SubagentCall` ticker and its dialog must stay readable after the + * stream ends. Wiping the atoms on `clearStepMaps` would leave a + * completed subagent tool call with no content to display, forcing + * the fallback "raw tool output" branch and losing interleaved tool + * calls / reasoning from the rendering. Growth is bounded (200-event + * cap per atom, one atom per subagent spawn). + */ + const { result, getProgress } = renderStepHandlerWithReader(); + const { submission } = seedResponseWithSubagentToolCalls(result, ['call_keep']); + + act(() => { + (result.current as any).stepHandler( + { + event: StepEvents.ON_SUBAGENT_UPDATE, + data: makeUpdate({ + parentToolCallId: 'call_keep', + phase: 'stop', + }), + }, + submission, + ); + }); + + expect(getProgress('call_keep')).not.toBeNull(); + + act(() => { + (result.current as any).clearStepMaps(); + }); + + expect(getProgress('call_keep')).not.toBeNull(); + }); + }); }); diff --git a/client/src/hooks/SSE/useEventHandlers.ts b/client/src/hooks/SSE/useEventHandlers.ts index 2759fb0527..e696eefa84 100644 --- a/client/src/hooks/SSE/useEventHandlers.ts +++ b/client/src/hooks/SSE/useEventHandlers.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { v4 } from 'uuid'; import { useSetRecoilState } from 'recoil'; import { useQueryClient } from '@tanstack/react-query'; @@ -188,7 +188,7 @@ export default function useEventHandlers({ const { token } = useAuthContext(); const { contentHandler, resetContentHandler } = useContentHandler({ setMessages, getMessages }); - const { stepHandler, clearStepMaps, syncStepMessage } = useStepHandler({ + const { stepHandler, clearStepMaps, resetSubagentAtoms, syncStepMessage } = useStepHandler({ setMessages, getMessages, announcePolite, @@ -197,6 +197,47 @@ export default function useEventHandlers({ }); const attachmentHandler = useAttachmentHandler(queryClient); + /** Wipe the per-subagent Recoil atoms on conversation navigation. + * Historical subagent dialogs rehydrate from the persisted + * `subagent_content` on each `tool_call` (written by the backend + * at message-save time), so clearing live atoms on switch + * doesn't lose any viewable history — it just keeps `atomFamily` + * bounded across multi-conversation sessions. + * + * Rule: only reset when transitioning AWAY FROM an established + * conversation (`previous != null`). Transitions FROM null or + * undefined pass through: + * - initial mount on a new-chat route: nothing to clear. + * - new-chat URL stamp mid-stream (null → newId): the in-flight + * subagent ticker/content state for that freshly-stamped id + * would be wiped if we reset here — that id IS the current + * run, not a stale one. + * Cases that DO reset (previous non-null, value changed): + * - id1 → id2 (switching between established chats) + * - id → null (user clicked "new chat") + * - id → undefined (route teardown / navigate away) */ + const lastConversationIdRef = useRef(paramId); + useEffect(() => { + const previous = lastConversationIdRef.current; + lastConversationIdRef.current = paramId; + if (previous != null && previous !== paramId) { + resetSubagentAtoms(); + } + }, [paramId, resetSubagentAtoms]); + + /** Final cleanup on component unmount. `useStepHandler` keeps the + * set of known atom keys in a ref; when the hook unmounts (user + * navigates away from the chat route entirely) that ref is lost, + * so a subsequent remount can't clear atoms it never saw created. + * Flush at the teardown boundary to keep `atomFamily` bounded + * across route changes. */ + useEffect( + () => () => { + resetSubagentAtoms(); + }, + [resetSubagentAtoms], + ); + const messageHandler = useCallback( (data: string | undefined, submission: EventSubmission) => { const { messages, userMessage, initialResponse, isRegenerate = false } = submission; diff --git a/client/src/hooks/SSE/useStepHandler.ts b/client/src/hooks/SSE/useStepHandler.ts index 1f28d97433..e4c6283269 100644 --- a/client/src/hooks/SSE/useStepHandler.ts +++ b/client/src/hooks/SSE/useStepHandler.ts @@ -1,4 +1,5 @@ import { useCallback, useRef } from 'react'; +import { useRecoilCallback } from 'recoil'; import { Constants, StepTypes, @@ -15,10 +16,18 @@ import type { EventSubmission, SummaryContentPart, TMessageContentParts, + SubagentUpdateEvent, } from 'librechat-data-provider'; import type { SetterOrUpdater } from 'recoil'; import type { AnnounceOptions } from '~/common'; import { MESSAGE_UPDATE_INTERVAL } from '~/common'; +import { subagentProgressByToolCallId } from '~/store'; +import { + foldSubagentEvent, + foldSubagentEventIntoTicker, + initSubagentAggregatorState, + initSubagentTickerState, +} from '~/utils/subagentContent'; type TUseStepHandler = { announcePolite: (options: AnnounceOptions) => void; @@ -38,7 +47,8 @@ type TStepEvent = | { event: StepEvents.ON_RUN_STEP_COMPLETED; data: { result: Agents.ToolEndEvent } } | { event: StepEvents.ON_SUMMARIZE_START; data: Agents.SummarizeStartEvent } | { event: StepEvents.ON_SUMMARIZE_DELTA; data: Agents.SummarizeDeltaEvent } - | { event: StepEvents.ON_SUMMARIZE_COMPLETE; data: Agents.SummarizeCompleteEvent }; + | { event: StepEvents.ON_SUMMARIZE_COMPLETE; data: Agents.SummarizeCompleteEvent } + | { event: StepEvents.ON_SUBAGENT_UPDATE; data: SubagentUpdateEvent }; type MessageDeltaUpdate = { type: ContentTypes.TEXT; text: string; tool_call_ids?: string[] }; @@ -66,6 +76,157 @@ export default function useStepHandler({ const pendingDeltaBuffer = useRef(new Map()); /** Coalesces rapid-fire summarize delta renders into a single rAF frame */ const summarizeDeltaRaf = useRef(null); + /** + * Maps `SubagentUpdateEvent.subagentRunId` → parent `tool_call_id`. + * Preferred source is `payload.parentToolCallId` (threaded through by the + * SDK from `ToolRunnableConfig.toolCall.id`, deterministic). If a host + * runs an older SDK that doesn't emit it, we fall back to a temporal + * claim: the OLDEST unclaimed `subagent` tool call in the active message. + * Forward (oldest-first) iteration matches the order tool calls are + * created in, so concurrent spawns map in creation order. + */ + const subagentRunToToolCallId = useRef(new Map()); + const claimedSubagentToolCallIds = useRef(new Set()); + /** + * Buffers for envelopes that arrive before their `subagent` tool call is + * reflected in `messageMap`. Keyed by `subagentRunId`. Once a tool call is + * claimed we drain the buffer into the Recoil atom in arrival order. + */ + const pendingSubagentBuffer = useRef(new Map()); + /** + * Tracked atom keys so `clearStepMaps` can reset them. Without this, each + * subagent invocation leaks an `events: SubagentUpdateEvent[]` array in the + * `atomFamily` — atoms persist for the app lifetime. + */ + const knownSubagentAtomKeys = useRef(new Set()); + + /** Both content parts and ticker lines are aggregated incrementally + * into the atom as each `ON_SUBAGENT_UPDATE` arrives — we never + * retain the raw event array, so no rolling window is needed. A + * talkative subagent can emit thousands of deltas without growing + * memory past what the structural output requires. */ + + /** + * Attempts to resolve the parent `tool_call_id` for a subagent run, using + * the SDK-provided `parentToolCallId` first and falling back to an + * oldest-unclaimed temporal claim. + */ + const resolveSubagentToolCallId = useCallback( + (payload: SubagentUpdateEvent): string | undefined => { + const cached = subagentRunToToolCallId.current.get(payload.subagentRunId); + if (cached != null) return cached; + + if (payload.parentToolCallId) { + subagentRunToToolCallId.current.set(payload.subagentRunId, payload.parentToolCallId); + claimedSubagentToolCallIds.current.add(payload.parentToolCallId); + return payload.parentToolCallId; + } + + // Fallback — oldest unclaimed subagent tool call wins. + for (const message of messageMap.current.values()) { + const content = message.content; + if (!Array.isArray(content)) continue; + for (let i = 0; i < content.length; i++) { + const part = content[i]; + if (part?.type !== ContentTypes.TOOL_CALL) continue; + const tc = (part as { [ContentTypes.TOOL_CALL]?: { id?: string; name?: string } })[ + ContentTypes.TOOL_CALL + ]; + if ( + tc?.name === Constants.SUBAGENT && + tc.id && + !claimedSubagentToolCallIds.current.has(tc.id) + ) { + subagentRunToToolCallId.current.set(payload.subagentRunId, tc.id); + claimedSubagentToolCallIds.current.add(tc.id); + return tc.id; + } + } + } + + return undefined; + }, + [], + ); + + /** + * Merges an incoming {@link SubagentUpdateEvent} into the Recoil atom bucket + * keyed by the parent `tool_call_id`. Buffers early-arriving events whose + * tool call is not yet mapped, and replays the buffer once correlation + * completes. + */ + const applySubagentUpdate = useRecoilCallback( + ({ set }) => + (payload: SubagentUpdateEvent): void => { + const toolCallId = resolveSubagentToolCallId(payload); + + if (!toolCallId) { + const queue = pendingSubagentBuffer.current.get(payload.subagentRunId) ?? []; + queue.push(payload); + pendingSubagentBuffer.current.set(payload.subagentRunId, queue); + return; + } + + const buffered = pendingSubagentBuffer.current.get(payload.subagentRunId); + if (buffered && buffered.length > 0) { + pendingSubagentBuffer.current.delete(payload.subagentRunId); + } + const toApply = buffered ? [...buffered, payload] : [payload]; + + knownSubagentAtomKeys.current.add(toolCallId); + set(subagentProgressByToolCallId(toolCallId), (prev) => { + /** Fold the batch into both aggregators. Pure functions — they + * return a new reference only when something actually changed, + * so React bails out of unnecessary re-renders downstream. */ + let contentParts = prev?.contentParts ?? []; + let aggregatorState = prev?.aggregatorState ?? initSubagentAggregatorState(); + let tickerState = prev?.tickerState ?? initSubagentTickerState(); + for (const event of toApply) { + ({ parts: contentParts, state: aggregatorState } = foldSubagentEvent( + contentParts, + aggregatorState, + event, + )); + tickerState = foldSubagentEventIntoTicker(tickerState, event); + } + + const last = toApply[toApply.length - 1]; + return { + subagentRunId: payload.subagentRunId, + subagentType: payload.subagentType, + subagentAgentId: payload.subagentAgentId ?? prev?.subagentAgentId, + contentParts, + aggregatorState, + tickerState, + status: last.phase, + latestLabel: last.label ?? prev?.latestLabel, + }; + }); + }, + [resolveSubagentToolCallId], + ); + + /** + * Resets all accumulated subagent Recoil state. Kept for conversation- + * switch cleanup (see top-level hook usage) but NOT called from + * `clearStepMaps` — the collapsed SubagentCall ticker and its dialog + * read from these atoms to render the child's content parts, and we + * want that history to remain visible after the stream ends so the + * user can reopen the dialog for auditability. The atoms are bounded + * per-call (200-event cap) and per-conversation (one atom per + * subagent spawn), so growth is proportional to messages — the same + * growth profile as the rest of the conversation state. + */ + const resetSubagentAtoms = useRecoilCallback( + ({ reset }) => + (): void => { + for (const toolCallId of knownSubagentAtomKeys.current) { + reset(subagentProgressByToolCallId(toolCallId)); + } + knownSubagentAtomKeys.current.clear(); + }, + [], + ); /** * Calculate content index for a run step. @@ -612,6 +773,8 @@ export default function useStepHandler({ setMessages(updatedMessages); } + } else if (stepEvent.event === StepEvents.ON_SUBAGENT_UPDATE) { + applySubagentUpdate(stepEvent.data); } else if (stepEvent.event === StepEvents.ON_SUMMARIZE_START) { announcePolite({ message: 'summarize_started', isStatus: true }); } else if (stepEvent.event === StepEvents.ON_SUMMARIZE_DELTA) { @@ -714,7 +877,14 @@ export default function useStepHandler({ console.warn('Unhandled step event', (_exhaustive as TStepEvent).event); } }, - [getMessages, lastAnnouncementTimeRef, announcePolite, setMessages, calculateContentIndex], + [ + getMessages, + lastAnnouncementTimeRef, + announcePolite, + setMessages, + calculateContentIndex, + applySubagentUpdate, + ], ); const clearStepMaps = useCallback(() => { @@ -726,6 +896,17 @@ export default function useStepHandler({ messageMap.current.clear(); stepMap.current.clear(); pendingDeltaBuffer.current.clear(); + subagentRunToToolCallId.current.clear(); + claimedSubagentToolCallIds.current.clear(); + pendingSubagentBuffer.current.clear(); + /** Intentionally NOT calling `resetSubagentAtoms()` here — users need + * to be able to reopen the SubagentCall dialog after completion to + * audit what the child did. `resetSubagentAtoms` is returned below + * so callers can wipe atoms on conversation-switch (see + * `useEventHandlers`) — that's the correct cleanup boundary: + * persisted `subagent_content` takes over for historical messages + * once the conversation is saved, and we prevent unbounded + * atomFamily growth across multi-conversation sessions. */ }, []); /** @@ -739,5 +920,5 @@ export default function useStepHandler({ } }, []); - return { stepHandler, clearStepMaps, syncStepMessage }; + return { stepHandler, clearStepMaps, resetSubagentAtoms, syncStepMessage }; } diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index b61f4bc6a6..6a17f85260 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -699,6 +699,33 @@ "com_ui_agent_handoff_prompt_key_placeholder": "Label the content passed (default: 'instructions')", "com_ui_agent_handoff_prompt_placeholder": "Tell this agent what content to generate and pass to the handoff agent. You need to add something here to enable this feature", "com_ui_agent_handoffs": "Agent Handoffs", + "com_ui_agent_subagents": "Subagents", + "com_ui_agent_subagents_enable": "Enable subagents", + "com_ui_agent_subagents_allow_self": "Allow self-spawn", + "com_ui_agent_subagents_allow_self_info": "Let this agent delegate focused subtasks to a copy of itself in an isolated context window.", + "com_ui_agent_subagents_agents": "Additional subagents", + "com_ui_agent_subagents_add": "Add subagent", + "com_ui_agent_subagents_remove": "Remove {{0}} from subagents", + "com_ui_agent_subagents_max": "Maximum {{0}} subagents reached.", + "com_ui_agent_subagents_empty": "No subagents available: enable self-spawn or add at least one agent.", + "com_ui_agent_subagents_info": "Subagents run in isolated context windows. Verbose tool output stays scoped to the child; only a summary returns to this agent.", + "com_ui_agent_subagents_info_2": "Enabling with no agents listed still allows self-spawn, so this agent can delegate focused subtasks to a fresh copy of itself.", + "com_ui_subagent_running": "Running agent", + "com_ui_subagent_complete": "Ran agent", + "com_ui_subagent_cancelled": "Cancelled agent", + "com_ui_subagent_errored": "Agent errored", + "com_ui_subagent_ticker_writing": "Writing", + "com_ui_subagent_ticker_reasoning": "Reasoning", + "com_ui_subagent_ticker_error": "Error", + "com_ui_subagent_ticker_using": "Using", + "com_ui_subagent_ticker_tool_done": "done", + "com_ui_subagent_waiting": "Waiting for first update…", + "com_ui_subagent_dialog_title": "\"{{0}}\" agent", + "com_ui_subagent_dialog_title_self": "Agent", + "com_ui_subagent_dialog_description": "Isolated-context child run. Activity and final result below.", + "com_ui_subagent_no_result_yet": "Still running — no final result yet.", + "com_ui_subagent_empty_result": "No text returned.", + "com_ui_subagent_scroll_to_bottom": "Scroll to latest", "com_ui_agent_description": "Agent description", "com_ui_agent_name": "Agent name", "com_ui_agent_name_is_required": "Agent name is required", @@ -1624,6 +1651,8 @@ "com_ui_use_prompt": "Use Prompt", "com_ui_used": "Used", "com_ui_used_n_tools": "Used {{0}} tools", + "com_ui_running_n_agents": "Running {{0}} agents", + "com_ui_ran_n_agents": "Ran {{0}} agents", "com_ui_user": "User", "com_ui_user_group_permissions": "User & Group Permissions", "com_ui_user_provides_key": "Each user provides their own key", diff --git a/client/src/store/index.ts b/client/src/store/index.ts index 25d721e65b..0584e9d805 100644 --- a/client/src/store/index.ts +++ b/client/src/store/index.ts @@ -15,6 +15,7 @@ import isTemporary from './temporary'; export * from './agents'; export * from './mcp'; export * from './favorites'; +export * from './subagents'; export default { ...artifacts, diff --git a/client/src/store/subagents.ts b/client/src/store/subagents.ts new file mode 100644 index 0000000000..cf243a8c03 --- /dev/null +++ b/client/src/store/subagents.ts @@ -0,0 +1,47 @@ +import { atomFamily } from 'recoil'; +import type { SubagentUpdatePhase } from 'librechat-data-provider'; +import type { + SubagentAggregatorState, + SubagentContentPart, + SubagentTickerState, +} from '~/utils/subagentContent'; + +/** + * Progress bucket captured per subagent tool call. Populated as + * `ON_SUBAGENT_UPDATE` SSE events stream in from the backend. Keyed by the + * parent's `tool_call_id` so the `SubagentCall` renderer can look the bucket + * up from the tool call it's rendering. + * + * Both the dialog content and the ticker are aggregated *incrementally* + * into the atom as each envelope arrives — the atom never keeps the raw + * event array. A long-running subagent can emit thousands of deltas + * without the state growing past what its structural output (N text + * runs + M tool calls + a bounded tail preview) needs. + */ +export interface SubagentProgress { + /** Child run id from the SDK — unique per spawn; one tool_call may only have one. */ + subagentRunId: string; + /** `type` identifier from the SubagentConfig (e.g. 'self', 'researcher'). */ + subagentType: string; + /** Child agent id (for avatar / name lookup in the ticker header). */ + subagentAgentId?: string; + /** + * Fully aggregated child content parts. Bounded by structure (text + * runs + reasoning runs + tool calls), not by delta volume. + */ + contentParts: SubagentContentPart[]; + /** Cursor carried across `foldSubagentEvent` calls. */ + aggregatorState: SubagentAggregatorState; + /** Ticker lines + live-cursor state, built incrementally. */ + tickerState: SubagentTickerState; + /** Current lifecycle phase — drives the header "running" / "done" state. */ + status: SubagentUpdatePhase; + /** Convenience: last event's `label` for quick ticker display. */ + latestLabel?: string; +} + +/** Progress state keyed by parent tool_call_id. */ +export const subagentProgressByToolCallId = atomFamily({ + key: 'subagentProgressByToolCallId', + default: null, +}); diff --git a/client/src/utils/__tests__/subagentContent.test.ts b/client/src/utils/__tests__/subagentContent.test.ts new file mode 100644 index 0000000000..35ef518e40 --- /dev/null +++ b/client/src/utils/__tests__/subagentContent.test.ts @@ -0,0 +1,472 @@ +import { ContentTypes } from 'librechat-data-provider'; +import type { SubagentUpdateEvent } from 'librechat-data-provider'; +import { aggregateSubagentContent, buildSubagentTickerLines } from '../subagentContent'; + +const makeEvent = (overrides: Partial): SubagentUpdateEvent => ({ + runId: 'parent-run', + subagentRunId: 'child-run', + subagentType: 'self', + subagentAgentId: 'child', + parentAgentId: 'parent', + phase: 'start', + timestamp: '2026-04-17T00:00:00Z', + ...overrides, +}); + +describe('aggregateSubagentContent', () => { + it('returns empty array for no events', () => { + expect(aggregateSubagentContent([])).toEqual([]); + }); + + it('concatenates adjacent message_delta chunks into a single TEXT part', () => { + const parts = aggregateSubagentContent([ + makeEvent({ + phase: 'message_delta', + data: { delta: { content: [{ type: 'text', text: 'Hello ' }] } }, + }), + makeEvent({ + phase: 'message_delta', + data: { delta: { content: [{ type: 'text', text: 'world' }] } }, + }), + makeEvent({ + phase: 'message_delta', + data: { delta: { content: [{ type: 'text', text: '!' }] } }, + }), + ]); + expect(parts).toEqual([{ type: ContentTypes.TEXT, text: 'Hello world!' }]); + }); + + it('concatenates reasoning_delta chunks into a single THINK part', () => { + const parts = aggregateSubagentContent([ + makeEvent({ + phase: 'reasoning_delta', + data: { delta: { content: [{ type: 'think', think: 'Let me ' }] } }, + }), + makeEvent({ + phase: 'reasoning_delta', + data: { delta: { content: [{ type: 'think', think: 'compute…' }] } }, + }), + ]); + expect(parts).toEqual([{ type: ContentTypes.THINK, think: 'Let me compute…' }]); + }); + + it('creates a TOOL_CALL part for each unique tool_call id on run_step', () => { + const parts = aggregateSubagentContent([ + makeEvent({ + phase: 'run_step', + data: { + stepDetails: { + type: 'tool_calls', + tool_calls: [ + { id: 'call_1', name: 'calculator', args: '{"expression":"1+1"}' }, + { id: 'call_2', name: 'web_search', args: '{"query":"x"}' }, + ], + }, + }, + }), + ]); + expect(parts).toHaveLength(2); + expect((parts[0] as { tool_call: { name: string; progress: number } }).tool_call.name).toBe( + 'calculator', + ); + expect((parts[1] as { tool_call: { name: string; progress: number } }).tool_call.name).toBe( + 'web_search', + ); + expect((parts[0] as { tool_call: { progress: number } }).tool_call.progress).toBe(0.1); + }); + + it('finalizes a TOOL_CALL part on run_step_completed with output and progress=1', () => { + const parts = aggregateSubagentContent([ + makeEvent({ + phase: 'run_step', + data: { + stepDetails: { + type: 'tool_calls', + tool_calls: [{ id: 'call_1', name: 'calculator', args: '{}' }], + }, + }, + }), + makeEvent({ + phase: 'run_step_completed', + data: { + result: { + type: 'tool_call', + tool_call: { + id: 'call_1', + name: 'calculator', + args: '{"expression":"1+1"}', + output: '1+1 = 2', + progress: 1, + }, + }, + }, + }), + ]); + expect(parts).toHaveLength(1); + const tc = (parts[0] as { tool_call: { output?: string; progress: number } }).tool_call; + expect(tc.output).toBe('1+1 = 2'); + expect(tc.progress).toBe(1); + }); + + it('interleaves tool calls between text parts in order', () => { + const parts = aggregateSubagentContent([ + makeEvent({ + phase: 'message_delta', + data: { delta: { content: [{ type: 'text', text: 'Computing…' }] } }, + }), + makeEvent({ + phase: 'run_step', + data: { + stepDetails: { + type: 'tool_calls', + tool_calls: [{ id: 'c1', name: 'calculator', args: '{}' }], + }, + }, + }), + makeEvent({ + phase: 'run_step_completed', + data: { + result: { + type: 'tool_call', + tool_call: { id: 'c1', name: 'calculator', output: '4', progress: 1 }, + }, + }, + }), + makeEvent({ + phase: 'message_delta', + data: { delta: { content: [{ type: 'text', text: 'The answer is 4.' }] } }, + }), + ]); + expect(parts).toHaveLength(3); + expect((parts[0] as { type: string }).type).toBe(ContentTypes.TEXT); + expect((parts[1] as { type: string }).type).toBe(ContentTypes.TOOL_CALL); + expect((parts[2] as { type: string }).type).toBe(ContentTypes.TEXT); + expect((parts[2] as { text: string }).text).toBe('The answer is 4.'); + }); + + it('ignores start, stop, error, and run_step_delta phases', () => { + const parts = aggregateSubagentContent([ + makeEvent({ phase: 'start' }), + makeEvent({ phase: 'run_step_delta' }), + makeEvent({ phase: 'stop' }), + makeEvent({ phase: 'error', data: { message: 'boom' } }), + ]); + expect(parts).toEqual([]); + }); + + it('preserves chronological order when reasoning and text arrive interleaved (ordering regression)', () => { + /** + * Before the delta-type-switch close, a run where the LLM emitted + * reasoning FIRST, then text, then a tool call would flush both + * buffers at the tool_call boundary in a fixed (text, think) order — + * landing text BEFORE think in the content array even though the + * user observed reasoning first. Fix: when a text chunk arrives + * close any open think buffer first, and vice versa. + */ + const parts = aggregateSubagentContent([ + makeEvent({ + phase: 'reasoning_delta', + data: { delta: { content: [{ type: 'think', think: 'Let me think.' }] } }, + }), + makeEvent({ + phase: 'message_delta', + data: { delta: { content: [{ type: 'text', text: 'Sure!' }] } }, + }), + makeEvent({ + phase: 'run_step', + data: { + stepDetails: { + type: 'tool_calls', + tool_calls: [{ id: 'c1', name: 'calculator', args: '{}' }], + }, + }, + }), + ]); + expect(parts).toHaveLength(3); + expect((parts[0] as { type: string; think?: string }).type).toBe(ContentTypes.THINK); + expect((parts[0] as { think: string }).think).toBe('Let me think.'); + expect((parts[1] as { type: string; text?: string }).type).toBe(ContentTypes.TEXT); + expect((parts[1] as { text: string }).text).toBe('Sure!'); + expect((parts[2] as { type: string }).type).toBe(ContentTypes.TOOL_CALL); + }); + + it('handles repeated reasoning → text → reasoning flows across a turn', () => { + /** Second pattern from the screenshot: reasoning before the final + * text, and the completed run still ending with streaming text. + * A new reasoning after a text streak should appear AFTER the text, + * not before. */ + const parts = aggregateSubagentContent([ + makeEvent({ + phase: 'reasoning_delta', + data: { delta: { content: [{ type: 'think', think: 'Pre-thinking.' }] } }, + }), + makeEvent({ + phase: 'message_delta', + data: { delta: { content: [{ type: 'text', text: 'First draft.' }] } }, + }), + makeEvent({ + phase: 'reasoning_delta', + data: { delta: { content: [{ type: 'think', think: 'Post-thinking.' }] } }, + }), + makeEvent({ + phase: 'message_delta', + data: { delta: { content: [{ type: 'text', text: 'Final.' }] } }, + }), + ]); + expect(parts.map((p) => (p as { type: string }).type)).toEqual([ + ContentTypes.THINK, + ContentTypes.TEXT, + ContentTypes.THINK, + ContentTypes.TEXT, + ]); + expect((parts[0] as { think: string }).think).toBe('Pre-thinking.'); + expect((parts[1] as { text: string }).text).toBe('First draft.'); + expect((parts[2] as { think: string }).think).toBe('Post-thinking.'); + expect((parts[3] as { text: string }).text).toBe('Final.'); + }); + + it('handles a run_step_completed without a preceding run_step (late arrival)', () => { + const parts = aggregateSubagentContent([ + makeEvent({ + phase: 'run_step_completed', + data: { + result: { + type: 'tool_call', + tool_call: { id: 'c1', name: 'web', output: 'x', progress: 1 }, + }, + }, + }), + ]); + expect(parts).toHaveLength(1); + const tc = (parts[0] as { tool_call: { output?: string } }).tool_call; + expect(tc.output).toBe('x'); + }); +}); + +describe('buildSubagentTickerLines', () => { + it('returns empty array when no meaningful events are present', () => { + expect( + buildSubagentTickerLines([ + makeEvent({ phase: 'start' }), + makeEvent({ phase: 'run_step_delta' }), + makeEvent({ phase: 'stop' }), + ]), + ).toEqual([]); + }); + + it('produces a single writing line whose body extends as deltas arrive', () => { + const lines = buildSubagentTickerLines([ + makeEvent({ + phase: 'message_delta', + data: { delta: { content: [{ type: 'text', text: 'Hello ' }] } }, + }), + makeEvent({ + phase: 'message_delta', + data: { delta: { content: [{ type: 'text', text: 'world' }] } }, + }), + ]); + expect(lines).toHaveLength(1); + expect(lines[0]).toEqual({ kind: 'writing', body: 'Hello world' }); + }); + + it('truncates the writing body to the tail when it grows past the cap', () => { + const longText = 'x'.repeat(1000); + const lines = buildSubagentTickerLines([ + makeEvent({ + phase: 'message_delta', + data: { delta: { content: [{ type: 'text', text: longText }] } }, + }), + ]); + expect(lines).toHaveLength(1); + expect(lines[0].kind).toBe('writing'); + const body = (lines[0] as { body: string }).body; + /** No data-level ellipsis prefix — the component's CSS tail-ellipsis + * (`dir="rtl"` + `text-overflow: ellipsis`) handles the visual cue + * on overflow. Prepending one in data would stack a visible dot + * right after the "Writing:" label even when the body fits the + * container. */ + expect(body.startsWith('…')).toBe(false); + expect(body.length).toBe(300); + }); + + it('emits using_tool + tool_complete lines', () => { + const lines = buildSubagentTickerLines([ + makeEvent({ + phase: 'run_step', + data: { + stepDetails: { + type: 'tool_calls', + tool_calls: [{ id: 'c1', name: 'calculator' }], + }, + }, + }), + makeEvent({ + phase: 'run_step_completed', + data: { + result: { + type: 'tool_call', + tool_call: { id: 'c1', name: 'calculator', output: '42*58 = 2436', progress: 1 }, + }, + }, + }), + ]); + expect(lines).toEqual([ + { kind: 'using_tool', toolNames: ['calculator'] }, + { kind: 'tool_complete', toolName: 'calculator', outputSnippet: '42*58 = 2436' }, + ]); + }); + + it('includes an args snippet for single tool_calls and drops it for parallel', () => { + const single = buildSubagentTickerLines([ + makeEvent({ + phase: 'run_step', + data: { + stepDetails: { + type: 'tool_calls', + tool_calls: [{ id: 'c1', name: 'calculator', args: '{"expression":"42*58"}' }], + }, + }, + }), + ]); + expect(single[0]).toEqual({ + kind: 'using_tool', + toolNames: ['calculator'], + argsSnippet: 'expression=42*58', + }); + + const parallel = buildSubagentTickerLines([ + makeEvent({ + phase: 'run_step', + data: { + stepDetails: { + type: 'tool_calls', + tool_calls: [ + { id: 'c1', name: 'calculator', args: '{"expression":"42*58"}' }, + { id: 'c2', name: 'web_search', args: '{"query":"weather"}' }, + ], + }, + }, + }), + ]); + expect(parallel[0]).toEqual({ + kind: 'using_tool', + toolNames: ['calculator', 'web_search'], + }); + }); + + it('truncates long tool output to a 48-char head snippet', () => { + const bigOutput = 'x'.repeat(500); + const lines = buildSubagentTickerLines([ + makeEvent({ + phase: 'run_step_completed', + data: { + result: { + type: 'tool_call', + tool_call: { id: 'c1', name: 'reader', output: bigOutput, progress: 1 }, + }, + }, + }), + ]); + expect(lines[0].kind).toBe('tool_complete'); + const snippet = (lines[0] as { outputSnippet?: string }).outputSnippet ?? ''; + expect(snippet).toMatch(/^x+…$/); + /** 48-char head + trailing ellipsis. */ + expect(snippet.length).toBe(49); + }); + + it('omits outputSnippet when output is empty', () => { + const lines = buildSubagentTickerLines([ + makeEvent({ + phase: 'run_step_completed', + data: { + result: { + type: 'tool_call', + tool_call: { id: 'c1', name: 'noop', output: '', progress: 1 }, + }, + }, + }), + ]); + expect(lines[0]).toEqual({ kind: 'tool_complete', toolName: 'noop' }); + }); + + it('closes a streaming line when a tool call arrives so subsequent deltas start fresh', () => { + const lines = buildSubagentTickerLines([ + makeEvent({ + phase: 'message_delta', + data: { delta: { content: [{ type: 'text', text: 'I will compute' }] } }, + }), + makeEvent({ + phase: 'run_step', + data: { + stepDetails: { + type: 'tool_calls', + tool_calls: [{ id: 'c1', name: 'calculator' }], + }, + }, + }), + makeEvent({ + phase: 'message_delta', + data: { delta: { content: [{ type: 'text', text: 'Result: 4' }] } }, + }), + ]); + expect(lines).toEqual([ + { kind: 'writing', body: 'I will compute' }, + { kind: 'using_tool', toolNames: ['calculator'] }, + { kind: 'writing', body: 'Result: 4' }, + ]); + }); + + it('distinguishes reasoning from writing', () => { + const lines = buildSubagentTickerLines([ + makeEvent({ + phase: 'reasoning_delta', + data: { delta: { content: [{ type: 'think', think: 'Step 1' }] } }, + }), + ]); + expect(lines[0]).toEqual({ kind: 'reasoning', body: 'Step 1' }); + }); + + it('closes the opposite cursor on text↔reasoning phase switches (chronological order)', () => { + /** Codex P2 regression: a `reasoning → text → reasoning` sequence + * used to append the second reasoning chunk to the first + * reasoning line (cursor never reset across the text switch), + * producing merged/out-of-order previews. Now each delta-type + * transition closes the opposite buffer/cursor, matching the + * content-parts reducer's chronological rule. */ + const lines = buildSubagentTickerLines([ + makeEvent({ + phase: 'reasoning_delta', + data: { delta: { content: [{ type: 'think', think: 'Plan step.' }] } }, + }), + makeEvent({ + phase: 'message_delta', + data: { delta: { content: [{ type: 'text', text: 'Answer part 1.' }] } }, + }), + makeEvent({ + phase: 'reasoning_delta', + data: { delta: { content: [{ type: 'think', think: 'Refined plan.' }] } }, + }), + makeEvent({ + phase: 'message_delta', + data: { delta: { content: [{ type: 'text', text: 'Answer part 2.' }] } }, + }), + ]); + expect(lines.map((l) => (l as { kind: string }).kind)).toEqual([ + 'reasoning', + 'writing', + 'reasoning', + 'writing', + ]); + expect((lines[0] as { body: string }).body).toBe('Plan step.'); + expect((lines[1] as { body: string }).body).toBe('Answer part 1.'); + expect((lines[2] as { body: string }).body).toBe('Refined plan.'); + expect((lines[3] as { body: string }).body).toBe('Answer part 2.'); + }); + + it('surfaces error envelopes with their message', () => { + const lines = buildSubagentTickerLines([ + makeEvent({ phase: 'error', data: { message: 'recursion limit' } }), + ]); + expect(lines).toEqual([{ kind: 'error', message: 'recursion limit' }]); + }); +}); diff --git a/client/src/utils/__tests__/toolLabels.test.ts b/client/src/utils/__tests__/toolLabels.test.ts new file mode 100644 index 0000000000..12fb0dcefa --- /dev/null +++ b/client/src/utils/__tests__/toolLabels.test.ts @@ -0,0 +1,67 @@ +import { parseToolName, getToolDisplayLabel, TOOL_FRIENDLY_NAME_KEYS } from '../toolLabels'; + +describe('parseToolName', () => { + it('splits an MCP tool id into server + tool name', () => { + const parsed = parseToolName('search_code_mcp_github'); + expect(parsed).toEqual({ + raw: 'search_code_mcp_github', + mcpServer: 'github', + toolName: 'search_code', + }); + }); + + it('handles an MCP id whose tool-name portion contains underscores', () => { + const parsed = parseToolName('deeply_nested_sub_tool_mcp_some-server'); + expect(parsed.mcpServer).toBe('some-server'); + expect(parsed.toolName).toBe('deeply_nested_sub_tool'); + }); + + it('returns empty mcpServer + friendlyKey for a known native tool', () => { + const parsed = parseToolName('web_search'); + expect(parsed).toEqual({ + raw: 'web_search', + mcpServer: '', + toolName: 'web_search', + friendlyKey: TOOL_FRIENDLY_NAME_KEYS.web_search, + }); + }); + + it('returns empty mcpServer + no friendlyKey for an unknown native tool', () => { + const parsed = parseToolName('custom_tool'); + expect(parsed).toEqual({ + raw: 'custom_tool', + mcpServer: '', + toolName: 'custom_tool', + }); + }); + + it('handles an MCP id with an empty server segment (pathological)', () => { + /** `_mcp_` with nothing after the delimiter — treat as MCP + * with an empty server name rather than falling through to the + * native-tool path, so the display logic can surface "broken" ids + * rather than silently showing the whole thing as a tool name. */ + const parsed = parseToolName('foo_mcp_'); + expect(parsed.mcpServer).toBe(''); + expect(parsed.toolName).toBe('foo'); + }); +}); + +describe('getToolDisplayLabel', () => { + const identityLocalize = (key: string): string => key; + + it('returns the MCP server name for an MCP tool', () => { + expect(getToolDisplayLabel('search_code_mcp_github', identityLocalize)).toBe('github'); + }); + + it('returns the friendly translation key for a known native tool', () => { + /** The identity-localize stub returns the translation key itself, + * which is what the real `useLocalize` would resolve at render time. */ + expect(getToolDisplayLabel('web_search', identityLocalize)).toBe( + TOOL_FRIENDLY_NAME_KEYS.web_search, + ); + }); + + it('returns the raw name for an unknown native tool', () => { + expect(getToolDisplayLabel('custom_tool', identityLocalize)).toBe('custom_tool'); + }); +}); diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index 32f7a67c4a..1918ca16d8 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -30,6 +30,7 @@ export * from './localStorage'; export * from './promptGroups'; export * from './previewCache'; export * from './groupToolCalls'; +export * from './toolLabels'; export * from './favoritesError'; export { default as cn } from './cn'; export { default as logger } from './logger'; diff --git a/client/src/utils/subagentContent.ts b/client/src/utils/subagentContent.ts new file mode 100644 index 0000000000..15755ac94b --- /dev/null +++ b/client/src/utils/subagentContent.ts @@ -0,0 +1,496 @@ +import { ContentTypes, ToolCallTypes } from 'librechat-data-provider'; +import type { SubagentUpdateEvent } from 'librechat-data-provider'; + +/** + * Client-side helpers for rendering the live `SubagentCall` UI while + * `ON_SUBAGENT_UPDATE` events stream in. Exports two pure transforms: + * + * - `aggregateSubagentContent` — folds the raw event stream into an + * ordered array of TEXT / THINK / TOOL_CALL parts so the dialog can + * render the child's activity through the same `` pipeline + * the parent conversation uses. Frontend-only: on the backend we + * fold directly into the SDK's `createContentAggregator` in the + * `ON_SUBAGENT_UPDATE` handler, so no shared aggregator is needed. + * - `buildSubagentTickerLines` — short, user-readable status lines + * for the collapsed ticker. Aggregates message/reasoning deltas + * into running previews, surfaces tool-call lifecycle with + * args/output snippets, drops low-signal events. + */ + +type RunStepData = { + id?: string; + stepDetails?: { + type?: string; + tool_calls?: Array<{ + id?: string; + name?: string; + args?: unknown; + type?: string; + }>; + }; +}; + +type RunStepCompletedData = { + result?: { + type?: string; + tool_call?: { + id?: string; + name?: string; + args?: unknown; + output?: string; + progress?: number; + }; + }; +}; + +type MessageDeltaData = { + delta?: { content?: Array<{ type?: string; text?: string }> }; +}; + +type ReasoningDeltaData = { + delta?: { content?: Array<{ type?: string; think?: string }> }; +}; + +type ErrorData = { message?: string }; + +type TextPart = { type: ContentTypes.TEXT; text: string }; +type ThinkPart = { type: ContentTypes.THINK; think: string }; +type ToolCallPart = { + type: ContentTypes.TOOL_CALL; + tool_call: { + id: string; + name: string; + args: string; + output?: string; + progress: number; + type?: string; + }; +}; + +/** Single content-part-shaped entry produced by the aggregator. The union + * matches the subset of `TMessageContentParts` a subagent run emits. */ +export type SubagentContentPart = TextPart | ThinkPart | ToolCallPart; + +const extractTextChunk = (data: MessageDeltaData | undefined): string => { + const content = data?.delta?.content; + if (!Array.isArray(content)) return ''; + for (const block of content) { + if (block?.type === 'text' && typeof block.text === 'string') { + return block.text; + } + } + return ''; +}; + +const extractThinkChunk = (data: ReasoningDeltaData | undefined): string => { + const content = data?.delta?.content; + if (!Array.isArray(content)) return ''; + for (const block of content) { + if (block?.type === 'think' && typeof block.think === 'string') { + return block.think; + } + } + return ''; +}; + +const stringifyArgs = (args: unknown): string => + typeof args === 'string' ? args : JSON.stringify(args ?? {}); + +/** + * Cursor carried across `foldSubagentEvent` calls so the aggregator can + * extend an in-flight TEXT/THINK run without re-scanning earlier parts + * on every event. `null` means the corresponding buffer is closed; + * otherwise it's the index of the still-growing part in `contentParts`. + */ +export interface SubagentAggregatorState { + /** Index of the currently-open TEXT part, or `null` when none. */ + openTextIdx: number | null; + /** Index of the currently-open THINK part, or `null` when none. */ + openThinkIdx: number | null; + /** `tool_call.id` → its index in `contentParts` for O(1) updates. */ + toolCallIndexById: Record; +} + +/** Initial empty aggregator state. */ +export function initSubagentAggregatorState(): SubagentAggregatorState { + return { + openTextIdx: null, + openThinkIdx: null, + toolCallIndexById: {}, + }; +} + +/** + * Incrementally fold a single {@link SubagentUpdateEvent} into an existing + * `contentParts` array, returning a new array + updated cursor state. + * Pure function — never mutates inputs. + * + * Adjacent `message_delta` / `reasoning_delta` events extend the in-flight + * TEXT / THINK part (tracked via the open*Idx cursors). When a delta + * type switches, the opposite buffer is closed first so chronological + * order is preserved — what the user saw is what lands in the array. + * + * `run_step` with `tool_calls` closes any open text/think and appends a + * TOOL_CALL part per unique id. `run_step_completed` updates the matching + * TOOL_CALL (output + progress). Late-arriving completions without a + * prior `run_step` synthesize the part. `start` / `stop` / `error` / + * `run_step_delta` contribute nothing to content. + */ +export function foldSubagentEvent( + parts: SubagentContentPart[], + state: SubagentAggregatorState, + event: SubagentUpdateEvent, +): { parts: SubagentContentPart[]; state: SubagentAggregatorState } { + if (event.phase === 'message_delta') { + const chunk = extractTextChunk(event.data as MessageDeltaData | undefined); + if (!chunk) return { parts, state }; + /** Reasoning→text transition: close the open THINK so the THINK part + * lands BEFORE the TEXT part in chronological order. */ + const afterThinkClose = state.openThinkIdx != null ? { ...state, openThinkIdx: null } : state; + if (afterThinkClose.openTextIdx != null) { + const idx = afterThinkClose.openTextIdx; + const existing = parts[idx] as TextPart; + const next = parts.slice(); + next[idx] = { type: ContentTypes.TEXT, text: existing.text + chunk }; + return { parts: next, state: afterThinkClose }; + } + const next = parts.slice(); + const newIdx = next.length; + next.push({ type: ContentTypes.TEXT, text: chunk }); + return { parts: next, state: { ...afterThinkClose, openTextIdx: newIdx } }; + } + + if (event.phase === 'reasoning_delta') { + const chunk = extractThinkChunk(event.data as ReasoningDeltaData | undefined); + if (!chunk) return { parts, state }; + const afterTextClose = state.openTextIdx != null ? { ...state, openTextIdx: null } : state; + if (afterTextClose.openThinkIdx != null) { + const idx = afterTextClose.openThinkIdx; + const existing = parts[idx] as ThinkPart; + const next = parts.slice(); + next[idx] = { type: ContentTypes.THINK, think: existing.think + chunk }; + return { parts: next, state: afterTextClose }; + } + const next = parts.slice(); + const newIdx = next.length; + next.push({ type: ContentTypes.THINK, think: chunk }); + return { parts: next, state: { ...afterTextClose, openThinkIdx: newIdx } }; + } + + if (event.phase === 'run_step') { + const data = event.data as RunStepData | undefined; + if (data?.stepDetails?.type !== 'tool_calls') return { parts, state }; + const toolCalls = data.stepDetails.tool_calls ?? []; + let next = parts; + const toolCallIndexById = { ...state.toolCallIndexById }; + for (const tc of toolCalls) { + if (typeof tc?.id !== 'string' || !tc.id || tc.id in toolCallIndexById) continue; + if (next === parts) next = parts.slice(); + toolCallIndexById[tc.id] = next.length; + next.push({ + type: ContentTypes.TOOL_CALL, + tool_call: { + id: tc.id, + name: tc.name ?? '', + args: stringifyArgs(tc.args), + progress: 0.1, + type: tc.type ?? ToolCallTypes.TOOL_CALL, + }, + }); + } + if (next === parts) return { parts, state: { ...state, toolCallIndexById } }; + /** New tool_call parts bound any open TEXT/THINK to the run before + * them — close the buffers. */ + return { + parts: next, + state: { openTextIdx: null, openThinkIdx: null, toolCallIndexById }, + }; + } + + if (event.phase === 'run_step_completed') { + const data = event.data as RunStepCompletedData | undefined; + const tc = data?.result?.tool_call; + if (typeof tc?.id !== 'string' || !tc.id) return { parts, state }; + const existingIdx = state.toolCallIndexById[tc.id]; + if (existingIdx != null) { + const existing = parts[existingIdx] as ToolCallPart; + const merged: ToolCallPart = { + type: ContentTypes.TOOL_CALL, + tool_call: { + ...existing.tool_call, + ...(tc.name ? { name: tc.name } : {}), + ...(tc.args != null ? { args: stringifyArgs(tc.args) } : {}), + ...(tc.output != null ? { output: tc.output } : {}), + progress: tc.progress ?? 1, + }, + }; + const next = parts.slice(); + next[existingIdx] = merged; + return { parts: next, state }; + } + /** Late-arriving completion without a prior run_step — synthesize the + * part (and close any open buffer like run_step would). */ + const next = parts.slice(); + const newIdx = next.length; + next.push({ + type: ContentTypes.TOOL_CALL, + tool_call: { + id: tc.id, + name: tc.name ?? '', + args: stringifyArgs(tc.args), + output: tc.output, + progress: tc.progress ?? 1, + type: ToolCallTypes.TOOL_CALL, + }, + }); + return { + parts: next, + state: { + openTextIdx: null, + openThinkIdx: null, + toolCallIndexById: { ...state.toolCallIndexById, [tc.id]: newIdx }, + }, + }; + } + + return { parts, state }; +} + +/** + * Batch wrapper around {@link foldSubagentEvent}: folds an entire event + * stream in one go and returns just the parts. Kept for tests and for + * legacy call-sites that don't need cursor state. + */ +export function aggregateSubagentContent(events: SubagentUpdateEvent[]): SubagentContentPart[] { + let parts: SubagentContentPart[] = []; + let state = initSubagentAggregatorState(); + for (const event of events) { + ({ parts, state } = foldSubagentEvent(parts, state, event)); + } + return parts; +} + +/** + * Discriminated-union ticker line. Keeping the label tokens + body/snippets + * separate from their rendered strings lets the caller localize at + * render time (hooks can't live in a pure aggregator) and — more + * importantly — lets the UI split a fixed prefix (e.g. "Writing: ") + * from a tail-truncatable body, so the prefix never gets clipped out + * of view when the body overflows. + */ +export type SubagentTickerLine = + | { kind: 'writing'; body: string } + | { kind: 'reasoning'; body: string } + | { kind: 'using_tool'; toolNames: string[]; argsSnippet?: string } + | { kind: 'tool_complete'; toolName: string; outputSnippet?: string } + | { kind: 'error'; message?: string }; + +/** Live-update cursor carried across incremental folds. Mirrors the + * content-parts aggregator pattern so the atom can own the ticker + * state and never has to re-aggregate from a trimmed event buffer. */ +export interface SubagentTickerState { + lines: SubagentTickerLine[]; + /** Index of the in-flight 'writing' line (for in-place tail updates). */ + textLineIdx: number | null; + /** Index of the in-flight 'reasoning' line. */ + thinkLineIdx: number | null; + /** Raw message-delta accumulator — truncated into `writing.body` but + * preserved so subsequent deltas extend the running preview. */ + textBuffer: string; + thinkBuffer: string; +} + +export function initSubagentTickerState(): SubagentTickerState { + return { + lines: [], + textLineIdx: null, + thinkLineIdx: null, + textBuffer: '', + thinkBuffer: '', + }; +} + +/** Generous tail window so wide ticker containers aren't half-empty. + * The component applies CSS tail-ellipsis (`dir="rtl"` + + * `text-overflow: ellipsis`) so narrow viewports clip from the oldest + * side; we deliberately DON'T prepend a data-level `…` on top of that + * CSS ellipsis — double-eliding would render a stray dot character + * right next to the "Writing:" / "Reasoning:" label. */ +const PREVIEW_MAX_CHARS = 300; +const truncatePreview = (input: string): string => { + const normalized = input.replace(/\s+/g, ' ').trim(); + if (normalized.length <= PREVIEW_MAX_CHARS) return normalized; + return normalized.slice(-PREVIEW_MAX_CHARS); +}; + +const SNIPPET_MAX_CHARS = 48; +/** Short head-truncation for tool args/output — caller labels what each + * side is. Whitespace collapsed so multi-line outputs stay one line. */ +const truncateSnippet = (input: string): string => { + const normalized = input.replace(/\s+/g, ' ').trim(); + if (normalized.length <= SNIPPET_MAX_CHARS) return normalized; + return `${normalized.slice(0, SNIPPET_MAX_CHARS)}…`; +}; + +/** Best-effort, non-rendering summary of a tool's args payload. Parsed JSON + * is collapsed into `key=value, key=value`; everything else falls back to + * the raw string. Returns `''` when nothing useful is extractable. */ +const summarizeArgs = (args: unknown): string => { + if (typeof args !== 'string' || args.length === 0) return ''; + const raw = args.trim(); + if (raw.length === 0 || raw === '{}' || raw === '[]') return ''; + try { + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const entries = Object.entries(parsed as Record) + .filter(([, v]) => v !== undefined && v !== null && v !== '') + .map(([k, v]) => { + const valueStr = typeof v === 'string' ? v : JSON.stringify(v); + return `${k}=${valueStr}`; + }); + if (entries.length === 0) return ''; + return truncateSnippet(entries.join(', ')); + } + } catch { + /* fall through to raw-string snippet */ + } + return truncateSnippet(raw); +}; + +const summarizeOutput = (output: unknown): string => { + if (typeof output === 'string') return truncateSnippet(output); + if (output == null) return ''; + try { + return truncateSnippet(JSON.stringify(output)); + } catch { + return ''; + } +}; + +/** + * Incrementally fold a single {@link SubagentUpdateEvent} into the ticker + * state. Pure — never mutates inputs. Stored in the Recoil atom so the + * ticker always reflects the *full* run, not just the rolling event + * window (which trims as deltas pile up and can drop earlier tool_call + * lifecycle events). + * + * Message/reasoning deltas extend an in-flight line via the `textLineIdx` + * / `thinkLineIdx` cursors. A `run_step` with tool_calls closes the + * running buffers and appends a `using_tool` line. `run_step_completed` + * appends a `tool_complete` line. `error` appends an `error` line. + * Phases we ignore (`start`, `stop`, `run_step_delta`): pass-through. + */ +export function foldSubagentEventIntoTicker( + state: SubagentTickerState, + event: SubagentUpdateEvent, +): SubagentTickerState { + if (event.phase === 'message_delta') { + const chunk = extractTextChunk(event.data as MessageDeltaData | undefined); + if (!chunk) return state; + /** Delta-type transition: close any open reasoning buffer/cursor so + * a later `reasoning_delta` starts a NEW line below this text, + * rather than appending to the original reasoning line (which + * would produce merged / out-of-order previews). Mirrors the + * content-parts reducer's chronological-order rule. */ + const afterClose = + state.thinkLineIdx != null || state.thinkBuffer + ? { ...state, thinkLineIdx: null, thinkBuffer: '' } + : state; + const textBuffer = afterClose.textBuffer + chunk; + const body = truncatePreview(textBuffer); + const line: SubagentTickerLine = { kind: 'writing', body }; + if (afterClose.textLineIdx == null) { + const lines = afterClose.lines.concat(line); + return { ...afterClose, textBuffer, lines, textLineIdx: lines.length - 1 }; + } + const lines = afterClose.lines.slice(); + lines[afterClose.textLineIdx] = line; + return { ...afterClose, textBuffer, lines }; + } + + if (event.phase === 'reasoning_delta') { + const chunk = extractThinkChunk(event.data as ReasoningDeltaData | undefined); + if (!chunk) return state; + /** Symmetric: close any open text buffer/cursor. */ + const afterClose = + state.textLineIdx != null || state.textBuffer + ? { ...state, textLineIdx: null, textBuffer: '' } + : state; + const thinkBuffer = afterClose.thinkBuffer + chunk; + const body = truncatePreview(thinkBuffer); + const line: SubagentTickerLine = { kind: 'reasoning', body }; + if (afterClose.thinkLineIdx == null) { + const lines = afterClose.lines.concat(line); + return { ...afterClose, thinkBuffer, lines, thinkLineIdx: lines.length - 1 }; + } + const lines = afterClose.lines.slice(); + lines[afterClose.thinkLineIdx] = line; + return { ...afterClose, thinkBuffer, lines }; + } + + if (event.phase === 'run_step') { + /** A new run_step starts a fresh lifecycle marker and closes any + * in-flight streaming line — the delta cursors reset so the *next* + * message/reasoning delta starts its own line below the tool call. */ + const afterClose: SubagentTickerState = { + ...state, + textBuffer: '', + thinkBuffer: '', + textLineIdx: null, + thinkLineIdx: null, + }; + const data = event.data as RunStepData | undefined; + if (data?.stepDetails?.type !== 'tool_calls') return afterClose; + const toolCalls = data.stepDetails.tool_calls ?? []; + const named = toolCalls.filter( + (tc): tc is { id?: string; name: string; args?: unknown } => + typeof tc?.name === 'string' && tc.name.length > 0, + ); + if (named.length === 0) return afterClose; + const toolNames = named.map((tc) => tc.name); + const argsSnippet = named.length === 1 ? summarizeArgs(named[0].args) : undefined; + const line: SubagentTickerLine = { + kind: 'using_tool', + toolNames, + ...(argsSnippet ? { argsSnippet } : {}), + }; + return { ...afterClose, lines: afterClose.lines.concat(line) }; + } + + if (event.phase === 'run_step_completed') { + const data = event.data as RunStepCompletedData | undefined; + const tc = data?.result?.tool_call; + if (typeof tc?.name !== 'string' || tc.name.length === 0) return state; + const outputSnippet = tc.output != null ? summarizeOutput(tc.output) : undefined; + const line: SubagentTickerLine = { + kind: 'tool_complete', + toolName: tc.name, + ...(outputSnippet ? { outputSnippet } : {}), + }; + return { ...state, lines: state.lines.concat(line) }; + } + + if (event.phase === 'error') { + const data = event.data as ErrorData | undefined; + const line: SubagentTickerLine = { + kind: 'error', + ...(data?.message ? { message: data.message } : {}), + }; + return { ...state, lines: state.lines.concat(line) }; + } + + return state; +} + +/** + * Batch wrapper around {@link foldSubagentEventIntoTicker} — folds an + * entire event stream in one shot. Kept for tests and any legacy + * consumer that prefers a one-call API. + */ +export function buildSubagentTickerLines(events: SubagentUpdateEvent[]): SubagentTickerLine[] { + let state = initSubagentTickerState(); + for (const event of events) { + state = foldSubagentEventIntoTicker(state, event); + } + return state.lines; +} diff --git a/client/src/utils/toolLabels.ts b/client/src/utils/toolLabels.ts new file mode 100644 index 0000000000..fcb6131213 --- /dev/null +++ b/client/src/utils/toolLabels.ts @@ -0,0 +1,79 @@ +import { Constants } from 'librechat-data-provider'; +import type { TranslationKeys } from '~/hooks'; + +/** + * Shared tool-name parsing + friendly-label mapping used by the main + * tool-call UI and the subagent ticker. Centralized so the MCP + * delimiter (`_mcp_`) and native-tool short names read + * consistently in every surface that renders tool lifecycle. + */ + +/** Native tool id → translation key for a user-readable short name. */ +export const TOOL_FRIENDLY_NAME_KEYS: Record = { + execute_code: 'com_ui_tool_name_code', + run_tools_with_code: 'com_ui_tool_name_code', + web_search: 'com_ui_tool_name_web_search', + image_gen_oai: 'com_ui_tool_name_image_gen', + image_edit_oai: 'com_ui_tool_name_image_edit', + gemini_image_gen: 'com_ui_tool_name_image_gen', + file_search: 'com_ui_tool_name_file_search', + code_interpreter: 'com_ui_tool_name_code_analysis', + retrieval: 'com_ui_tool_name_file_search', +}; + +export interface ParsedToolName { + /** Original tool id (unchanged). */ + raw: string; + /** MCP server name when the tool follows `_mcp_`, else `''`. */ + mcpServer: string; + /** Tool-specific name — the `` half of an MCP id, or the raw + * name for native tools. Useful as the "action" label shown in a + * code-style badge next to the server name. */ + toolName: string; + /** Translation key for a user-friendly display name, when the raw + * name matches a built-in native tool (web_search, execute_code, …). + * Absent for MCP tools and unknown names. */ + friendlyKey?: TranslationKeys; +} + +/** + * Split an incoming tool id into its constituent parts: + * + * - `search_code_mcp_github` → `{ mcpServer: 'github', toolName: 'search_code' }` + * - `web_search` → `{ mcpServer: '', toolName: 'web_search', friendlyKey: 'com_ui_tool_name_web_search' }` + * - `some_custom_tool` → `{ mcpServer: '', toolName: 'some_custom_tool' }` + */ +export function parseToolName(rawName: string): ParsedToolName { + const idx = rawName.indexOf(Constants.mcp_delimiter); + if (idx >= 0) { + const mcpServer = rawName.slice(idx + Constants.mcp_delimiter.length); + const toolName = rawName.slice(0, idx); + return { raw: rawName, mcpServer, toolName }; + } + const friendlyKey = TOOL_FRIENDLY_NAME_KEYS[rawName]; + return { + raw: rawName, + mcpServer: '', + toolName: rawName, + ...(friendlyKey ? { friendlyKey } : {}), + }; +} + +/** + * Resolve a tool id to a single user-facing short label. Pure string — + * use `parseToolName` directly when you need structured parts (e.g. to + * render a code-style badge for the tool name next to the server). + * + * - MCP tool → server name (keeps the header summary short) + * - Native → localized friendly name from {@link TOOL_FRIENDLY_NAME_KEYS} + * - Unknown → raw name + */ +export function getToolDisplayLabel( + rawName: string, + localize: (key: TranslationKeys) => string, +): string { + const parsed = parseToolName(rawName); + if (parsed.mcpServer) return parsed.mcpServer; + if (parsed.friendlyKey) return localize(parsed.friendlyKey); + return parsed.toolName; +} diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index 2461b6416a..4164d5ecf4 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -805,3 +805,118 @@ describe('custom-endpoint provider resolution', () => { expect(parameters.modelName).toBeUndefined(); }); }); + +// --------------------------------------------------------------------------- +// Suite 8: subagentConfigs +// --------------------------------------------------------------------------- +describe('subagentConfigs', () => { + it('is undefined when subagents are not enabled', async () => { + const agents = await callAndCapture({}); + expect(agents[0].subagentConfigs).toBeUndefined(); + }); + + it('adds self-spawn when enabled and allowSelf defaults to true', async () => { + const agents = await callAndCapture({ + agents: [makeAgent({ subagents: { enabled: true } })], + }); + const configs = agents[0].subagentConfigs as Array>; + expect(Array.isArray(configs)).toBe(true); + expect(configs).toHaveLength(1); + expect(configs[0]).toMatchObject({ self: true, type: 'self' }); + }); + + it('omits self-spawn when allowSelf is false', async () => { + const agents = await callAndCapture({ + agents: [makeAgent({ subagents: { enabled: true, allowSelf: false } })], + }); + expect(agents[0].subagentConfigs).toBeUndefined(); + }); + + it('adds explicit subagent configs with agentInputs', async () => { + const child = 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'] }, + subagentAgentConfigs: [child], + }), + ], + }); + const configs = agents[0].subagentConfigs as Array>; + expect(configs).toHaveLength(1); + expect(configs[0]).toMatchObject({ + type: 'agent_child', + name: 'Researcher', + description: 'Deep web research', + }); + expect(configs[0].agentInputs).toBeDefined(); + expect(configs[0].self).toBeUndefined(); + }); + + it('combines self-spawn and explicit subagents when both enabled', async () => { + const child = makeAgent({ id: 'agent_child', name: 'Helper' }); + const agents = await callAndCapture({ + agents: [ + makeAgent({ + subagents: { enabled: true, agent_ids: ['agent_child'] }, + subagentAgentConfigs: [child], + }), + ], + }); + const configs = agents[0].subagentConfigs as Array>; + expect(configs).toHaveLength(2); + expect(configs[0].self).toBe(true); + expect(configs[1].type).toBe('agent_child'); + }); + + it('skips a child that points at the parent itself', async () => { + const self = makeAgent({ id: 'agent_1' }); + const agents = await callAndCapture({ + agents: [ + makeAgent({ + subagents: { enabled: true, allowSelf: false, agent_ids: ['agent_1'] }, + subagentAgentConfigs: [self], + }), + ], + }); + expect(agents[0].subagentConfigs).toBeUndefined(); + }); + + it('does NOT leak the parent run `initialSummary` into an explicit child (Codex P1 regression)', async () => { + /** + * `buildAgentInput` is a shared factory that always stamps the parent + * run's `initialSummary` on the returned AgentInputs. When it's reused + * to build a subagent child's inputs, `buildSubagentConfigs` must clear + * that field — otherwise the child inherits unrelated conversation + * context, defeating the isolation contract (and burning extra tokens). + */ + const summary = { text: 'parent conversation summary', tokenCount: 99 }; + const child = makeAgent({ id: 'agent_child', name: 'Child' }); + const agents = await callAndCapture({ + initialSummary: summary, + agents: [ + makeAgent({ + subagents: { enabled: true, allowSelf: false, agent_ids: ['agent_child'] }, + subagentAgentConfigs: [child], + }), + ], + }); + + const parent = agents[0]; + /** The parent itself keeps the summary — that's how it receives + * cross-turn context. */ + expect(parent.initialSummary).toEqual(summary); + + const childConfig = (parent.subagentConfigs as Array>)[0]; + const childInputs = childConfig.agentInputs as { + initialSummary?: unknown; + discoveredTools?: unknown; + }; + expect(childInputs.initialSummary).toBeUndefined(); + expect(childInputs.discoveredTools).toBeUndefined(); + }); +}); diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index b79fe92cfd..d86b15a470 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -13,13 +13,14 @@ import type { OpenAIClientOptions, StandardGraphConfig, LCToolRegistry, + SubagentConfig, AgentInputs, GenericTool, RunConfig, IState, LCTool, } from '@librechat/agents'; -import type { Agent, SummarizationConfig } from 'librechat-data-provider'; +import type { Agent, AgentSubagentsConfig, SummarizationConfig } from 'librechat-data-provider'; import type { BaseMessage } from '@langchain/core/messages'; import type { AppConfig, IUser } from '@librechat/data-schemas'; import type * as t from '~/types'; @@ -245,6 +246,10 @@ type RunAgent = Omit & { * Overrides the default computed from maxContextTokens. */ maxToolResultChars?: number; + /** Initialized subagent configs (loaded by initialize.js from agent.subagents.agent_ids). */ + subagentAgentConfigs?: RunAgent[]; + /** Source subagent spawning configuration (enabled / allowSelf / agent_ids). */ + subagents?: AgentSubagentsConfig; }; function isNonEmptyString(value: unknown): value is string { @@ -494,6 +499,91 @@ function computeEffectiveMaxContextTokens( return Math.min(maxContextTokens ?? ratioComputed, ratioComputed); } +/** Identifier for the self-spawn subagent (reuses parent's AgentInputs in an isolated child graph). */ +const SELF_SUBAGENT_TYPE = 'self'; + +/** + * 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. + */ +function buildSubagentConfigs( + agent: RunAgent, + agentInput: AgentInputs, + toInput: (child: RunAgent, opts?: { isSubagent?: boolean }) => AgentInputs, + ancestors: Set = new Set(), +): SubagentConfig[] { + if (!agent.subagents?.enabled) { + return []; + } + + const configs: SubagentConfig[] = []; + const allowSelf = agent.subagents.allowSelf !== false; + + if (allowSelf) { + const selfName = agentInput.name ?? agent.name ?? 'self'; + configs.push({ + self: true, + type: SELF_SUBAGENT_TYPE, + name: selfName, + description: `Spawn ${selfName} in an isolated context to handle a focused subtask. Verbose tool output stays in the child's context; only a summary returns.`, + }); + } + + /** Cycle-safety: include the current agent in `ancestors` before + * descending into children so a `A → B → A` configuration stops at + * the second encounter of A rather than recursing forever. Skip + * `A → A` too (already guarded) and anything that would re-enter + * an ancestor. */ + const nextAncestors = new Set(ancestors); + nextAncestors.add(agent.id); + + for (const child of agent.subagentAgentConfigs ?? []) { + if (!child?.id || child.id === agent.id) { + continue; + } + if (ancestors.has(child.id)) { + continue; + } + /** + * `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 }); + /** + * Recursively resolve the child's own spawn targets so multi-level + * delegation (A → B → C) works. Without this, a child whose own + * `subagents.enabled` is true loses every explicit target when + * invoked as a subagent — only the top-level loop attaches + * `subagentConfigs`, and that only runs for the outer agents in + * `agents[]`. Cycle-safe via `nextAncestors`. + */ + const grandchildConfigs = buildSubagentConfigs(child, childInputs, toInput, nextAncestors); + if (grandchildConfigs.length > 0) { + childInputs.subagentConfigs = grandchildConfigs; + } + configs.push({ + 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.`, + agentInputs: childInputs, + }); + } + + return configs; +} + /** * Creates a new Run instance with custom handlers and configuration. * @@ -565,8 +655,8 @@ export async function createRun({ ? extractDiscoveredToolsFromHistory(messages) : new Set(); - const agentInputs: AgentInputs[] = []; - const buildAgentContext = (agent: RunAgent) => { + const buildAgentInput = (agent: RunAgent, opts: { isSubagent?: boolean } = {}): AgentInputs => { + const isSubagent = opts.isSubagent === true; const provider = (providerEndpointMap[ agent.provider as keyof typeof providerEndpointMap @@ -627,12 +717,22 @@ export async function createRun({ } /** - * Override defer_loading for tools that were discovered in previous turns. - * This prevents the LLM from having to re-discover tools via tool_search. - * Also add the discovered tools' definitions so the LLM has their schemas. + * Override defer_loading for tools that were discovered in previous + * turns. This prevents the LLM from having to re-discover tools via + * tool_search. Also add the discovered tools' definitions so the + * LLM has their schemas. + * + * Skipped for subagent children (`isSubagent`) — they run in an + * isolated context by contract, so inheriting the parent's + * tool-search state leaks unrelated history and pre-loads tools the + * child shouldn't care about. Mutations on `agent.toolRegistry` + * and additions to `toolDefinitions` both happen here, so the flag + * has to gate the whole block (clearing fields post-return can't + * undo registry writes). */ let toolDefinitions = agent.toolDefinitions ?? []; - if (discoveredTools.size > 0 && agent.toolRegistry) { + let toolRegistry = agent.toolRegistry; + if (!isSubagent && discoveredTools.size > 0 && agent.toolRegistry) { overrideDeferLoadingForDiscoveredTools(agent.toolRegistry, discoveredTools); /** Add discovered tools' definitions so the LLM can see their schemas */ @@ -646,6 +746,25 @@ export async function createRun({ toolDefinitions = [...toolDefinitions, toolDef]; } } + } else if (isSubagent && agent.toolRegistry) { + /** + * Subagent children: hand the child a deep-enough clone of the + * registry so later parent-graph builds (e.g. when the same + * agent also appears as a handoff target in the outer loop) + * can't mutate `defer_loading` on tool definitions the child + * already holds a reference to. Clone the `Map` *and* each + * `LCTool` — `overrideDeferLoadingForDiscoveredTools` writes + * through to the tool object itself, so a shallow Map copy + * alone wouldn't isolate the flag. + */ + toolRegistry = new Map(); + for (const [name, tool] of agent.toolRegistry.entries()) { + toolRegistry.set(name, { ...tool }); + } + /** Child's own `toolDefinitions` list gets the same shallow- + * copied view so any later parent mutation of shared definitions + * is contained to the parent-graph path. */ + toolDefinitions = toolDefinitions.map((def) => ({ ...def })); } const effectiveMaxContextTokens = computeEffectiveMaxContextTokens( @@ -655,7 +774,7 @@ export async function createRun({ ); const reasoningKey = getReasoningKey(provider, llmConfig, agent.endpoint); - const agentInput: AgentInputs = { + return { provider, reasoningKey, toolDefinitions, @@ -664,21 +783,27 @@ export async function createRun({ clientOptions: llmConfig, instructions: systemContent, name: agent.name ?? undefined, - toolRegistry: agent.toolRegistry, + toolRegistry, maxContextTokens: effectiveMaxContextTokens, useLegacyContent: agent.useLegacyContent ?? false, - discoveredTools: discoveredTools.size > 0 ? Array.from(discoveredTools) : undefined, + discoveredTools: + !isSubagent && discoveredTools.size > 0 ? Array.from(discoveredTools) : undefined, summarizationEnabled: summarization.enabled, summarizationConfig: summarization.config, - initialSummary, + initialSummary: isSubagent ? undefined : initialSummary, contextPruningConfig: summarization.contextPruning, maxToolResultChars: agent.maxToolResultChars, }; - agentInputs.push(agentInput); }; + const agentInputs: AgentInputs[] = []; for (const agent of agents) { - buildAgentContext(agent); + const agentInput = buildAgentInput(agent); + const subagentConfigs = buildSubagentConfigs(agent, agentInput, buildAgentInput); + if (subagentConfigs.length > 0) { + agentInput.subagentConfigs = subagentConfigs; + } + agentInputs.push(agentInput); } const graphConfig: RunConfig['graphConfig'] = { diff --git a/packages/api/src/agents/validation.spec.ts b/packages/api/src/agents/validation.spec.ts new file mode 100644 index 0000000000..743217780f --- /dev/null +++ b/packages/api/src/agents/validation.spec.ts @@ -0,0 +1,83 @@ +import { MAX_SUBAGENTS } from 'librechat-data-provider'; +import { agentCreateSchema, agentUpdateSchema, agentSubagentsSchema } from './validation'; + +describe('agentSubagentsSchema', () => { + it('accepts enabled:true with a list within the cap', () => { + const result = agentSubagentsSchema.safeParse({ + enabled: true, + allowSelf: false, + agent_ids: ['agent_1', 'agent_2'], + }); + expect(result.success).toBe(true); + }); + + it('accepts the feature-off shape (enabled:false, no agents)', () => { + const result = agentSubagentsSchema.safeParse({ enabled: false }); + expect(result.success).toBe(true); + }); + + it('rejects agent_ids longer than MAX_SUBAGENTS', () => { + const oversized = Array.from({ length: MAX_SUBAGENTS + 1 }, (_, i) => `agent_${i}`); + const result = agentSubagentsSchema.safeParse({ + enabled: true, + agent_ids: oversized, + }); + expect(result.success).toBe(false); + }); + + it('accepts exactly MAX_SUBAGENTS entries', () => { + const atCap = Array.from({ length: MAX_SUBAGENTS }, (_, i) => `agent_${i}`); + const result = agentSubagentsSchema.safeParse({ + enabled: true, + agent_ids: atCap, + }); + expect(result.success).toBe(true); + }); +}); + +describe('agentCreateSchema with subagents', () => { + const base = { + provider: 'openAI', + model: 'gpt-4o-mini', + tools: [], + }; + + it('passes with subagents omitted', () => { + const result = agentCreateSchema.safeParse(base); + expect(result.success).toBe(true); + }); + + it('passes with a valid subagents config', () => { + const result = agentCreateSchema.safeParse({ + ...base, + subagents: { enabled: true, allowSelf: true, agent_ids: [] }, + }); + expect(result.success).toBe(true); + }); + + it('rejects when subagents.agent_ids exceeds the cap', () => { + const oversized = Array.from({ length: MAX_SUBAGENTS + 1 }, (_, i) => `agent_${i}`); + const result = agentCreateSchema.safeParse({ + ...base, + subagents: { enabled: true, agent_ids: oversized }, + }); + expect(result.success).toBe(false); + }); +}); + +describe('agentUpdateSchema with subagents', () => { + it('accepts a partial update with only the disabled flag set', () => { + const result = agentUpdateSchema.safeParse({ + subagents: { enabled: false, allowSelf: true, agent_ids: [] }, + }); + expect(result.success).toBe(true); + }); + + it('rejects oversized agent_ids on update', () => { + const oversized = Array.from({ length: MAX_SUBAGENTS + 3 }, (_, i) => `agent_${i}`); + const result = agentUpdateSchema.safeParse({ + subagents: { enabled: true, agent_ids: oversized }, + }); + expect(result.success).toBe(false); + }); +}); diff --git a/packages/api/src/agents/validation.ts b/packages/api/src/agents/validation.ts index dd12a9ffe6..877614bf95 100644 --- a/packages/api/src/agents/validation.ts +++ b/packages/api/src/agents/validation.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { ViolationTypes, ErrorTypes } from 'librechat-data-provider'; +import { MAX_SUBAGENTS, ViolationTypes, ErrorTypes } from 'librechat-data-provider'; import type { Agent, TModelsConfig } from 'librechat-data-provider'; import type { Request, Response } from 'express'; @@ -51,11 +51,20 @@ export const agentSupportContactSchema = z export const graphEdgeSchema = z.object({ from: z.union([z.string(), z.array(z.string())]), to: z.union([z.string(), z.array(z.string())]), - description: z.string().optional().transform((v) => (v === '' ? undefined : v)), + description: z + .string() + .optional() + .transform((v) => (v === '' ? undefined : v)), edgeType: z.enum(['handoff', 'direct']).optional(), - prompt: z.union([z.string(), z.function()]).optional().transform((v) => (v === '' ? undefined : v)), + prompt: z + .union([z.string(), z.function()]) + .optional() + .transform((v) => (v === '' ? undefined : v)), excludeResults: z.boolean().optional(), - promptKey: z.string().optional().transform((v) => (v === '' ? undefined : v)), + promptKey: z + .string() + .optional() + .transform((v) => (v === '' ? undefined : v)), }); /** Per-tool options schema (defer_loading, allowed_callers) */ @@ -67,6 +76,20 @@ export const toolOptionsSchema = z.object({ /** Agent tool options - map of tool_id to tool options */ export const agentToolOptionsSchema = z.record(z.string(), toolOptionsSchema).optional(); +/** + * Subagent spawning configuration for an agent. `agent_ids` is capped at + * `Constants.MAX_SUBAGENTS` so a crafted API request cannot trigger hundreds + * of `processAgent` calls (DB lookup + permission check + tool loading). + * The UI enforces the same cap, so legitimate payloads never hit the bound. + */ +export const agentSubagentsSchema = z + .object({ + enabled: z.boolean().optional(), + allowSelf: z.boolean().optional(), + agent_ids: z.array(z.string()).max(MAX_SUBAGENTS).optional(), + }) + .optional(); + /** Base agent schema with all common fields */ export const agentBaseSchema = z.object({ name: z.string().nullable().optional(), @@ -86,6 +109,7 @@ export const agentBaseSchema = z.object({ conversation_starters: z.array(z.string()).optional(), tool_resources: agentToolResourcesSchema, tool_options: agentToolOptionsSchema, + subagents: agentSubagentsSchema, support_contact: agentSupportContactSchema, category: z.string().optional(), }); diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 6b1f5aed6c..f783391323 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -223,6 +223,7 @@ export enum AgentCapabilities { file_search = 'file_search', web_search = 'web_search', artifacts = 'artifacts', + subagents = 'subagents', actions = 'actions', context = 'context', skills = 'skills', @@ -313,6 +314,7 @@ export const defaultAgentCapabilities = [ AgentCapabilities.file_search, AgentCapabilities.web_search, AgentCapabilities.artifacts, + AgentCapabilities.subagents, AgentCapabilities.actions, AgentCapabilities.context, AgentCapabilities.skills, @@ -1925,8 +1927,13 @@ export enum Constants { EPHEMERAL_AGENT_ID = 'ephemeral', /** Programmatic Tool Calling tool name */ PROGRAMMATIC_TOOL_CALLING = 'run_tools_with_code', + /** Subagent spawn tool name (must match `@librechat/agents` `Constants.SUBAGENT`). */ + SUBAGENT = 'subagent', } +/** Maximum number of explicit subagents per parent agent. UI + Zod schema share this. */ +export const MAX_SUBAGENTS = 10; + export enum LocalStorageKeys { /** Key for the admin defined App Title */ APP_TITLE = 'appTitle', diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index dd611250cd..9b52e3711d 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -298,6 +298,10 @@ export const defaultAgentFormValues = { * interacted with the skills UI) does not accidentally persist "explicit none" * on first save — removeNullishValues strips the field server-side. */ skills: undefined as string[] | undefined, + /** `undefined` = feature disabled by default (no subagent tool injected). */ + subagents: undefined as + | { enabled?: boolean; allowSelf?: boolean; agent_ids?: string[] } + | undefined, }; export const ImageVisionTool: FunctionTool = { diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts index 4618f4f49f..402a6de2af 100644 --- a/packages/data-provider/src/types/assistants.ts +++ b/packages/data-provider/src/types/assistants.ts @@ -241,6 +241,19 @@ export type ToolOptions = { */ export type AgentToolOptions = Record; +/** + * Configuration for spawning subagents (isolated-context child agents) from an agent. + * When `enabled` is true, the agent gets a subagent-spawn tool that can delegate work + * to either itself (when `allowSelf` is true) and/or the listed `agent_ids`. + */ +export type AgentSubagentsConfig = { + enabled?: boolean; + /** When true (default), the agent may spawn itself in an isolated context. */ + allowSelf?: boolean; + /** Specific agents that may be spawned as subagents. */ + agent_ids?: string[]; +}; + export type Agent = { _id?: string; id: string; @@ -277,6 +290,8 @@ export type Agent = { tool_options?: AgentToolOptions; /** Skill ObjectIds the agent can invoke — phase 2 wiring in AgentConfig. */ skills?: string[]; + /** Subagent spawning configuration — isolated-context child agents. */ + subagents?: AgentSubagentsConfig; }; export type TAgentsMap = Record; @@ -303,6 +318,7 @@ export type AgentCreateParams = { | 'support_contact' | 'tool_options' | 'skills' + | 'subagents' >; export type AgentUpdateParams = { @@ -328,6 +344,7 @@ export type AgentUpdateParams = { | 'support_contact' | 'tool_options' | 'skills' + | 'subagents' >; export type AgentListParams = { diff --git a/packages/data-provider/src/types/runs.ts b/packages/data-provider/src/types/runs.ts index b159f99daf..706c380c6f 100644 --- a/packages/data-provider/src/types/runs.ts +++ b/packages/data-provider/src/types/runs.ts @@ -37,4 +37,33 @@ export enum StepEvents { ON_SUMMARIZE_START = 'on_summarize_start', ON_SUMMARIZE_DELTA = 'on_summarize_delta', ON_SUMMARIZE_COMPLETE = 'on_summarize_complete', + ON_SUBAGENT_UPDATE = 'on_subagent_update', +} + +/** Lifecycle phase carried on subagent-progress envelopes (mirrors SDK SubagentUpdatePhase). */ +export type SubagentUpdatePhase = + | 'start' + | 'run_step' + | 'run_step_delta' + | 'run_step_completed' + | 'message_delta' + | 'reasoning_delta' + | 'stop' + | 'error'; + +/** Single streamed subagent update forwarded by the SDK's SubagentExecutor. */ +export interface SubagentUpdateEvent { + runId: string; + subagentRunId: string; + /** Parent-side `tool_call_id` for the `subagent` tool invocation that + * triggered this run. Surfaces from the SDK (`3.1.67-dev.2`+) so hosts + * can correlate child progress to the parent tool call deterministically. */ + parentToolCallId?: string; + subagentType: string; + subagentAgentId: string; + parentAgentId?: string; + phase: SubagentUpdatePhase; + data?: unknown; + label?: string; + timestamp: string; } diff --git a/packages/data-schemas/src/schema/agent.ts b/packages/data-schemas/src/schema/agent.ts index b09bf04a6d..a6d893d628 100644 --- a/packages/data-schemas/src/schema/agent.ts +++ b/packages/data-schemas/src/schema/agent.ts @@ -116,6 +116,11 @@ const agentSchema = new Schema( type: Schema.Types.Mixed, default: undefined, }, + /** Subagent spawning configuration — isolated-context child agents. */ + subagents: { + type: Schema.Types.Mixed, + default: undefined, + }, tenantId: { type: String, index: true, diff --git a/packages/data-schemas/src/types/agent.ts b/packages/data-schemas/src/types/agent.ts index e66af874af..f10b0ffe76 100644 --- a/packages/data-schemas/src/types/agent.ts +++ b/packages/data-schemas/src/types/agent.ts @@ -1,5 +1,10 @@ import { Document, Types } from 'mongoose'; -import type { GraphEdge, AgentToolOptions, AgentToolResources } from 'librechat-data-provider'; +import type { + GraphEdge, + AgentToolOptions, + AgentToolResources, + AgentSubagentsConfig, +} from 'librechat-data-provider'; export interface ISupportContact { name?: string; @@ -42,5 +47,7 @@ export interface IAgent extends Omit { mcpServerNames?: string[]; /** Per-tool configuration (defer_loading, allowed_callers) */ tool_options?: AgentToolOptions; + /** Subagent spawning configuration — isolated-context child agents. */ + subagents?: AgentSubagentsConfig; tenantId?: string; }