From 69e7c73614a01f6e8fd79ffb4859e58ee26fe15e Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 24 Aug 2026 20:37:49 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=9B=EF=B8=8F=20feat:=20Expose=20Author?= =?UTF-8?q?itative=20Subagent=20Controls=20(#15169)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: expose authoritative subagent controls * fix: reconcile subagent control races * fix: reconcile durable control conflicts * fix: preserve authoritative subagent control outcomes * fix: fence subagent controls to child thread * fix: validate subagent control targets before routing * fix: close subagent control boundary gaps * fix: keep control reservations private * fix: close subagent control admission gaps * fix: preserve authoritative control history * style: sort subagent control imports * fix: preserve authoritative subagent control retries * style: sort control state imports --- .../__test-utils__/convos-route-mocks.js | 44 ++ api/server/routes/__tests__/convos.spec.js | 97 ++- api/server/routes/convos.js | 77 +++ .../Chat/Subagents/SubagentActivity.test.tsx | 100 +++ .../Chat/Subagents/SubagentActivity.tsx | 114 +++- .../Subagents/SubagentThreadPanel.test.tsx | 539 +++++++++++++++- .../Chat/Subagents/SubagentThreadPanel.tsx | 373 ++++++++++- .../src/components/Chat/Subagents/adapters.ts | 10 + .../data-provider/Subagents/queries.test.ts | 18 + client/src/data-provider/Subagents/queries.ts | 79 ++- client/src/locales/en/translation.json | 27 + client/src/store/subagents.ts | 119 ++++ packages/api/src/agents/control.spec.ts | 577 ++++++++++++++++++ packages/api/src/agents/control.ts | 279 +++++++++ packages/api/src/agents/index.ts | 1 + packages/api/src/agents/subagentThreads.ts | 5 + packages/data-provider/src/api-endpoints.ts | 3 + packages/data-provider/src/data-service.ts | 8 + packages/data-provider/src/keys.ts | 1 + packages/data-provider/src/types/subagents.ts | 16 +- 20 files changed, 2460 insertions(+), 27 deletions(-) create mode 100644 packages/api/src/agents/control.spec.ts create mode 100644 packages/api/src/agents/control.ts diff --git a/api/server/routes/__test-utils__/convos-route-mocks.js b/api/server/routes/__test-utils__/convos-route-mocks.js index 0cef2cbfee..5ae8b2b636 100644 --- a/api/server/routes/__test-utils__/convos-route-mocks.js +++ b/api/server/routes/__test-utils__/convos-route-mocks.js @@ -4,11 +4,22 @@ const generationJobManager = { abortJob: jest.fn().mockResolvedValue({ success: true }), }; const subagentActivityHandlerInputs = []; +const moderatedTexts = []; +const moderateText = jest.fn((req, _res, next) => { + moderatedTexts.push(req.body?.text); + next(); +}); +const messageIpLimiter = jest.fn((_req, _res, next) => next()); +const messageUserLimiter = jest.fn((_req, _res, next) => next()); module.exports = { archiveAllHandler, generationJobManager, subagentActivityHandlerInputs, + moderateText, + moderatedTexts, + messageIpLimiter, + messageUserLimiter, agents: () => ({ sleep: jest.fn() }), @@ -40,6 +51,36 @@ module.exports = { return archiveAllHandler; }), createSubagentThreadViewHandler: jest.fn(() => (_req, res) => res.status(200).json({})), + createSubagentControlHandler: jest.fn(() => (_req, res) => res.status(200).json({})), + isValidSubagentControlRequest: jest.fn((body) => { + if (body == null || typeof body !== 'object') return false; + const commonKeys = ['taskId', 'invocationId', 'action']; + let allowedKeys = [...commonKeys, 'message']; + if (body.action === 'cancel_message') allowedKeys = [...commonKeys, 'controlId']; + if (body.action === 'cancel') allowedKeys = commonKeys; + if (Object.keys(body).some((key) => !allowedKeys.includes(key))) return false; + if (typeof body.taskId !== 'string' || body.taskId.length === 0 || body.taskId.length > 256) { + return false; + } + if ( + typeof body.invocationId !== 'string' || + body.invocationId.length === 0 || + body.invocationId.length > 128 + ) { + return false; + } + if (body.action === 'cancel') return true; + if (body.action === 'cancel_message') { + return typeof body.controlId === 'string' && body.controlId.length > 0; + } + return ( + ['steer', 'queue', 'interrupt'].includes(body.action) && + typeof body.message === 'string' && + body.message.trim() !== '' && + body.message.length <= 4 * 1024 + ); + }), + exemptAgentTriggerFromIpLimiter: jest.fn(() => false), createParentSubagentIndexHandler: jest.fn( () => (_req, res) => res.status(200).json({ threads: [] }), ), @@ -116,6 +157,9 @@ module.exports = { forkUserLimiter: (req, res, next) => next(), })), configMiddleware: (req, res, next) => next(), + moderateText, + messageIpLimiter, + messageUserLimiter, validateConvoAccess: (req, res, next) => next(), }), diff --git a/api/server/routes/__tests__/convos.spec.js b/api/server/routes/__tests__/convos.spec.js index 635503a8ea..45c7f4a4dc 100644 --- a/api/server/routes/__tests__/convos.spec.js +++ b/api/server/routes/__tests__/convos.spec.js @@ -2,12 +2,32 @@ const express = require('express'); const request = require('supertest'); const MOCKS = '../__test-utils__/convos-route-mocks'; -const { archiveAllHandler, generationJobManager, subagentActivityHandlerInputs } = require(MOCKS); +const { + archiveAllHandler, + generationJobManager, + moderateText, + moderatedTexts, + messageIpLimiter, + messageUserLimiter, + subagentActivityHandlerInputs, +} = require(MOCKS); + +const priorLimitMessageIp = process.env.LIMIT_MESSAGE_IP; +const priorLimitMessageUser = process.env.LIMIT_MESSAGE_USER; +process.env.LIMIT_MESSAGE_IP = 'true'; +process.env.LIMIT_MESSAGE_USER = 'true'; jest.mock('@librechat/agents', () => require(MOCKS).agents()); jest.mock('@librechat/api', () => require(MOCKS).api({ - createContentFilter: jest.fn(() => (req, res, next) => next()), + createContentFilter: jest.fn((options) => (req, res, next) => { + const extracted = [...options.extract(req)]; + if (JSON.stringify(extracted).includes('BLOCK-CONTROL')) { + return res.status(400).json({ error: 'content_filter_block' }); + } + next(); + }), + extractStoredMessageContent: jest.fn((input) => [input]), inspectContent: jest.fn(() => null), extractConversationTitleContent: jest.fn(() => []), contentFilterBlockResponse: jest.fn(), @@ -77,8 +97,16 @@ describe('Convos Routes', () => { app.use('/api/convos', convosRouter); }); + afterAll(() => { + if (priorLimitMessageIp == null) delete process.env.LIMIT_MESSAGE_IP; + else process.env.LIMIT_MESSAGE_IP = priorLimitMessageIp; + if (priorLimitMessageUser == null) delete process.env.LIMIT_MESSAGE_USER; + else process.env.LIMIT_MESSAGE_USER = priorLimitMessageUser; + }); + beforeEach(() => { jest.clearAllMocks(); + moderatedTexts.length = 0; generationJobManager.getJob.mockResolvedValue(null); generationJobManager.abortJob.mockResolvedValue({ success: true }); }); @@ -96,6 +124,71 @@ describe('Convos Routes', () => { ); }); + it('filters and moderates subagent guidance as ordinary user text before control handling', async () => { + const response = await request(app).post('/api/convos/parent/subagents/child/control').send({ + taskId: 'task-1', + invocationId: 'invocation-1', + action: 'queue', + message: 'Guide the child.', + }); + + expect(response.status).toBe(200); + expect(messageIpLimiter).toHaveBeenCalledTimes(1); + expect(messageUserLimiter).toHaveBeenCalledTimes(1); + expect(moderateText).toHaveBeenCalledTimes(1); + expect(moderatedTexts).toEqual(['Guide the child.']); + + moderateText.mockClear(); + moderatedTexts.length = 0; + const blocked = await request(app).post('/api/convos/parent/subagents/child/control').send({ + taskId: 'task-1', + invocationId: 'invocation-2', + action: 'interrupt', + message: 'BLOCK-CONTROL', + }); + + expect(blocked.status).toBe(400); + expect(blocked.body).toEqual({ error: 'content_filter_block' }); + expect(moderateText).not.toHaveBeenCalled(); + + moderateText.mockClear(); + const oversized = await request(app) + .post('/api/convos/parent/subagents/child/control') + .send({ + taskId: 'task-1', + invocationId: 'invocation-3', + action: 'queue', + message: 'x'.repeat(4 * 1024 + 1), + }); + + expect(oversized.status).toBe(400); + expect(oversized.body).toEqual({ error: 'Invalid subagent control request' }); + expect(moderateText).not.toHaveBeenCalled(); + + const cancelled = await request(app).post('/api/convos/parent/subagents/child/control').send({ + taskId: 'task-1', + invocationId: 'invocation-4', + action: 'cancel', + }); + + expect(cancelled.status).toBe(200); + expect(moderateText).not.toHaveBeenCalled(); + + const crafted = await request(app) + .post('/api/convos/parent/subagents/child/control') + .send({ + taskId: 'task-1', + invocationId: 'invocation-5', + action: 'queue', + message: 'Use only this bounded guidance.', + answers: ['This unrelated field must not reach moderation.'], + }); + + expect(crafted.status).toBe(400); + expect(crafted.body).toEqual({ error: 'Invalid subagent control request' }); + expect(moderateText).not.toHaveBeenCalled(); + }); + describe('GET /:conversationId', () => { it('returns an ordinary owned conversation', async () => { getConvo.mockResolvedValue({ conversationId: 'ordinary', title: 'Ordinary' }); diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index 0750e4044a..8e30299e2c 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -6,6 +6,9 @@ const { deleteAgentCheckpoints, createArchiveAllHandler, createSubagentActivityStreamHandler, + createSubagentControlHandler, + isValidSubagentControlRequest, + exemptAgentTriggerFromIpLimiter, createParentSubagentIndexHandler, createSubagentThreadViewHandler, resolveImportMaxFileSize, @@ -17,6 +20,7 @@ const { isContentFilterError, contentFilterBlockResponse, extractConversationTitleContent, + extractStoredMessageContent, GenerationJobManager, isStopConfirmed, } = require('@librechat/api'); @@ -27,6 +31,9 @@ const { validateConvoAccess, createForkLimiters, configMiddleware, + messageIpLimiter, + messageUserLimiter, + moderateText, } = require('~/server/middleware'); const { forkConversation, duplicateConversation } = require('~/server/utils/import/fork'); const { storage, importFileFilter } = require('~/server/routes/files/multer'); @@ -57,6 +64,60 @@ const filterConversationTitle = createContentFilter({ getFilters: (req) => req.config?.filters, extract: (req) => extractConversationTitleContent(req.body), }); +const filterSubagentControlMessage = createContentFilter({ + getFilters: (req) => req.config?.filters, + getLegacyPii: (req) => req.config?.messageFilter?.pii, + extract: (req) => + ['steer', 'queue', 'interrupt'].includes(req.body?.action) + ? extractStoredMessageContent({ text: req.body?.message }) + : [], +}); +const unless = (isExempt, middleware) => (req, res, next) => + isExempt(req) ? next() : middleware(req, res, next); +const subagentControlLimiters = []; +if (isEnabled(process.env.LIMIT_MESSAGE_IP)) { + subagentControlLimiters.push(unless(exemptAgentTriggerFromIpLimiter, messageIpLimiter)); +} +if (isEnabled(process.env.LIMIT_MESSAGE_USER)) { + subagentControlLimiters.push(messageUserLimiter); +} + +function validateSubagentControlRequest(req, res, next) { + if (!isValidSubagentControlRequest(req.body)) { + return res.status(400).json({ error: 'Invalid subagent control request' }); + } + next(); +} + +/** Present guidance to the existing moderation middleware as ordinary user text. + * The controller continues to consume `message`; `text` is restored before it runs. */ +async function moderateSubagentControlMessage(req, res, next) { + const body = (req.body ??= {}); + if (!['steer', 'queue', 'interrupt'].includes(body.action)) { + next(); + return; + } + const hadText = Object.prototype.hasOwnProperty.call(body, 'text'); + const originalText = body.text; + if (typeof body.message === 'string') { + body.text = body.message; + } + const restore = () => { + if (hadText) { + body.text = originalText; + } else { + delete body.text; + } + }; + try { + await moderateText(req, res, (error) => { + restore(); + next(error); + }); + } finally { + restore(); + } +} const subagentActivityStreamHandler = createSubagentActivityStreamHandler( { getConvoOwnership: db.getConvoOwnership, @@ -67,6 +128,13 @@ const subagentActivityStreamHandler = createSubagentActivityStreamHandler( subscribe: subagentThreadTaskStore.subscribeActivity.bind(subagentThreadTaskStore), }, ); +const subagentControlHandler = createSubagentControlHandler({ + getConvoOwnership: db.getConvoOwnership, + getSubagentThreadForParent: db.getSubagentThreadForParent, + getMessages: db.getMessages, + getSubagentTaskControlReceipt: db.getSubagentTaskControlReceipt, + store: subagentThreadTaskStore, +}); router.use(requireJwtAuth); const isValidProjectFilter = (projectId) => @@ -117,6 +185,15 @@ router.get( '/:parentConversationId/subagents/:threadId/tasks/:taskId/activity', subagentActivityStreamHandler, ); +router.post( + '/:parentConversationId/subagents/:threadId/control', + configMiddleware, + ...subagentControlLimiters, + validateSubagentControlRequest, + filterSubagentControlMessage, + moderateSubagentControlMessage, + subagentControlHandler, +); router.get('/:parentConversationId/subagents', parentSubagentIndexHandler); router.get('/:parentConversationId/subagents/:threadId', subagentThreadViewHandler); diff --git a/client/src/components/Chat/Subagents/SubagentActivity.test.tsx b/client/src/components/Chat/Subagents/SubagentActivity.test.tsx index b82e7dfc02..bd9bfe078a 100644 --- a/client/src/components/Chat/Subagents/SubagentActivity.test.tsx +++ b/client/src/components/Chat/Subagents/SubagentActivity.test.tsx @@ -216,6 +216,12 @@ describe('SubagentActivity', () => { }, ); + it('reports when the durable control history is bounded', () => { + render(); + + expect(screen.getByText('com_ui_subagent_control_history_truncated')).toBeInTheDocument(); + }); + it('preserves question input-validation failure for the regular renderer', () => { render( { expect(screen.getByText('Prepared the release')).toBeInTheDocument(); }); + it('renders command receipts separately from child status and allows an accepted withdrawal', () => { + const onCancelControl = jest.fn(); + render( + , + ); + + expect(screen.getByText('com_ui_subagent_thread_status_running')).toBeInTheDocument(); + expect(screen.getByText('com_ui_subagent_control_status_submitted')).toBeInTheDocument(); + expect(screen.getByText('com_ui_subagent_control_status_accepted')).toBeInTheDocument(); + expect(screen.getByText('com_ui_subagent_control_status_applied')).toBeInTheDocument(); + expect(screen.getByText('com_ui_subagent_control_status_rejected')).toBeInTheDocument(); + expect(screen.getByText('com_ui_subagent_control_message_truncated')).toBeInTheDocument(); + expect(screen.getByText('com_ui_subagent_control_reason_task_completed')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'com_ui_subagent_control_withdraw' })); + expect(onCancelControl).toHaveBeenCalledWith('control-1'); + }); + + it('renders storage-prioritized control receipts in chronological order', () => { + render( + , + ); + + const applied = screen.getByText('com_ui_subagent_control_status_applied'); + const accepted = screen.getByText('com_ui_subagent_control_status_accepted'); + expect( + applied.compareDocumentPosition(accepted) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + it('scopes regular-chat renderer state to the selected child activity', () => { render(); diff --git a/client/src/components/Chat/Subagents/SubagentActivity.tsx b/client/src/components/Chat/Subagents/SubagentActivity.tsx index c21d0cbdfa..b3b552a18a 100644 --- a/client/src/components/Chat/Subagents/SubagentActivity.tsx +++ b/client/src/components/Chat/Subagents/SubagentActivity.tsx @@ -1,9 +1,10 @@ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import { Button } from '@librechat/client'; import { ContentTypes } from 'librechat-data-provider'; -import { ArrowDown, Maximize2, Minimize2 } from 'lucide-react'; +import { ArrowDown, CheckCircle2, Clock3, Maximize2, Minimize2, XCircle } from 'lucide-react'; import type { TMessageContentParts } from 'librechat-data-provider'; import type { ChildActivity, ChildActivityItem } from './adapters'; +import type { TranslationKeys } from '~/hooks'; import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite'; import ContentParts from '~/components/Chat/Messages/Content/ContentParts'; import { subagentStatusIcon, subagentStatusLabelKey } from './status'; @@ -13,6 +14,106 @@ import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; const AT_BOTTOM_THRESHOLD_PX = 120; +const CONTROL_ACTION_LABELS = { + steer: 'com_ui_subagent_control_steer', + queue: 'com_ui_subagent_control_queue', + interrupt: 'com_ui_subagent_control_interrupt', + cancel: 'com_ui_subagent_control_cancel', + cancel_message: 'com_ui_subagent_control_cancel_message', +} as const satisfies Record; +const CONTROL_STATUS_LABELS = { + submitted: 'com_ui_subagent_control_status_submitted', + accepted: 'com_ui_subagent_control_status_accepted', + applied: 'com_ui_subagent_control_status_applied', + rejected: 'com_ui_subagent_control_status_rejected', + failed: 'com_ui_subagent_control_status_failed', +} as const satisfies Record; +const CONTROL_REASON_LABELS: Record = { + control_not_found: 'com_ui_subagent_control_reason_control_not_found', + invalid_command: 'com_ui_subagent_control_reason_invalid_command', + owner_unavailable: 'com_ui_subagent_control_reason_owner_unavailable', + task_inaccessible: 'com_ui_subagent_control_reason_task_inaccessible', + task_cancelled: 'com_ui_subagent_control_reason_task_cancelled', + task_completed: 'com_ui_subagent_control_reason_task_completed', + task_failed: 'com_ui_subagent_control_reason_task_failed', + task_not_running: 'com_ui_subagent_control_reason_task_not_running', + withdrawn: 'com_ui_subagent_control_reason_withdrawn', +}; + +function SubagentControlHistory({ + controls, + onCancelControl, +}: { + controls: NonNullable; + onCancelControl?: (controlId: string) => void; +}) { + const localize = useLocalize(); + if (controls.length === 0) return null; + /** Storage keeps actionable accepted receipts ahead of bounded terminal history. + * Presentation restores chronology without changing that retention priority. */ + const chronologicalControls = controls + .map((control, index) => ({ control, index })) + .sort( + (left, right) => + left.control.createdAt.localeCompare(right.control.createdAt) || left.index - right.index, + ) + .map(({ control }) => control); + return ( +
+ {chronologicalControls.map((control) => { + const pending = control.status === 'submitted' || control.status === 'accepted'; + let StatusIcon = XCircle; + if (pending) StatusIcon = Clock3; + if (control.status === 'applied') StatusIcon = CheckCircle2; + return ( +
+
+ + {localize(CONTROL_ACTION_LABELS[control.action])} + + {localize(CONTROL_STATUS_LABELS[control.status])} + +
+ {control.message != null && control.message !== '' && ( +
+ {control.message} + {control.messageTruncated === true && ( + + {localize('com_ui_subagent_control_message_truncated')} + + )} +
+ )} + {control.reason != null && ( +
+ {localize( + CONTROL_REASON_LABELS[control.reason] ?? + 'com_ui_subagent_control_reason_invalid_command', + )} +
+ )} + {control.status === 'accepted' && + control.controlId != null && + onCancelControl != null && ( + + )} +
+ ); + })} +
+ ); +} export function SubagentActivityScrollSurface({ children, @@ -182,11 +283,13 @@ export default function SubagentActivity({ activityId, state = 'ready', embedded = false, + onCancelControl, }: { activity: ChildActivity; activityId?: string; state?: 'ready' | 'loading' | 'error'; embedded?: boolean; + onCancelControl?: (controlId: string) => void; }) { const localize = useLocalize(); const isSubmitting = activity.status === 'running' || activity.status === 'dispatched'; @@ -256,6 +359,15 @@ export default function SubagentActivity({ const content = (
{activity.prompt != null && } + + {activity.controlsTruncated === true && ( +
+ {localize('com_ui_subagent_control_history_truncated')} +
+ )} {activityTruncated && (
{localize('com_ui_subagent_thread_history_truncated')} diff --git a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx index 98aad64b86..4e429803fc 100644 --- a/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx +++ b/client/src/components/Chat/Subagents/SubagentThreadPanel.test.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { RecoilRoot, useRecoilValue } from 'recoil'; import { ContentTypes, ForkOptions } from 'librechat-data-provider'; +import { RecoilRoot, useRecoilValue, useSetRecoilState } from 'recoil'; import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import type { ParentSubagentSummary, @@ -19,6 +19,7 @@ import SubagentThreadPanel from './SubagentThreadPanel'; const mockUseSubagentThreadQuery = jest.fn(); const mockUseSubagentActivityStream = jest.fn(); const mockForkMutate = jest.fn(); +const mockControlMutate = jest.fn(); const mockNavigateToConvo = jest.fn(); const mockShowToast = jest.fn(); const mockApprovalProviderMounted = jest.fn(); @@ -43,6 +44,17 @@ jest.mock('~/data-provider', () => ({ mutate: (payload: unknown) => mockForkMutate(payload, options), isLoading: false, }), + useSubagentControlMutation: (options: { + onSuccess: (result: unknown, variables: unknown) => void; + onError: (error: unknown, variables: unknown) => void; + }) => ({ + mutate: (variables: unknown) => + mockControlMutate(variables, { + onSuccess: (result: unknown) => options.onSuccess(result, variables), + onError: (error: unknown) => options.onError(error, variables), + }), + isLoading: false, + }), })); jest.mock('~/data-provider/Subagents/useSubagentActivityStream', () => ({ @@ -97,21 +109,39 @@ jest.mock('./SubagentActivity', () => ({ activity, activityId, state, + onCancelControl, }: { - activity: { status: string; prompt?: string; items: Array<{ type: string; text?: string }> }; + activity: { + status: string; + prompt?: string; + items: Array<{ type: string; text?: string }>; + controls?: Array<{ invocationId: string; status: string }>; + }; activityId?: string; state: string; + onCancelControl?: (controlId: string) => void; }) => (
{activity.prompt} {activity.items.map((item, index) => ( {item.text ?? item.type} ))} + {activity.controls?.map((control) => ( + {control.status} + ))} + {onCancelControl != null && ( +
), })); @@ -120,6 +150,11 @@ jest.mock('@librechat/client', () => { const mockReact = jest.requireActual('react'); const MockSelectContext = mockReact.createContext((_value: string): void => {}); return { + Alert: ({ children, ...props }: React.ComponentProps<'div'>) => ( +
+ {children} +
+ ), Button: ({ children, ...props }: React.ComponentProps<'button'>) => ( ), @@ -151,6 +186,7 @@ jest.mock('@librechat/client', () => { ); }, + Textarea: (props: React.ComponentProps<'textarea'>) =>