mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-01 03:27:01 +00:00
📇 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:
parent
8a118c7cb3
commit
fc2b8584c4
46 changed files with 2730 additions and 83 deletions
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue