Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
45138f9
feat: expose subagent graphs as a native tool
lia-librechat Oct 5, 2026
874807d
fix: preserve graph metadata and legacy capability choices
lia-librechat Oct 5, 2026
32a75ed
fix: apply subagent capabilities to composer graph traversal
lia-librechat Oct 5, 2026
9fb56bf
fix: unify graph capability gating across stored routing nodes
lia-librechat Oct 5, 2026
8fb8558
fix: preserve disabled graph teams when saving edits
lia-librechat Oct 5, 2026
7abc167
fix: gate graph file sharing by deployment capabilities
lia-librechat Oct 5, 2026
53f0f9e
fix: preserve graph choices in management API responses
lia-librechat Oct 5, 2026
d64bb55
merge: preserve graph gates with current subagent machine routing
lia-librechat Oct 5, 2026
9958b45
fix: Preserve Graph Capability Choices and Validate Draft Limits
lia-librechat Oct 5, 2026
2b87550
fix: Handle Nullable Graph Code Environments
lia-librechat Oct 5, 2026
9622248
fix: Warn for Attached Orchestration Descendants
lia-librechat Oct 5, 2026
d875aca
fix: Gate Implicit Graph Warnings on Code Capability
lia-librechat Oct 5, 2026
e63ec7e
fix: Use Public Approval Metadata for Graph Warnings
lia-librechat Oct 5, 2026
afdadad
Merge origin/dev into lia/subagent-graph-tool
lia-librechat Oct 17, 2026
14d77d0
Merge origin/dev into lia/subagent-graph-tool
lia-librechat Oct 22, 2026
083f85d
Merge remote-tracking branch 'origin/dev' into lia/subagent-graph-tool
lia-librechat Oct 22, 2026
564702c
fix: Align Graph Member Policy and Terminal Reachability
lia-librechat Oct 22, 2026
4364c1c
Merge remote-tracking branch 'origin/dev' into lia/subagent-graph-tool
lia-librechat Oct 23, 2026
6deb4c9
fix: Respect Graph Capability Gates in Tool Selection
lia-librechat Oct 23, 2026
4bacfb1
fix: Align Graph Editor State and Resume Member Limits
lia-librechat Oct 23, 2026
7b4c9ef
test: Type the Resume Preflight Policy Fixture
lia-librechat Oct 23, 2026
25ee0ed
fix: Align Handoff Roots Across Workspace and Resume Discovery
lia-librechat Oct 23, 2026
08529ac
fix: Drain Cached Workspace Upgrades at the Member Limit
lia-librechat Oct 23, 2026
88a4190
fix: Count Only Admitted Resume Subagent Members
lia-librechat Oct 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/playwright-mock.yml
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,16 @@ jobs:
conversation-management.spec.ts --grep 'first-turn|final-title polling' --reporter=line
--output=e2e/specs/.test-results/title-ownership

- name: Run graph subagent execution regression
if: ${{ !cancelled() && matrix.stream_store == 'memory' && startsWith(matrix.shard, '1/') }}
env:
CI: 'true'
E2E_GRAPH_SUBAGENTS: 'true'
run: >-
npx playwright test --config=e2e/playwright.config.mock.ts
agent-graphs.spec.ts --reporter=line
--output=e2e/specs/.test-results/graph-subagents

- name: Upload title ownership diagnostics
if: ${{ always() && matrix.stream_store == 'memory' && startsWith(matrix.shard, '1/') }}
uses: actions/upload-artifact@v6
Expand Down
282 changes: 282 additions & 0 deletions api/server/controllers/agents/__tests__/resume.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1719,6 +1719,288 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
expect(mockInitializeClient).not.toHaveBeenCalled();
});

