📇 feat: Surface Event Child Activity Through a Bounded Parent Index (#15142)

* feat: surface event-driven child activity

* fix: keep child task aggregation documentdb-compatible

* fix: address event activity review findings

* test: provide markdown message context defaults

* fix: report bounded child history truncation

* fix: preserve current child activity state

* fix: preserve durable event child activity

* fix: handle missing task timestamps

* fix: keep active event snapshots live

* fix: preserve event activity across valid anchors

* fix: close event child activity gaps

* fix: preserve event activity across resume
This commit is contained in:
Danny Avila 2026-08-23 18:50:00 -04:00 committed by GitHub
parent 8a118c7cb3
commit fc2b8584c4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
46 changed files with 2730 additions and 83 deletions

View file

@ -124,6 +124,76 @@ describe('resumable event generation fencing', () => {
);
});
it('publishes root event-child progress through the child activity transport', async () => {
const { nanoid } = require('nanoid');
nanoid.mockReturnValueOnce('invocation-1').mockReturnValueOnce('invocation-2');
const { GraphEvents } = jest.requireActual('@librechat/agents');
const { getDefaultHandlers } = require('../callbacks');
const publish = jest.fn().mockResolvedValue(undefined);
const data = {
id: 'step-1',
index: 0,
stepDetails: { type: 'message_creation' },
};
const handlers = getDefaultHandlers({
res: { write: jest.fn() },
aggregateContent: jest.fn(),
toolEndCallback: jest.fn(),
collectedUsage: [],
streamId: 'event-thread',
jobCreatedAt: 1234,
eventChildActivity: {
runId: 'event-thread',
parentRunId: 'parent-conversation',
subagentRunId: 'delivery-1',
subagentType: 'agent-1',
subagentAgentId: 'agent-1',
parentAgentId: 'director',
publish,
},
});
await handlers[GraphEvents.ON_RUN_STEP].handle(GraphEvents.ON_RUN_STEP, data);
await Promise.resolve();
await Promise.resolve();
const firstUpdate = publish.mock.calls[0][0];
expect(firstUpdate).toEqual(
expect.objectContaining({
runId: 'event-thread',
parentRunId: 'parent-conversation',
subagentRunId: 'delivery-1',
phase: 'run_step',
activityEventId: expect.stringMatching(/^delivery-1:.+:0$/),
data,
}),
);
expect(firstUpdate).not.toHaveProperty('activitySequence');
const resumedPublish = jest.fn().mockResolvedValue(undefined);
const resumedHandlers = getDefaultHandlers({
res: { write: jest.fn() },
aggregateContent: jest.fn(),
toolEndCallback: jest.fn(),
collectedUsage: [],
streamId: 'event-thread',
jobCreatedAt: 1234,
eventChildActivity: {
runId: 'event-thread',
parentRunId: 'parent-conversation',
subagentRunId: 'delivery-1',
subagentType: 'agent-1',
subagentAgentId: 'agent-1',
parentAgentId: 'director',
publish: resumedPublish,
},
});
await resumedHandlers[GraphEvents.ON_RUN_STEP].handle(GraphEvents.ON_RUN_STEP, data);
await Promise.resolve();
await Promise.resolve();
expect(resumedPublish.mock.calls[0][0].activityEventId).not.toBe(firstUpdate.activityEventId);
});
it('forwards the originating job epoch with deferred attachments', () => {
const { GenerationJobManager } = require('@librechat/api');
const { createAttachmentEmitter } = require('../callbacks');

View file

@ -3649,7 +3649,14 @@ describe('ResumableAgentController resume metadata', () => {
);
expect(mockGenerationJobManager.createJob).toHaveBeenCalledTimes(1);
expect(mockAcquireEventChildGenerationLease).toHaveBeenCalledWith(
expect.objectContaining({ retentionExpiresAt: expiredAt }),
expect.objectContaining({ taskId: 'req-event', retentionExpiresAt: expiredAt }),
);
const eventJobOptions = mockGenerationJobManager.createJob.mock.calls[0][3];
expect(eventJobOptions.initialMetadata).toEqual(
expect.objectContaining({
responseMessageId: 'req-event:assistant',
userMessage: expect.objectContaining({ messageId: 'req-event:user' }),
}),
);
});

View file

@ -466,7 +466,12 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
it('owns the lease before consuming approval and preserves the inherited deadline', async () => {
const expiredAt = configureEventActorResume();
mockGenerationJobManager.getJob.mockResolvedValue(
makeToolApprovalJob({ metadata: { isTemporary: true } }),
makeToolApprovalJob({
metadata: {
isTemporary: true,
idempotencyClientRequestId: 'trigger_event_delivery',
},
}),
);
const res = await post(approveBody());
@ -481,7 +486,10 @@ describe('ResumeAgentController (POST /agents/chat/resume)', () => {
mockGenerationJobManager.approvals.resolve.mock.invocationCallOrder[0],
);
expect(mockAcquireEventChildGenerationLease).toHaveBeenCalledWith(
expect.objectContaining({ retentionExpiresAt: expiredAt }),
expect.objectContaining({
taskId: 'trigger_event_delivery',
retentionExpiresAt: expiredAt,
}),
);
expect(mockSaveMessage).toHaveBeenCalledWith(
expect.objectContaining({ isTemporary: true, expiredAt }),

View file

@ -15,6 +15,7 @@ const {
GraphNodeKeys,
ToolEndHandler,
createContentAggregator,
summarizeEvent,
} = require('@librechat/agents');
const {
sendEvent,
@ -381,13 +382,73 @@ function getDefaultHandlers({
usageCost = null,
contextUsageSink = null,
usageEmitSink = null,
eventChildActivity = null,
}) {
if (!res || !aggregateContent) {
throw new Error(
`[getDefaultHandlers] Missing required options: res: ${!res}, aggregateContent: ${!aggregateContent}`,
);
}
const emitForJob = (eventData) => emitEvent(res, streamId, eventData, jobCreatedAt);
const eventActivityPhases = {
[GraphEvents.ON_RUN_STEP]: 'run_step',
[GraphEvents.ON_RUN_STEP_DELTA]: 'run_step_delta',
[GraphEvents.ON_RUN_STEP_COMPLETED]: 'run_step_completed',
[GraphEvents.ON_RUN_STEP_CLOSED]: 'run_step_closed',
[GraphEvents.ON_MESSAGE_DELTA]: 'message_delta',
[GraphEvents.ON_REASONING_DELTA]: 'reasoning_delta',
};
/** Event tasks retain one logical task id across HITL resume, while each
* handler instance is a new generation invocation. Keep replay identity
* unique per invocation; the existing stream transport preserves order.
* Reusing a zero-based activitySequence here would make the client discard
* resumed frames as duplicates of the pre-pause generation. */
const eventActivityInvocationId = eventChildActivity == null ? null : nanoid();
let eventActivitySequence = 0;
let eventActivityPending = 0;
let eventActivityCircuitOpen = false;
let eventActivityTail = Promise.resolve();
const publishEventChildActivity = (eventData) => {
const phase = eventActivityPhases[eventData?.event];
if (
eventChildActivity == null ||
phase == null ||
eventActivityCircuitOpen ||
eventActivityPending >= 128
) {
return;
}
const sequence = eventActivitySequence++;
eventActivityPending += 1;
const update = {
runId: eventChildActivity.runId,
parentRunId: eventChildActivity.parentRunId,
subagentRunId: eventChildActivity.subagentRunId,
subagentType: eventChildActivity.subagentType,
subagentKind: 'agent',
subagentAgentId: eventChildActivity.subagentAgentId,
parentAgentId: eventChildActivity.parentAgentId,
depth: 1,
ancestry: [],
phase,
data: eventData.data,
label: summarizeEvent(eventData.event, eventData.data),
timestamp: new Date().toISOString(),
activityEventId: `${eventChildActivity.subagentRunId}:${eventActivityInvocationId}:${sequence}`,
};
eventActivityTail = eventActivityTail
.then(() => eventChildActivity.publish(update))
.catch((error) => {
eventActivityCircuitOpen = true;
logger.warn('[getDefaultHandlers] Failed to publish event child activity', error);
})
.finally(() => {
eventActivityPending = Math.max(0, eventActivityPending - 1);
});
};
const emitForJob = (eventData) => {
publishEventChildActivity(eventData);
return emitEvent(res, streamId, eventData, jobCreatedAt);
};
/**
* Emit a token-usage event, attaching the authoritative per-event USD cost
* when cost display is enabled. The backend is the single source of truth

View file

@ -1009,13 +1009,28 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
const overrideUserMessageId = rawOverrideUserMessageId
? rawOverrideUserMessageId.split(Constants.COMMON_DIVIDER)[0]
: undefined;
/** Event deliveries already carry a stable, retry-safe idempotency key. Reuse
* it as the public child-task identity so the lease, persisted turn, and
* parent activity index continue to agree after the live lease is released. */
const eventTaskId =
req._agentEventBindingParentConversationId != null
? (clientRequestId ?? crypto.randomUUID())
: undefined;
if (eventTaskId != null) {
req._agentEventTaskId = eventTaskId;
}
const preallocatedUserMessageId =
overrideUserMessageId ?? overrideParentMessageId ?? crypto.randomUUID();
eventTaskId == null
? (overrideUserMessageId ?? overrideParentMessageId ?? crypto.randomUUID())
: `${eventTaskId}:user`;
const overrideConversationId = rawOverrideConversationId
? rawOverrideConversationId.split(Constants.COMMON_DIVIDER)[0]
: undefined;
const effectiveConversationId = overrideConversationId ?? conversationId;
let preallocatedResponseMessageId = editedResponseMessageId ?? crypto.randomUUID();
let preallocatedResponseMessageId =
eventTaskId == null
? (editedResponseMessageId ?? crypto.randomUUID())
: `${eventTaskId}:assistant`;
if (
(editedContent != null && !isContinued) ||
(isRegenerate && preallocatedResponseMessageId.endsWith('_'))
@ -1140,6 +1155,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
tenantId: req._agentEventBindingTenantId,
conversationId,
streamId,
taskId: eventTaskId,
jobCreatedAt,
retentionExpiresAt: req._agentEventBindingRetention?.expiredAt,
});

View file

@ -1107,12 +1107,14 @@ const ResumeAgentController = async (req, res, next, initializeClient, addTitle)
const providerExecutionId = randomUUID();
try {
if (req._agentEventBindingParentConversationId != null) {
req._agentEventTaskId = job.metadata.idempotencyClientRequestId;
try {
releaseEventChildLease = await acquireEventChildGenerationLease({
userId,
tenantId: req._agentEventBindingTenantId,
conversationId,
streamId,
taskId: job.metadata.idempotencyClientRequestId,
jobCreatedAt: job.createdAt,
retentionExpiresAt: req._agentEventBindingRetention?.expiredAt,
});

View file

@ -6,6 +6,7 @@ const {
deleteAgentCheckpoints,
createArchiveAllHandler,
createSubagentActivityStreamHandler,
createParentSubagentIndexHandler,
createSubagentThreadViewHandler,
resolveImportMaxFileSize,
restoreTenantContextFromReq,
@ -47,6 +48,11 @@ const subagentThreadViewHandler = createSubagentThreadViewHandler({
getSubagentThreadForParent: db.getSubagentThreadForParent,
getMessagesForSubagentThreadView: db.getMessagesForSubagentThreadView,
});
const parentSubagentIndexHandler = createParentSubagentIndexHandler({
getConvoOwnership: db.getConvoOwnership,
listSubagentThreadsForParent: db.listSubagentThreadsForParent,
listSubagentTasksForThreads: db.listSubagentTasksForThreads,
});
const filterConversationTitle = createContentFilter({
getFilters: (req) => req.config?.filters,
extract: (req) => extractConversationTitleContent(req.body),
@ -111,6 +117,7 @@ router.get(
'/:parentConversationId/subagents/:threadId/tasks/:taskId/activity',
subagentActivityStreamHandler,
);
router.get('/:parentConversationId/subagents', parentSubagentIndexHandler);
router.get('/:parentConversationId/subagents/:threadId', subagentThreadViewHandler);
router.get('/:conversationId', async (req, res) => {

View file

@ -1372,6 +1372,25 @@ const initializeClient = async ({
fallback: usageCost.endpointTokenConfig,
});
const eventTaskId = req._agentEventTaskId;
const eventChildActivity =
req._agentEventBindingParentConversationId != null &&
typeof conversationId === 'string' &&
conversationId !== '' &&
typeof eventTaskId === 'string' &&
eventTaskId !== ''
? {
runId: streamId ?? eventTaskId,
parentRunId: req._agentEventBindingParentConversationId,
subagentRunId: eventTaskId,
subagentType: primaryConfig.id,
subagentAgentId: primaryConfig.id,
parentAgentId: req._agentEventBindingParentAgentId,
publish: (event) =>
subagentThreadTaskStore.publishTaskActivity(conversationId, eventTaskId, event),
}
: null;
const eventHandlers = getDefaultHandlers({
res,
contentParts,
@ -1389,6 +1408,7 @@ const initializeClient = async ({
usageCost,
contextUsageSink,
usageEmitSink,
eventChildActivity,
});
const client = new AgentClient({

View file

@ -257,6 +257,46 @@ describe('initializeClient — processAgent ACL gate', () => {
);
});
it('publishes event-root activity through the owning child task stream', async () => {
const subagentThreadTaskStore = require('./subagentThreadStore');
const publishTaskActivity = jest
.spyOn(subagentThreadTaskStore, 'publishTaskActivity')
.mockResolvedValueOnce(undefined);
mockInitializeAgent.mockResolvedValue(makePrimaryConfig([]));
const req = makeReq();
req._resumableStreamId = 'child-conversation';
req.body.conversationId = 'child-conversation';
req._agentEventTaskId = 'event-task';
req._agentEventBindingParentConversationId = 'parent-conversation';
req._agentEventBindingParentAgentId = 'parent-agent';
await initializeClient({
req,
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
expect(capturedDefaultHandlerOptions.eventChildActivity).toEqual(
expect.objectContaining({
runId: 'child-conversation',
parentRunId: 'parent-conversation',
subagentRunId: 'event-task',
subagentType: PRIMARY_ID,
subagentAgentId: PRIMARY_ID,
parentAgentId: 'parent-agent',
}),
);
await capturedDefaultHandlerOptions.eventChildActivity.publish({
phase: 'writing',
label: 'Drafting response',
});
expect(publishTaskActivity).toHaveBeenCalledWith('child-conversation', 'event-task', {
phase: 'writing',
label: 'Drafting response',
});
});
it('propagates an expected-MCP-tools failure from the runtime tool loader', async () => {
const toolError = Object.assign(new Error('Expected MCP tools are unavailable'), {
code: 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',