📨 feat: Continue Bound Child Agents from Events (#15112)

* feat: add authenticated agent event ingress

* style: sort agent ingress imports

* fix: harden agent event ingress

* fix: bind event provenance to API keys

* fix: inspect event input with legacy PII filters

* fix: scope event status reads to source keys

* fix: bind event status reads to remote sources

* feat: add bound event-driven child turns

* fix: harden event-bound child continuations

* fix: satisfy event binding type contracts

* fix: close event actor lifecycle races

* fix: harden event actor dispatch continuity

* fix: fence event actor resume lifecycle

* fix: bind event actor state to lifecycle

* fix: preserve cascade write outcomes

* test: type cascade failure injection

* style: sort cascade test imports

* fix: harden event child lifecycle boundaries

* fix: make event cleanup retryable

* fix: annotate event retention clock

* fix: reconcile partial cascade metadata

* fix: recheck event binding expiry on resume

* fix: fence event actors by retention deadline

* fix: close event actor lifecycle races

* fix: harden event child lease acquisition

* fix: lazy-load event child lease adapter
This commit is contained in:
Danny Avila 2026-08-23 01:15:57 -04:00 committed by GitHub
parent 8f9fae0a6e
commit 1de88e7e91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 3888 additions and 246 deletions

View file

@ -729,6 +729,10 @@ MESSAGE_USER_WINDOW=1
AGENT_EVENT_USER_MAX=40
AGENT_EVENT_USER_WINDOW=1
# Enable only after every API replica runs a release that understands
# source-bound child actor continuations.
ENABLE_AGENT_EVENT_CHILD_TURNS=false
ILLEGAL_MODEL_REQ_SCORE=5
#========================#

View file

@ -1230,7 +1230,9 @@ class BaseClient {
const hasAddedConvo = options?.req?.body?.addedConvo != null;
const reqCtx = {
userId: options?.req?.user?.id,
isTemporary: options?.req?.body?.isTemporary,
isTemporary:
options?.req?._agentEventBindingRetention?.isTemporary ?? options?.req?.body?.isTemporary,
expiredAt: options?.req?._agentEventBindingRetention?.expiredAt,
interfaceConfig: options?.req?.config?.interfaceConfig,
};
const savedMessage = await db.saveMessage(
@ -1306,6 +1308,7 @@ class BaseClient {
const conversation = await db.saveConvo(reqCtx, fieldsToKeep, {
context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveConvo',
unsetFields,
noUpsert: req?._agentEventBindingParentConversationId != null,
createdAtOnInsert: shouldSetCreatedAtOnInsert ? validCreatedAtOnInsert : undefined,
});

View file

@ -64,6 +64,8 @@ const mockGetConvo = jest.fn();
const mockGetMessages = jest.fn();
const mockSaveMessage = jest.fn();
const mockIsAgentTriggerPrincipalActive = jest.fn();
const mockIsSubagentOwnerAdmissible = jest.fn();
const mockAcquireEventChildGenerationLease = jest.fn();
const mockIsScheduleFireRequest = jest.fn();
const mockExemptFromConcurrencyLimiter = jest.fn();
const mockRecordScheduleOutcome = jest.fn();
@ -195,6 +197,8 @@ jest.mock('@librechat/api', () => ({
return messages.length === 0;
},
deleteAgentCheckpoint: (...args) => mockDeleteAgentCheckpoint(...args),
isAgentEventRetentionActive: (expiredAt) =>
expiredAt == null || new Date(expiredAt).getTime() > Date.now(),
createMCPRuntimeRequestBody: ({ messageId, conversationId, parentMessageId }) => ({
messageId,
conversationId,
@ -223,6 +227,11 @@ jest.mock('~/models', () => ({
getMessages: (...args) => mockGetMessages(...args),
getConvo: (...args) => mockGetConvo(...args),
isAgentTriggerPrincipalActive: (...args) => mockIsAgentTriggerPrincipalActive(...args),
isSubagentOwnerAdmissible: (...args) => mockIsSubagentOwnerAdmissible(...args),
}));
jest.mock('~/server/services/Endpoints/agents/eventChildLease', () => ({
acquireEventChildGenerationLease: (...args) => mockAcquireEventChildGenerationLease(...args),
}));
jest.mock('~/server/services/Schedules', () => ({
@ -266,6 +275,8 @@ describe('ResumableAgentController resume metadata', () => {
mockGetConvo.mockResolvedValue({ createdAt: '2026-06-07T00:00:00.000Z' });
mockGetMessages.mockResolvedValue([]);
mockIsAgentTriggerPrincipalActive.mockResolvedValue(true);
mockIsSubagentOwnerAdmissible.mockResolvedValue(true);
mockAcquireEventChildGenerationLease.mockResolvedValue(jest.fn());
mockIsScheduleFireRequest.mockImplementation((req) => req?._isScheduledFire === true);
mockExemptFromConcurrencyLimiter.mockImplementation(
(req) => req?._isScheduledFire === true && req?._isManualScheduledFire !== true,
@ -3599,6 +3610,120 @@ describe('ResumableAgentController resume metadata', () => {
);
});
it('reports a temporary event-actor fence as retryable rather than ending the binding', async () => {
const expiredAt = new Date(Date.now() + 60_000);
mockGenerationJobManager.claimGeneration.mockResolvedValue(
wonGenerationClaim({
streamId: 'child-conversation',
conversationId: 'child-conversation',
}),
);
mockGetConvo.mockResolvedValue({
conversationId: 'parent-conversation',
agent_id: 'parent-agent',
tenantId: 'tenant-1',
});
mockIsSubagentOwnerAdmissible.mockResolvedValue(false);
const req = {
user: { id: 'user-123', tenantId: 'tenant-1' },
body: {
text: 'Continue from event.',
messageId: 'user-msg',
clientRequestId: 'req-event',
conversationId: 'child-conversation',
endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } },
},
config: {},
_agentEventBindingParentConversationId: 'parent-conversation',
_agentEventBindingParentAgentId: 'parent-agent',
_agentEventBindingTenantId: 'tenant-1',
_agentEventBindingRetention: { isTemporary: true, expiredAt },
};
const res = { json: jest.fn(), status: jest.fn(() => res), set: jest.fn() };
await AgentController(req, res, jest.fn(), jest.fn(), null);
expect(res.status).toHaveBeenCalledWith(409);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ code: 'EVENT_ACTOR_NOT_READY' }),
);
expect(mockGenerationJobManager.createJob).toHaveBeenCalledTimes(1);
expect(mockAcquireEventChildGenerationLease).toHaveBeenCalledWith(
expect.objectContaining({ retentionExpiresAt: expiredAt }),
);
});
it('uses the guard-normalized tenant for a legacy untenanted event actor', async () => {
const expiredAt = new Date(Date.now() + 60_000);
mockGenerationJobManager.claimGeneration.mockResolvedValue(
wonGenerationClaim({
streamId: 'child-conversation',
conversationId: 'child-conversation',
}),
);
mockGetConvo.mockResolvedValue({
conversationId: 'parent-conversation',
agent_id: 'parent-agent',
});
const req = {
user: { id: 'user-123', tenantId: '' },
body: {
text: 'Continue from an old untenanted binding.',
messageId: 'user-msg',
clientRequestId: 'req-event-legacy',
conversationId: 'child-conversation',
endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } },
},
config: {},
_agentEventBindingParentConversationId: 'parent-conversation',
_agentEventBindingParentAgentId: 'parent-agent',
_agentEventBindingTenantId: undefined,
_agentEventBindingRetention: { isTemporary: true, expiredAt },
};
const res = { json: jest.fn(), status: jest.fn(() => res), set: jest.fn() };
await AgentController(req, res, jest.fn(), jest.fn(), null);
expect(mockAcquireEventChildGenerationLease).toHaveBeenCalledWith(
expect.objectContaining({ tenantId: undefined, retentionExpiresAt: expiredAt }),
);
});
it('does not start an event actor whose inherited binding expired after the guard', async () => {
const expiredAt = new Date(Date.now() - 1);
mockGenerationJobManager.claimGeneration.mockResolvedValue(
wonGenerationClaim({
streamId: 'child-conversation',
conversationId: 'child-conversation',
}),
);
mockAcquireEventChildGenerationLease.mockResolvedValue(null);
const req = {
user: { id: 'user-123', tenantId: 'tenant-1' },
body: {
text: 'This event arrived too late.',
messageId: 'user-msg',
clientRequestId: 'req-event-expired',
conversationId: 'child-conversation',
endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } },
},
config: {},
_agentEventBindingParentConversationId: 'parent-conversation',
_agentEventBindingParentAgentId: 'parent-agent',
_agentEventBindingTenantId: 'tenant-1',
_agentEventBindingRetention: { isTemporary: true, expiredAt },
};
const res = { json: jest.fn(), status: jest.fn(() => res), set: jest.fn() };
await AgentController(req, res, jest.fn(), jest.fn(), null);
expect(res.status).toHaveBeenCalledWith(409);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({ code: 'EVENT_BINDING_PARENT_ENDED' }),
);
expect(mockIsSubagentOwnerAdmissible).not.toHaveBeenCalled();
});
it('releases the idempotency claim on a 429 only when it won the claim', async () => {
mockGenerationJobManager.claimGeneration.mockResolvedValue(wonGenerationClaim());
mockCheckAndIncrementPendingRequest.mockResolvedValue({

View file

@ -99,6 +99,9 @@ const mockClaimScheduleResume = jest.fn();
const mockReleaseScheduleResumeClaim = jest.fn();
const mockFinalizeScheduleResumeClaim = jest.fn();
const mockReleaseScheduleResumeFence = jest.fn();
const mockAcquireEventChildGenerationLease = jest.fn();
const mockReleaseEventChildLease = jest.fn();
const mockIsSubagentOwnerAdmissible = jest.fn();
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
@ -131,6 +134,11 @@ jest.mock('~/models', () => ({
getActions: (...args) => mockGetActions(...args),
getUserMemories: (...args) => mockGetUserMemories(...args),
getRoleByName: (...args) => mockGetRoleByName(...args),
isSubagentOwnerAdmissible: (...args) => mockIsSubagentOwnerAdmissible(...args),
}));
jest.mock('~/server/services/Endpoints/agents/eventChildLease', () => ({
acquireEventChildGenerationLease: (...args) => mockAcquireEventChildGenerationLease(...args),
}));
jest.mock('~/server/services/ActionService', () => ({
@ -259,6 +267,7 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
let mockAddTitle;
let capturedInit;
let requestConfigOverrides;
let requestStateOverrides;
let endpointAgent;
let settle;
let settled;
@ -268,6 +277,7 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
capturedInit = null;
requestConfigOverrides = {};
requestStateOverrides = {};
mockCheckAndIncrementPendingRequest.mockResolvedValue({ allowed: true });
mockDecrementPendingRequest.mockResolvedValue(undefined);
mockDeleteAgentCheckpoint.mockResolvedValue(undefined);
@ -338,6 +348,9 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
mockReleaseScheduleResumeClaim.mockResolvedValue(true);
mockFinalizeScheduleResumeClaim.mockResolvedValue(true);
mockReleaseScheduleResumeFence.mockResolvedValue(undefined);
mockAcquireEventChildGenerationLease.mockResolvedValue(mockReleaseEventChildLease);
mockReleaseEventChildLease.mockResolvedValue(undefined);
mockIsSubagentOwnerAdmissible.mockResolvedValue(true);
endpointAgent = {
_id: 'mongo-agent-abc',
id: AGENT_ID,
@ -388,6 +401,7 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
model_parameters: {},
agent: Promise.resolve(endpointAgent),
};
Object.assign(req, requestStateOverrides);
next();
});
app.post('/api/agents/chat/resume', (req, res, next) =>
@ -406,6 +420,149 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
...extra,
});
const configureEventActorResume = (expiredAt = new Date(Date.now() + 60_000)) => {
requestStateOverrides = {
_agentEventBindingParentConversationId: 'parent-conversation',
_agentEventBindingParentAgentId: 'parent-agent',
_agentEventBindingTenantId: TENANT_ID,
_agentEventBindingRetention: { isTemporary: true, expiredAt },
};
mockGetConvo.mockResolvedValue({
conversationId: 'parent-conversation',
agent_id: 'parent-agent',
tenantId: TENANT_ID,
createdAt: new Date('2026-08-22T00:00:00.000Z'),
});
return expiredAt;
};
describe('event-bound actor resume lifecycle', () => {
it('leaves the approval pending when the previous segment still owns the lease', async () => {
configureEventActorResume();
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
mockAcquireEventChildGenerationLease.mockResolvedValue(null);
const res = await post(approveBody());
expect(res.status).toBe(409);
expect(res.body).toMatchObject({ code: 'EVENT_ACTOR_NOT_READY' });
expect(mockAcquireEventChildGenerationLease).toHaveBeenCalled();
expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
expect(mockGenerationJobManager.abortJob).not.toHaveBeenCalled();
});
it('classifies an expired binding as ended when no lease can be acquired', async () => {
configureEventActorResume(new Date(Date.now() - 1));
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
mockAcquireEventChildGenerationLease.mockResolvedValue(null);
const res = await post(approveBody());
expect(res.status).toBe(409);
expect(res.body).toMatchObject({ code: 'EVENT_BINDING_PARENT_ENDED' });
expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
});
it('owns the lease before consuming approval and preserves the inherited deadline', async () => {
const expiredAt = configureEventActorResume();
mockGenerationJobManager.getJob.mockResolvedValue(
makeToolApprovalJob({ metadata: { isTemporary: true } }),
);
const res = await post(approveBody());
expect(res.status).toBe(200);
await settled;
await flush();
expect(mockAcquireEventChildGenerationLease.mock.invocationCallOrder[0]).toBeLessThan(
mockGenerationJobManager.approvals.resolve.mock.invocationCallOrder[0],
);
expect(mockIsSubagentOwnerAdmissible.mock.invocationCallOrder[0]).toBeLessThan(
mockGenerationJobManager.approvals.resolve.mock.invocationCallOrder[0],
);
expect(mockAcquireEventChildGenerationLease).toHaveBeenCalledWith(
expect.objectContaining({ retentionExpiresAt: expiredAt }),
);
expect(mockSaveMessage).toHaveBeenCalledWith(
expect.objectContaining({ isTemporary: true, expiredAt }),
expect.anything(),
expect.anything(),
);
expect(mockReleaseEventChildLease).toHaveBeenCalledTimes(1);
});
it('preserves the inherited deadline when the resumed actor pauses again', async () => {
const expiredAt = configureEventActorResume();
mockGenerationJobManager.getJob.mockResolvedValue(
makeToolApprovalJob({ metadata: { isTemporary: true } }),
);
mockInitializeClient.mockResolvedValue({
client: makeClient({
pendingApproval: { actionId: NEXT_ACTION_ID },
contentParts: [{ type: 'text', text: 'partial' }],
}),
userMCPAuthMap: {},
});
const res = await post(approveBody());
expect(res.status).toBe(200);
await settled;
await flush();
expect(mockSaveMessage).toHaveBeenCalledWith(
expect.objectContaining({ isTemporary: true, expiredAt }),
expect.objectContaining({ unfinished: true }),
expect.objectContaining({
context: 'api/server/controllers/agents/resume.js - re-pause progress persist',
}),
);
});
it('defers a resume when the owner admission fence is temporarily closed', async () => {
configureEventActorResume();
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
mockIsSubagentOwnerAdmissible.mockResolvedValue(false);
const res = await post(approveBody());
expect(res.status).toBe(409);
expect(res.body).toMatchObject({ code: 'EVENT_ACTOR_NOT_READY' });
expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
expect(mockGenerationJobManager.abortJob).not.toHaveBeenCalled();
});
it('rejects a binding that expires after the route guard but before approval consumption', async () => {
configureEventActorResume(new Date(Date.now() - 1));
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
const res = await post(approveBody());
expect(res.body).toMatchObject({ code: 'EVENT_BINDING_PARENT_ENDED' });
expect(res.status).toBe(409);
expect(mockGenerationJobManager.approvals.resolve).not.toHaveBeenCalled();
expect(mockGenerationJobManager.abortJob).not.toHaveBeenCalled();
});
it('uses the guard-normalized tenant for a legacy untenanted event actor', async () => {
const expiredAt = configureEventActorResume();
requestStateOverrides._agentEventBindingTenantId = undefined;
mockGetConvo.mockResolvedValue({
conversationId: 'parent-conversation',
agent_id: 'parent-agent',
});
mockGenerationJobManager.getJob.mockResolvedValue(makeToolApprovalJob());
const res = await post(approveBody());
expect(res.status).toBe(200);
await settled;
await flush();
expect(mockAcquireEventChildGenerationLease).toHaveBeenCalledWith(
expect.objectContaining({ tenantId: undefined, retentionExpiresAt: expiredAt }),
);
});
});
describe('scheduled occurrence lifecycle', () => {
const scheduledFor = '2026-08-17T12:00:00.000Z';
const makeScheduledJob = () =>

View file

@ -152,6 +152,19 @@ const db = require('~/models');
const loadAgent = (params) => loadAgentFn(params, { getAgent: db.getAgent, getMCPServerTools });
function getInterruptTtlMs(checkpointerCfg, req) {
const configuredTtlMs = getApprovalTtlMs(checkpointerCfg);
const retention = req?._agentEventBindingRetention;
if (retention?.expiredAt == null) {
return configuredTtlMs;
}
const bindingDeadline = new Date(retention.expiredAt).getTime();
if (!Number.isFinite(bindingDeadline)) {
return configuredTtlMs;
}
return Math.min(configuredTtlMs, Math.max(0, bindingDeadline - Date.now()));
}
const MEMORY_INPUT_CHARS_PER_TOKEN = 8;
function getUserFacingRequestError(baseMessage, error, appConfig) {
@ -3088,7 +3101,7 @@ class AgentClient extends BaseClient {
// thread_id was bound to conversationId at run config (config.configurable);
// fall back to it when the SDK doesn't echo threadId on the interrupt.
threadId: interrupt.threadId ?? this.conversationId,
ttlMs: getApprovalTtlMs(checkpointerCfg),
ttlMs: getInterruptTtlMs(checkpointerCfg, this.options.req),
// Pin the graph-determining request fields so resume can't rebuild this paused
// run on a different agent/tool set (esp. ephemeral agents, whose agent_id is
// undefined so the id guard can't tell two configs apart).

View file

@ -516,6 +516,57 @@ describe('AgentClient - interrupt discovery persistence', () => {
expect(paused?.status).toBe('requires_action');
expect(paused?.metadata.discoveredTools).toEqual(['save_issue_mcp_linear']);
});
it('caps an event-bound pause at the inherited binding deadline', async () => {
const now = Date.now();
const streamId = 'conversation-event-bound-pause';
const job = await GenerationJobManager.createJob(streamId, 'user-123', streamId);
const client = new AgentClient({
req: {
user: { id: 'user-123' },
body: { endpoint: EModelEndpoint.agents, agent_id: 'agent-123' },
config: { endpoints: { [EModelEndpoint.agents]: { checkpointer: { ttl: 3600 } } } },
_agentEventBindingRetention: {
/** RetentionMode.ALL conversations are not temporary but still have a deadline. */
isTemporary: false,
expiredAt: new Date(now + 5_000),
},
},
res: {},
agent: {
id: 'agent-123',
endpoint: EModelEndpoint.openAI,
provider: EModelEndpoint.openAI,
model_parameters: { model: 'gpt-4' },
},
contentParts: [],
collectedUsage: [],
artifactPromises: [],
});
client.conversationId = streamId;
client.responseMessageId = 'response-event-bound-pause';
client.jobCreatedAt = job.createdAt;
await client.handleRunInterrupt(
{
getInterrupt: () => ({
interruptId: 'ask-interrupt',
threadId: streamId,
payload: {
type: 'ask_user_question',
question: { question: 'Proceed?' },
},
}),
getDiscoveredTools: () => [],
getRunMessages: () => [],
},
streamId,
);
const paused = await GenerationJobManager.getJob(streamId);
expect(paused?.metadata.pendingAction.expiresAt).toBeGreaterThanOrEqual(now + 4_900);
expect(paused?.metadata.pendingAction.expiresAt).toBeLessThanOrEqual(now + 5_000);
});
});
jest.mock('~/server/services/Config', () => ({

View file

@ -29,6 +29,7 @@ const {
deleteAgentCheckpoint,
getAttachmentTitleText,
createMCPRuntimeRequestBody,
isAgentEventRetentionActive,
} = require('@librechat/api');
const { disposeClient } = require('~/server/cleanup');
const {
@ -37,7 +38,16 @@ const {
} = require('~/server/services/MCPRequestContext');
const { logViolation } = require('~/cache');
const { recordScheduleOutcome, isScheduleLive } = require('~/server/services/Schedules');
const { saveMessage, getMessages, getConvo, isAgentTriggerPrincipalActive } = require('~/models');
const {
saveMessage,
getMessages,
getConvo,
isAgentTriggerPrincipalActive,
isSubagentOwnerAdmissible,
} = require('~/models');
const {
acquireEventChildGenerationLease,
} = require('~/server/services/Endpoints/agents/eventChildLease');
const {
GENERATION_PROTOCOL_HEADER,
GENERATION_PROTOCOL_V2,
@ -1022,6 +1032,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
let client = null;
let jobCreatedAt;
let providerExecutionId;
let releaseEventChildLease;
let scheduleTerminalOutcomeRecorded = false;
const settleScheduledRun = async ({ status, error, clearConversationId = false }) => {
if (!scheduleId) {
@ -1085,7 +1096,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
agent_id: endpointOption.agent_id ?? req.body?.agent_id,
// Persist temporary-chat state so a HITL resume keeps the resumed response
// non-persisted instead of trusting the resume request to re-send the flag.
isTemporary: req.body?.isTemporary,
isTemporary: req._agentEventBindingRetention?.isTemporary ?? req.body?.isTemporary,
...(isRegenerate && { isRegenerate: true }),
...(scheduleId
? {
@ -1120,6 +1131,58 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
status: 409,
});
}
if (req._agentEventBindingParentConversationId != null) {
/** The generation job is the durable marker that a deletion on another replica
* can abort. Recheck only after that marker exists: either the deletion fence
* wins and this run stops here, or the deletion observes and drains this job. */
releaseEventChildLease = await acquireEventChildGenerationLease({
userId,
tenantId: req._agentEventBindingTenantId,
conversationId,
streamId,
jobCreatedAt,
retentionExpiresAt: req._agentEventBindingRetention?.expiredAt,
});
if (releaseEventChildLease == null) {
const bindingActive = isAgentEventRetentionActive(
req._agentEventBindingRetention?.expiredAt,
);
throw Object.assign(
new Error(
bindingActive
? 'The event actor is already handling another turn'
: 'The event binding parent is no longer available',
),
{
code: bindingActive ? 'EVENT_ACTOR_NOT_READY' : 'EVENT_BINDING_PARENT_ENDED',
status: 409,
},
);
}
const [eventParent, ownerAdmissible] = await Promise.all([
getConvo(userId, req._agentEventBindingParentConversationId),
isSubagentOwnerAdmissible(userId),
]);
if (!ownerAdmissible) {
throw Object.assign(new Error('The event actor is temporarily unavailable'), {
code: 'EVENT_ACTOR_NOT_READY',
status: 409,
});
}
if (
eventParent == null ||
eventParent.subagentThread != null ||
eventParent.agent_id !== req._agentEventBindingParentAgentId ||
(eventParent.tenantId ?? undefined) !== req._agentEventBindingTenantId ||
!isAgentEventRetentionActive(req._agentEventBindingRetention?.expiredAt) ||
!isAgentEventRetentionActive(eventParent.expiredAt)
) {
throw Object.assign(new Error('The event binding parent is no longer available'), {
code: 'EVENT_BINDING_PARENT_ENDED',
status: 409,
});
}
}
if (
scheduleId &&
!(await isScheduleLive(scheduleId, scheduleConfigRevision, {
@ -1248,7 +1311,8 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
saveMessage(
{
userId,
isTemporary: req?.body?.isTemporary,
isTemporary: req?._agentEventBindingRetention?.isTemporary ?? req?.body?.isTemporary,
expiredAt: req?._agentEventBindingRetention?.expiredAt,
interfaceConfig: req?.config?.interfaceConfig,
},
partialMessage,
@ -1676,7 +1740,9 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
const savedUserMessage = await saveMessage(
{
userId,
isTemporary: req?.body?.isTemporary,
isTemporary:
req?._agentEventBindingRetention?.isTemporary ?? req?.body?.isTemporary,
expiredAt: req?._agentEventBindingRetention?.expiredAt,
interfaceConfig: req?.config?.interfaceConfig,
},
userMessage,
@ -1696,7 +1762,9 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
const savedResponseMessage = await saveMessage(
{
userId,
isTemporary: req?.body?.isTemporary,
isTemporary:
req?._agentEventBindingRetention?.isTemporary ?? req?.body?.isTemporary,
expiredAt: req?._agentEventBindingRetention?.expiredAt,
interfaceConfig: req?.config?.interfaceConfig,
},
{
@ -1855,7 +1923,8 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
// where client refetch happens before database is updated
const reqCtx = {
userId: req?.user?.id,
isTemporary: req?.body?.isTemporary,
isTemporary: req?._agentEventBindingRetention?.isTemporary ?? req?.body?.isTemporary,
expiredAt: req?._agentEventBindingRetention?.expiredAt,
interfaceConfig: req?.config?.interfaceConfig,
};
@ -2162,6 +2231,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
providerExecutionId,
);
}
await releaseEventChildLease?.();
})
.catch((drainError) => {
logger.warn(
@ -2302,6 +2372,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
);
});
}
await releaseEventChildLease?.();
}
};

View file

@ -38,6 +38,7 @@ const {
toPendingSteer,
createMCPRuntimeRequestBody,
getSafeErrorMetadata,
isAgentEventRetentionActive,
} = require('@librechat/api');
const { disposeClient } = require('~/server/cleanup');
const { decryptMetadata } = require('~/server/services/ActionService');
@ -55,7 +56,11 @@ const {
getActions,
getUserMemories,
getRoleByName,
isSubagentOwnerAdmissible,
} = require('~/models');
const {
acquireEventChildGenerationLease,
} = require('~/server/services/Endpoints/agents/eventChildLease');
const {
recordScheduleOutcome,
claimScheduleResume,
@ -240,6 +245,7 @@ async function persistRePauseProgress({ req, client, job, streamId, conversation
{
userId,
isTemporary: meta.isTemporary ?? req.body?.isTemporary,
expiredAt: req._agentEventBindingRetention?.expiredAt,
interfaceConfig: req?.config?.interfaceConfig,
},
{
@ -454,7 +460,12 @@ async function finalizeResumedTurn({
let terminalPublicationStarted = false;
try {
const savedResponseMessage = await saveMessage(
{ userId, isTemporary, interfaceConfig: req?.config?.interfaceConfig },
{
userId,
isTemporary,
expiredAt: req._agentEventBindingRetention?.expiredAt,
interfaceConfig: req?.config?.interfaceConfig,
},
responseMessage,
{ context: 'api/server/controllers/agents/resume.js - resumed response end' },
);
@ -1091,122 +1102,229 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle)
}
};
// Atomically claim the resume. The single winner drives the run; a racing second
// submit (double-click, two tabs) gets false and must not re-drive — that would
// re-execute tools and double-bill.
//
// The claim runs AFTER the slot increment above but BEFORE the run's own try/finally
// that releases it, so a store/Redis error here (unlike the clean `!claimed` branch)
// would leak the concurrency slot until the counter TTL expires — spuriously 429'ing
// the user when they retry the still-paused approval. Release the slot on that path too.
let claimed;
let releaseEventChildLease;
let eventLeaseTransferredToRun = false;
const providerExecutionId = randomUUID();
try {
/** The CAS that reopens steering must also publish THIS owner's seal
* capability. A separate write after status=`running` leaves a window in
* which steer/arm requests read the previous replica's capability. */
claimed = await GenerationJobManager.approvals.resolve(
streamId,
pendingAction.actionId,
{
preemptCapable: isSteerPreemptSupported(),
providerExecutionId,
providerDrained: true,
...(resolvedAskUserQuestion && { resolvedAskUserQuestions }),
},
job.createdAt,
);
} catch (err) {
const currentJob = await GenerationJobManager.getJob(streamId).catch(() => null);
await rollbackUnconsumedScheduleClaim(currentJob);
await releaseScheduleFence();
await decrementPendingRequest(userId);
logger.error('[ResumeAgentController] Failed to claim resume', getSafeErrorMetadata(err));
return sendGenerationJson(res, 500, { error: 'Failed to resume' }, generationProtocolVersion);
}
if (!claimed) {
await decrementPendingRequest(userId);
const currentJob = await GenerationJobManager.getJob(streamId).catch(() => null);
await rollbackUnconsumedScheduleClaim(currentJob);
await releaseScheduleFence();
if (currentJob != null && currentJob.createdAt !== job.createdAt) {
return sendGenerationJson(res, 409, { code: 'RUN_REPLACED' }, generationProtocolVersion);
}
return sendGenerationJson(
res,
409,
{ error: 'This action was already resolved or has expired' },
generationProtocolVersion,
);
}
// Linearize the consumed approval against the schedule's live config. The schedule
// document fence was acquired only after all async policy reads, and this atomic
// consume checks its token/revision/enabled state immediately after the approval CAS.
// An edit/disable that won first makes this fail; one that lands afterward is ordered
// after the continuation has started. Never begin provider execution on a stale claim.
if (scheduleId) {
let scheduleClaimCurrent = false;
try {
scheduleClaimCurrent = await finalizeScheduleResumeClaim(
scheduleId,
scheduleResumeClaimToken,
scheduleResumeLeaseBy,
scheduleResumeOptions,
);
} catch (error) {
logger.error('[ResumeAgentController] Failed to finalize scheduled resume fence', error);
await releaseScheduleFence();
}
if (!scheduleClaimCurrent) {
await decrementPendingRequest(userId);
let stopped = false;
if (req._agentEventBindingParentConversationId != null) {
try {
const abortResult = await GenerationJobManager.abortJob(streamId, {
expectedCreatedAt: job.createdAt,
awaitProviderDrain: true,
releaseEventChildLease = await acquireEventChildGenerationLease({
userId,
tenantId: req._agentEventBindingTenantId,
conversationId,
streamId,
jobCreatedAt: job.createdAt,
retentionExpiresAt: req._agentEventBindingRetention?.expiredAt,
});
// Same authoritative gate as the inactive-schedule path above: only a landed
// abort (or an already-terminal, drained generation) may settle this occurrence.
stopped = isStopConfirmed(abortResult);
} catch (error) {
logger.warn('[ResumeAgentController] Failed to stop stale scheduled resume', error);
}
if (!stopped) {
logger.warn('[ResumeAgentController] Event actor resume lease is unavailable', error);
const currentJob = await GenerationJobManager.getJob(streamId).catch(() => null);
await rollbackUnconsumedScheduleClaim(currentJob);
await releaseScheduleFence();
await decrementPendingRequest(userId);
res.set('Retry-After', '1');
return sendGenerationJson(
res,
503,
{
code: 'SCHEDULE_STOP_UNCONFIRMED',
error: 'The stale scheduled resume could not be confirmed stopped.',
code: 'EVENT_ACTOR_LEASE_UNAVAILABLE',
error: 'The event actor lease is temporarily unavailable',
},
generationProtocolVersion,
);
}
await recordScheduleOutcome({
scheduleId,
scheduledFor,
if (releaseEventChildLease == null) {
const bindingActive = isAgentEventRetentionActive(
req._agentEventBindingRetention?.expiredAt,
);
const currentJob = await GenerationJobManager.getJob(streamId).catch(() => null);
await rollbackUnconsumedScheduleClaim(currentJob);
await releaseScheduleFence();
await decrementPendingRequest(userId);
if (bindingActive) {
res.set('Retry-After', '1');
}
return sendGenerationJson(
res,
409,
{
code: bindingActive ? 'EVENT_ACTOR_NOT_READY' : 'EVENT_BINDING_PARENT_ENDED',
error: bindingActive
? 'The event actor is still finishing its previous segment'
: 'The event binding parent is no longer available',
},
generationProtocolVersion,
);
}
/** Validate the durable parent/owner fence before consuming the HITL action.
* Once `approvals.resolve` wins its CAS, the action is irreversibly spent; a
* retryable fence rejection after that point could never replay the user's
* decision. A deletion that starts after this check observes the generation
* job plus the event-child lease and owns the corresponding abort. */
let eventActorRejection;
try {
const [eventParent, ownerAdmissible] = await Promise.all([
getConvo(userId, req._agentEventBindingParentConversationId),
isSubagentOwnerAdmissible(userId),
]);
if (!ownerAdmissible) {
eventActorRejection = {
code: 'EVENT_ACTOR_NOT_READY',
error: 'The event actor owner is temporarily unavailable',
};
} else if (
eventParent == null ||
eventParent.subagentThread != null ||
eventParent.agent_id !== req._agentEventBindingParentAgentId ||
(eventParent.tenantId ?? undefined) !== req._agentEventBindingTenantId ||
!isAgentEventRetentionActive(req._agentEventBindingRetention?.expiredAt) ||
!isAgentEventRetentionActive(eventParent.expiredAt)
) {
eventActorRejection = {
code: 'EVENT_BINDING_PARENT_ENDED',
error: 'The event binding parent is no longer available',
};
}
} catch (error) {
logger.warn('[ResumeAgentController] Event actor fence recheck failed', error);
eventActorRejection = {
code: 'EVENT_ACTOR_NOT_READY',
error: 'The event actor owner is temporarily unavailable',
};
}
if (eventActorRejection != null) {
const currentJob = await GenerationJobManager.getJob(streamId).catch(() => null);
await rollbackUnconsumedScheduleClaim(currentJob);
await releaseScheduleFence();
await decrementPendingRequest(userId);
return sendGenerationJson(res, 409, eventActorRejection, generationProtocolVersion);
}
}
// Atomically claim the resume. The single winner drives the run; a racing second
// submit (double-click, two tabs) gets false and must not re-drive — that would
// re-execute tools and double-bill.
//
// The claim runs AFTER the slot increment above but BEFORE the run's own try/finally
// that releases it, so a store/Redis error here (unlike the clean `!claimed` branch)
// would leak the concurrency slot until the counter TTL expires — spuriously 429'ing
// the user when they retry the still-paused approval. Release the slot on that path too.
let claimed;
try {
/** The CAS that reopens steering must also publish THIS owner's seal
* capability. A separate write after status=`running` leaves a window in
* which steer/arm requests read the previous replica's capability. */
claimed = await GenerationJobManager.approvals.resolve(
streamId,
jobCreatedAt: job.createdAt,
status: 'interrupted',
conversationId,
error: 'Schedule was disabled, changed, or deleted before approval',
});
if (checkpointNamespace !== '') {
await deleteAgentCheckpoint(conversationId, checkpointerCfg, undefined, {
checkpointNamespace,
}).catch((error) => {
logger.warn('[ResumeAgentController] Failed to prune stale schedule checkpoint', error);
});
pendingAction.actionId,
{
preemptCapable: isSteerPreemptSupported(),
providerExecutionId,
providerDrained: true,
...(resolvedAskUserQuestion && { resolvedAskUserQuestions }),
},
job.createdAt,
);
} catch (err) {
const currentJob = await GenerationJobManager.getJob(streamId).catch(() => null);
await rollbackUnconsumedScheduleClaim(currentJob);
await releaseScheduleFence();
await decrementPendingRequest(userId);
logger.error('[ResumeAgentController] Failed to claim resume', getSafeErrorMetadata(err));
return sendGenerationJson(res, 500, { error: 'Failed to resume' }, generationProtocolVersion);
}
if (!claimed) {
await decrementPendingRequest(userId);
const currentJob = await GenerationJobManager.getJob(streamId).catch(() => null);
await rollbackUnconsumedScheduleClaim(currentJob);
await releaseScheduleFence();
if (currentJob != null && currentJob.createdAt !== job.createdAt) {
return sendGenerationJson(res, 409, { code: 'RUN_REPLACED' }, generationProtocolVersion);
}
return sendGenerationJson(
res,
409,
{ code: 'SCHEDULE_NO_LONGER_ACTIVE', error: 'This schedule can no longer be resumed' },
{ error: 'This action was already resolved or has expired' },
generationProtocolVersion,
);
}
// Linearize the consumed approval against the schedule's live config. The schedule
// document fence was acquired only after all async policy reads, and this atomic
// consume checks its token/revision/enabled state immediately after the approval CAS.
// An edit/disable that won first makes this fail; one that lands afterward is ordered
// after the continuation has started. Never begin provider execution on a stale claim.
if (scheduleId) {
let scheduleClaimCurrent = false;
try {
scheduleClaimCurrent = await finalizeScheduleResumeClaim(
scheduleId,
scheduleResumeClaimToken,
scheduleResumeLeaseBy,
scheduleResumeOptions,
);
} catch (error) {
logger.error('[ResumeAgentController] Failed to finalize scheduled resume fence', error);
await releaseScheduleFence();
}
if (!scheduleClaimCurrent) {
await decrementPendingRequest(userId);
let stopped = false;
try {
const abortResult = await GenerationJobManager.abortJob(streamId, {
expectedCreatedAt: job.createdAt,
awaitProviderDrain: true,
});
// Same authoritative gate as the inactive-schedule path above: only a landed
// abort (or an already-terminal, drained generation) may settle this occurrence.
stopped = isStopConfirmed(abortResult);
} catch (error) {
logger.warn('[ResumeAgentController] Failed to stop stale scheduled resume', error);
}
if (!stopped) {
res.set('Retry-After', '1');
return sendGenerationJson(
res,
503,
{
code: 'SCHEDULE_STOP_UNCONFIRMED',
error: 'The stale scheduled resume could not be confirmed stopped.',
},
generationProtocolVersion,
);
}
await recordScheduleOutcome({
scheduleId,
scheduledFor,
streamId,
jobCreatedAt: job.createdAt,
status: 'interrupted',
conversationId,
error: 'Schedule was disabled, changed, or deleted before approval',
});
if (checkpointNamespace !== '') {
await deleteAgentCheckpoint(conversationId, checkpointerCfg, undefined, {
checkpointNamespace,
}).catch((error) => {
logger.warn('[ResumeAgentController] Failed to prune stale schedule checkpoint', error);
});
}
return sendGenerationJson(
res,
409,
{ code: 'SCHEDULE_NO_LONGER_ACTIVE', error: 'This schedule can no longer be resumed' },
generationProtocolVersion,
);
}
}
eventLeaseTransferredToRun = true;
} finally {
if (!eventLeaseTransferredToRun) {
await releaseEventChildLease?.();
releaseEventChildLease = undefined;
}
}
/**
@ -1581,6 +1699,7 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle)
).catch((drainError) => {
logger.warn('[ResumeAgentController] Failed to record provider drain', drainError);
});
await releaseEventChildLease?.();
}
}
};

View file

@ -1,8 +1,17 @@
const { createSubagentThreadTurnGuard } = require('@librechat/api');
const { createSubagentThreadTurnGuard, GenerationJobManager } = require('@librechat/api');
const subagentThreadTaskStore = require('~/server/services/Endpoints/agents/subagentThreadStore');
const db = require('~/models');
module.exports = createSubagentThreadTurnGuard({
getConvo: db.getConvo,
getEventBinding: db.getAgentEventBinding,
isHumanResumeAllowed: async ({ userId, tenantId, conversationId }) => {
const job = await GenerationJobManager.getJob(conversationId);
return (
job?.status === 'requires_action' &&
job.metadata?.userId === userId &&
(job.metadata?.tenantId ?? undefined) === tenantId
);
},
store: subagentThreadTaskStore,
});

View file

@ -1,8 +1,13 @@
const archiveAllHandler = jest.fn();
const generationJobManager = {
getJob: jest.fn().mockResolvedValue(null),
abortJob: jest.fn().mockResolvedValue({ success: true }),
};
const subagentActivityHandlerInputs = [];
module.exports = {
archiveAllHandler,
generationJobManager,
subagentActivityHandlerInputs,
agents: () => ({ sleep: jest.fn() }),
@ -35,6 +40,10 @@ module.exports = {
return archiveAllHandler;
}),
createSubagentThreadViewHandler: jest.fn(() => (_req, res) => res.status(200).json({})),
GenerationJobManager: generationJobManager,
isStopConfirmed: jest.fn(
(result) => result?.success === true || result?.failureReason === 'already_settled',
),
createSubagentActivityStreamHandler: jest.fn((deps, stream) => {
subagentActivityHandlerInputs.push({ deps, stream });
return (_req, res) => res.status(200).end();
@ -73,6 +82,7 @@ module.exports = {
getConvosByCursor: jest.fn(),
getConvo: jest.fn(),
deleteConvos: jest.fn(),
deleteMessages: jest.fn().mockResolvedValue({ deletedCount: 0 }),
saveConvo: jest.fn(),
}),
@ -82,6 +92,7 @@ module.exports = {
getConvosByCursor: jest.fn(),
getConvo: jest.fn(),
deleteConvos: jest.fn(),
deleteMessages: jest.fn().mockResolvedValue({ deletedCount: 0 }),
archiveAllConvos: jest.fn(),
saveConvo: jest.fn(),
setConvoPinned: jest.fn(),

View file

@ -2,7 +2,7 @@ const express = require('express');
const request = require('supertest');
const MOCKS = '../__test-utils__/convos-route-mocks';
const { archiveAllHandler, subagentActivityHandlerInputs } = require(MOCKS);
const { archiveAllHandler, generationJobManager, subagentActivityHandlerInputs } = require(MOCKS);
jest.mock('@librechat/agents', () => require(MOCKS).agents());
jest.mock('@librechat/api', () =>
@ -33,7 +33,13 @@ jest.mock('~/server/services/Endpoints/agents/subagentThreadStore', () =>
describe('Convos Routes', () => {
let app;
let convosRouter;
const { deleteToolCalls, deleteConvos, getConvo, saveConvo } = require('~/models');
const {
deleteToolCalls,
deleteConvos,
deleteMessages,
getConvo,
saveConvo,
} = require('~/models');
const {
deleteAgentCheckpoints,
deleteAllSharedLinksWithCleanup,
@ -73,6 +79,8 @@ describe('Convos Routes', () => {
beforeEach(() => {
jest.clearAllMocks();
generationJobManager.getJob.mockResolvedValue(null);
generationJobManager.abortJob.mockResolvedValue({ success: true });
});
it('binds the activity subscription adapter to the subagent task store', () => {
@ -303,6 +311,31 @@ describe('Convos Routes', () => {
expect(subagentThreadStore.cancelAndDrainForOwner).not.toHaveBeenCalled();
});
it('drains a paused event actor after owner-wide deletion removes its conversation', async () => {
const createdAt = Date.now();
deleteConvos.mockResolvedValue({
deletedCount: 1,
conversationIds: ['paused-event-child'],
});
generationJobManager.getJob.mockImplementation(async (conversationId) =>
conversationId === 'paused-event-child'
? {
metadata: { userId: 'test-user-123' },
status: 'requires_action',
createdAt,
}
: null,
);
const response = await request(app).delete('/api/convos/all');
expect(response.status).toBe(201);
expect(generationJobManager.abortJob).toHaveBeenCalledWith('paused-event-child', {
expectedCreatedAt: createdAt,
awaitProviderDrain: true,
});
});
it('should delete all conversations, tool calls, and shared links for a user', async () => {
const mockDbResponse = {
deletedCount: 5,
@ -322,7 +355,11 @@ describe('Convos Routes', () => {
expect(response.body).toEqual(mockDbResponse);
/** Verify deleteConvos was called with correct userId */
expect(deleteConvos).toHaveBeenCalledWith('test-user-123', {});
expect(deleteConvos).toHaveBeenCalledWith(
'test-user-123',
{},
expect.objectContaining({ beforeDelete: expect.any(Function) }),
);
expect(deleteConvos).toHaveBeenCalledTimes(1);
/** Verify deleteToolCalls was called with correct userId */
@ -501,7 +538,54 @@ describe('Convos Routes', () => {
expect(subagentThreadStore.withOwnerDeletionFence).toHaveBeenCalledTimes(1);
expect(subagentThreadStore.withOwnerDeletionFence.mock.calls[0][0]).toBe('test-user-123');
expect(subagentThreadStore.cancelAndDrainForOwner).not.toHaveBeenCalled();
expect(deleteConvos).toHaveBeenCalledWith('test-user-123', {});
expect(deleteConvos).toHaveBeenCalledWith(
'test-user-123',
{},
expect.objectContaining({ beforeDelete: expect.any(Function) }),
);
});
it('drains a paused event actor after an empty-filter deletion removes it', async () => {
const createdAt = Date.now();
deleteConvos.mockResolvedValue({
deletedCount: 1,
conversationIds: ['paused-event-child'],
});
generationJobManager.getJob.mockImplementation(async (conversationId) =>
conversationId === 'paused-event-child'
? {
metadata: { userId: 'test-user-123' },
status: 'requires_action',
createdAt,
}
: null,
);
const response = await request(app)
.delete('/api/convos')
.send({ arg: { thread_id: 'thread-abc' } });
expect(response.status).toBe(201);
expect(generationJobManager.abortJob).toHaveBeenCalledWith('paused-event-child', {
expectedCreatedAt: createdAt,
awaitProviderDrain: true,
});
});
it('fails closed before checkpoint pruning when generation lookup stays unavailable', async () => {
deleteConvos.mockResolvedValue({
deletedCount: 1,
conversationIds: ['paused-event-child'],
});
generationJobManager.getJob.mockRejectedValue(new Error('generation store unavailable'));
const response = await request(app)
.delete('/api/convos')
.send({ arg: { thread_id: 'thread-abc' } });
expect(response.status).toBe(500);
expect(generationJobManager.getJob).toHaveBeenCalledTimes(3);
expect(deleteAgentCheckpoints).not.toHaveBeenCalled();
});
it('cancels root and descendant leases and cleans every cascaded conversation', async () => {
@ -545,6 +629,117 @@ describe('Convos Routes', () => {
]);
});
it('drains an active child generation and removes persistence that races deletion', async () => {
const createdAt = Date.now();
deleteConvos.mockResolvedValue({
deletedCount: 2,
conversationIds: ['parent-conversation', 'child-conversation'],
});
generationJobManager.getJob.mockImplementation(async (conversationId) =>
conversationId === 'child-conversation'
? { metadata: { userId: 'test-user-123' }, status: 'running', createdAt }
: null,
);
const response = await request(app)
.delete('/api/convos')
.send({ arg: { conversationId: 'parent-conversation' } });
expect(response.status).toBe(201);
expect(generationJobManager.abortJob).toHaveBeenCalledWith('child-conversation', {
expectedCreatedAt: createdAt,
awaitProviderDrain: true,
});
expect(deleteConvos).toHaveBeenNthCalledWith(2, 'test-user-123', {
conversationId: { $in: ['parent-conversation', 'child-conversation'] },
});
expect(deleteMessages).toHaveBeenCalledWith({
user: 'test-user-123',
conversationId: { $in: ['parent-conversation', 'child-conversation'] },
});
});
it('does not prune generation persistence when provider stop is unconfirmed', async () => {
const createdAt = Date.now();
let deletionCommitted = false;
deleteConvos.mockImplementation(async (_userId, _filter, options) => {
await options.beforeDelete(['child-conversation']);
deletionCommitted = true;
return {
deletedCount: 2,
conversationIds: ['parent-conversation', 'child-conversation'],
};
});
generationJobManager.getJob.mockImplementation(async (conversationId) =>
conversationId === 'child-conversation'
? { metadata: { userId: 'test-user-123' }, status: 'running', createdAt }
: null,
);
generationJobManager.abortJob.mockResolvedValue({
success: false,
failureReason: 'job_still_active',
});
const response = await request(app)
.delete('/api/convos')
.send({ arg: { conversationId: 'parent-conversation' } });
expect(response.status).toBe(500);
expect(deleteConvos).toHaveBeenCalledTimes(1);
expect(deletionCommitted).toBe(false);
expect(deleteMessages).not.toHaveBeenCalled();
});
it('drains terminal persistence only for leases removed by this deletion', async () => {
const createdAt = Date.now();
deleteConvos.mockResolvedValue({
deletedCount: 2,
conversationIds: ['parent-conversation', 'child-conversation'],
});
subagentThreadStore.planCancellationForConversations.mockResolvedValueOnce({
userId: 'test-user-123',
conversationIds: ['parent-conversation'],
scopes: [],
leases: [
{
taskId: 'related-generation',
parentConversationId: 'parent-conversation',
conversationId: 'child-conversation',
},
{
taskId: 'unrelated-generation',
parentConversationId: 'other-parent',
conversationId: 'other-child',
},
],
});
let relatedReads = 0;
generationJobManager.getJob.mockImplementation(async (conversationId) => {
if (conversationId !== 'related-generation') return null;
relatedReads += 1;
return {
status: 'complete',
createdAt,
metadata: {
userId: 'test-user-123',
terminalPersistencePending: relatedReads === 1,
},
};
});
const response = await request(app)
.delete('/api/convos')
.send({ arg: { conversationId: 'parent-conversation' } });
expect(response.status).toBe(201);
expect(generationJobManager.abortJob).toHaveBeenCalledWith('related-generation', {
expectedCreatedAt: createdAt,
awaitProviderDrain: true,
});
expect(generationJobManager.getJob).not.toHaveBeenCalledWith('unrelated-generation');
expect(deleteConvos).toHaveBeenCalledTimes(2);
});
it('should delete a single conversation, tool calls, and associated shared links', async () => {
const mockConversationId = 'conv-123';
const mockDbResponse = {
@ -571,9 +766,11 @@ describe('Convos Routes', () => {
expect(response.body).toEqual(mockDbResponse);
/** Verify deleteConvos was called with correct parameters */
expect(deleteConvos).toHaveBeenCalledWith('test-user-123', {
conversationId: mockConversationId,
});
expect(deleteConvos).toHaveBeenCalledWith(
'test-user-123',
{ conversationId: mockConversationId },
expect.objectContaining({ beforeDelete: expect.any(Function) }),
);
/** Verify deleteToolCalls was called */
expect(deleteToolCalls).toHaveBeenCalledWith('test-user-123', mockConversationId);

View file

@ -5,6 +5,8 @@ const mockEnqueueAgentTrigger = jest.fn();
const mockGetAgentTriggerDeliveryStatus = jest.fn();
const mockEnqueueEvent = jest.fn((_req, res) => res.status(202).json({ id: 'trigger-1' }));
const mockGetEvent = jest.fn((_req, res) => res.status(200).json({ status: 'succeeded' }));
const mockRegisterBinding = jest.fn((_req, res) => res.status(201).json({ id: 'evtbind-1' }));
const mockResolveBinding = jest.fn((_req, _res, next) => next());
let mockIngressDependencies;
const mockCreateAgentTriggerIngressHandlers = jest.fn((dependencies) => {
mockIngressDependencies = dependencies;
@ -15,10 +17,21 @@ const mockCreateAgentTriggerIngressHandlers = jest.fn((dependencies) => {
});
jest.mock('@librechat/api', () => ({
createAgentEventBindingHandlers: () => ({
register: mockRegisterBinding,
resolve: mockResolveBinding,
}),
createAgentTriggerIngressHandlers: mockCreateAgentTriggerIngressHandlers,
createMessageFilterPii: () => (_req, _res, next) => next(),
}));
jest.mock('~/models', () => ({
getAgent: jest.fn(),
getConvo: jest.fn(),
getAgentEventBinding: jest.fn(),
reserveSubagentThread: jest.fn(),
}));
jest.mock('~/server/controllers/agents/openai', () => ({
OpenAIChatCompletionController: jest.fn(),
ListModelsController: jest.fn(),
@ -56,6 +69,17 @@ describe('Remote Agents event routes', () => {
beforeEach(() => {
mockEnqueueEvent.mockClear();
mockGetEvent.mockClear();
mockRegisterBinding.mockClear();
mockResolveBinding.mockClear();
});
it('registers a source-bound child actor thread', async () => {
const response = await request(app)
.post('/api/agents/v1/events/bindings')
.send({ target: { agentId: 'agent-1' } });
expect(response.status).toBe(201);
expect(mockRegisterBinding).toHaveBeenCalledTimes(1);
});
it('wires durable event admission to the trigger service', async () => {

View file

@ -19,7 +19,12 @@
* }
*/
const express = require('express');
const { createAgentTriggerIngressHandlers, createMessageFilterPii } = require('@librechat/api');
const {
createAgentEventBindingHandlers,
createAgentTriggerIngressHandlers,
createMessageFilterPii,
isEnabled,
} = require('@librechat/api');
const {
OpenAIChatCompletionController,
ListModelsController,
@ -37,18 +42,40 @@ const {
requireRemoteAgentAuth,
checkRemoteAgentsFeature,
} = require('./middleware');
const db = require('~/models');
const router = express.Router();
const eventHandlers = createAgentTriggerIngressHandlers({
enqueue: enqueueAgentTrigger,
getDeliveryStatus: getAgentTriggerDeliveryStatus,
});
const eventBindingHandlers = createAgentEventBindingHandlers({
getAgent: db.getAgent,
getConvo: db.getConvo,
getBinding: db.getAgentEventBinding,
getMessage: db.getMessage,
deleteConvos: db.deleteConvos,
reserveThread: db.reserveSubagentThread,
enabled: () => isEnabled(process.env.ENABLE_AGENT_EVENT_CHILD_TURNS),
});
router.use(preAuthTenantMiddleware);
router.use(requireRemoteAgentAuth);
router.use(configMiddleware);
router.use(checkRemoteAgentsFeature);
/**
* @route POST /v1/events/bindings
* @desc Bind one authenticated source key to a durable child actor thread
* @access Private (API key auth required)
*/
router.post(
'/events/bindings',
agentEventUserLimiter,
checkAgentTriggerPermission,
eventBindingHandlers.register,
);
/**
* @route POST /v1/events
* @desc Durably deliver a source-neutral event to an agent
@ -58,6 +85,7 @@ router.post(
'/events',
agentEventUserLimiter,
createMessageFilterPii({ getConfig: (req) => req.config?.messageFilter?.pii }),
eventBindingHandlers.resolve,
checkAgentTriggerPermission,
eventHandlers.enqueueEvent,
);

View file

@ -16,6 +16,8 @@ const {
isContentFilterError,
contentFilterBlockResponse,
extractConversationTitleContent,
GenerationJobManager,
isStopConfirmed,
} = require('@librechat/api');
const { logger } = require('@librechat/data-schemas');
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
@ -152,6 +154,24 @@ router.get('/gen_title/:conversationId', async (req, res) => {
const POST_DELETE_CANCEL_ATTEMPTS = 3;
const POST_DELETE_CANCEL_BACKOFF_MS = 250;
const GENERATION_PERSISTENCE_DRAIN_TIMEOUT_MS = 45_000;
const GENERATION_PERSISTENCE_DRAIN_POLL_MS = 100;
const GENERATION_LOOKUP_ATTEMPTS = 3;
async function readGenerationForDeletion(conversationId) {
let lastError;
for (let attempt = 1; attempt <= GENERATION_LOOKUP_ATTEMPTS; attempt += 1) {
try {
return await GenerationJobManager.getJob(conversationId);
} catch (error) {
lastError = error;
if (attempt < GENERATION_LOOKUP_ATTEMPTS) {
await new Promise((resolve) => setTimeout(resolve, 25 * attempt));
}
}
}
throw lastError;
}
/** Replays a cancellation plan after deletion, retrying a transiently unreachable
* owner rather than losing the only pass that can stop a late-admitted child. */
@ -170,6 +190,92 @@ async function retryPostDeleteCancellation(cancellationPlan, deletedConversation
}
}
/** Confirms every exact generation is stopped before its conversation wave is removed. */
async function confirmAgentGenerationsDrained(userId, conversationIds, leaseTaskIds = []) {
let foundActiveGeneration = false;
const drainErrors = [];
const generationIds = [...new Set([...conversationIds, ...leaseTaskIds])];
await Promise.all(
generationIds.map(async (conversationId) => {
let job;
try {
job = await readGenerationForDeletion(conversationId);
} catch (error) {
logger.warn('Deleted child generation lookup failed', error);
foundActiveGeneration = true;
drainErrors.push(error);
return;
}
if (job == null || job.metadata?.userId !== userId) {
return;
}
const needsDrain =
job.status === 'running' ||
job.status === 'requires_action' ||
job.metadata?.terminalPersistencePending === true;
if (!needsDrain) return;
foundActiveGeneration = true;
try {
const abortResult = await GenerationJobManager.abortJob(conversationId, {
expectedCreatedAt: job.createdAt,
awaitProviderDrain: true,
});
if (!isStopConfirmed(abortResult)) {
throw new Error(
`Could not confirm generation stop for ${conversationId}: ${abortResult?.failureReason ?? 'unknown'}`,
);
}
const deadline = Date.now() + GENERATION_PERSISTENCE_DRAIN_TIMEOUT_MS;
while (true) {
const current = await GenerationJobManager.getJob(conversationId);
if (
current == null ||
current.createdAt !== job.createdAt ||
current.metadata?.terminalPersistencePending !== true
) {
break;
}
if (Date.now() >= deadline) {
throw new Error(`Timed out waiting for generation persistence: ${conversationId}`);
}
await new Promise((resolve) => setTimeout(resolve, GENERATION_PERSISTENCE_DRAIN_POLL_MS));
}
} catch (error) {
logger.warn('Deleted child generation drain failed', error);
drainErrors.push(error);
}
}),
);
if (!foundActiveGeneration) {
return false;
}
if (drainErrors.length > 0) {
throw new Error('One or more deleted child generations could not be confirmed drained.');
}
return true;
}
/** Stops event-bound child generations on their owning replica and then removes
* persistence that raced the first conversation cascade. */
async function drainDeletedAgentGenerations(userId, conversationIds, leaseTaskIds = []) {
const foundActiveGeneration = await confirmAgentGenerationsDrained(
userId,
conversationIds,
leaseTaskIds,
);
if (!foundActiveGeneration) {
return;
}
try {
await db.deleteConvos(userId, { conversationId: { $in: conversationIds } });
} catch {
// Expected when no generation raced the first cascade.
}
await db
.deleteMessages({ user: userId, conversationId: { $in: conversationIds } })
.catch((error) => logger.warn('Deleted child message remnant cleanup failed', error));
}
router.delete('/', configMiddleware, async (req, res) => {
let filter = {};
const { conversationId, source, thread_id, endpoint } = req.body?.arg ?? {};
@ -217,12 +323,18 @@ router.delete('/', configMiddleware, async (req, res) => {
tenantId,
);
await subagentThreadTaskStore.cancelPlan(cancellationPlan);
dbResponse = await db.deleteConvos(req.user.id, filter);
dbResponse = await db.deleteConvos(req.user.id, filter, {
beforeDelete: (conversationIds) =>
confirmAgentGenerationsDrained(req.user.id, conversationIds),
});
} else {
/** An empty filter deletes every conversation this owner has, so it runs behind
* the same admission fence as `DELETE /all` rather than a bare drain. */
dbResponse = await subagentThreadTaskStore.withOwnerDeletionFence(req.user.id, tenantId, () =>
db.deleteConvos(req.user.id, filter),
db.deleteConvos(req.user.id, filter, {
beforeDelete: (conversationIds) =>
confirmAgentGenerationsDrained(req.user.id, conversationIds),
}),
);
}
const deletedConversationIds =
@ -235,6 +347,23 @@ router.delete('/', configMiddleware, async (req, res) => {
* stop a child admitted after the first one. It cannot fail the request the
* deletion already committed so it retries briefly before giving up. */
await retryPostDeleteCancellation(cancellationPlan, deletedConversationIds);
await drainDeletedAgentGenerations(
req.user.id,
deletedConversationIds,
cancellationPlan.leases
.filter(
(lease) =>
deletedConversationIds.includes(lease.parentConversationId) ||
deletedConversationIds.includes(lease.conversationId),
)
.map((lease) => lease.taskId),
);
} else if (deletedConversationIds.length > 0) {
/** Owner-wide deletion drains lease-backed tasks before the cascade, but a
* requires_action event actor has intentionally released its lease. Its durable
* generation is still addressable by the deleted conversation id and must be
* terminalized before its checkpoint is pruned. */
await drainDeletedAgentGenerations(req.user.id, deletedConversationIds);
}
// HITL: prune the deleted conversations' durable checkpoints — a paused run's
// checkpoint would otherwise persist until the Mongo TTL. Never throws.
@ -267,8 +396,17 @@ router.delete('/all', configMiddleware, async (req, res) => {
const dbResponse = await subagentThreadTaskStore.withOwnerDeletionFence(
req.user.id,
tenantId,
() => db.deleteConvos(req.user.id, {}),
() =>
db.deleteConvos(
req.user.id,
{},
{
beforeDelete: (conversationIds) =>
confirmAgentGenerationsDrained(req.user.id, conversationIds),
},
),
);
await drainDeletedAgentGenerations(req.user.id, dbResponse.conversationIds ?? []);
// HITL: prune ALL the deleted conversations' durable checkpoints in one bulk pass.
await deleteAgentCheckpoints(
dbResponse.conversationIds,

View file

@ -1,16 +1,25 @@
const {
createAgentTriggerService,
createAgentEventContinueResolver,
createSubagentCompletionWakeupResolver,
GenerationJobManager,
isEnabled,
} = require('@librechat/api');
const methods = require('~/models');
const completionResolver = createSubagentCompletionWakeupResolver({
methods,
getGenerationJob: (conversationId) => GenerationJobManager.getJob(conversationId),
});
const service = createAgentTriggerService({
methods,
isPrincipalActive: methods.isAgentTriggerPrincipalActive,
prepareContinue: createSubagentCompletionWakeupResolver({
prepareContinue: createAgentEventContinueResolver({
methods,
getGenerationJob: (conversationId) => GenerationJobManager.getJob(conversationId),
fallback: completionResolver,
enabled: () => isEnabled(process.env.ENABLE_AGENT_EVENT_CHILD_TURNS),
}),
});

View file

@ -0,0 +1,24 @@
const librechatApi = require('@librechat/api');
const { GenerationJobManager } = librechatApi;
const {
acquireSubagentThreadLease,
renewSubagentThreadLease,
releaseSubagentThreadLease,
} = require('~/models');
let acquireLease;
function acquireEventChildGenerationLease(input) {
acquireLease ??= librechatApi.createEventChildGenerationLeaseAcquirer({
methods: {
acquireSubagentThreadLease,
renewSubagentThreadLease,
releaseSubagentThreadLease,
},
abortGeneration: (streamId, options) => GenerationJobManager.abortJob(streamId, options),
});
return acquireLease(input);
}
module.exports = { acquireEventChildGenerationLease };

View file

@ -6,6 +6,7 @@ const {
duplicateIoRedisClient,
createSubagentThreadTaskStore,
createSubagentCompletionWakeupHandler,
GenerationJobManager,
RedisSubagentTaskControlTransport,
RedisEventTransport,
SubagentActivityStream,
@ -17,6 +18,40 @@ const { enqueueAgentTrigger } = require('../../Agents/triggers');
* permanently reject the new `continue` envelope. Enable only after every API
* replica runs a release that understands completion wakeups. */
const completionWakeupsEnabled = isEnabled(process.env.ENABLE_SUBAGENT_COMPLETION_WAKEUPS);
const GENERATION_DRAIN_TIMEOUT_MS = 45_000;
const GENERATION_DRAIN_POLL_MS = 100;
async function cancelUnroutedGeneration({ userId, tenantId, taskId }) {
let job = await GenerationJobManager.getJob(taskId);
if (
job == null ||
job.metadata?.userId !== userId ||
(job.metadata?.tenantId ?? undefined) !== tenantId
) {
return false;
}
await GenerationJobManager.abortJob(taskId, {
expectedCreatedAt: job.createdAt,
awaitProviderDrain: true,
});
const deadline = Date.now() + GENERATION_DRAIN_TIMEOUT_MS;
while (true) {
job = await GenerationJobManager.getJob(taskId);
if (job == null || job.metadata?.terminalPersistencePending !== true) {
return (
job == null ||
(job.metadata?.userId === userId &&
(job.metadata?.tenantId ?? undefined) === tenantId &&
job.status !== 'running' &&
job.status !== 'requires_action')
);
}
if (Date.now() >= deadline) {
return false;
}
await new Promise((resolve) => setTimeout(resolve, GENERATION_DRAIN_POLL_MS));
}
}
/** Durable logical threads use normal LibreChat conversations/messages. Mongo
* fences continuation; optional Redis routing reaches the live owning process. */
@ -42,6 +77,7 @@ const subagentThreadTaskStore = createSubagentThreadTaskStore(
fenceOwnerAdmission: db.fenceSubagentAdmission,
renewOwnerAdmission: db.renewSubagentAdmission,
releaseOwnerAdmission: db.releaseSubagentAdmission,
cancelUnroutedTask: cancelUnroutedGeneration,
...(completionWakeupsEnabled && {
onTaskPrepared: createSubagentCompletionWakeupHandler(enqueueAgentTrigger),
}),

View file

@ -11,6 +11,18 @@ const getRetentionDependencies = () => ({
logger,
});
/** Event-bound actors inherit the binding's server-authenticated deadline. Never
* extend files beyond the child conversation that makes them addressable. */
function getEventBindingRetention(req) {
const retention = req?._agentEventBindingRetention;
if (retention?.expiredAt == null) {
return null;
}
const expiredAt =
retention.expiredAt instanceof Date ? retention.expiredAt : new Date(retention.expiredAt);
return Number.isNaN(expiredAt.getTime()) ? null : { expiredAt };
}
/**
* Returns `{ expiredAt }` when the request indicates data retention applies, otherwise `{}`.
* Spread into file data objects before calling createFile.
@ -18,6 +30,10 @@ const getRetentionDependencies = () => ({
* @returns {Promise<{ expiredAt?: Date | null }>}
*/
async function getRetentionExpiry(req) {
const inherited = getEventBindingRetention(req);
if (inherited != null) {
return inherited;
}
return getRetentionExpiryWithDeps(req, getRetentionDependencies());
}
@ -31,6 +47,10 @@ async function getRetentionExpiry(req) {
* @returns {Promise<{ expiredAt?: Date | null }>}
*/
async function getAgentFileRetentionExpiry({ tool_resource, toolResource, ...params }) {
const inherited = getEventBindingRetention(params.req);
if (inherited != null) {
return inherited;
}
return getAgentFileRetentionExpiryWithDeps(
{ ...params, toolResource: tool_resource ?? toolResource },
getRetentionDependencies(),

View file

@ -0,0 +1,33 @@
const mockGetRetentionExpiry = jest.fn();
const mockGetAgentFileRetentionExpiry = jest.fn();
jest.mock('@librechat/api', () => ({
getRetentionExpiry: (...args) => mockGetRetentionExpiry(...args),
getAgentFileRetentionExpiry: (...args) => mockGetAgentFileRetentionExpiry(...args),
}));
jest.mock('@librechat/data-schemas', () => ({
logger: {},
createTempChatExpirationDate: jest.fn(),
}));
jest.mock('~/models', () => ({ getConvo: jest.fn() }));
const { getRetentionExpiry, getAgentFileRetentionExpiry } = require('./retention');
describe('event-bound file retention', () => {
const expiredAt = new Date('2026-08-22T12:00:00.000Z');
const req = { _agentEventBindingRetention: { isTemporary: false, expiredAt } };
beforeEach(() => jest.clearAllMocks());
it('uses the trusted binding deadline for generated files', async () => {
await expect(getRetentionExpiry(req)).resolves.toEqual({ expiredAt });
expect(mockGetRetentionExpiry).not.toHaveBeenCalled();
});
it('uses the same binding deadline for agent resource files', async () => {
await expect(
getAgentFileRetentionExpiry({ req, tool_resource: 'execute_code' }),
).resolves.toEqual({ expiredAt });
expect(mockGetAgentFileRetentionExpiry).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,9 @@
/** A missing deadline is durable; an invalid or elapsed deadline is never active. */
export function isAgentEventRetentionActive(expiredAt: unknown, now: number = Date.now()): boolean {
if (expiredAt == null) {
return true;
}
const deadline =
expiredAt instanceof Date ? expiredAt.getTime() : new Date(String(expiredAt)).getTime();
return Number.isFinite(deadline) && deadline > now;
}

View file

@ -20,6 +20,7 @@ function childConversation(): IConversation {
parentConversationId: 'parent-conversation',
parentMessageId: 'parent-message',
parentToolCallId: 'parent-tool-call',
parentAgentId: 'parent-agent',
subagentType: 'child-agent',
subagentKind: 'agent',
depth: 1,
@ -46,20 +47,43 @@ function makeStore(): SubagentThreadTaskStore {
});
}
function createApp(getConvo: AllMethods['getConvo'], store: SubagentThreadTaskStore) {
function createApp(
getConvo: AllMethods['getConvo'],
store: SubagentThreadTaskStore,
getEventBinding?: AllMethods['getAgentEventBinding'],
isHumanResumeAllowed?: () => Promise<boolean>,
) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
req.user = { id: 'user-1', tenantId: 'tenant-1' };
(req as typeof req & { _isAgentTrigger?: boolean })._isAgentTrigger =
req.get('x-test-trigger') === '1';
next();
});
app.post('/chat', createSubagentThreadTurnGuard({ getConvo, store }), (req, res) => {
const guard = createSubagentThreadTurnGuard({
getConvo,
store,
getEventBinding,
isHumanResumeAllowed,
});
const handler = (req: express.Request, res: express.Response) => {
res.json({
ok: true,
resolvedConversationId: (req as typeof req & { resolvedConversation?: IConversation | null })
.resolvedConversation?.conversationId,
parentConversationId: (
req as typeof req & { _agentEventBindingParentConversationId?: string }
)._agentEventBindingParentConversationId,
retention: (
req as typeof req & {
_agentEventBindingRetention?: { isTemporary?: boolean; expiredAt?: Date };
}
)._agentEventBindingRetention,
});
});
};
app.post('/chat', guard, handler);
app.post('/resume', guard, handler);
return app;
}
@ -98,6 +122,145 @@ describe('subagent child-thread write policy', () => {
expect(response.body).toEqual({ error: CHILD_THREAD_READ_ONLY_ERROR });
});
it('allows only the authenticated trigger bound to this child conversation', async () => {
const store = makeStore();
const getEventBinding = jest.fn(async () => ({
conversationId: 'child-conversation',
agentId: 'child-agent',
tenantId: 'tenant-1',
isTemporary: true,
expiredAt: new Date('2099-08-22T00:00:00.000Z'),
binding: {
bindingId: `evtbind_${'a'.repeat(48)}`,
sourceKeyId: 'source-key',
actorId: 'player',
},
lineage: childConversation().subagentThread!,
}));
const app = createApp(
jest.fn(async (_user, conversationId) =>
conversationId === 'parent-conversation'
? ({
conversationId,
agent_id: 'parent-agent',
tenantId: 'tenant-1',
} as IConversation)
: childConversation(),
),
store,
getEventBinding as AllMethods['getAgentEventBinding'],
);
const response = await request(app)
.post('/chat')
.set('x-test-trigger', '1')
.set('x-lc-agent-event-binding', `evtbind_${'a'.repeat(48)}`)
.set('x-lc-agent-event-source-key', 'source-key')
.send({ conversationId: 'child-conversation' });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
parentConversationId: 'parent-conversation',
retention: { isTemporary: true, expiredAt: '2099-08-22T00:00:00.000Z' },
});
expect(getEventBinding).toHaveBeenCalledWith({
user: 'user-1',
tenantId: 'tenant-1',
bindingId: `evtbind_${'a'.repeat(48)}`,
sourceKeyId: 'source-key',
});
});
it('allows only an exact pending human resume for a bound child', async () => {
const store = makeStore();
const reservedThreadId = createSubagentThreadId('scope', 'bound-child');
const boundChild = {
...childConversation(),
conversationId: reservedThreadId,
tenantId: 'tenant-1',
isTemporary: true,
agentEventBinding: {
bindingId: `evtbind_${'b'.repeat(48)}`,
sourceKeyId: 'source-key',
actorId: 'player',
},
} as unknown as IConversation;
const isHumanResumeAllowed = jest.fn(async () => true);
const app = createApp(
jest.fn(async (_user, conversationId) =>
conversationId === 'parent-conversation'
? ({ conversationId, agent_id: 'parent-agent', tenantId: 'tenant-1' } as IConversation)
: boundChild,
),
store,
undefined,
isHumanResumeAllowed,
);
const response = await request(app)
.post('/resume')
.send({ conversationId: reservedThreadId, actionId: 'action-1' });
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
resolvedConversationId: reservedThreadId,
parentConversationId: 'parent-conversation',
retention: { isTemporary: true },
});
expect(isHumanResumeAllowed).toHaveBeenCalledWith({
userId: 'user-1',
tenantId: 'tenant-1',
conversationId: reservedThreadId,
});
});
it('rejects trigger continuations and human resumes after binding retention expires', async () => {
const store = makeStore();
const expiredChild = {
...childConversation(),
tenantId: 'tenant-1',
expiredAt: new Date(0),
agentEventBinding: {
bindingId: `evtbind_${'c'.repeat(48)}`,
sourceKeyId: 'source-key',
actorId: 'player',
},
} as unknown as IConversation;
const getEventBinding = jest.fn(async () => ({
conversationId: expiredChild.conversationId,
agentId: expiredChild.agent_id,
tenantId: 'tenant-1',
expiredAt: expiredChild.expiredAt,
binding: expiredChild.agentEventBinding,
lineage: expiredChild.subagentThread!,
}));
const getConvo = jest.fn(async (_user, conversationId) =>
conversationId === 'parent-conversation'
? ({ conversationId, agent_id: 'parent-agent', tenantId: 'tenant-1' } as IConversation)
: expiredChild,
);
const isHumanResumeAllowed = jest.fn(async () => true);
const testApp = createApp(
getConvo,
store,
getEventBinding as AllMethods['getAgentEventBinding'],
isHumanResumeAllowed,
);
const trigger = await request(testApp)
.post('/chat')
.set('x-test-trigger', '1')
.set('x-lc-agent-event-binding', `evtbind_${'c'.repeat(48)}`)
.set('x-lc-agent-event-source-key', 'source-key')
.send({ conversationId: 'child-conversation' });
const human = await request(testApp)
.post('/resume')
.send({ conversationId: 'child-conversation', actionId: 'action-1' });
expect(trigger.status).toBe(409);
expect(human.status).toBe(409);
});
it('rejects a provisional child before its conversation becomes durable', async () => {
const store = makeStore();
jest.spyOn(store, 'isThreadActiveForOwner').mockReturnValue(true);
@ -128,7 +291,7 @@ describe('subagent child-thread write policy', () => {
expect(response.status).toBe(409);
expect(response.body).toEqual({ error: CHILD_THREAD_READ_ONLY_ERROR });
expect(getConvo).not.toHaveBeenCalled();
expect(getConvo).toHaveBeenCalledWith('user-1', reservedThreadId);
});
it('keeps the shared policy owner-scoped and treats child lineage as immutable', async () => {

View file

@ -3,6 +3,7 @@ import type { ConversationMethods, IConversation } from '@librechat/data-schemas
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import type { SubagentThreadTaskStore } from './subagentThreads';
import { isReservedSubagentThreadId } from './subagentThreadIds';
import { isAgentEventRetentionActive } from './eventRetention';
export const CHILD_THREAD_READ_ONLY_ERROR =
'This subagent thread is view-only. Continue it from its parent agent or create a separate chat.';
@ -20,6 +21,8 @@ interface SubagentTurnUser {
export interface SubagentThreadWriteGuardDeps {
getConvo: ConversationMethods['getConvo'];
getEventBinding?: ConversationMethods['getAgentEventBinding'];
isHumanResumeAllowed?: (target: SubagentThreadWriteTarget) => Promise<boolean>;
store: SubagentThreadTaskStore;
}
@ -36,6 +39,75 @@ interface SubagentThreadWriteResolution {
interface ResolvedConversationRequest extends Request {
resolvedConversation?: IConversation | null;
_isAgentTrigger?: boolean;
_agentEventBindingRetention?: {
isTemporary?: boolean;
expiredAt?: Date;
};
_agentEventBindingParentConversationId?: string;
_agentEventBindingParentAgentId?: string;
_agentEventBindingTenantId?: string;
}
function applyEventBindingContext(
request: ResolvedConversationRequest,
conversation: IConversation,
): void {
request.resolvedConversation = conversation;
request._agentEventBindingRetention = {
...(conversation.isTemporary == null ? {} : { isTemporary: conversation.isTemporary }),
...(conversation.expiredAt == null ? {} : { expiredAt: conversation.expiredAt }),
};
request._agentEventBindingParentConversationId =
conversation.subagentThread?.parentConversationId;
request._agentEventBindingParentAgentId = conversation.subagentThread?.parentAgentId;
request._agentEventBindingTenantId = conversation.tenantId;
}
async function isBoundEventContinuation(
deps: SubagentThreadWriteGuardDeps,
request: ResolvedConversationRequest,
target: SubagentThreadWriteTarget,
): Promise<IConversation | null> {
if (request._isAgentTrigger !== true || deps.getEventBinding == null) {
return null;
}
const bindingId = request.get('x-lc-agent-event-binding');
const sourceKeyId = request.get('x-lc-agent-event-source-key');
if (bindingId == null || sourceKeyId == null) {
return null;
}
const binding = await deps.getEventBinding({
user: target.userId,
bindingId,
sourceKeyId,
...(target.tenantId == null ? {} : { tenantId: target.tenantId }),
});
if (
binding?.conversationId !== target.conversationId ||
!isAgentEventRetentionActive(binding?.expiredAt)
) {
return null;
}
const parent = await deps.getConvo(target.userId, binding.lineage.parentConversationId);
if (
parent == null ||
parent.subagentThread != null ||
parent.agent_id !== binding.lineage.parentAgentId ||
(parent.tenantId ?? undefined) !== target.tenantId ||
!isAgentEventRetentionActive(parent.expiredAt)
) {
return null;
}
return {
conversationId: binding.conversationId,
agent_id: binding.agentId,
...(binding.tenantId == null ? {} : { tenantId: binding.tenantId }),
...(binding.isTemporary == null ? {} : { isTemporary: binding.isTemporary }),
...(binding.expiredAt == null ? {} : { expiredAt: binding.expiredAt }),
agentEventBinding: binding.binding,
subagentThread: binding.lineage,
} as unknown as IConversation;
}
async function resolveSubagentThreadWrite(
@ -45,7 +117,8 @@ async function resolveSubagentThreadWrite(
/** New child IDs are returned synchronously by the SDK before Mongo creation can
* finish. Their reserved UUID namespace closes that brief window on every replica. */
if (isReservedSubagentThreadId(conversationId)) {
return { blocked: true };
const conversation = await getConvo(userId, conversationId);
return { blocked: true, conversation };
}
if (store.isThreadActiveForOwner(userId, conversationId, tenantId)) {
return { blocked: true };
@ -98,6 +171,49 @@ export function createSubagentThreadTurnGuard(deps: SubagentThreadWriteGuardDeps
next();
return;
}
const resolvedRequest = request as ResolvedConversationRequest;
const resolvedConversation = resolved.conversation;
const lineage = resolvedConversation?.subagentThread;
const humanResume = deps.isHumanResumeAllowed;
if (
request.path === '/resume' &&
resolvedConversation?.agentEventBinding != null &&
lineage != null &&
isAgentEventRetentionActive(resolvedConversation.expiredAt) &&
humanResume != null &&
(await humanResume({
userId,
conversationId: candidateConversationId,
...(tenantId == null ? {} : { tenantId }),
}))
) {
const parent = await deps.getConvo(userId, lineage.parentConversationId);
if (
parent != null &&
parent.subagentThread == null &&
parent.agent_id === lineage.parentAgentId &&
(parent.tenantId ?? undefined) === tenantId &&
isAgentEventRetentionActive(parent.expiredAt)
) {
applyEventBindingContext(resolvedRequest, resolvedConversation);
next();
return;
}
}
const boundConversation = await isBoundEventContinuation(
deps,
request as ResolvedConversationRequest,
{
userId,
conversationId: candidateConversationId,
...(tenantId == null ? {} : { tenantId }),
},
);
if (boundConversation != null) {
applyEventBindingContext(resolvedRequest, boundConversation);
next();
return;
}
res.status(409).json({ error: CHILD_THREAD_READ_ONLY_ERROR });
} catch (error) {
next(error);

View file

@ -11,6 +11,7 @@ export * from './conversation';
export * from './discovery';
export * from './edges';
export * from './errors';
export * from './eventRetention';
export * from './envelope';
export * from './execution';
export * from './handlers';

View file

@ -2925,6 +2925,62 @@ describe('SubagentThreadTaskStore', () => {
]);
});
it('delegates an owner drain for host work that is not in the task store', async () => {
const userId = 'host-generation-drain-user';
const parentConversationId = randomUUID();
const conversationId = randomUUID();
const taskId = randomUUID();
const token = randomUUID();
await saveParent(userId, parentConversationId);
await methods.saveConvo(
{ userId },
{
conversationId,
endpoint: EModelEndpoint.agents,
title: 'Event actor',
agent_id: 'child-agent',
subagentThread: {
rootConversationId: parentConversationId,
parentConversationId,
parentMessageId: 'parent-message',
parentToolCallId: 'event-binding',
parentAgentId: 'parent-agent',
subagentType: 'child-agent',
subagentKind: 'agent',
depth: 1,
},
},
);
await methods.acquireSubagentThreadLease({
user: userId,
conversationId,
taskId,
token,
now: new Date(),
expiresAt: new Date(Date.now() + 30_000),
});
const cancelUnroutedTask = jest.fn(async () => {
await methods.releaseSubagentThreadLease({ user: userId, conversationId, token });
return true;
});
const deletingStore = new SubagentThreadTaskStore(methods, {
cancelUnroutedTask,
ownerDrainPollMs: 1,
});
await deletingStore.cancelAndDrainForOwner(userId);
expect(cancelUnroutedTask).toHaveBeenCalledWith({
userId,
parentConversationId,
taskId,
tenantId: undefined,
});
expect(await methods.countActiveSubagentThreadLeases({ user: userId, now: new Date() })).toBe(
0,
);
});
it('bounds durable delegation depth to one by default', async () => {
const userId = 'depth-user';
const rootConversationId = randomUUID();

View file

@ -192,6 +192,14 @@ export interface SubagentThreadTaskStoreOptions extends InMemorySubagentTaskStor
fenceOwnerAdmission?: (userId: string, token: string, fencedUntil: Date) => Promise<void>;
renewOwnerAdmission?: (userId: string, token: string, fencedUntil: Date) => Promise<boolean>;
releaseOwnerAdmission?: (userId: string, token: string) => Promise<void>;
/** Host-owned work may share the durable child lease protocol without living in
* this in-memory task store. Return true only after that work is stopped. */
cancelUnroutedTask?: (target: {
userId: string;
parentConversationId: string;
taskId: string;
tenantId?: string;
}) => Promise<boolean>;
onTaskPrepared?: (registration: SubagentTaskWakeupRegistration) => Promise<void> | void;
}
@ -473,6 +481,7 @@ export class SubagentThreadTaskStore extends InMemorySubagentTaskStore {
) => Promise<boolean>;
private readonly releaseOwnerAdmission?: (userId: string, token: string) => Promise<void>;
private readonly cancelUnroutedTask?: SubagentThreadTaskStoreOptions['cancelUnroutedTask'];
private readonly onTaskPrepared?: SubagentThreadTaskStoreOptions['onTaskPrepared'];
private taskControlTransport?: SubagentTaskControlTransport;
private activityStream = new SubagentActivityStream(new InMemoryEventTransport());
@ -506,6 +515,7 @@ export class SubagentThreadTaskStore extends InMemorySubagentTaskStore {
this.fenceOwnerAdmission = options.fenceOwnerAdmission;
this.renewOwnerAdmission = options.renewOwnerAdmission;
this.releaseOwnerAdmission = options.releaseOwnerAdmission;
this.cancelUnroutedTask = options.cancelUnroutedTask;
this.onTaskPrepared = options.onTaskPrepared;
}
@ -1513,6 +1523,11 @@ export class SubagentThreadTaskStore extends InMemorySubagentTaskStore {
* an unconfirmed delivery, retried once the owner republishes itself. */
if (result.status === 'cancelled' || result.status === 'not_running') {
answered.add(key);
} else if (result.status === 'not_found' && this.cancelUnroutedTask != null) {
const stopped = await this.cancelUnroutedTask(target);
if (stopped) {
answered.add(key);
}
}
} catch (error) {
logger.warn('[subagentThreads] Retrying an unconfirmed child cancellation', error);

View file

@ -14,6 +14,8 @@ envelope and calls `enqueueAgentTrigger`; the adapter does not invoke an agent r
- Use `continue` only with a persisted `conversationId` and exact `parentMessageId`. The host defers
that delivery while the parent generation is still running or paused, so it cannot replace the
generation it is meant to follow.
- External sources never supply a child `conversationId`, `parentMessageId`, or `agentId` on a
continue delivery. Register an event binding once, then address only its opaque binding id.
- Use `orderingKey` only when deliveries must remain ordered across different event sources.
Without an override, ordering is scoped to the user, source, mode, agent, and conversation.
@ -105,3 +107,56 @@ Poll that location to read `pending`, `leased`, `succeeded`, or `dead` state. Su
results include the conversation and generation identity needed for a later `steer` event. Status
responses never expose the stored source payload, ordering key, retry history, or worker identity.
Callers must sanitize `event.payload`; credentials and transport secrets must not be persisted.
### Event-driven child actors
Register a direct child agent once under the same Remote Agents API key that will deliver events.
The parent must be an ordinary agent conversation, and the target must be enabled in that parent
agent's direct `subagents.agent_ids` list (or be an allowed self-spawn). The reserved child
conversation is hidden from conversation lists and remains read-only to human chat routes.
`ENABLE_AGENT_EVENT_CHILD_TURNS` defaults to false; enable it only after every API replica runs a
release that understands bound child continuations, otherwise an older worker could permanently
reject a new envelope during a rolling deployment.
```http
POST /api/agents/v1/events/bindings
Authorization: Bearer <remote-agents-api-key>
Idempotency-Key: championship-7-player-hanae
Content-Type: application/json
{
"actorId": "hanae-kobayashi",
"parentConversationId": "director-conversation-id",
"parentMessageId": "director-message-id",
"target": { "agentId": "agent-hanae" }
}
```
The response contains an opaque `id` and the child `threadId`. Store the binding id with the
source actor. Deliver every later turn with a source-stable event id and the same API key:
```http
POST /api/agents/v1/events
Authorization: Bearer <remote-agents-api-key>
Idempotency-Key: game-12-ply-17-hanae
Content-Type: application/json
{
"mode": "continue",
"bindingId": "evtbind_…",
"event": {
"id": "game-12-ply-17",
"type": "chess.turn.ready",
"occurredAt": 1786968000000,
"source": { "id": "speed-chess", "type": "mcp" },
"payload": { "gameId": "game-12", "expectedPly": 17 }
},
"input": "Your clock is running. Read the position and submit one legal move."
}
```
LibreChat resolves the bound agent and child conversation from `(user, tenant, API key, binding)`;
caller-supplied target fields are discarded. It also resolves the latest assistant branch leaf
immediately before dispatch, so queued events do not persist stale chat topology. Each actor binding
is its default ordering lane. A short-lived internal trigger token plus a second binding lookup is
required to pass the child-thread write guard; possessing a binding id alone grants no access.

View file

@ -0,0 +1,200 @@
import { AIMessage } from '@langchain/core/messages';
import type { IConversation } from '@librechat/data-schemas';
import { createAgentTriggerEnvelope, type AgentContinueTriggerEnvelope } from './envelope';
import { createAgentEventContinueResolver } from './bindingResolver';
const bindingId = `evtbind_${'a'.repeat(48)}`;
const sourceKeyId = '507f191e810c19729de860eb';
function envelope(): AgentContinueTriggerEnvelope {
return createAgentTriggerEnvelope({
mode: 'continue',
requestId: 'request-1',
deliveryId: 'delivery-1',
receivedAt: 1,
principal: { id: 'user-1', tenantId: 'tenant-1' },
event: {
id: 'event-1',
type: 'chess.turn.ready',
occurredAt: 1,
source: { id: 'chess', type: 'webhook' },
},
input: 'Your turn.',
target: {
agentId: 'agent-player',
conversationId: 'child-thread',
parentMessageId: 'placeholder',
bindingId,
sourceKeyId,
},
}) as AgentContinueTriggerEnvelope;
}
describe('agent event continuation resolver', () => {
it('defers without consuming attempts while the rollout gate is disabled', async () => {
const resolver = createAgentEventContinueResolver({
enabled: () => false,
methods: {
getAgentEventBinding: jest.fn(),
getConvo: jest.fn(),
getMessages: jest.fn(),
} as never,
});
await expect(
resolver(envelope(), { idempotencyKey: 'trigger-1' } as never),
).rejects.toMatchObject({
code: 'EVENT_BINDING_DISABLED',
retryable: true,
deferWithoutAttempt: true,
});
});
it('re-resolves the latest assistant leaf immediately before dispatch', async () => {
const getMessages = jest.fn(async () => [
Object.assign(new AIMessage('done'), {
messageId: 'assistant-1',
isCreatedByUser: false,
createdAt: new Date(2),
}),
]) as never;
const resolver = createAgentEventContinueResolver({
enabled: () => true,
methods: {
getAgentEventBinding: jest.fn(async () => ({
conversationId: 'child-thread',
agentId: 'agent-player',
tenantId: 'tenant-1',
binding: { bindingId, sourceKeyId, actorId: 'player' },
lineage: {
parentConversationId: 'parent-thread',
parentAgentId: 'agent-director',
} as never,
})),
getConvo: jest.fn(
async () =>
({
conversationId: 'parent-thread',
agent_id: 'agent-director',
tenantId: 'tenant-1',
}) as IConversation,
),
getMessages,
},
});
await expect(resolver(envelope(), { idempotencyKey: 'trigger-1' } as never)).resolves.toEqual({
status: 'ready',
input: 'Your turn.',
parentMessageId: 'assistant-1',
});
expect(getMessages).toHaveBeenCalledWith(
{ user: 'user-1', conversationId: 'child-thread', isCreatedByUser: false },
'messageId createdAt',
{ sort: { createdAt: -1, _id: -1 }, limit: 1 },
);
});
it('fails closed when the durable binding target changed', async () => {
const resolver = createAgentEventContinueResolver({
enabled: () => true,
methods: {
getAgentEventBinding: jest.fn(async () => ({
conversationId: 'another-thread',
agentId: 'agent-player',
binding: { bindingId, sourceKeyId, actorId: 'player' },
lineage: {} as never,
})),
getConvo: jest.fn(),
getMessages: jest.fn(async () => []) as never,
},
});
await expect(
resolver(envelope(), { idempotencyKey: 'trigger-1' } as never),
).rejects.toMatchObject({ code: 'EVENT_BINDING_INVALID', retryable: false });
});
it('defers an event while the actor has an active generation', async () => {
const resolver = createAgentEventContinueResolver({
enabled: () => true,
getGenerationJob: jest.fn(async () => ({ status: 'running' })),
methods: {
getAgentEventBinding: jest.fn(async () => ({
conversationId: 'child-thread',
agentId: 'agent-player',
tenantId: 'tenant-1',
binding: { bindingId, sourceKeyId, actorId: 'player' },
lineage: {
parentConversationId: 'parent-thread',
parentAgentId: 'agent-director',
} as never,
})),
getConvo: jest.fn(
async () =>
({
conversationId: 'parent-thread',
agent_id: 'agent-director',
tenantId: 'tenant-1',
}) as IConversation,
),
getMessages: jest.fn(),
},
});
await expect(
resolver(envelope(), { idempotencyKey: 'trigger-1' } as never),
).rejects.toMatchObject({
code: 'EVENT_ACTOR_NOT_READY',
retryable: true,
deferWithoutAttempt: true,
});
});
it('fails closed after the binding parent is removed', async () => {
const resolver = createAgentEventContinueResolver({
enabled: () => true,
methods: {
getAgentEventBinding: jest.fn(async () => ({
conversationId: 'child-thread',
agentId: 'agent-player',
tenantId: 'tenant-1',
binding: { bindingId, sourceKeyId, actorId: 'player' },
lineage: { parentConversationId: 'missing-parent' } as never,
})),
getConvo: jest.fn(async () => null),
getMessages: jest.fn(),
},
});
await expect(
resolver(envelope(), { idempotencyKey: 'trigger-1' } as never),
).rejects.toMatchObject({ code: 'EVENT_BINDING_INVALID', retryable: false });
});
it('fails closed when the binding or its parent passed its retention deadline', async () => {
const getAgentEventBinding = jest.fn(async () => ({
conversationId: 'child-thread',
agentId: 'agent-player',
tenantId: 'tenant-1',
expiredAt: new Date(0),
binding: { bindingId, sourceKeyId, actorId: 'player' },
lineage: {
parentConversationId: 'parent-thread',
parentAgentId: 'agent-director',
} as never,
}));
const resolver = createAgentEventContinueResolver({
enabled: () => true,
methods: {
getAgentEventBinding,
getConvo: jest.fn(),
getMessages: jest.fn(),
},
});
await expect(
resolver(envelope(), { idempotencyKey: 'trigger-1' } as never),
).rejects.toMatchObject({ code: 'EVENT_BINDING_INVALID', retryable: false });
});
});

View file

@ -0,0 +1,166 @@
import { Constants } from 'librechat-data-provider';
import type { ConversationMethods, MessageMethods } from '@librechat/data-schemas';
import type { AgentTriggerContinuePreparation, AgentTriggerExecutionHostDeps } from './host';
import type { AgentContinueTriggerEnvelope } from './envelope';
import type { AgentTriggerDispatchContext } from './dispatch';
import { isAgentEventRetentionActive } from '../eventRetention';
import { AgentTriggerExecutionError } from './host';
type ContinueResolver = NonNullable<AgentTriggerExecutionHostDeps['prepareContinue']>;
export interface AgentEventContinueResolverDeps {
methods: Pick<ConversationMethods, 'getAgentEventBinding' | 'getConvo'> &
Pick<MessageMethods, 'getMessages'>;
getGenerationJob?: (conversationId: string) => Promise<
| {
status?: string;
metadata?: { terminalPersistencePending?: boolean };
}
| null
| undefined
>;
fallback?: ContinueResolver;
enabled?: () => boolean;
}
function invalidBinding(message: string, retryable = false): AgentTriggerExecutionError {
return new AgentTriggerExecutionError(message, {
mode: 'continue',
certainty: 'definite',
retryable,
code: 'EVENT_BINDING_INVALID',
status: retryable ? 503 : 404,
});
}
/** Resolves the branch leaf at dispatch time so queued events never persist a stale parent. */
export function createAgentEventContinueResolver({
methods,
getGenerationJob,
fallback,
enabled,
}: AgentEventContinueResolverDeps): ContinueResolver {
return async (
envelope: AgentContinueTriggerEnvelope,
context: AgentTriggerDispatchContext,
): Promise<AgentTriggerContinuePreparation | undefined> => {
const { bindingId, sourceKeyId } = envelope.target;
if (bindingId == null || sourceKeyId == null) {
return fallback?.(envelope, context);
}
if (enabled?.() !== true) {
throw new AgentTriggerExecutionError(
'Event-driven child turns are disabled on this worker.',
{
mode: 'continue',
certainty: 'definite',
retryable: true,
deferWithoutAttempt: true,
code: 'EVENT_BINDING_DISABLED',
status: 503,
},
);
}
let binding;
let latestAssistant;
try {
binding = await methods.getAgentEventBinding({
user: envelope.principal.userId,
bindingId,
sourceKeyId,
...(envelope.principal.tenantId == null ? {} : { tenantId: envelope.principal.tenantId }),
});
} catch (error) {
throw invalidBinding(
`Event binding state is temporarily unavailable: ${
error instanceof Error ? error.message : String(error)
}`,
true,
);
}
if (
binding == null ||
binding.conversationId !== envelope.target.conversationId ||
binding.agentId !== envelope.target.agentId ||
binding.binding.bindingId !== bindingId ||
binding.binding.sourceKeyId !== sourceKeyId ||
!isAgentEventRetentionActive(binding.expiredAt)
) {
throw invalidBinding('The event binding no longer authorizes this child thread.');
}
let parent;
try {
parent = await methods.getConvo(
envelope.principal.userId,
binding.lineage.parentConversationId,
);
} catch (error) {
throw invalidBinding(
`Event binding parent state is temporarily unavailable: ${
error instanceof Error ? error.message : String(error)
}`,
true,
);
}
if (
parent == null ||
parent.subagentThread != null ||
parent.agent_id !== binding.lineage.parentAgentId ||
(parent.tenantId ?? undefined) !== envelope.principal.tenantId ||
!isAgentEventRetentionActive(parent.expiredAt)
) {
throw invalidBinding('The event binding parent no longer authorizes this child thread.');
}
if (getGenerationJob != null) {
let active;
try {
active = await getGenerationJob(binding.conversationId);
} catch (error) {
throw invalidBinding(
`Event actor generation state is temporarily unavailable: ${
error instanceof Error ? error.message : String(error)
}`,
true,
);
}
if (
active?.status === 'running' ||
active?.status === 'requires_action' ||
active?.metadata?.terminalPersistencePending === true
) {
throw new AgentTriggerExecutionError('The event actor is still handling an earlier turn.', {
mode: 'continue',
certainty: 'definite',
retryable: true,
deferWithoutAttempt: true,
code: 'EVENT_ACTOR_NOT_READY',
status: 409,
});
}
}
try {
[latestAssistant] = await methods.getMessages(
{
user: envelope.principal.userId,
conversationId: binding.conversationId,
isCreatedByUser: false,
},
'messageId createdAt',
{ sort: { createdAt: -1, _id: -1 }, limit: 1 },
);
} catch (error) {
throw invalidBinding(
`Event actor history is temporarily unavailable: ${
error instanceof Error ? error.message : String(error)
}`,
true,
);
}
return {
status: 'ready',
input: envelope.input,
parentMessageId: latestAssistant?.messageId ?? Constants.NO_PARENT,
};
};
}

View file

@ -0,0 +1,418 @@
import express from 'express';
import request from 'supertest';
import type { IConversation } from '@librechat/data-schemas';
import { createAgentEventBindingHandlers } from './bindings';
const USER_ID = '507f191e810c19729de860ea';
const SOURCE_KEY_ID = '507f191e810c19729de860eb';
const PARENT_ID = 'parent-conversation';
const PARENT_MESSAGE_ID = 'parent-message';
const PARENT_AGENT_ID = 'agent_director';
const CHILD_AGENT_ID = 'agent_player';
function parent(): IConversation {
return {
conversationId: PARENT_ID,
user: USER_ID,
tenantId: 'tenant-1',
agent_id: PARENT_AGENT_ID,
} as IConversation;
}
function dependencies() {
const reserveThread = jest.fn(async (input) => ({
created: true,
conversation: {
...input.conversation,
user: input.user,
conversationId: input.conversationId,
},
}));
return {
getAgent: jest.fn<Promise<unknown>, [Record<string, unknown>]>(async ({ id }) =>
id === PARENT_AGENT_ID
? {
id: PARENT_AGENT_ID,
subagents: { enabled: true, allowSelf: false, agent_ids: [CHILD_AGENT_ID] },
}
: { id },
),
getConvo: jest.fn<Promise<IConversation | null>, [string, string]>(async () => parent()),
getBinding: jest.fn<Promise<unknown>, [Record<string, unknown>]>(async () => null),
getMessage: jest.fn(async () => ({
messageId: PARENT_MESSAGE_ID,
conversationId: PARENT_ID,
user: USER_ID,
})),
deleteConvos: jest.fn(async () => ({ deletedCount: 1 })),
reserveThread,
enabled: () => true,
};
}
function app(deps = dependencies()) {
const handlers = createAgentEventBindingHandlers(deps as never);
const server = express();
server.use(express.json());
server.use((req, _res, next) => {
Object.assign(req, {
user: { id: USER_ID, tenantId: 'tenant-1' },
apiKeyId: { toString: () => SOURCE_KEY_ID },
});
next();
});
server.post('/bindings', handlers.register);
server.post('/resolve', handlers.resolve, (req, res) => {
res.json(req.body);
});
return { server, deps };
}
describe('agent event bindings', () => {
it('keeps registration off until every API replica supports child turns', async () => {
const deps = dependencies();
deps.enabled = () => false;
const { server } = app(deps);
const response = await request(server)
.post('/bindings')
.set('Idempotency-Key', 'disabled-binding')
.send({
actorId: 'player-a',
parentConversationId: PARENT_ID,
parentMessageId: PARENT_MESSAGE_ID,
target: { agentId: CHILD_AGENT_ID },
});
expect(response.status).toBe(503);
expect(response.body.error.code).toBe('event_binding_unavailable');
expect(deps.reserveThread).not.toHaveBeenCalled();
});
it('reserves a hidden depth-one actor thread bound to the authenticated API key', async () => {
const { server, deps } = app();
const response = await request(server)
.post('/bindings')
.set('Idempotency-Key', 'championship-player-a')
.send({
actorId: 'player-a',
parentConversationId: PARENT_ID,
parentMessageId: PARENT_MESSAGE_ID,
target: { agentId: CHILD_AGENT_ID },
});
expect(response.status).toBe(201);
expect(response.body).toMatchObject({
id: expect.stringMatching(/^evtbind_/),
actorId: 'player-a',
agentId: CHILD_AGENT_ID,
threadId: expect.any(String),
});
expect(deps.reserveThread).toHaveBeenCalledWith(
expect.objectContaining({
user: USER_ID,
tenantId: 'tenant-1',
conversation: expect.objectContaining({
agent_id: CHILD_AGENT_ID,
agentEventBinding: expect.objectContaining({ sourceKeyId: SOURCE_KEY_ID }),
subagentThread: expect.objectContaining({
parentConversationId: PARENT_ID,
parentMessageId: PARENT_MESSAGE_ID,
parentAgentId: PARENT_AGENT_ID,
subagentType: CHILD_AGENT_ID,
depth: 1,
}),
}),
}),
);
});
it('rejects a target that is not a configured direct child', async () => {
const { server, deps } = app();
deps.getAgent.mockResolvedValueOnce({
id: PARENT_AGENT_ID,
subagents: { enabled: true, allowSelf: false, agent_ids: [] },
} as never);
const response = await request(server)
.post('/bindings')
.set('Idempotency-Key', 'not-configured')
.send({
actorId: 'player-a',
parentConversationId: PARENT_ID,
parentMessageId: PARENT_MESSAGE_ID,
target: { agentId: CHILD_AGENT_ID },
});
expect(response.status).toBe(403);
expect(deps.reserveThread).not.toHaveBeenCalled();
});
it('resolves a bound continue without accepting a caller-selected target', async () => {
const deps = dependencies();
deps.getBinding.mockResolvedValue({
conversationId: 'child-thread',
agentId: CHILD_AGENT_ID,
tenantId: 'tenant-1',
binding: {
bindingId: `evtbind_${'a'.repeat(48)}`,
sourceKeyId: SOURCE_KEY_ID,
actorId: 'player-a',
},
lineage: {
rootConversationId: PARENT_ID,
parentConversationId: PARENT_ID,
parentMessageId: PARENT_MESSAGE_ID,
parentToolCallId: 'event-binding',
parentAgentId: PARENT_AGENT_ID,
subagentType: CHILD_AGENT_ID,
subagentKind: 'agent',
depth: 1,
},
});
const { server } = app(deps);
const response = await request(server)
.post('/resolve')
.send({
mode: 'continue',
bindingId: `evtbind_${'a'.repeat(48)}`,
orderingKey: 'attacker-selected-lane',
target: { agentId: 'agent_attacker', conversationId: 'foreign-thread' },
});
expect(response.status).toBe(200);
expect(response.body).toMatchObject({
mode: 'continue',
orderingKey: `evtbind_${'a'.repeat(48)}`,
target: {
agentId: CHILD_AGENT_ID,
conversationId: 'child-thread',
bindingId: `evtbind_${'a'.repeat(48)}`,
sourceKeyId: SOURCE_KEY_ID,
},
});
expect(response.body.target.agentId).not.toBe('agent_attacker');
expect(response.body.orderingKey).not.toBe('attacker-selected-lane');
expect(deps.getBinding).toHaveBeenCalledWith({
user: USER_ID,
tenantId: 'tenant-1',
bindingId: `evtbind_${'a'.repeat(48)}`,
sourceKeyId: SOURCE_KEY_ID,
});
});
it('rejects a parent message outside the selected conversation', async () => {
const deps = dependencies();
deps.getMessage.mockResolvedValueOnce({
messageId: PARENT_MESSAGE_ID,
conversationId: 'another-conversation',
user: USER_ID,
});
const { server } = app(deps);
const response = await request(server)
.post('/bindings')
.set('Idempotency-Key', 'bad-parent-message')
.send({
actorId: 'player-a',
parentConversationId: PARENT_ID,
parentMessageId: PARENT_MESSAGE_ID,
target: { agentId: CHILD_AGENT_ID },
});
expect(response.status).toBe(404);
expect(deps.reserveThread).not.toHaveBeenCalled();
});
it('returns an idempotency conflict before reserving under a different parent', async () => {
const deps = dependencies();
const { server } = app(deps);
const first = await request(server)
.post('/bindings')
.set('Idempotency-Key', 'cross-parent-replay')
.send({
actorId: 'player-a',
parentConversationId: PARENT_ID,
parentMessageId: PARENT_MESSAGE_ID,
target: { agentId: CHILD_AGENT_ID },
});
expect(first.status).toBe(201);
const reservation = await deps.reserveThread.mock.results[0].value;
deps.getConvo.mockResolvedValueOnce({
...parent(),
conversationId: 'other-parent',
} as unknown as IConversation);
deps.getMessage.mockResolvedValueOnce({
messageId: PARENT_MESSAGE_ID,
conversationId: 'other-parent',
user: USER_ID,
});
deps.getBinding.mockResolvedValueOnce({
conversationId: reservation.conversation.conversationId,
agentId: reservation.conversation.agent_id,
tenantId: reservation.conversation.tenantId,
binding: reservation.conversation.agentEventBinding,
lineage: reservation.conversation.subagentThread,
} as never);
const response = await request(server)
.post('/bindings')
.set('Idempotency-Key', 'cross-parent-replay')
.send({
actorId: 'player-a',
parentConversationId: 'other-parent',
parentMessageId: PARENT_MESSAGE_ID,
target: { agentId: CHILD_AGENT_ID },
});
expect(response.status).toBe(409);
expect(deps.reserveThread).toHaveBeenCalledTimes(1);
});
it('rolls back a new binding when its parent loses the registration race', async () => {
const deps = dependencies();
deps.getConvo.mockResolvedValueOnce(parent()).mockResolvedValueOnce(null);
const { server } = app(deps);
const response = await request(server)
.post('/bindings')
.set('Idempotency-Key', 'parent-delete-race')
.send({
actorId: 'player-a',
parentConversationId: PARENT_ID,
parentMessageId: PARENT_MESSAGE_ID,
target: { agentId: CHILD_AGENT_ID },
});
expect(response.status).toBe(409);
expect(deps.deleteConvos).toHaveBeenCalledWith(
USER_ID,
expect.objectContaining({ conversationId: expect.any(String) }),
);
});
it('rejects an idempotent replay when the parent disappears after the first read', async () => {
const deps = dependencies();
/** Fill the deterministic binding id after the request computes it. */
deps.getBinding.mockImplementationOnce(async (input) => ({
conversationId: 'child-thread',
agentId: CHILD_AGENT_ID,
tenantId: 'tenant-1',
binding: {
bindingId: input.bindingId,
sourceKeyId: SOURCE_KEY_ID,
actorId: 'player-a',
},
lineage: {
rootConversationId: PARENT_ID,
parentConversationId: PARENT_ID,
parentMessageId: PARENT_MESSAGE_ID,
parentToolCallId: `event-binding:${input.bindingId}`,
parentAgentId: PARENT_AGENT_ID,
subagentType: CHILD_AGENT_ID,
subagentKind: 'agent',
depth: 1,
},
}));
deps.getConvo.mockResolvedValueOnce(parent()).mockResolvedValueOnce(null);
const { server } = app(deps);
const response = await request(server)
.post('/bindings')
.set('Idempotency-Key', 'parent-replay-race')
.send({
actorId: 'player-a',
parentConversationId: PARENT_ID,
parentMessageId: PARENT_MESSAGE_ID,
target: { agentId: CHILD_AGENT_ID },
});
expect(response.status).toBe(409);
expect(response.body.error.code).toBe('event_binding_parent_ended');
expect(deps.deleteConvos).toHaveBeenCalledWith(USER_ID, {
conversationId: 'child-thread',
});
expect(deps.reserveThread).not.toHaveBeenCalled();
});
it('surfaces a failed rollback and lets a retry reconcile the orphan', async () => {
const deps = dependencies();
deps.getConvo.mockResolvedValueOnce(parent()).mockResolvedValueOnce(null);
deps.deleteConvos
.mockRejectedValueOnce(new Error('stepdown'))
.mockRejectedValueOnce(new Error('stepdown'))
.mockRejectedValueOnce(new Error('stepdown'));
const { server } = app(deps);
const body = {
actorId: 'player-a',
parentConversationId: PARENT_ID,
parentMessageId: PARENT_MESSAGE_ID,
target: { agentId: CHILD_AGENT_ID },
};
const first = await request(server)
.post('/bindings')
.set('Idempotency-Key', 'rollback-recovery')
.send(body);
expect(first.status).toBe(503);
expect(first.body.error.code).toBe('event_binding_cleanup_failed');
const reservation = await deps.reserveThread.mock.results[0].value;
deps.getConvo.mockResolvedValue(null);
deps.getBinding.mockResolvedValue({
conversationId: reservation.conversation.conversationId,
agentId: reservation.conversation.agent_id,
tenantId: reservation.conversation.tenantId,
binding: reservation.conversation.agentEventBinding,
lineage: reservation.conversation.subagentThread,
} as never);
const retry = await request(server)
.post('/bindings')
.set('Idempotency-Key', 'rollback-recovery')
.send(body);
expect(retry.status).toBe(409);
expect(retry.body.error.code).toBe('event_binding_parent_ended');
expect(deps.deleteConvos).toHaveBeenCalledTimes(4);
});
it('rejects registration after the parent retention deadline', async () => {
const deps = dependencies();
deps.getConvo.mockResolvedValueOnce({ ...parent(), expiredAt: new Date(0) } as IConversation);
const { server } = app(deps);
const response = await request(server)
.post('/bindings')
.set('Idempotency-Key', 'expired-parent')
.send({
actorId: 'player-a',
parentConversationId: PARENT_ID,
parentMessageId: PARENT_MESSAGE_ID,
target: { agentId: CHILD_AGENT_ID },
});
expect(response.status).toBe(404);
expect(deps.reserveThread).not.toHaveBeenCalled();
});
it('leaves fire and steer deliveries unchanged', async () => {
const { server, deps } = app();
const response = await request(server)
.post('/resolve')
.send({ mode: 'fire', target: { agentId: CHILD_AGENT_ID } });
expect(response.status).toBe(200);
expect(response.body).toEqual({ mode: 'fire', target: { agentId: CHILD_AGENT_ID } });
expect(deps.getBinding).not.toHaveBeenCalled();
});
it('does not reinterpret another mode merely because it contains a binding id', async () => {
const { server, deps } = app();
const response = await request(server)
.post('/resolve')
.send({
mode: 'fire',
bindingId: `evtbind_${'a'.repeat(48)}`,
target: { agentId: CHILD_AGENT_ID },
});
expect(response.status).toBe(400);
expect(deps.getBinding).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,462 @@
import { createHash } from 'node:crypto';
import { Constants, EModelEndpoint } from 'librechat-data-provider';
import type {
AgentMethods,
ConversationMethods,
IAgent,
IAgentEventBindingRecord,
IConversation,
MessageMethods,
} from '@librechat/data-schemas';
import type { Request, RequestHandler, Response } from 'express';
import { isAgentEventRetentionActive } from '../eventRetention';
import { createSubagentThreadId } from '../subagentThreadIds';
const BINDING_ID_PATTERN = /^evtbind_[a-f0-9]{48}$/;
const IDEMPOTENCY_KEY_PATTERN = /^[A-Za-z0-9._~:/+=-]+$/;
const MAX_ACTOR_ID_LENGTH = 128;
const MAX_REGISTRATION_KEY_LENGTH = 256;
interface EventBindingUser {
id?: string;
role?: string;
tenantId?: string;
}
interface EventBindingRequest extends Request {
apiKeyId?: { toString(): string } | string;
user?: EventBindingUser;
_agentEventBindingResolved?: boolean;
}
interface RegisterBindingBody {
actorId?: unknown;
parentConversationId?: unknown;
parentMessageId?: unknown;
target?: { agentId?: unknown };
}
export interface AgentEventBindingDependencies {
getAgent: AgentMethods['getAgent'];
getConvo: ConversationMethods['getConvo'];
getBinding: ConversationMethods['getAgentEventBinding'];
getMessage: MessageMethods['getMessage'];
deleteConvos: ConversationMethods['deleteConvos'];
reserveThread: ConversationMethods['reserveSubagentThread'];
enabled?: () => boolean;
}
class AgentEventBindingError extends Error {
constructor(
message: string,
readonly status = 400,
readonly code = 'invalid_event_binding',
) {
super(message);
this.name = 'AgentEventBindingError';
}
}
function requireString(value: unknown, name: string, max = 256): string {
if (typeof value !== 'string' || value.trim() === '' || value.length > max) {
throw new AgentEventBindingError(`${name} must be a non-empty string of at most ${max} bytes`);
}
return value;
}
function requirePrincipal(req: EventBindingRequest): {
userId: string;
tenantId?: string;
sourceKeyId: string;
} {
const userId = requireString(req.user?.id, 'Authenticated user ID');
const sourceKeyId = requireString(req.apiKeyId?.toString(), 'Authenticated API key ID');
return {
userId,
sourceKeyId,
...(typeof req.user?.tenantId === 'string' && req.user.tenantId !== ''
? { tenantId: req.user.tenantId }
: {}),
};
}
function tenantMatches(actual: string | undefined, expected: string | undefined): boolean {
return actual == null ? expected == null : actual === expected;
}
function configuredChild(parentAgent: IAgent, targetAgentId: string): boolean {
const parentId = typeof parentAgent.id === 'string' ? parentAgent.id : undefined;
const subagents = parentAgent.subagents as
| { enabled?: boolean; allowSelf?: boolean; agent_ids?: unknown[] }
| undefined;
if (subagents?.enabled !== true) {
return false;
}
if (targetAgentId === parentId && subagents.allowSelf !== false) {
return true;
}
return subagents.agent_ids?.includes(targetAgentId) === true;
}
function bindingId(
userId: string,
tenantId: string | undefined,
sourceKeyId: string,
registrationKey: string,
): string {
const digest = createHash('sha256')
.update(
`librechat:agent-event-binding:v1\u0000${userId}\u0000${tenantId ?? ''}\u0000${sourceKeyId}\u0000${registrationKey}`,
)
.digest('hex');
return `evtbind_${digest.slice(0, 48)}`;
}
function registrationKey(req: Request): string {
const values: string[] = [];
for (let index = 0; index < req.rawHeaders.length; index += 2) {
if (req.rawHeaders[index]?.toLowerCase() === 'idempotency-key') {
values.push(req.rawHeaders[index + 1] ?? '');
}
}
if (values.length !== 1) {
throw new AgentEventBindingError('Exactly one Idempotency-Key header is required');
}
const value = requireString(values[0].trim(), 'Idempotency-Key', MAX_REGISTRATION_KEY_LENGTH);
if (!IDEMPOTENCY_KEY_PATTERN.test(value)) {
throw new AgentEventBindingError('Idempotency-Key contains invalid characters');
}
return value;
}
function publicBinding(record: IAgentEventBindingRecord) {
return {
id: record.binding.bindingId,
actorId: record.binding.actorId,
agentId: record.agentId,
threadId: record.conversationId,
};
}
function assertReplay(
record: IAgentEventBindingRecord,
expected: {
bindingId: string;
sourceKeyId: string;
actorId: string;
parentConversationId: string;
parentMessageId: string;
parentAgentId: string;
targetAgentId: string;
},
): void {
const binding = record.binding;
const lineage = record.lineage;
if (
binding?.bindingId !== expected.bindingId ||
binding.sourceKeyId !== expected.sourceKeyId ||
binding.actorId !== expected.actorId ||
record.agentId !== expected.targetAgentId ||
lineage?.parentConversationId !== expected.parentConversationId ||
lineage.parentMessageId !== expected.parentMessageId ||
lineage.parentAgentId !== expected.parentAgentId ||
lineage.subagentType !== expected.targetAgentId ||
lineage.subagentKind !== 'agent' ||
lineage.depth !== 1
) {
throw new AgentEventBindingError(
'Idempotency-Key was already used for a different event binding',
409,
'idempotency_conflict',
);
}
}
function bindingRecord(conversation: IConversation): IAgentEventBindingRecord {
if (
conversation.agentEventBinding == null ||
conversation.subagentThread == null ||
typeof conversation.agent_id !== 'string'
) {
throw new AgentEventBindingError('Reserved event binding is incomplete', 500);
}
return {
conversationId: conversation.conversationId,
agentId: conversation.agent_id,
...(conversation.tenantId == null ? {} : { tenantId: conversation.tenantId }),
...(conversation.isTemporary == null ? {} : { isTemporary: conversation.isTemporary }),
...(conversation.expiredAt == null ? {} : { expiredAt: conversation.expiredAt }),
binding: conversation.agentEventBinding,
lineage: conversation.subagentThread,
};
}
function sendError(res: Response, error: unknown): void {
if (error instanceof AgentEventBindingError) {
res.status(error.status).json({
error: { message: error.message, type: 'invalid_request_error', code: error.code },
});
return;
}
throw error;
}
function requireEnabled(deps: AgentEventBindingDependencies): void {
if (deps.enabled?.() !== true) {
throw new AgentEventBindingError(
'Event-driven child turns are not enabled on this deployment',
503,
'event_binding_unavailable',
);
}
}
export function createAgentEventBindingHandlers(deps: AgentEventBindingDependencies): {
register: RequestHandler;
resolve: RequestHandler;
} {
const register: RequestHandler = async (baseReq, res, next) => {
const req = baseReq as EventBindingRequest;
try {
requireEnabled(deps);
const principal = requirePrincipal(req);
const body = (req.body ?? {}) as RegisterBindingBody;
const actorId = requireString(body.actorId, 'actorId', MAX_ACTOR_ID_LENGTH);
const parentConversationId = requireString(body.parentConversationId, 'parentConversationId');
const parentMessageId = requireString(body.parentMessageId, 'parentMessageId');
const targetAgentId = requireString(body.target?.agentId, 'target.agentId');
const id = bindingId(
principal.userId,
principal.tenantId,
principal.sourceKeyId,
registrationKey(req),
);
const bindingQuery = {
user: principal.userId,
bindingId: id,
sourceKeyId: principal.sourceKeyId,
...(principal.tenantId == null ? {} : { tenantId: principal.tenantId }),
};
const cleanupBinding = async (conversationId: string): Promise<void> => {
let lastError: unknown;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
await deps.deleteConvos(principal.userId, { conversationId });
return;
} catch (error) {
lastError = error;
}
}
throw new AgentEventBindingError(
`Reserved event binding cleanup failed: ${lastError instanceof Error ? lastError.message : String(lastError)}`,
503,
'event_binding_cleanup_failed',
);
};
const expectedBinding = (parentAgentId: string) => ({
bindingId: id,
sourceKeyId: principal.sourceKeyId,
actorId,
parentConversationId,
parentMessageId,
parentAgentId,
targetAgentId,
});
const [parent, parentMessage] = await Promise.all([
deps.getConvo(principal.userId, parentConversationId),
deps.getMessage({ user: principal.userId, messageId: parentMessageId }),
]);
if (
parent == null ||
parent.subagentThread != null ||
!tenantMatches(parent.tenantId, principal.tenantId) ||
typeof parent.agent_id !== 'string' ||
!isAgentEventRetentionActive(parent.expiredAt)
) {
const orphan = await deps.getBinding(bindingQuery);
if (orphan != null) {
const orphanParentAgentId = orphan.lineage.parentAgentId;
if (typeof orphanParentAgentId !== 'string' || orphanParentAgentId === '') {
await cleanupBinding(orphan.conversationId);
throw new AgentEventBindingError(
'Parent agent conversation ended during binding registration',
409,
'event_binding_parent_ended',
);
}
assertReplay(orphan, expectedBinding(orphanParentAgentId));
await cleanupBinding(orphan.conversationId);
throw new AgentEventBindingError(
'Parent agent conversation ended during binding registration',
409,
'event_binding_parent_ended',
);
}
throw new AgentEventBindingError('Parent agent conversation was not found', 404);
}
if (parentMessage?.conversationId !== parentConversationId) {
throw new AgentEventBindingError('Parent agent message was not found', 404);
}
const resolvedParentAgent = await deps.getAgent({ id: parent.agent_id });
if (resolvedParentAgent == null || !configuredChild(resolvedParentAgent, targetAgentId)) {
throw new AgentEventBindingError(
'Target agent is not configured as a direct child of the parent agent',
403,
'event_binding_forbidden',
);
}
const scopeId = JSON.stringify({
userId: principal.userId,
parentConversationId,
...(principal.tenantId == null ? {} : { tenantId: principal.tenantId }),
});
const threadId = createSubagentThreadId(scopeId, id);
const expected = expectedBinding(parent.agent_id);
const assertCurrentParent = async (): Promise<void> => {
const currentParent = await deps.getConvo(principal.userId, parentConversationId);
if (
currentParent == null ||
currentParent.subagentThread != null ||
currentParent.agent_id !== parent.agent_id ||
!tenantMatches(currentParent.tenantId, principal.tenantId) ||
!isAgentEventRetentionActive(currentParent.expiredAt)
) {
throw new AgentEventBindingError(
'Parent agent conversation ended during binding registration',
409,
'event_binding_parent_ended',
);
}
};
const assertCurrentParentOrCleanup = async (
record: IAgentEventBindingRecord,
): Promise<void> => {
try {
await assertCurrentParent();
} catch (error) {
if (
!(error instanceof AgentEventBindingError) ||
error.code !== 'event_binding_parent_ended'
) {
throw error;
}
await cleanupBinding(record.conversationId);
throw error;
}
};
const replay = await deps.getBinding(bindingQuery);
if (replay != null) {
assertReplay(replay, expected);
await assertCurrentParentOrCleanup(replay);
res.status(200).json(publicBinding(replay));
return;
}
let reserved;
try {
reserved = await deps.reserveThread({
user: principal.userId,
conversationId: threadId,
...(principal.tenantId == null ? {} : { tenantId: principal.tenantId }),
conversation: {
conversationId: threadId,
endpoint: EModelEndpoint.agents,
title: `Agent actor: ${actorId}`.slice(0, 120),
agent_id: targetAgentId,
...(parent.isTemporary == null ? {} : { isTemporary: parent.isTemporary }),
...(parent.expiredAt == null ? {} : { expiredAt: parent.expiredAt }),
...(principal.tenantId == null ? {} : { tenantId: principal.tenantId }),
agentEventBinding: { bindingId: id, sourceKeyId: principal.sourceKeyId, actorId },
subagentThread: {
rootConversationId: parentConversationId,
parentConversationId,
parentMessageId,
parentToolCallId: `event-binding:${id}`,
parentAgentId: parent.agent_id,
subagentType: targetAgentId,
subagentKind: 'agent',
depth: 1,
},
},
});
} catch (error) {
if ((error as { code?: number }).code !== 11000) {
throw error;
}
const winner = await deps.getBinding(bindingQuery);
if (winner == null) {
throw error;
}
assertReplay(winner, expected);
await assertCurrentParentOrCleanup(winner);
res.status(200).json(publicBinding(winner));
return;
}
const record = bindingRecord(reserved.conversation);
assertReplay(record, expected);
await assertCurrentParentOrCleanup(record);
res.status(reserved.created ? 201 : 200).json(publicBinding(record));
} catch (error) {
try {
sendError(res, error);
} catch (unexpected) {
next(unexpected);
}
}
};
const resolve: RequestHandler = async (baseReq, res, next) => {
const req = baseReq as EventBindingRequest;
try {
const principal = requirePrincipal(req);
const body = (req.body ?? {}) as Record<string, unknown>;
if (body.mode !== 'continue') {
if (body.bindingId != null) {
throw new AgentEventBindingError('bindingId is valid only for continue events');
}
next();
return;
}
requireEnabled(deps);
const id = requireString(body.bindingId, 'bindingId');
if (!BINDING_ID_PATTERN.test(id)) {
throw new AgentEventBindingError('bindingId is invalid');
}
const binding = await deps.getBinding({
user: principal.userId,
bindingId: id,
sourceKeyId: principal.sourceKeyId,
...(principal.tenantId == null ? {} : { tenantId: principal.tenantId }),
});
if (binding == null) {
throw new AgentEventBindingError(
'Event binding was not found',
404,
'event_binding_not_found',
);
}
req.body = {
...body,
orderingKey: id,
mode: 'continue',
target: {
agentId: binding.agentId,
conversationId: binding.conversationId,
parentMessageId: Constants.NO_PARENT,
bindingId: id,
sourceKeyId: principal.sourceKeyId,
},
};
req._agentEventBindingResolved = true;
next();
} catch (error) {
try {
sendError(res, error);
} catch (unexpected) {
next(unexpected);
}
}
};
return { register, resolve };
}

View file

@ -125,6 +125,34 @@ describe('createAgentTriggerEnvelope', () => {
expect(parseAgentTriggerEnvelope(JSON.parse(JSON.stringify(envelope)))).toEqual(envelope);
});
it('preserves only complete authenticated binding metadata on continuations', () => {
const envelope = createAgentTriggerEnvelope({
...createFireInput(),
mode: 'continue',
target: {
agentId: 'agent-1',
conversationId: 'conversation-1',
parentMessageId: 'response-1',
bindingId: `evtbind_${'a'.repeat(48)}`,
sourceKeyId: 'source-key',
},
});
expect(parseAgentTriggerEnvelope(JSON.parse(JSON.stringify(envelope)))).toEqual(envelope);
expect(() =>
createAgentTriggerEnvelope({
...createFireInput(),
mode: 'continue',
target: {
agentId: 'agent-1',
conversationId: 'conversation-1',
parentMessageId: 'response-1',
bindingId: `evtbind_${'a'.repeat(48)}`,
},
}),
).toThrow('target.bindingId and target.sourceKeyId must be provided together');
});
it('builds a stable generation-compatible idempotency key per delivery target', () => {
const first = createAgentTriggerEnvelope(createFireInput());
const retry = createAgentTriggerEnvelope({

View file

@ -58,6 +58,10 @@ export interface AgentContinueTarget extends AgentTriggerTarget {
conversationId: string;
/** Persisted branch leaf below which the new turn is appended. */
parentMessageId: string;
/** Present only after an authenticated source binding resolved the target. */
bindingId?: string;
/** API-key identity captured by the ingress adapter and rechecked at dispatch. */
sourceKeyId?: string;
}
export interface AgentSteerTarget extends AgentTriggerTarget {
@ -261,6 +265,11 @@ export function createAgentTriggerEnvelope(
}
if (input.mode === 'continue') {
const bindingId = input.target?.bindingId;
const sourceKeyId = input.target?.sourceKeyId;
if ((bindingId == null) !== (sourceKeyId == null)) {
throw error('target.bindingId and target.sourceKeyId must be provided together');
}
return {
...base,
mode: input.mode,
@ -268,6 +277,12 @@ export function createAgentTriggerEnvelope(
agentId: requireString(input.target?.agentId, 'target.agentId'),
conversationId: requireString(input.target?.conversationId, 'target.conversationId'),
parentMessageId: requireString(input.target?.parentMessageId, 'target.parentMessageId'),
...(bindingId == null
? {}
: {
bindingId: requireString(bindingId, 'target.bindingId'),
sourceKeyId: requireString(sourceKeyId, 'target.sourceKeyId'),
}),
},
};
}
@ -338,6 +353,9 @@ export function parseAgentTriggerEnvelope(input: unknown): AgentTriggerEnvelope
}
if (mode === 'continue') {
if ((target.bindingId == null) !== (target.sourceKeyId == null)) {
throw error('target.bindingId and target.sourceKeyId must be provided together');
}
return {
...base,
mode,
@ -345,6 +363,12 @@ export function parseAgentTriggerEnvelope(input: unknown): AgentTriggerEnvelope
agentId: requireString(target.agentId, 'target.agentId'),
conversationId: requireString(target.conversationId, 'target.conversationId'),
parentMessageId: requireString(target.parentMessageId, 'target.parentMessageId'),
...(target.bindingId == null
? {}
: {
bindingId: requireString(target.bindingId, 'target.bindingId'),
sourceKeyId: requireString(target.sourceKeyId, 'target.sourceKeyId'),
}),
},
};
}
@ -387,6 +411,8 @@ export function getAgentTriggerIdempotencyKey(envelope: AgentTriggerEnvelope): s
envelope.target.agentId,
envelope.mode === 'fire' ? '' : envelope.target.conversationId,
envelope.mode === 'continue' ? envelope.target.parentMessageId : '',
envelope.mode === 'continue' ? (envelope.target.bindingId ?? '') : '',
envelope.mode === 'continue' ? (envelope.target.sourceKeyId ?? '') : '',
]),
)
.digest('hex');

View file

@ -575,31 +575,56 @@ describe('createAgentTriggerExecutionHost continue adapter', () => {
});
});
it('retries without consuming the logical delivery when the parent is not settled', async () => {
expect.hasAssertions();
const host = createAgentTriggerExecutionHost(
deps(
fetchMock(async () =>
response(
{ code: 'PARENT_NOT_READY', error: 'The parent is still running.' },
{ status: 409 },
),
),
),
it('carries server-resolved binding identity only on bound child continuations', async () => {
const base = createContinueEnvelope();
const envelope = {
...base,
target: {
...base.target,
bindingId: `evtbind_${'a'.repeat(48)}`,
sourceKeyId: 'source-key',
},
};
const fetcher = fetchMock(async () =>
response({
streamId: 'conversation-1',
conversationId: 'conversation-1',
status: 'started',
}),
);
await host.dispatch(createContinueEnvelope()).catch((error: unknown) => {
expectExecutionError(error, {
mode: 'continue',
certainty: 'definite',
retryable: true,
deferWithoutAttempt: true,
code: 'PARENT_NOT_READY',
status: 409,
});
});
await createAgentTriggerExecutionHost(deps(fetcher)).dispatch(envelope);
const headers = fetcher.mock.calls[0][1]?.headers as Record<string, string>;
expect(headers['x-lc-agent-event-binding']).toBe(`evtbind_${'a'.repeat(48)}`);
expect(headers['x-lc-agent-event-source-key']).toBe('source-key');
});
it.each(['PARENT_NOT_READY', 'EVENT_ACTOR_NOT_READY'])(
'retries without consuming the logical delivery for temporary admission code %s',
async (code) => {
expect.hasAssertions();
const host = createAgentTriggerExecutionHost(
deps(
fetchMock(async () =>
response({ code, error: 'The actor is still busy.' }, { status: 409 }),
),
),
);
await host.dispatch(createContinueEnvelope()).catch((error: unknown) => {
expectExecutionError(error, {
mode: 'continue',
certainty: 'definite',
retryable: true,
deferWithoutAttempt: true,
code,
status: 409,
});
});
},
);
it('releases a prepared durable result after a definite admission rejection', async () => {
const releaseOnDefiniteFailure = jest.fn(async () => undefined);
const host = createAgentTriggerExecutionHost(

View file

@ -505,7 +505,11 @@ function canReleasePreparedResult(error: AgentTriggerExecutionError): boolean {
if (error.code === 'START_ABORTED' || error.status == null) {
return true;
}
if (error.code === 'PARENT_NOT_READY' || error.code === 'PARENT_STATE_UNAVAILABLE') {
if (
error.code === 'PARENT_NOT_READY' ||
error.code === 'PARENT_STATE_UNAVAILABLE' ||
error.code === 'EVENT_ACTOR_NOT_READY'
) {
return true;
}
return error.status >= 400 && error.status < 500 && error.status !== 408 && error.status !== 409;
@ -554,7 +558,8 @@ async function startRun(
conversationId: envelope.target.conversationId,
};
}
const input = preparation?.status === 'ready' ? preparation.input : envelope.input;
const readyPreparation = preparation?.status === 'ready' ? preparation : undefined;
const input = readyPreparation?.input ?? envelope.input;
const parentMessageId = resolveParentMessageId(preparation, envelope);
const [token, resolvedTimezone, baseUrl] = await Promise.all([
setupValue(
@ -590,6 +595,12 @@ async function startRun(
'User-Agent': TRIGGER_USER_AGENT,
'x-lc-agent-trigger': '1',
'x-request-id': context.idempotencyKey,
...(envelope.mode === 'continue' && envelope.target.bindingId != null
? {
'x-lc-agent-event-binding': envelope.target.bindingId,
'x-lc-agent-event-source-key': envelope.target.sourceKeyId!,
}
: {}),
[GENERATION_PROTOCOL_HEADER]: '2',
},
body: JSON.stringify({
@ -666,18 +677,15 @@ async function startRun(
if (!response.ok) {
const message =
errorMessage(payload) ?? (boundedBody.text.slice(0, 300) || 'request rejected');
const deferredContinue =
mode === 'continue' &&
response.status === 409 &&
['PARENT_NOT_READY', 'EVENT_ACTOR_NOT_READY'].includes(errorCode(payload) ?? '');
throw executionError(`Agent trigger ${mode} was rejected (${response.status}): ${message}`, {
mode,
certainty: 'definite',
retryable:
isRetryableStatus(response.status) ||
(mode === 'continue' &&
response.status === 409 &&
errorCode(payload) === 'PARENT_NOT_READY'),
deferWithoutAttempt:
mode === 'continue' &&
response.status === 409 &&
errorCode(payload) === 'PARENT_NOT_READY',
retryable: isRetryableStatus(response.status) || deferredContinue,
deferWithoutAttempt: deferredContinue,
code: errorCode(payload) ?? (mode === 'fire' ? 'FIRE_REJECTED' : 'CONTINUE_REJECTED'),
status: response.status,
...(response.headers.get('retry-after') != null && {

View file

@ -1,7 +1,10 @@
export * from './dispatch';
export * from './bindings';
export * from './bindingResolver';
export * from './delivery';
export * from './envelope';
export * from './host';
export * from './ingress';
export * from './lease';
export * from './service';
export * from './engine';

View file

@ -62,6 +62,7 @@ function createApp(
role: 'USER',
tenantId: 'tenant-1',
},
bindingResolved = false,
): Application {
const app = express();
const handlers = createAgentTriggerIngressHandlers(deps);
@ -71,6 +72,7 @@ function createApp(
user: user ?? undefined,
apiKeyId: API_KEY_ID,
requestId: 'request-from-context',
_agentEventBindingResolved: bindingResolved,
});
next();
});
@ -169,6 +171,40 @@ describe('agent trigger event ingress', () => {
);
});
it('admits continue only after a source binding resolved its trusted target', async () => {
const event = {
mode: 'continue',
event: fireEvent().event,
target: {
agentId: 'agent-player',
conversationId: 'child-thread',
parentMessageId: 'placeholder',
bindingId: `evtbind_${'a'.repeat(48)}`,
sourceKeyId: 'source-key',
},
input: 'Make the next move.',
};
const rejected = dependencies();
const accepted = dependencies();
const directResponse = await request(createApp(rejected))
.post('/api/agents/v1/events')
.set('Idempotency-Key', 'continue-direct')
.send(event);
const boundResponse = await request(createApp(accepted, undefined, true))
.post('/api/agents/v1/events')
.set('Idempotency-Key', 'continue-bound')
.send(event);
expect(directResponse.status).toBe(400);
expect(rejected.enqueue).not.toHaveBeenCalled();
expect(boundResponse.status).toBe(202);
expect(accepted.enqueue).toHaveBeenCalledWith(
expect.objectContaining({ mode: 'continue', target: event.target }),
{},
);
});
it('fails closed when the idempotency header is absent or duplicated', async () => {
const deps = dependencies();
const app = createApp(deps);

View file

@ -3,6 +3,7 @@ import { logger } from '@librechat/data-schemas';
import type { Request, RequestHandler, Response } from 'express';
import type {
AgentFireTarget,
AgentContinueTarget,
AgentSteerTarget,
AgentTriggerEvent,
AgentTriggerMode,
@ -28,12 +29,13 @@ interface AgentTriggerIngressRequest extends Request {
apiKeyId?: { toString(): string } | string;
requestId?: string;
user?: AgentTriggerIngressUser;
_agentEventBindingResolved?: boolean;
}
interface AgentTriggerIngressBody {
mode?: AgentTriggerMode;
event?: AgentTriggerEvent;
target?: AgentFireTarget | AgentSteerTarget;
target?: AgentContinueTarget | AgentFireTarget | AgentSteerTarget;
input?: string;
orderingKey?: string;
}
@ -216,18 +218,31 @@ export function createAgentTriggerIngressHandlers(deps: AgentTriggerIngressDepen
},
input: body.input as string,
};
const envelope =
body.mode === 'fire'
? createAgentTriggerEnvelope({
...common,
mode: body.mode,
target: body.target as AgentFireTarget,
})
: createAgentTriggerEnvelope({
...common,
mode: body.mode as 'steer',
target: body.target as AgentSteerTarget,
});
if (body.mode === 'continue' && req._agentEventBindingResolved !== true) {
throw new AgentTriggerIngressError(
'Continue events require an authenticated agent-event binding',
);
}
let envelope;
if (body.mode === 'fire') {
envelope = createAgentTriggerEnvelope({
...common,
mode: 'fire',
target: body.target as AgentFireTarget,
});
} else if (body.mode === 'continue') {
envelope = createAgentTriggerEnvelope({
...common,
mode: 'continue',
target: body.target as AgentContinueTarget,
});
} else {
envelope = createAgentTriggerEnvelope({
...common,
mode: 'steer',
target: body.target as AgentSteerTarget,
});
}
const receipt = await deps.enqueue(envelope, enqueueOptions(body));
logger.info('[agent-trigger-ingress] delivery accepted', {

View file

@ -0,0 +1,230 @@
import type { ConversationMethods } from '@librechat/data-schemas';
import type { AbortResult } from '../../stream/interfaces/IJobStore';
import { createEventChildGenerationLeaseAcquirer } from './lease';
type LeaseMethods = Pick<
ConversationMethods,
'acquireSubagentThreadLease' | 'renewSubagentThreadLease' | 'releaseSubagentThreadLease'
>;
const stoppedResult = (failureReason?: AbortResult['failureReason']): AbortResult => ({
success: failureReason == null,
failureReason,
jobData: null,
content: [],
finalEvent: null,
text: '',
collectedUsage: [],
});
describe('event child generation lease', () => {
const acquireSubagentThreadLease = jest.fn<
ReturnType<LeaseMethods['acquireSubagentThreadLease']>,
Parameters<LeaseMethods['acquireSubagentThreadLease']>
>();
const renewSubagentThreadLease = jest.fn<
ReturnType<LeaseMethods['renewSubagentThreadLease']>,
Parameters<LeaseMethods['renewSubagentThreadLease']>
>();
const releaseSubagentThreadLease = jest.fn<
ReturnType<LeaseMethods['releaseSubagentThreadLease']>,
Parameters<LeaseMethods['releaseSubagentThreadLease']>
>();
const abortGeneration = jest.fn<
Promise<AbortResult>,
[string, { expectedCreatedAt: number; awaitProviderDrain: true }]
>();
const acquireEventChildGenerationLease = createEventChildGenerationLeaseAcquirer({
methods: {
acquireSubagentThreadLease,
renewSubagentThreadLease,
releaseSubagentThreadLease,
},
abortGeneration,
});
beforeEach(() => {
jest.useFakeTimers({ now: new Date('2026-08-22T00:00:00.000Z') });
jest.clearAllMocks();
acquireSubagentThreadLease.mockResolvedValue(true);
renewSubagentThreadLease.mockResolvedValue(true);
releaseSubagentThreadLease.mockResolvedValue(true);
abortGeneration.mockResolvedValue(stoppedResult());
});
afterEach(() => {
jest.useRealTimers();
});
it('rejects and releases an initial lease that resolves after its deadline', async () => {
let resolveAcquisition: (acquired: boolean) => void = () => undefined;
acquireSubagentThreadLease.mockImplementation(
() =>
new Promise((resolve) => {
resolveAcquisition = resolve;
}),
);
const acquisition = acquireEventChildGenerationLease({
userId: 'user-1',
tenantId: 'tenant-1',
conversationId: 'child-1',
streamId: 'child-1',
jobCreatedAt: 123,
});
jest.setSystemTime(new Date('2026-08-22T00:00:30.001Z'));
resolveAcquisition(true);
await expect(acquisition).resolves.toBeNull();
expect(releaseSubagentThreadLease).toHaveBeenCalledWith(
expect.objectContaining({
user: 'user-1',
tenantId: 'tenant-1',
conversationId: 'child-1',
}),
);
expect(abortGeneration).not.toHaveBeenCalled();
expect(jest.getTimerCount()).toBe(0);
});
it('refreshes a near-expiry initial lease before accepting it', async () => {
let resolveAcquisition: (acquired: boolean) => void = () => undefined;
acquireSubagentThreadLease.mockImplementation(
() =>
new Promise((resolve) => {
resolveAcquisition = resolve;
}),
);
const acquisition = acquireEventChildGenerationLease({
userId: 'user-1',
conversationId: 'child-1',
streamId: 'child-1',
jobCreatedAt: 123,
});
jest.setSystemTime(new Date('2026-08-22T00:00:25.000Z'));
resolveAcquisition(true);
const release = await acquisition;
expect(renewSubagentThreadLease).toHaveBeenCalledWith(
expect.objectContaining({
now: new Date('2026-08-22T00:00:25.000Z'),
expiresAt: new Date('2026-08-22T00:00:55.000Z'),
}),
);
expect(release).not.toBeNull();
await release?.();
});
it('aborts when a renewal lands after continuous ownership expired', async () => {
let resolveRenewal: (renewed: boolean) => void = () => undefined;
renewSubagentThreadLease.mockImplementation(
() =>
new Promise((resolve) => {
resolveRenewal = resolve;
}),
);
const release = await acquireEventChildGenerationLease({
userId: 'user-1',
tenantId: 'tenant-1',
conversationId: 'child-1',
streamId: 'child-1',
jobCreatedAt: 123,
});
jest.advanceTimersByTime(10_000);
await Promise.resolve();
jest.setSystemTime(new Date('2026-08-22T00:00:30.001Z'));
resolveRenewal(true);
await Promise.resolve();
await Promise.resolve();
expect(abortGeneration).toHaveBeenCalledWith('child-1', {
expectedCreatedAt: 123,
awaitProviderDrain: true,
});
await release?.();
});
it('aborts when renewal throws instead of silently running past expiry', async () => {
renewSubagentThreadLease.mockRejectedValue(new Error('mongo unavailable'));
const release = await acquireEventChildGenerationLease({
userId: 'user-1',
conversationId: 'child-1',
streamId: 'child-1',
jobCreatedAt: 123,
});
await jest.advanceTimersByTimeAsync(10_000);
expect(abortGeneration).toHaveBeenCalledWith('child-1', {
expectedCreatedAt: 123,
awaitProviderDrain: true,
});
await release?.();
});
it('caps ownership and aborts the exact generation at the inherited retention deadline', async () => {
const retentionExpiresAt = new Date('2026-08-22T00:00:05.000Z');
const release = await acquireEventChildGenerationLease({
userId: 'user-1',
tenantId: 'tenant-1',
conversationId: 'child-1',
streamId: 'child-1',
jobCreatedAt: 123,
retentionExpiresAt,
});
expect(acquireSubagentThreadLease).toHaveBeenCalledWith(
expect.objectContaining({ expiresAt: retentionExpiresAt }),
);
await jest.advanceTimersByTimeAsync(4_999);
expect(abortGeneration).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(1);
expect(abortGeneration).toHaveBeenCalledWith('child-1', {
expectedCreatedAt: 123,
awaitProviderDrain: true,
});
await release?.();
});
it('retains the fence and retries an unconfirmed deadline abort', async () => {
abortGeneration
.mockResolvedValueOnce(stoppedResult('job_still_active'))
.mockResolvedValueOnce(stoppedResult());
const release = await acquireEventChildGenerationLease({
userId: 'user-1',
conversationId: 'child-1',
streamId: 'child-1',
jobCreatedAt: 123,
retentionExpiresAt: new Date('2026-08-22T00:00:05.000Z'),
});
await jest.advanceTimersByTimeAsync(5_000);
expect(abortGeneration).toHaveBeenCalledTimes(1);
expect(releaseSubagentThreadLease).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(250);
expect(abortGeneration).toHaveBeenCalledTimes(2);
await release?.();
});
it('retains the fence and retries when the deadline abort throws', async () => {
abortGeneration
.mockRejectedValueOnce(new Error('abort store unavailable'))
.mockResolvedValueOnce(stoppedResult());
const release = await acquireEventChildGenerationLease({
userId: 'user-1',
conversationId: 'child-1',
streamId: 'child-1',
jobCreatedAt: 123,
retentionExpiresAt: new Date('2026-08-22T00:00:05.000Z'),
});
await jest.advanceTimersByTimeAsync(5_250);
expect(abortGeneration).toHaveBeenCalledTimes(2);
expect(releaseSubagentThreadLease).not.toHaveBeenCalled();
await release?.();
});
});

View file

@ -0,0 +1,228 @@
import { randomUUID } from 'node:crypto';
import { logger } from '@librechat/data-schemas';
import type { ConversationMethods } from '@librechat/data-schemas';
import type { AbortResult } from '../../stream/interfaces/IJobStore';
import { isStopConfirmed } from '../../stream/interfaces/IJobStore';
const EVENT_CHILD_LEASE_TTL_MS = 30_000;
const EVENT_CHILD_LEASE_HEARTBEAT_MS = 10_000;
const EVENT_CHILD_ABORT_RETRY_MS = 250;
const MAX_TIMER_DELAY_MS = 2_147_483_647;
type EventChildLeaseMethods = Pick<
ConversationMethods,
'acquireSubagentThreadLease' | 'renewSubagentThreadLease' | 'releaseSubagentThreadLease'
>;
interface AbortGenerationOptions {
expectedCreatedAt: number;
awaitProviderDrain: true;
}
export interface EventChildGenerationLeaseDependencies {
methods: EventChildLeaseMethods;
abortGeneration: (streamId: string, options: AbortGenerationOptions) => Promise<AbortResult>;
}
export interface EventChildGenerationLeaseInput {
userId: string;
tenantId?: string;
conversationId: string;
streamId: string;
jobCreatedAt: number;
retentionExpiresAt?: Date | string | number;
}
export type ReleaseEventChildGenerationLease = () => Promise<void>;
/** Makes an event-driven child generation visible to the durable deletion protocol. */
export function createEventChildGenerationLeaseAcquirer({
methods,
abortGeneration,
}: EventChildGenerationLeaseDependencies) {
return async function acquireEventChildGenerationLease({
userId,
tenantId,
conversationId,
streamId,
jobCreatedAt,
retentionExpiresAt,
}: EventChildGenerationLeaseInput): Promise<ReleaseEventChildGenerationLease | null> {
const token = randomUUID();
const leaseIdentity = {
user: userId,
conversationId,
token,
...(tenantId == null ? {} : { tenantId }),
};
const initialTime = Date.now();
const retentionDeadline =
retentionExpiresAt == null ? undefined : new Date(retentionExpiresAt).getTime();
if (
retentionDeadline != null &&
(!Number.isFinite(retentionDeadline) || retentionDeadline <= initialTime)
) {
return null;
}
const initialLeaseDeadline = Math.min(
initialTime + EVENT_CHILD_LEASE_TTL_MS,
retentionDeadline ?? Number.POSITIVE_INFINITY,
);
const releaseRejectedLease = async (): Promise<void> => {
await methods.releaseSubagentThreadLease(leaseIdentity).catch((error) => {
logger.warn('[EventChildLease] Failed to release a rejected initial lease', { error });
});
};
const acquired = await methods.acquireSubagentThreadLease({
...leaseIdentity,
taskId: streamId,
now: new Date(initialTime),
expiresAt: new Date(initialLeaseDeadline),
});
if (!acquired) {
return null;
}
const acquiredAt = Date.now();
if (acquiredAt >= initialLeaseDeadline) {
await releaseRejectedLease();
return null;
}
let stopped = false;
let leaseLost = false;
let heldUntil = initialLeaseDeadline;
if (
initialLeaseDeadline !== retentionDeadline &&
initialLeaseDeadline - acquiredAt <= EVENT_CHILD_LEASE_HEARTBEAT_MS
) {
const refreshedUntil = Math.min(
acquiredAt + EVENT_CHILD_LEASE_TTL_MS,
retentionDeadline ?? Number.POSITIVE_INFINITY,
);
let refreshed: boolean;
try {
refreshed = await methods.renewSubagentThreadLease({
...leaseIdentity,
now: new Date(acquiredAt),
expiresAt: new Date(refreshedUntil),
});
} catch (error) {
await releaseRejectedLease();
throw error;
}
if (!refreshed || Date.now() >= initialLeaseDeadline) {
await releaseRejectedLease();
return null;
}
heldUntil = refreshedUntil;
}
let renewalInFlight: Promise<void> | undefined;
let abortInFlight: Promise<void> | undefined;
let deadlineTimer: NodeJS.Timeout | undefined;
const abortForLostLease = (message: string, error?: unknown): Promise<void> => {
if (stopped) {
return Promise.resolve();
}
if (abortInFlight != null) {
return abortInFlight;
}
leaseLost = true;
logger.warn(message, error == null ? undefined : { error });
/** Retain the durable fence until the exact generation is confirmed stopped.
* An abort reply can be ambiguous (`job_still_active`, `job_not_found`) and a
* store/provider failure can throw after the deadline has already fired. The
* owner therefore retries until abort is authoritative or its own provider
* finishes and calls `release`, which is the alternate proof of drain. */
abortInFlight = (async () => {
while (!stopped) {
try {
const result = await abortGeneration(streamId, {
expectedCreatedAt: jobCreatedAt,
awaitProviderDrain: true,
});
if (isStopConfirmed(result)) {
return;
}
logger.warn('[EventChildLease] Generation stop was not confirmed; retrying', {
streamId,
failureReason: result.failureReason,
});
} catch (abortError) {
logger.warn('[EventChildLease] Failed to stop generation after lease loss; retrying', {
streamId,
error: abortError,
});
}
await new Promise((resolve) => setTimeout(resolve, EVENT_CHILD_ABORT_RETRY_MS));
}
})();
return abortInFlight;
};
const renew = (): void => {
if (stopped || leaseLost || renewalInFlight != null) {
return;
}
renewalInFlight = (async () => {
const previousDeadline = heldUntil;
const renewalTime = Date.now();
const renewedUntil = Math.min(
renewalTime + EVENT_CHILD_LEASE_TTL_MS,
retentionDeadline ?? Number.POSITIVE_INFINITY,
);
if (renewedUntil <= renewalTime) {
await abortForLostLease(
'[EventChildLease] Generation reached its inherited retention deadline',
);
return;
}
const held = await methods.renewSubagentThreadLease({
...leaseIdentity,
now: new Date(renewalTime),
expiresAt: new Date(renewedUntil),
});
if (!held || Date.now() >= previousDeadline) {
await abortForLostLease(
'[EventChildLease] Generation lost continuous ownership of its lease',
);
return;
}
heldUntil = renewedUntil;
})()
.catch((error) =>
abortForLostLease('[EventChildLease] Renewal failed; stopping generation', error),
)
.finally(() => {
renewalInFlight = undefined;
});
};
const armRetentionDeadline = (): void => {
if (retentionDeadline == null || stopped || leaseLost) {
return;
}
const remaining = retentionDeadline - Date.now();
if (remaining <= 0) {
void abortForLostLease(
'[EventChildLease] Generation reached its inherited retention deadline',
);
return;
}
deadlineTimer = setTimeout(armRetentionDeadline, Math.min(remaining, MAX_TIMER_DELAY_MS));
};
const heartbeat = setInterval(renew, EVENT_CHILD_LEASE_HEARTBEAT_MS);
armRetentionDeadline();
return async () => {
if (stopped) {
return;
}
stopped = true;
clearInterval(heartbeat);
clearTimeout(deadlineTimer);
await renewalInFlight;
await abortInFlight;
await methods.releaseSubagentThreadLease(leaseIdentity).catch((error) => {
logger.warn('[EventChildLease] Release failed', { error });
});
};
};
}

View file

@ -21,9 +21,12 @@ const endpointsConfig: TEndpointsConfig = {
};
describe('excludedKeys', () => {
it.each(['_id', 'user', 'conversationId', '__v'])('excludes system field "%s"', (field) => {
expect(excludedKeys.has(field)).toBe(true);
});
it.each(['_id', 'user', 'conversationId', 'agentEventBinding', '__v'])(
'excludes system field "%s"',
(field) => {
expect(excludedKeys.has(field)).toBe(true);
},
);
it('does not exclude tenantId (plugin-level guard owns this)', () => {
expect(excludedKeys.has('tenantId')).toBe(false);

View file

@ -69,6 +69,7 @@ export const defaultRetrievalModels = [
export const excludedKeys = new Set([
'conversationId',
'agentEventBinding',
'subagentThread',
'title',
'iconURL',

View file

@ -1,5 +1,5 @@
import mongoose from 'mongoose';
import { v4 as uuidv4 } from 'uuid';
import mongoose, { type FilterQuery } from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { EModelEndpoint, RetentionMode } from 'librechat-data-provider';
import type {
@ -114,6 +114,7 @@ describe('Conversation Operations', () => {
let mockCtx: {
userId: string;
isTemporary?: boolean;
expiredAt?: Date;
interfaceConfig?: { temporaryChatRetention?: number; retentionMode?: RetentionMode };
};
let mockConversationData: {
@ -984,6 +985,18 @@ describe('Conversation Operations', () => {
);
});
it('preserves an exact inherited expiration instead of recomputing retention', async () => {
const inheritedExpiration = new Date('2026-08-22T03:04:05.000Z');
mockCtx.isTemporary = true;
mockCtx.expiredAt = inheritedExpiration;
mockCtx.interfaceConfig = { temporaryChatRetention: 48 };
const result = await saveConvo(mockCtx, mockConversationData);
expect(result?.isTemporary).toBe(true);
expect(result?.expiredAt).toEqual(inheritedExpiration);
});
it('should save a conversation without expiredAt when isTemporary is false', async () => {
mockCtx.isTemporary = false;
@ -1727,11 +1740,17 @@ describe('Conversation Operations', () => {
},
]);
deleteMessages.mockResolvedValue({ acknowledged: true, deletedCount: 3 });
const beforeDelete = jest.fn(async (conversationIds: string[]) => {
expect(
await Conversation.countDocuments({ conversationId: { $in: conversationIds } }),
).toBe(conversationIds.length);
});
const result = await deleteConvos('user123', { conversationId: parentId });
const result = await deleteConvos('user123', { conversationId: parentId }, { beforeDelete });
expect(result.deletedCount).toBe(3);
expect(result.conversationIds).toEqual([parentId, childId, grandchildId]);
expect(beforeDelete.mock.calls).toEqual([[[parentId]], [[childId]], [[grandchildId]]]);
expect(deleteMessages).toHaveBeenCalledWith({
conversationId: { $in: [parentId, childId, grandchildId] },
user: 'user123',
@ -1740,6 +1759,82 @@ describe('Conversation Operations', () => {
expect(await Conversation.findOne({ conversationId: otherUsersChildId })).not.toBeNull();
});
it('reports a partial cascade failure instead of silently succeeding', async () => {
const parentId = uuidv4();
const childId = uuidv4();
const project = await ChatProject.create({
user: 'user123',
name: 'Partial Cascade',
conversationCount: 1,
lastConversationId: parentId,
});
await ConversationTag.create({ user: 'user123', tag: 'work', count: 1, position: 1 });
await Conversation.create([
{
conversationId: parentId,
user: 'user123',
endpoint: EModelEndpoint.agents,
chatProjectId: project._id!.toString(),
tags: ['work'],
},
{
conversationId: childId,
user: 'user123',
endpoint: EModelEndpoint.agents,
subagentThread: {
rootConversationId: parentId,
parentConversationId: parentId,
parentMessageId: 'message-1',
parentToolCallId: 'tool-1',
subagentType: 'agent-child',
subagentKind: 'agent',
depth: 1,
},
},
]);
const realFind = Conversation.find.bind(Conversation);
const findSpy = jest.spyOn(Conversation, 'find').mockImplementation(((filter) => {
if (
filter != null &&
Object.prototype.hasOwnProperty.call(filter, 'subagentThread.parentConversationId')
) {
return {
select: () => ({ lean: () => Promise.reject(new Error('stepdown')) }),
};
}
return realFind(filter as FilterQuery<IConversation>);
}) as typeof Conversation.find);
await expect(deleteConvos('user123', { conversationId: parentId })).rejects.toThrow(
'stepdown',
);
expect(findSpy).toHaveBeenCalledTimes(4);
expect(await Conversation.findOne({ conversationId: parentId })).toBeNull();
expect(await Conversation.findOne({ conversationId: childId })).not.toBeNull();
expect(deleteMessages).not.toHaveBeenCalled();
expect((await ConversationTag.findOne({ user: 'user123', tag: 'work' }).lean())?.count).toBe(
0,
);
expect(
(await ChatProject.findById(project._id).lean<IChatProject>())?.conversationCount,
).toBe(0);
findSpy.mockRestore();
deleteMessages.mockResolvedValue({ acknowledged: true, deletedCount: 2 });
const recovered = await deleteConvos('user123', { conversationId: parentId });
expect(recovered.conversationIds).toEqual([parentId, childId]);
expect(await Conversation.findOne({ conversationId: childId })).toBeNull();
expect(deleteMessages).toHaveBeenCalledWith({
conversationId: { $in: [parentId, childId] },
user: 'user123',
});
expect((await ConversationTag.findOne({ user: 'user123', tag: 'work' }).lean())?.count).toBe(
0,
);
findSpy.mockRestore();
});
it('does not delete a parent when deleting one child thread', async () => {
const parentId = uuidv4();
const childId = uuidv4();
@ -3494,6 +3589,70 @@ describe('Conversation Operations', () => {
);
});
it('resolves an event binding only through its owner, tenant, and API key', async () => {
const conversationId = uuidv4();
const bindingId = `evtbind_${'a'.repeat(48)}`;
await Conversation.create({
conversationId,
user: 'binding-user',
tenantId: 'tenant-a',
endpoint: EModelEndpoint.agents,
agent_id: 'agent-player',
agentEventBinding: { bindingId, sourceKeyId: 'key-a', actorId: 'player-a' },
subagentThread: {
rootConversationId: 'parent',
parentConversationId: 'parent',
parentMessageId: 'parent-message',
parentToolCallId: 'event-binding',
parentAgentId: 'agent-director',
subagentType: 'agent-player',
subagentKind: 'agent',
depth: 1,
},
});
await expect(
methods.getAgentEventBinding({
user: 'binding-user',
tenantId: 'tenant-a',
bindingId,
sourceKeyId: 'key-a',
}),
).resolves.toMatchObject({
conversationId,
agentId: 'agent-player',
binding: { bindingId, sourceKeyId: 'key-a', actorId: 'player-a' },
});
await expect(
methods.getAgentEventBinding({
user: 'binding-user',
tenantId: 'tenant-a',
bindingId,
sourceKeyId: 'key-b',
}),
).resolves.toBeNull();
await expect(
methods.getAgentEventBinding({
user: 'binding-user',
tenantId: 'tenant-b',
bindingId,
sourceKeyId: 'key-a',
}),
).resolves.toBeNull();
await Conversation.updateOne({ conversationId }, { expiredAt: new Date(0) });
await expect(
methods.getAgentEventBinding({
user: 'binding-user',
tenantId: 'tenant-a',
bindingId,
sourceKeyId: 'key-a',
}),
).resolves.toBeNull();
expect(await methods.getConvo('binding-user', conversationId)).not.toHaveProperty(
'agentEventBinding',
);
});
it('admits one cross-replica owner and fences renewal and release by token', async () => {
const conversationId = uuidv4();
await Conversation.create({

View file

@ -2,6 +2,7 @@ import { RetentionMode } from 'librechat-data-provider';
import type { FilterQuery, Model, SortOrder, Types } from 'mongoose';
import type { DeleteResult } from 'mongoose';
import type {
IAgentEventBindingRecord,
AppConfig,
IChatProjectDocument,
IActiveSubagentThreadLease,
@ -120,7 +121,12 @@ export interface ConversationMethods {
messages: { deletedCount?: number };
}>;
saveConvo(
ctx: { userId: string; isTemporary?: boolean; interfaceConfig?: AppConfig['interfaceConfig'] },
ctx: {
userId: string;
isTemporary?: boolean;
expiredAt?: Date;
interfaceConfig?: AppConfig['interfaceConfig'];
},
data: { conversationId: string; newConversationId?: string; [key: string]: unknown },
metadata?: {
context?: string;
@ -167,6 +173,12 @@ export interface ConversationMethods {
conversationId: string;
tenantId?: string;
}): Promise<SubagentThreadReadRecord | null>;
getAgentEventBinding(input: {
user: string;
bindingId: string;
sourceKeyId: string;
tenantId?: string;
}): Promise<IAgentEventBindingRecord | null>;
reserveSubagentThread(input: {
user: string;
conversationId: string;
@ -219,6 +231,7 @@ export interface ConversationMethods {
deleteConvos(
user: string,
filter: FilterQuery<IConversation>,
options?: { beforeDelete?: (conversationIds: string[]) => Promise<void> },
): Promise<DeleteResult & { messages: DeleteResult; conversationIds: string[] }>;
archiveAllConvos(user: string): Promise<{ archivedCount: number }>;
}
@ -297,6 +310,43 @@ export function createConversationMethods(
}
}
/** Resolves an event target only when the API key, owner, and tenant all match. */
async function getAgentEventBinding(input: {
user: string;
bindingId: string;
sourceKeyId: string;
tenantId?: string;
}): Promise<IAgentEventBindingRecord | null> {
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const conversation = await Conversation.findOne({
user: input.user,
'agentEventBinding.bindingId': input.bindingId,
'agentEventBinding.sourceKeyId': input.sourceKeyId,
...subagentLeaseTenantFilter(input.tenantId),
...activeExpirationFilter<IConversation>(),
})
.select(
'conversationId agent_id tenantId isTemporary expiredAt subagentThread +agentEventBinding',
)
.lean<IConversation>();
if (
conversation?.agentEventBinding == null ||
conversation.subagentThread == null ||
typeof conversation.agent_id !== 'string'
) {
return null;
}
return {
conversationId: conversation.conversationId,
agentId: conversation.agent_id,
...(conversation.tenantId == null ? {} : { tenantId: conversation.tenantId }),
...(conversation.isTemporary == null ? {} : { isTemporary: conversation.isTemporary }),
...(conversation.expiredAt == null ? {} : { expiredAt: conversation.expiredAt }),
binding: conversation.agentEventBinding,
lineage: conversation.subagentThread,
};
}
/** Creates immutable child lineage exactly once without overwriting a concurrent winner. */
async function reserveSubagentThread(input: {
user: string;
@ -322,7 +372,7 @@ export function createConversationMethods(
},
},
{ new: true, upsert: true, includeResultMetadata: true, setDefaultsOnInsert: true },
)) as unknown as ConversationUpdateResult;
).select('+agentEventBinding')) as unknown as ConversationUpdateResult;
if (result.value == null) {
throw new Error('Unable to reserve the subagent thread.');
}
@ -334,7 +384,9 @@ export function createConversationMethods(
/** Concurrent upserts can race at the unique index. The document that won is
* the reservation; callers still validate its immutable lineage before use. */
if ((error as { code?: number }).code === 11000) {
const existing = await Conversation.findOne(filter).lean<IConversation>();
const existing = await Conversation.findOne(filter)
.select('+agentEventBinding')
.lean<IConversation>();
if (existing != null) {
return { conversation: existing, created: false };
}
@ -570,10 +622,12 @@ export function createConversationMethods(
{
userId,
isTemporary,
expiredAt,
interfaceConfig,
}: {
userId: string;
isTemporary?: boolean;
expiredAt?: Date;
interfaceConfig?: AppConfig['interfaceConfig'];
},
{
@ -640,7 +694,12 @@ export function createConversationMethods(
update.conversationId = newConversationId;
}
if (interfaceConfig?.retentionMode === RetentionMode.ALL) {
if (expiredAt instanceof Date && !Number.isNaN(expiredAt.getTime())) {
if (typeof isTemporary === 'boolean') {
update.isTemporary = isTemporary;
}
update.expiredAt = expiredAt;
} else if (interfaceConfig?.retentionMode === RetentionMode.ALL) {
if (typeof isTemporary === 'boolean') {
update.isTemporary = isTemporary;
}
@ -1347,16 +1406,56 @@ export function createConversationMethods(
/**
* Deletes conversations and their associated messages for a given user and filter.
*/
async function deleteConvos(user: string, filter: FilterQuery<IConversation>) {
async function deleteConvos(
user: string,
filter: FilterQuery<IConversation>,
options?: { beforeDelete?: (conversationIds: string[]) => Promise<void> },
) {
try {
const Conversation = mongoose.models.Conversation as Model<IConversation>;
const { deleteMessages } = getMessageMethods();
const { deleteMessages, getMessages } = getMessageMethods();
const userFilter = { ...filter, user };
type DeletionConversation = Pick<IConversation, 'conversationId' | 'chatProjectId' | 'tags'>;
const conversations = await Conversation.find(userFilter)
const retryCascadeOperation = async <T>(operation: () => PromiseLike<T> | T): Promise<T> => {
let lastError: unknown;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
return await operation();
} catch (error) {
lastError = error;
if (attempt < 3) {
await new Promise((resolve) => setTimeout(resolve, 25 * attempt));
}
}
}
throw lastError;
};
let conversations = await Conversation.find(userFilter)
.select('conversationId chatProjectId tags')
.lean<DeletionConversation[]>();
if (!conversations.length) {
const recoveryConversationIds: string[] = [];
if (!conversations.length && typeof filter.conversationId === 'string') {
/** A prior attempt may have deleted the root before a descendant read failed.
* Resume from immutable root lineage and retain the root id for message,
* checkpoint, and tool cleanup. The message probe distinguishes that partial
* commit from a conversation id that never existed. */
const [descendants, rootMessages] = await Promise.all([
retryCascadeOperation(() =>
Conversation.find({
user,
'subagentThread.rootConversationId': filter.conversationId,
})
.select('conversationId chatProjectId tags')
.lean<DeletionConversation[]>(),
),
getMessages({ user, conversationId: filter.conversationId }, '_id', { limit: 1 }),
]);
if (descendants.length === 0 && rootMessages.length === 0) {
throw new Error('Conversation not found or already deleted.');
}
conversations = descendants;
recoveryConversationIds.push(filter.conversationId);
} else if (!conversations.length) {
throw new Error('Conversation not found or already deleted.');
}
@ -1372,72 +1471,72 @@ export function createConversationMethods(
let pending = conversations;
let acknowledged = true;
let deletedCount = 0;
const reconcileDeletedWave = async (
wave: DeletionConversation[],
waveDeletedCount: number,
): Promise<void> => {
if (waveDeletedCount === 0) {
return;
}
/**
* Commit derived metadata while the deleted documents are still available in
* memory. Descendant discovery can fail after this point; deferring the
* reconciliation until the whole walk completes would make the root's tags and
* project impossible to recover on a later retry.
*/
const tagDecrements: string[] = [];
for (const conversation of wave) {
for (const tag of new Set(conversation.tags ?? [])) {
tagDecrements.push(tag);
}
}
await decrementTagCounts(mongoose, user, tagDecrements);
const waveProjectIds = new Set(
wave
.map((conversation) => conversation.chatProjectId)
.filter((projectId): projectId is string => Boolean(projectId)),
);
if (waveProjectIds.size > 0) {
try {
await refreshChatProjectStatsInBatches(mongoose, user, waveProjectIds);
} catch (error) {
logger.error('[deleteConvos] Conversations deleted but stats refresh failed', error);
}
}
};
while (pending.length > 0) {
const wave = pending.filter((conversation) => !seen.has(conversation.conversationId));
if (wave.length === 0) {
break;
}
const waveIds = wave.map((conversation) => conversation.conversationId);
try {
const result = await Conversation.deleteMany({ user, conversationId: { $in: waveIds } });
acknowledged &&= result.acknowledged;
deletedCount += result.deletedCount;
for (const conversation of wave) {
seen.add(conversation.conversationId);
deletedConversations.push(conversation);
}
pending = await Conversation.find({
await options?.beforeDelete?.(waveIds);
const result = await Conversation.deleteMany({ user, conversationId: { $in: waveIds } });
acknowledged &&= result.acknowledged;
deletedCount += result.deletedCount;
await reconcileDeletedWave(wave, result.deletedCount);
for (const conversation of wave) {
seen.add(conversation.conversationId);
deletedConversations.push(conversation);
}
pending = await retryCascadeOperation(() =>
Conversation.find({
user,
'subagentThread.parentConversationId': { $in: waveIds },
})
.select('conversationId chatProjectId tags')
.lean<DeletionConversation[]>();
} catch (error) {
if (deletedConversations.length === 0) {
throw error;
}
logger.error('[deleteConvos] Root deleted but child-thread cascade failed', error);
break;
}
.lean<DeletionConversation[]>(),
);
}
const conversationIds = deletedConversations.map((c) => c.conversationId);
const projectIds = new Set(
deletedConversations
.map((conversation) => conversation.chatProjectId)
.filter((projectId): projectId is string => Boolean(projectId)),
);
/**
* One entry per (conversation, tag) association: each conversation's tags are
* deduped so a duplicate tag entry within a single conversation only decrements
* the bookmark count once.
*/
const tagDecrements: string[] = [];
for (const conversation of deletedConversations) {
if (!conversation.tags?.length) {
continue;
}
for (const tag of new Set(conversation.tags)) {
tagDecrements.push(tag);
}
}
const conversationIds = [
...recoveryConversationIds,
...deletedConversations.map((conversation) => conversation.conversationId),
];
const deleteConvoResult: DeleteResult = { acknowledged, deletedCount };
const deleted = deleteConvoResult.deletedCount > 0;
/**
* Reconcile bookmark counts from the deletion before message cleanup: if
* `deleteMessages` later throws, the conversation is already gone and a retry
* finds nothing, so the count must be reconciled here or it would stay stale.
* The decrement is best-effort and never throws, so it cannot block message
* cleanup. The `deletedCount` guard skips a losing concurrent delete whose
* pre-delete snapshot would otherwise decrement a conversation it did not
* actually remove.
*/
if (deleted) {
await decrementTagCounts(mongoose, user, tagDecrements);
}
/**
* Post-delete cleanup is best-effort: the conversations are already gone, so a
@ -1455,19 +1554,6 @@ export function createConversationMethods(
logger.error('[deleteConvos] Conversations deleted but message cleanup failed', error);
}
/**
* Refresh project stats after message cleanup so a stats-refresh error cannot
* prevent `deleteMessages` from running, which would orphan the deleted
* conversations' messages.
*/
if (deleted && projectIds.size > 0) {
try {
await refreshChatProjectStatsInBatches(mongoose, user, projectIds);
} catch (error) {
logger.error('[deleteConvos] Conversations deleted but stats refresh failed', error);
}
}
// conversationIds lets callers run sibling cleanup that lives in higher layers
// (e.g. pruning the conversations' durable agent checkpoints) without re-querying
// documents that no longer exist.
@ -1617,6 +1703,7 @@ export function createConversationMethods(
getConvosQueried,
getConvo,
getSubagentThreadForParent,
getAgentEventBinding,
reserveSubagentThread,
acquireSubagentThreadLease,
renewSubagentThreadLease,

View file

@ -74,6 +74,7 @@ describe('Message Operations', () => {
let mockCtx: {
userId: string;
isTemporary?: boolean;
expiredAt?: Date;
interfaceConfig?: { temporaryChatRetention?: number; retentionMode?: RetentionMode };
};
let mockMessageData: Partial<IMessage> = {
@ -1310,6 +1311,18 @@ describe('Message Operations', () => {
);
});
it('preserves an exact inherited expiration instead of recomputing retention', async () => {
const inheritedExpiration = new Date('2026-08-22T03:04:05.000Z');
mockCtx.isTemporary = true;
mockCtx.expiredAt = inheritedExpiration;
mockCtx.interfaceConfig = { temporaryChatRetention: 48 };
const result = await saveMessage(mockCtx, mockMessageData);
expect(result?.isTemporary).toBe(true);
expect(result?.expiredAt).toEqual(inheritedExpiration);
});
it('should save a message without expiredAt when isTemporary is false', async () => {
mockCtx.isTemporary = false;

View file

@ -258,7 +258,12 @@ export type SubagentThreadViewMessageRecord = Pick<
export interface MessageMethods {
saveMessage(
ctx: { userId: string; isTemporary?: boolean; interfaceConfig?: AppConfig['interfaceConfig'] },
ctx: {
userId: string;
isTemporary?: boolean;
expiredAt?: Date;
interfaceConfig?: AppConfig['interfaceConfig'];
},
params: Partial<IMessage> & { newMessageId?: string },
metadata?: { context?: string },
): Promise<IMessage | null | undefined>;
@ -347,10 +352,12 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
{
userId,
isTemporary,
expiredAt,
interfaceConfig,
}: {
userId: string;
isTemporary?: boolean;
expiredAt?: Date;
interfaceConfig?: AppConfig['interfaceConfig'];
},
params: Partial<IMessage> & { newMessageId?: string },
@ -376,7 +383,12 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa
messageId: params.newMessageId || params.messageId,
};
if (interfaceConfig?.retentionMode === RetentionMode.ALL) {
if (expiredAt instanceof Date && !Number.isNaN(expiredAt.getTime())) {
if (typeof isTemporary === 'boolean') {
update.isTemporary = isTemporary;
}
update.expiredAt = expiredAt;
} else if (interfaceConfig?.retentionMode === RetentionMode.ALL) {
if (typeof isTemporary === 'boolean') {
update.isTemporary = isTemporary;
}

View file

@ -54,6 +54,18 @@ const convoSchema: Schema<IConversation> = new Schema(
default: undefined,
select: false,
},
/** Authenticated event sources address child actors through this opaque binding.
* The source never supplies the stored agent/thread target during delivery. */
agentEventBinding: {
type: {
bindingId: { type: String, required: true },
sourceKeyId: { type: String, required: true },
actorId: { type: String, required: true },
},
_id: false,
default: undefined,
select: false,
},
tags: {
type: [String],
default: [],
@ -106,6 +118,10 @@ convoSchema.index({ user: 1, isTemporary: 1, expiredAt: 1 });
/** Owner-scoped child-thread cascade lookup used when a parent is deleted. */
convoSchema.index({ user: 1, 'subagentThread.parentConversationId': 1 });
convoSchema.index({ user: 1, 'subagentThreadLease.expiresAt': 1 });
convoSchema.index(
{ 'agentEventBinding.bindingId': 1 },
{ unique: true, sparse: true, name: 'agent_event_binding_unique' },
);
// index for MeiliSearch sync operations
convoSchema.index({ _meiliIndex: 1, isTemporary: 1, expiredAt: 1 });

View file

@ -7,6 +7,23 @@ export interface ISubagentThreadLease {
expiresAt: Date;
}
/** Server-private route from one authenticated event source to a child actor thread. */
export interface IAgentEventBinding {
bindingId: string;
sourceKeyId: string;
actorId: string;
}
export interface IAgentEventBindingRecord {
conversationId: string;
agentId: string;
tenantId?: string;
isTemporary?: boolean;
expiredAt?: Date;
binding: IAgentEventBinding;
lineage: TSubagentThreadLineage;
}
export interface IActiveSubagentThreadLease {
conversationId: string;
parentConversationId: string;
@ -56,6 +73,8 @@ export interface IConversation extends Document {
subagentThread?: TSubagentThreadLineage;
/** Internal execution fence. Excluded from ordinary conversation reads. */
subagentThreadLease?: ISubagentThreadLease;
/** Internal event-source identity. Excluded from ordinary conversation reads. */
agentEventBinding?: IAgentEventBinding;
assistant_id?: string;
instructions?: string;
stop?: string[];