it.each([
{
name: 'graph-only edited member',
capabilities: [AgentCapabilities.subagent_graphs],
enabled: false,
graphsEnabled: true,
failure: 'content_filter_block',
},
{
name: 'legacy edited member',
capabilities: [AgentCapabilities.subagents],
enabled: true,
failure: 'content_filter_block',
},
{
name: 'missing active member',
capabilities: [AgentCapabilities.subagent_graphs],
enabled: false,
graphsEnabled: true,
missing: true,
failure: 'content_filter_uninspectable',
},
{
name: 'revoked VIEW on active member',
capabilities: [AgentCapabilities.subagent_graphs],
enabled: false,
graphsEnabled: true,
denied: true,
failure: 'content_filter_uninspectable',
},
{
name: 'disabled graph capability',
capabilities: [AgentCapabilities.subagents],
enabled: false,
graphsEnabled: true,
},
{
name: 'explicitly disabled graph',
capabilities: [AgentCapabilities.subagent_graphs],
enabled: false,
graphsEnabled: false,
},
])(
'checks $name before claiming the pause',
async ({ capabilities, enabled, graphsEnabled, missing, denied, failure }) => {
requestConfigOverrides = {
endpoints: { agents: { checkpointer: { type: 'mongo' }, capabilities } },
filters: {
agentInstructions: {
pii: { fields: ['instructions'], starterPatterns: ['sk_prefix'] },
},
},
};
endpointAgent.subagents = {
enabled,
...(graphsEnabled == null ? {} : { graphsEnabled }),
graphs: [
{
type: 'team',
name: 'Team',
description: 'Work',
agent_ids: ['member'],
edges: [],
entry_agent_id: 'member',
result_agent_id: 'member',
},
],
};
mockGetAgent.mockResolvedValue(
missing
? null
: {
_id: 'mongo-member',
id: 'member',
provider: 'openAI',
model: 'test',
instructions: 'Use sk-current-graph-member-secret',
tools: [],
},
);
mockCheckPermission.mockResolvedValue(!denied);
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
const res = await post(approveBody());
if (failure) {
expect(res.status).toBe(400);
expect(res.body.error).toBe(failure);
expect(mockGetAgent).toHaveBeenCalledTimes(1);
expect(mockGetAgent).toHaveBeenCalledWith({ id: 'member' });
expect(mockCheckAndIncrementPendingRequest).not.toHaveBeenCalled();
expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
expect(mockInitializeClient).not.toHaveBeenCalled();
expect(mockDeleteAgentCheckpoint).not.toHaveBeenCalled();
} else {
expect(res.status).toBe(200);
await settled;
await flush();
expect(mockGetAgent).not.toHaveBeenCalled();
expect(mockGenerationJobManager.approvals.resolve).toHaveBeenCalledTimes(1);
}
},
);

