Skip to content

Commit f030edc

Browse files
danny-avilakrgokul
authored andcommitted
🤝 fix: Load Handoff Agents for Agents API (danny-avila#12740)
* 🤝 fix: load handoff sub-agents on OpenAI-compat endpoints (danny-avila#12726) Extracts the BFS discovery + ACL-gated initialization of handoff sub-agents into a shared `discoverConnectedAgents` helper in `@librechat/api` and wires it into the OpenAI-compatible `/v1/chat/completions` and Open Responses `/v1/responses` controllers. These endpoints previously only passed the primary agent config to `createRun` while keeping `primaryConfig.edges` intact, which forced `MultiAgentGraph` into multi-agent mode without loading the referenced sub-agents and caused StateGraph to throw "Found edge ending at unknown node <id>". The discovery helper also filters orphaned edges (deleted sub-agents or those the caller lacks VIEW permission on), so API users see the same graceful fallback the chat UI already had. * 🧪 fix: use ServerRequest in discovery spec helpers CI `tsc --noEmit -p packages/api/tsconfig.json` caught that the test helpers typed `req` as `express.Request`, which is not assignable to `DiscoverConnectedAgentsParams.req` (typed as `ServerRequest` whose `user` is `IUser`). Local jest passed because ts-jest is transpile-only, but the CI typecheck uses the full compiler. * 🪲 fix: drop orphan edges on both endpoints, not just `to` Addresses the P1 codex finding on danny-avila#12740: `filterOrphanedEdges` previously only removed edges whose `to` referenced a skipped agent. Edges whose `from` was a skipped agent — the symmetric case in a bidirectional graph like `A <-> B` where `B` is deleted or the user lacks VIEW on it — leaked through to `createRun` and re-triggered `Found edge ending at unknown node <id>` at StateGraph compile time. The filter now drops an edge if either endpoint references a skipped id, and the existing `to`-only test cases were updated to reflect the stricter behavior. Adds a bidirectional-graph regression test in `discovery.spec.ts`. * 🔒 fix: enforce REMOTE_AGENT ACL on handoff sub-agents for API routes Addresses the second P1 codex finding on danny-avila#12740: the OpenAI-compat `/v1/chat/completions` and Open Responses `/v1/responses` routes gate the primary agent on `REMOTE_AGENT` (via `createCheckRemoteAgentAccess`), but `discoverConnectedAgents` was checking handoff sub-agents against the looser in-app `AGENT` resource type. That allowed a remote caller who could reach the orchestrator but had only in-app visibility on a sub-agent to invoke it via the API — bypassing the remote-sharing boundary. Adds an optional `resourceType` param to `discoverConnectedAgents` (defaulting to `AGENT` for the chat UI path) and passes `ResourceType.REMOTE_AGENT` from both API controllers so every discovered sub-agent clears the same sharing boundary enforced at route entry. * 🧯 fix: enforce allowedProviders for discovered sub-agents Addresses the third P1 codex finding on danny-avila#12740: `discoverConnectedAgents` forwarded the caller's `endpointOption` verbatim into `initializeAgent`, but on the OpenAI-compat routes that option's `endpoint` is the primary agent's provider (e.g. `openai`), not `agents`. `initializeAgent` only enforces `allowedProviders` when `isAgentsEndpoint(endpointOption.endpoint)` is true, so handoff sub-agents silently bypassed the provider allowlist configured under `endpoints.agents.allowedProviders`. Override `endpointOption.endpoint` to `EModelEndpoint.agents` for every per-sub-agent init call. The primary agent still uses the caller's endpointOption as before — this only affects the BFS-loaded handoff targets. Regression test asserts the override. * ✂️ fix: prune unreachable sub-agents after orphan-edge filtering Addresses the fourth P1 codex finding on danny-avila#12740: BFS eagerly initializes every sub-agent referenced in the primary's edge scan, but once `filterOrphanedEdges` drops edges whose endpoints were skipped, some of those sub-agents end up disconnected from the primary. In an `A -> B -> C` graph (edges stored directly on A) where B is skipped (missing or no VIEW), both edges are filtered, but C was already loaded and would still be passed to `createRun` — which flips into multi-agent mode on `agents.length > 1` and turns C into an unintended parallel start node. After filtering edges, compute the set of agent ids reachable from the primary through the surviving edge set and prune `agentConfigs` to that set. Two regression tests added: one for the pruning case, one that confirms agents connected via surviving edges are still kept. * 🔁 fix: don't seed initialize.js agentConfigs from the pre-pruning callback Addresses the fifth P1 codex finding on danny-avila#12740: `onAgentInitialized` fires during BFS, BEFORE the helper prunes agents that become disconnected once `filterOrphanedEdges` runs. Writing the sub-agent straight into the outer `agentConfigs` there and then only additively merging the pruned `discoveredConfigs` left stranded entries in the outer map, and `AgentClient` would still hand them to `createRun` as extra parallel start nodes (the exact failure mode the pass-4 prune was meant to eliminate for the API controllers). Drop the `agentConfigs.set` from the callback and replace the additive merge with a direct copy from `discoveredConfigs`, which is now the single authoritative source of what the run should see. The per-agent tool context map is still populated during BFS — stale entries there are harmless because they're only read by closure inside `ON_TOOL_EXECUTE` and are unreachable once the agent is not in `agentConfigs`. * 🔬 fix: address audit findings on discovery helper Resolves findings from a comprehensive external audit of danny-avila#12740. **Finding 1 (CRITICAL) — stale edges survive the reachability prune.** The pass-4 prune removed unreachable agents from `agentConfigs` but left matching edges in the return value. In an `A -> B -> C -> D` graph (all edges stored on A) where B is skipped, `filterOrphanedEdges` drops A->B and B->C but keeps C->D (neither endpoint is skipped). The caller then sees `agentConfigs` without C/D but `edges` still references them, flipping `createRun` into multi-agent mode with mismatched agents/edges — the exact crash this PR is supposed to fix. Now filter the edge list to the reachable set in the same pass, so the returned shape is self-consistent: every edge endpoint is either the primary id or a key of `agentConfigs`. New regression test covers A->B->C->D with B skipped. **Finding 2 (MAJOR) — unconditional `getModelsConfig` on every API request.** The OpenAI-compat and Responses controllers called `getModelsConfig(req)` and `discoverConnectedAgents` even when the primary agent had no edges (the common single-agent API case). Gate both behind `primaryConfig.edges?.length > 0` so single-agent runs don't pay that cost. **Finding 5 (MINOR) — silent mutation of caller's `primaryConfig.userMCPAuthMap`.** The helper aliased that object and then `Object.assign`'d sub-agent entries into it, changing the caller's config in-place. Shallow-clone up front so the returned merged map is the only destination. **Finding 7 (NIT) — dead `?? []` coalescing.** `filterOrphanedEdges` always returns a concrete array, so the `discoveredEdges ?? []` fallback was never reached. Simplified the `primaryConfig.edges = …` assignment. Also adds a test that verifies `primaryConfig.userMCPAuthMap` is not mutated in-place. * 🧹 chore: address audit NITs on discovery helper Addresses two NIT findings from the post-fix audit: **F1** — the shallow clone on `primaryConfig.userMCPAuthMap` was only applied on the primary side; the `else` branch (hit when the primary had no MCP auth and the first sub-agent seeds the map) assigned the sub-agent's `config.userMCPAuthMap` directly, so a later sub-agent's `Object.assign` mutated the first one's map in place. Harmless in practice (per-request ephemeral objects) but asymmetric. Clone in the else branch too. Test added. **F2** — `initialize.js` had a defensive `if (agentConfigs.size > 0 && !edges) edges = []` normalizer. Pre-existing dead code: the helper now always returns a concrete array from `filteredEdges.filter(...)`. Removed for clarity. * 🕸 fix: require all sources reachable when traversing fan-in edges Addresses the seventh P1 codex finding on danny-avila#12740: the reachability BFS advanced through an edge as soon as any of its `from` endpoints matched the current frontier node (`sources.includes(current)`), but the subsequent edge filter required ALL sources to be reachable (`every`). The two-semantics mismatch let a fan-in edge like `{from: ['A','B'], to: 'C'}` mark C reachable purely via A even when B had no path from the primary, then drop the edge itself at filter time. Result: C survived in `agentConfigs` with no surviving edge connecting it to A, so `createRun` flipped into multi-agent mode on `agents.length > 1` and C ran as an unintended parallel root. Replace the BFS with a fixed-point iteration keyed on the same all-sources-reachable predicate used by the filter, so traversal and filtering stay aligned and multi-source edges only fire once every source is in the reachable set. Two regression tests added: - `{from: ['A','B'], to: 'C'}` with B having no incoming path — asserts neither B nor C leak into the result. - `A -> B`, `A -> C`, `['B','C'] -> D` — asserts the fan-in edge fires and D becomes reachable once both B and C are. * 🔀 fix: match SDK OR semantics for multi-source edge reachability Reverts the all-sources-required reachability gate from 4982f1c and replaces it with an any-source-reachable model, which matches how `@librechat/agents`'s `MultiAgentGraph.createWorkflow` actually wires multi-source edges at runtime (per-source `builder.addEdge(source, destination)`). With the previous `every` gate, a legitimate handoff edge `{ from: ['A', 'B'], to: 'C' }` where B had no incoming path was pruned along with C, regressing OR-semantics routing that the SDK would otherwise handle correctly. New behavior: 1. Reachability: an edge advances when ANY of its `from` endpoints is already reachable. Fixed-point iteration over `filteredEdges`. 2. Edge filter: keep an edge when it has at least one reachable source AND all destinations are reachable (a missing destination would still crash `StateGraph.compile` with `Found edge ending at unknown node`). 3. Agent prune: keep agents that are reachable OR referenced on any endpoint of a surviving edge. The second clause preserves co-sources in multi-source edges (B in `{ from: ['A','B'], to: 'C' }` when nothing else reaches B) so the SDK's per-source `addEdge` — and the `validateEdgeAgents` safety-net I added to the SDK in danny-avila#111 — still finds B as a node. The pass-audit A->B->C->D regression test continues to pass: with B skipped, `filterOrphanedEdges` drops both B-adjacent edges, reachability never expands past A, C->D has no reachable source so it gets filtered, and C/D are pruned because they're neither reachable nor referenced. * ✂️ fix: strip skipped co-members from multi-source/multi-dest edges Addresses codex pass-9 P2 on danny-avila#12740. `filterOrphanedEdges` previously dropped an edge whenever any `from` id was skipped, which was correct for scalar edges but over-aggressive for multi-source ones: the agents SDK adds one `builder.addEdge(source, destination)` per source, so `{ from: ['A','B'], to: 'C' }` with B skipped still has a valid `A -> C` route that was being thrown away. Now sanitize each endpoint: - Scalar skipped → drop the whole edge (no route survives). - Array with some skipped → strip the skipped ids, keep the edge with the surviving members. If the array empties out, drop the edge. Symmetric handling for `to` covers multi-destination fan-out when one co-destination is skipped. Tests updated/added: - `strips skipped co-sources from multi-source edges…` - `strips skipped co-destinations from multi-destination edges` - `drops multi-member edges only when every member on a side is skipped` - Discovery-side: `preserves valid routes when one co-source of a multi-source edge is skipped` asserts the end-to-end behavior — skipped co-source B gets stripped from the edge, A->C routing survives, and C remains in `agentConfigs`. * 🔓 fix: respect SHARE-on-AGENT fallback for handoff ACL on API routes Addresses codex pass-10 P1 on danny-avila#12740. The API controllers were handing `discoverConnectedAgents` a raw `PermissionService.checkPermission` call against `ResourceType.REMOTE_AGENT`, but the route-level middleware (`createCheckRemoteAgentAccess`) authorizes the primary agent via `getRemoteAgentPermissions`, which first consults the AGENT ACL and treats owners with the SHARE bit as remotely authorized even without an explicit REMOTE_AGENT grant. The mismatch meant a user could open the primary via `/v1/chat/completions` or `/v1/responses`, but their own owned handoff sub-agents were silently skipped — breaking multi-agent handoffs for the common "owner runs their own multi-agent orchestrator" case. Both controllers now pass `discoverConnectedAgents` a `checkPermission` wrapper that delegates to `getRemoteAgentPermissions` (with `getEffectivePermissions` injected from `PermissionService`) and compares the returned bitmask against the required permission via `hasPermissions`. Sub-agents are now authorized by the exact same rules the route middleware applies to the primary. * 🌱 fix: preserve user-defined parallel-start branches Addresses codex pass-11 P2 on danny-avila#12740. The post-filter reachability prune seeded only from `primaryConfig.id`, which killed `MultiAgentGraph`'s legitimate multi-start pattern — a user-defined edge like `X -> Y` where X has no incoming path (X is an intentional parallel starting node, run alongside the primary) was being dropped because neither X nor Y was reachable from the primary. Reconcile the tension with pass-4 ("prune accidental orphans when an intermediate is skipped") by using pre-filter reachability as the signal: - An agent that WAS reachable from the primary via the original (pre-filter) edges but loses that path when `filterOrphanedEdges` runs is an accidental orphan (a skipped hop broke the chain) — prune. - An agent that was NEVER reachable from the primary, even pre-filter, is an intentional parallel start — seed it into post-filter reachability so its component survives. Surviving-edge endpoint references still keep an agent (co-sources in multi-source edges). New test `preserves user-defined parallel-start branches disconnected from the primary` covers the pass-11 scenario; the existing `A->B->C->D, B skipped` regression test continues to pass because C/D were pre-filter reachable through B and lose that reachability after filtering. * 🎯 fix: tighten parallel-start seed criterion to 'no pre-filter incoming edge' Addresses codex pass-12 P1 on danny-avila#12740. The pass-11 seed heuristic — 'agent is in `agentConfigs` but was not pre-filter reachable from the primary' — was too permissive. A downstream agent like Y in `X -> Y` where X gets skipped (missing / no VIEW) was never pre-filter reachable from the primary either, so the old rule promoted Y to a parallel start node and discovery returned `agents: [primary, Y]` with no connecting edge. The SDK then ran Y as an unintended parallel root — exactly the orphan behavior pass-4 wanted to prevent. Tighter criterion: seed a post-filter reachability root only when the agent had NO incoming edge in the pre-filter graph. That matches `MultiAgentGraph.analyzeGraph`'s "no-incoming-edge" definition of a start node applied to the user's original declared topology, so: - `A -> B` plus a user-defined `X -> Y` parallel branch: X has no incoming pre-filter → seeded → X and Y both survive. - `A -> B` plus `X -> Y` with X skipped: Y had an incoming pre-filter (`X -> Y`) → NOT seeded → Y is pruned as the orphan it is. - `A -> B -> C` with B skipped: C had an incoming pre-filter (`B -> C`) → NOT seeded → C is pruned. New test `does not promote a downstream orphan to a parallel start when its only upstream is skipped` locks in the pass-12 scenario. The pass-11 `preserves user-defined parallel-start branches` test continues to hold. * 📁 fix: don't enforce AGENT-only file ACL on REMOTE_AGENT API callers Addresses codex pass-13 P1 on danny-avila#12740. When I refactored the API controllers' DB-method bundle, I inadvertently started forwarding `filterFilesByAgentAccess` into `initializeAgent`. That helper calls `checkPermission` with `resourceType: ResourceType.AGENT`, but these routes authorize callers through `REMOTE_AGENT` (via `getRemoteAgentPermissions`). A user granted `REMOTE_AGENT_VIEWER` on a shared agent but lacking direct `AGENT_VIEW` could invoke the agent yet all its owner-attached context files would get silently filtered out — breaking `file_search`/context retrieval for remote consumers. Drop `filterFilesByAgentAccess` from the OpenAI-compat and Responses controllers' `dbMethods` (and remove the now-unused import). The chat UI's `initialize.js` keeps it since that path legitimately authorizes at the AGENT level. No functional change inside the helper — passing `undefined` simply tells `primeResources` to skip the per-file ACL filter, restoring the pre-refactor API behavior. * 🪓 fix: strip unreachable co-sources from surviving multi-source edges Addresses codex pass-14 P1 on danny-avila#12740. The earlier pass-8 fix kept any agent referenced as an endpoint of a surviving edge (via a `referencedByEdge` fallback) to avoid the SDK's `validateEdgeAgents` failing on missing nodes. But that fallback propped up unreachable co-sources too: with `[A -> C, X -> B, [B,C] -> D]` and X skipped, `X -> B` gets filtered, the `[B,C] -> D` fan-in survives because C is reachable, and B stays in `agentConfigs` solely because the fan-in still lists it. `MultiAgentGraph.analyzeGraph` then sees B with no incoming edge and runs it as an unintended parallel root. Sanitize surviving edges instead: for a kept edge whose `from` is an array, filter out any co-source that isn't reachable. The SDK's per-source `addEdge` fires independently, so dropping an unreachable co-source doesn't invalidate the remaining routes — in the scenario above `[B,C] -> D` becomes `[C] -> D`, every endpoint of every surviving edge is now reachable, and the agent prune collapses to a strict `reachable.has(agentId)` check. No more referenced-by-edge fallback. Regression test added: `strips unreachable co-sources from surviving multi-source edges (no stray parallel root)` — asserts B is absent from every surviving edge endpoint and the fan-in's `from` is just `['C']`. All 22 prior discovery tests still pass unchanged.
1 parent dda39a6 commit f030edc

11 files changed

Lines changed: 1931 additions & 201 deletions

File tree

api/server/controllers/agents/__tests__/openai.spec.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,11 @@ jest.mock('@librechat/api', () => ({
4646
.fn()
4747
.mockReturnValue({ request: { model: 'agent-123', messages: [], stream: false } }),
4848
initializeAgent: jest.fn().mockResolvedValue({
49+
id: 'agent-123',
4950
model: 'gpt-4',
5051
model_parameters: {},
5152
toolRegistry: {},
53+
edges: [],
5254
}),
5355
getBalanceConfig: mockGetBalanceConfig,
5456
createErrorResponse: jest.fn(),
@@ -72,6 +74,24 @@ jest.mock('@librechat/api', () => ({
7274
resolveRecursionLimit: jest.fn().mockReturnValue(50),
7375
createToolExecuteHandler: jest.fn().mockReturnValue({ handle: jest.fn() }),
7476
isChatCompletionValidationFailure: jest.fn().mockReturnValue(false),
77+
discoverConnectedAgents: jest.fn().mockResolvedValue({
78+
agentConfigs: new Map(),
79+
edges: [],
80+
skippedAgentIds: new Set(),
81+
userMCPAuthMap: undefined,
82+
}),
83+
}));
84+
85+
jest.mock('~/server/controllers/ModelController', () => ({
86+
getModelsConfig: jest.fn().mockResolvedValue({}),
87+
}));
88+
89+
jest.mock('~/server/services/Files/permissions', () => ({
90+
filterFilesByAgentAccess: jest.fn(),
91+
}));
92+
93+
jest.mock('~/cache', () => ({
94+
logViolation: jest.fn(),
7595
}));
7696

7797
jest.mock('~/server/services/ToolService', () => ({
@@ -91,6 +111,7 @@ jest.mock('~/server/controllers/agents/callbacks', () => ({
91111

92112
jest.mock('~/server/services/PermissionService', () => ({
93113
findAccessibleResources: jest.fn().mockResolvedValue([]),
114+
checkPermission: jest.fn().mockResolvedValue(true),
94115
}));
95116

96117
const mockUpdateBalance = jest.fn().mockResolvedValue({});

api/server/controllers/agents/__tests__/responses.unit.spec.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,17 @@ jest.mock('@librechat/api', () => ({
4343
buildToolSet: jest.fn().mockReturnValue(new Set()),
4444
createSafeUser: jest.fn().mockReturnValue({ id: 'user-123' }),
4545
initializeAgent: jest.fn().mockResolvedValue({
46+
id: 'agent-123',
4647
model: 'claude-3',
4748
model_parameters: {},
4849
toolRegistry: {},
50+
edges: [],
51+
}),
52+
discoverConnectedAgents: jest.fn().mockResolvedValue({
53+
agentConfigs: new Map(),
54+
edges: [],
55+
skippedAgentIds: new Set(),
56+
userMCPAuthMap: undefined,
4957
}),
5058
getBalanceConfig: mockGetBalanceConfig,
5159
getTransactionsConfig: mockGetTransactionsConfig,
@@ -121,6 +129,19 @@ jest.mock('~/server/controllers/agents/callbacks', () => {
121129

122130
jest.mock('~/server/services/PermissionService', () => ({
123131
findAccessibleResources: jest.fn().mockResolvedValue([]),
132+
checkPermission: jest.fn().mockResolvedValue(true),
133+
}));
134+
135+
jest.mock('~/server/controllers/ModelController', () => ({
136+
getModelsConfig: jest.fn().mockResolvedValue({}),
137+
}));
138+
139+
jest.mock('~/server/services/Files/permissions', () => ({
140+
filterFilesByAgentAccess: jest.fn(),
141+
}));
142+
143+
jest.mock('~/cache', () => ({
144+
logViolation: jest.fn(),
124145
}));
125146

126147
const mockUpdateBalance = jest.fn().mockResolvedValue({});

api/server/controllers/agents/openai.js

Lines changed: 129 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
const { nanoid } = require('nanoid');
22
const { logger } = require('@librechat/data-schemas');
33
const { Callback, ToolEndHandler, formatAgentMessages } = require('@librechat/agents');
4-
const { EModelEndpoint, ResourceType, PermissionBits } = require('librechat-data-provider');
4+
const {
5+
EModelEndpoint,
6+
ResourceType,
7+
PermissionBits,
8+
hasPermissions,
9+
} = require('librechat-data-provider');
510
const {
611
writeSSE,
712
createRun,
@@ -21,6 +26,8 @@ const {
2126
createOpenAIStreamTracker,
2227
createOpenAIContentAggregator,
2328
isChatCompletionValidationFailure,
29+
discoverConnectedAgents,
30+
getRemoteAgentPermissions,
2431
} = require('@librechat/api');
2532
const {
2633
buildSummarizationHandlers,
@@ -29,7 +36,12 @@ const {
2936
agentLogHandlerObj,
3037
} = require('~/server/controllers/agents/callbacks');
3138
const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService');
32-
const { findAccessibleResources } = require('~/server/services/PermissionService');
39+
const {
40+
findAccessibleResources,
41+
getEffectivePermissions,
42+
} = require('~/server/services/PermissionService');
43+
const { getModelsConfig } = require('~/server/controllers/ModelController');
44+
const { logViolation } = require('~/cache');
3345
const db = require('~/models');
3446

3547
/**
@@ -207,6 +219,24 @@ const OpenAIChatCompletionController = async (req, res) => {
207219
model_parameters: agent.model_parameters ?? {},
208220
};
209221

222+
// `filterFilesByAgentAccess` is intentionally omitted: it calls
223+
// `checkPermission` with `resourceType: AGENT`, but this route
224+
// authorizes callers through `REMOTE_AGENT` (via
225+
// `getRemoteAgentPermissions`), so including it would silently drop
226+
// owner-attached context files for any remote user who has
227+
// `REMOTE_AGENT_VIEWER` but not direct `AGENT_VIEW`.
228+
const dbMethods = {
229+
getConvoFiles: db.getConvoFiles,
230+
getFiles: db.getFiles,
231+
getUserKey: db.getUserKey,
232+
getMessages: db.getMessages,
233+
updateFilesUsage: db.updateFilesUsage,
234+
getUserKeyValues: db.getUserKeyValues,
235+
getUserCodeFiles: db.getUserCodeFiles,
236+
getToolFilesByIds: db.getToolFilesByIds,
237+
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
238+
};
239+
210240
const primaryConfig = await initializeAgent(
211241
{
212242
req,
@@ -220,19 +250,92 @@ const OpenAIChatCompletionController = async (req, res) => {
220250
allowedProviders,
221251
isInitialAgent: true,
222252
},
223-
{
224-
getConvoFiles: db.getConvoFiles,
225-
getFiles: db.getFiles,
226-
getUserKey: db.getUserKey,
227-
getMessages: db.getMessages,
228-
updateFilesUsage: db.updateFilesUsage,
229-
getUserKeyValues: db.getUserKeyValues,
230-
getUserCodeFiles: db.getUserCodeFiles,
231-
getToolFilesByIds: db.getToolFilesByIds,
232-
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
233-
},
253+
dbMethods,
234254
);
235255

256+
/**
257+
* Per-agent tool-execution context map, keyed by agentId.
258+
* Needed so the ON_TOOL_EXECUTE callback routes each sub-agent's tool calls
259+
* to the correct toolRegistry / userMCPAuthMap / tool_resources.
260+
* @type {Map<string, {
261+
* agent: object,
262+
* toolRegistry?: import('@librechat/agents').LCToolRegistry,
263+
* userMCPAuthMap?: Record<string, Record<string, string>>,
264+
* tool_resources?: object,
265+
* actionsEnabled?: boolean,
266+
* }>}
267+
*/
268+
const agentToolContexts = new Map();
269+
agentToolContexts.set(primaryConfig.id, {
270+
agent,
271+
toolRegistry: primaryConfig.toolRegistry,
272+
userMCPAuthMap: primaryConfig.userMCPAuthMap,
273+
tool_resources: primaryConfig.tool_resources,
274+
actionsEnabled: primaryConfig.actionsEnabled,
275+
});
276+
277+
// Only run BFS discovery (and pay `getModelsConfig` upfront) when the
278+
// primary has edges to follow — the common API case is single-agent.
279+
let handoffAgentConfigs = new Map();
280+
let discoveredEdges = [];
281+
let discoveredMCPAuthMap;
282+
if (primaryConfig.edges?.length) {
283+
const modelsConfig = await getModelsConfig(req);
284+
({
285+
agentConfigs: handoffAgentConfigs,
286+
edges: discoveredEdges,
287+
userMCPAuthMap: discoveredMCPAuthMap,
288+
} = await discoverConnectedAgents(
289+
{
290+
req,
291+
res,
292+
primaryConfig,
293+
endpointOption,
294+
allowedProviders,
295+
modelsConfig,
296+
loadTools,
297+
requestFiles: [],
298+
conversationId,
299+
parentMessageId,
300+
// The route enforces REMOTE_AGENT on the primary; every discovered
301+
// sub-agent must clear the same sharing boundary, not the looser
302+
// in-app AGENT one.
303+
resourceType: ResourceType.REMOTE_AGENT,
304+
},
305+
{
306+
getAgent: db.getAgent,
307+
// Use `getRemoteAgentPermissions` so sub-agent authorization
308+
// matches what the route's `createCheckRemoteAgentAccess`
309+
// middleware does for the primary: AGENT owners with the SHARE
310+
// bit are treated as remotely authorized even without an
311+
// explicit REMOTE_AGENT grant.
312+
checkPermission: async ({ userId, role, resourceId, requiredPermission }) => {
313+
const permissions = await getRemoteAgentPermissions(
314+
{ getEffectivePermissions },
315+
userId,
316+
role,
317+
resourceId,
318+
);
319+
return hasPermissions(permissions, requiredPermission);
320+
},
321+
logViolation,
322+
db: dbMethods,
323+
onAgentInitialized: (agentId, handoffAgent, config) => {
324+
agentToolContexts.set(agentId, {
325+
agent: handoffAgent,
326+
toolRegistry: config.toolRegistry,
327+
userMCPAuthMap: config.userMCPAuthMap,
328+
tool_resources: config.tool_resources,
329+
actionsEnabled: config.actionsEnabled,
330+
});
331+
},
332+
initializeAgent,
333+
},
334+
));
335+
}
336+
337+
primaryConfig.edges = discoveredEdges;
338+
236339
// Determine if streaming is enabled (check both request and agent config)
237340
const streamingDisabled = !!primaryConfig.model_parameters?.disableStreaming;
238341
const isStreaming = request.stream === true && !streamingDisabled;
@@ -270,17 +373,18 @@ const OpenAIChatCompletionController = async (req, res) => {
270373
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises, streamId: null });
271374

272375
const toolExecuteOptions = {
273-
loadTools: async (toolNames) => {
376+
loadTools: async (toolNames, agentId) => {
377+
const ctx = agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {};
274378
return loadToolsForExecution({
275379
req,
276380
res,
277-
agent,
278381
toolNames,
382+
agent: ctx.agent ?? agent,
279383
signal: abortController.signal,
280-
toolRegistry: primaryConfig.toolRegistry,
281-
userMCPAuthMap: primaryConfig.userMCPAuthMap,
282-
tool_resources: primaryConfig.tool_resources,
283-
actionsEnabled: primaryConfig.actionsEnabled,
384+
toolRegistry: ctx.toolRegistry,
385+
userMCPAuthMap: ctx.userMCPAuthMap,
386+
tool_resources: ctx.tool_resources,
387+
actionsEnabled: ctx.actionsEnabled,
284388
});
285389
},
286390
toolEndCallback,
@@ -467,11 +571,14 @@ const OpenAIChatCompletionController = async (req, res) => {
467571
// Create and run the agent
468572
const userId = req.user?.id ?? 'api-user';
469573

470-
// Extract userMCPAuthMap from primaryConfig (needed for MCP tool connections)
471-
const userMCPAuthMap = primaryConfig.userMCPAuthMap;
574+
// Extract merged userMCPAuthMap (needed for MCP tool connections across
575+
// the primary and any discovered handoff sub-agents)
576+
const userMCPAuthMap = discoveredMCPAuthMap ?? primaryConfig.userMCPAuthMap;
577+
578+
const runAgents = [primaryConfig, ...handoffAgentConfigs.values()];
472579

473580
const run = await createRun({
474-
agents: [primaryConfig],
581+
agents: runAgents,
475582
messages: formattedMessages,
476583
indexTokenCountMap,
477584
initialSummary,

0 commit comments

Comments
 (0)