it.each(['safe', 'blocked', 'missing', 'revoked', 'over-budget'])(
'checks discovered handoffs plus graph members before claiming a parent ask: %s',
async (state) => {
requestConfigOverrides = {
endpoints: {
agents: {
checkpointer: { type: 'mongo' },
capabilities: [AgentCapabilities.subagent_graphs],
},
},
filters: {
agentInstructions: {
pii: { fields: ['instructions'], starterPatterns: ['sk_prefix'] },
},
},
};
const count = 50 + (state === 'over-budget' ? 1 : 0);
const ids = Array.from({ length: count }, (_, index) => `member-${index}`);
const graphs = [];
for (let start = 0; start < count; start += 25) {
const members = ids.slice(start, start + 25);
graphs.push({
type: `team-${start}`,
name: 'Team',
description: 'Work',
agent_ids: members,
entry_agent_id: members[0],
result_agent_id: members[members.length - 1],
edges: members
.slice(1)
.map((id, index) => ({ from: members[index], to: id, edgeType: 'direct' })),
});
}
endpointAgent.edges = [{ from: AGENT_ID, to: 'handoff', edgeType: 'handoff' }];
endpointAgent.subagents = { enabled: false, graphsEnabled: true, graphs };
const last = ids[count - 1];
mockGetAgent.mockImplementation(async ({ id }) =>
id === last && state === 'missing'
? null
: {
_id: `mongo-${id}`,
id,
provider: 'openAI',
model: 'test',
tools: [],
instructions:
id === last && state === 'blocked' ? 'Use sk-boundary-member-secret' : '',
},
);
mockCheckPermission.mockImplementation(
async ({ resourceId }) => resourceId !== `mongo-${last}` || state !== 'revoked',
);
mockGenerationJobManager.getJob.mockResolvedValue(makeAskUserJob());
const res = await post({
conversationId: CONVO_ID,
actionId: ACTION_ID,
agent_id: AGENT_ID,
endpoint: 'agents',
answer: 'continue',
});
if (state === 'safe') {
expect(res.status).toBe(200);
await settled;
await flush();
expect(mockGenerationJobManager.approvals.resolve).toHaveBeenCalledTimes(1);
expect(mockInitializeClient).toHaveBeenCalledTimes(1);
} else {
expect(res.status).toBe(400);
expect(res.body.error).toBe(
state === 'blocked' ? 'content_filter_block' : 'content_filter_uninspectable',
);
expect(mockCheckAndIncrementPendingRequest).not.toHaveBeenCalled();
expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
expect(mockInitializeClient).not.toHaveBeenCalled();
expect(mockDeleteAgentCheckpoint).not.toHaveBeenCalled();
}
expect(mockGetAgent).toHaveBeenCalledWith({ id: 'handoff' });
expect(mockGetAgent).toHaveBeenCalledTimes(state === 'over-budget' ? 1 : count + 1);
},
);

it.each(['missing', 'revoked', 'required-missing', 'required-revoked', 'extra-allowed'])(
'counts admitted ordinary members before claiming a parent ask: %s',
async (state) => {
requestConfigOverrides = {
endpoints: {
agents: {
checkpointer: { type: 'mongo' },
capabilities: [AgentCapabilities.subagents, AgentCapabilities.subagent_graphs],
},
},
filters: {
agentInstructions: {
pii: { fields: ['instructions'], starterPatterns: ['sk_prefix'] },
},
},
};
const ids = Array.from({ length: 49 }, (_, index) => `member-c19-${index}`);
const graphs = [0, 25].map((start) => {
const members = ids.slice(start, start + 25);
return {
type: `team-${start}`,
name: 'Team',
description: 'Work',
agent_ids: members,
entry_agent_id: members[0],
result_agent_id: members[members.length - 1],
edges: members
.slice(1)
.map((id, index) => ({ from: members[index], to: id, edgeType: 'direct' })),
};
});
endpointAgent.subagents = {
enabled: true,
graphsEnabled: true,
agent_ids: ['child'],
graphs,
};
const child = {
_id: 'mongo-child',
id: 'child',
provider: 'openAI',
model: 'test',
tools: [],
subagents: {
enabled: true,
agent_ids: ['stale', ...(state === 'extra-allowed' ? ['extra'] : [])],
...(state.startsWith('required')
? {
graphsEnabled: true,
graphs: [
{
type: 'required',
name: 'Required',
description: 'Work',
agent_ids: ['stale'],
entry_agent_id: 'stale',
result_agent_id: 'stale',
edges: [],
},
],
}
: {}),
},
};
mockGetAgent.mockImplementation(async ({ id }) => {
if (id === 'stale' && !state.endsWith('revoked')) return null;
if (id === 'child') return child;
return { _id: `mongo-${id}`, id, provider: 'openAI', model: 'test', tools: [] };
});
mockCheckPermission.mockImplementation(
async ({ resourceId }) => resourceId !== 'mongo-stale',
);
mockGenerationJobManager.getJob.mockResolvedValue(makeAskUserJob());
const res = await post({
conversationId: CONVO_ID,
actionId: ACTION_ID,
agent_id: AGENT_ID,
endpoint: 'agents',
answer: 'continue',
});
if (state === 'missing' || state === 'revoked') {
expect(res.status).toBe(200);
await settled;
await flush();
expect(mockGenerationJobManager.approvals.resolve).toHaveBeenCalledTimes(1);
expect(mockInitializeClient).toHaveBeenCalledTimes(1);
} else {
expect(res.status).toBe(400);
expect(res.body.error).toBe('content_filter_uninspectable');
expect(mockCheckAndIncrementPendingRequest).not.toHaveBeenCalled();
expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
expect(mockInitializeClient).not.toHaveBeenCalled();
expect(mockDeleteAgentCheckpoint).not.toHaveBeenCalled();
}
expect(mockGetAgent).toHaveBeenCalledWith({ id: 'stale' });
if (state === 'extra-allowed') expect(mockGetAgent).toHaveBeenCalledWith({ id: 'extra' });
},
);

it('blocks current action metadata before consuming approval', async () => {
requestConfigOverrides = {
filters: {
Expand Down
3 changes: 2 additions & 1 deletion api/server/controllers/agents/openai.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const {
createOpenAIToolCallStream: createAcceptedToolCallStream,
} = require('@librechat/agents/openai');
const {
hasSubagentCapability,
EModelEndpoint,
ResourceType,
PermissionBits,
Expand Down Expand Up @@ -704,7 +705,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
let handoffAgentConfigs = new Map();
let discoveredEdges = [];
let discoveredMCPAuthMap;
const subagentsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.subagents);
const subagentsCapabilityEnabled = hasSubagentCapability([...enabledCapabilities]);
const primaryHasGraphSubagents =
subagentsCapabilityEnabled &&
primaryConfig.subagents?.enabled === true &&
Expand Down
3 changes: 2 additions & 1 deletion api/server/controllers/agents/responses.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const { v4: uuidv4 } = require('uuid');
const { logger } = require('@librechat/data-schemas');
const { Callback, formatAgentMessages } = require('@librechat/agents');
const {
hasSubagentCapability,
EModelEndpoint,
ResourceType,
PermissionBits,
Expand Down Expand Up @@ -959,7 +960,7 @@ const executeResponse = async (envelope, { req, res }) => {
let handoffAgentConfigs = new Map();
let discoveredEdges = [];
let discoveredMCPAuthMap;
const subagentsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.subagents);
const subagentsCapabilityEnabled = hasSubagentCapability([...enabledCapabilities]);
const primaryHasGraphSubagents =
subagentsCapabilityEnabled &&
primaryConfig.subagents?.enabled === true &&
Expand Down
5 changes: 4 additions & 1 deletion api/server/controllers/agents/v1.js
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ const {
instructionsContentForScan,
} = require('@librechat/api');
const {
resolveSubagents,
hasSubagentCapability,
Time,
Tools,
SkillsScope,
Expand Down Expand Up @@ -476,7 +478,7 @@ const validateSubagentReferences = async (
const isSubagentsCapabilityEnabled = (req) => {
const capabilities = req.config?.endpoints?.[EModelEndpoint.agents]?.capabilities;
if (!Array.isArray(capabilities)) return false;
return capabilities.includes(AgentCapabilities.subagents);
return hasSubagentCapability(capabilities);
};

const isCodeInterpreterCapabilityEnabled = (req) => {
Expand All @@ -491,6 +493,7 @@ const isCodeInterpreterCapabilityEnabled = (req) => {
* @returns {Promise<{ status: number, body: { error: string, agent_ids: string[] } } | null>}
*/
const getSubagentReferenceError = async (subagents, req, allowedMissingIds = new Set()) => {
subagents = resolveSubagents(subagents, req.config?.endpoints?.agents?.capabilities ?? []);
if (
!isSubagentsCapabilityEnabled(req) ||
subagents?.enabled !== true ||
Expand Down
Loading
Loading