Skip to content

Commit c06b09c

Browse files
authored
🤖 feat: make Event Actor HITL durable (#15305)
* 🤖 feat: make event actor HITL durable * 🤖 fix: break event actor outcome cycle * 🤖 fix: close durable actor terminal races * fix: harden durable event actor recovery proofs * fix: close event actor resume publication races
1 parent 31325b4 commit c06b09c

35 files changed

Lines changed: 3812 additions & 121 deletions

‎CONTEXT.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
- **Agent event handling outcome**: the durable, generation-fenced result of a previously accepted event delivery. `started` proves generation admission; terminal states distinguish verified tool application, clean completion without action, failure, and cancellation. Transport success remains separate so an accepted event cannot masquerade as completed work.
1313
- **Agent event expected action**: an optional source-declared tool name and bounded argument subset evaluated against host-observed completed run steps. It is evidence policy, not authorization and not a model-authored success claim.
1414
- **Event actor head**: the private, durable pointer on an event-bound child conversation to its latest committed LangGraph checkpoint, plus one previous checkpoint for safe cleanup. Only a qualifying applied action advances it through compare-and-swap; failed, cancelled, or no-action invocations leave it unchanged. A legacy-path event marks the head for a cold rebuild from durable message history before fork mode can resume. Every applied commit conflict, unverified commit, or post-commit persistence failure is retained in a private reconciliation journal that blocks later actor turns instead of continuing from stale state; an exact marker can be cleared only after its checkpoint is verified authoritative, its history is repaired, or its external action is explicitly compensated.
15-
- **Event actor invocation fork**: a delivery-owned checkpoint namespace copied from the event actor head. A warm invocation receives only the new trusted event, then commits its terminal checkpoint when the expected action is observed or deletes the fork otherwise. Pause-capable actors remain on the existing resumable path until forked HITL has an explicit contract.
15+
- **Event actor invocation fork**: a delivery-owned checkpoint namespace copied from the event actor head. A warm invocation receives only the new trusted event, then commits its terminal checkpoint when the expected action is observed or deletes the fork otherwise. When the invocation pauses for approval or Ask User, the SDK emits signed, versioned suspension evidence. The child Conversation is the canonical one-shot suspension authority; the generation job carries only a versioned projection for UI, rolling-deploy routing, and the existing resume endpoint. Shared-store deployments publish new suspensions only under generation protocol v2, after the homogeneous-fleet cutover; protocol v1 keeps pause-capable actors on the legacy history path so an old resume consumer cannot consume unknown evidence. A resume shares one identity between its Conversation claim and provider-owner CAS, clears the predecessor projection, and publishes a successor only after a re-pause is canonical. A pending interrupt takes precedence over expected-action evidence from the same segment; if that segment already applied the expected action, publishing its successor pause cold-marks the prior head until a later applied commit replaces it. An ambiguous projection write is accepted only after reading back the exact generation, action, and suspension. The provider-start CAS is written only after client reconstruction and immediately before the continuation gate opens, then retains its exact execution identity after drain, so terminal recovery can compensate a projected claim only when that identity proves execution never began. Durable approval projection is exposed before the persistence barrier opens, preventing a resolved action from being announced afterward. Terminal no-action retirement cancels or settles the exact suspension and releases its delivery-side action admission before public settlement; if retention already removed the child Conversation, the delivery remains authoritative for its exact admission identity. Resume, re-pause, cancellation, and expiry claim or replace that exact suspension before touching its job projection, so later mailbox deliveries stay blocked until terminal history and handling evidence settle.
1616
- **Event actor receipt**: the private, terminal proof stored on the authoritative `AgentTriggerDelivery` row for one bound actor invocation. Its unique delivery identity, terminal resolution, exact checkpoint, and bounded action identity provide replay and recovery for the retention window without storing prompts, events, tool arguments, tool output, or conversation history. It does not own the actor checkpoint; the conversation keeps only the actor head and any active unresolved reconciliation until this receipt is durable.
1717
- **Agent event actor mailbox**: the automatic durable delivery-ordering lane for one authenticated source binding. It keeps later deliveries queued after transport admission until the current child turn records an authoritative terminal handling outcome. It serializes existing coalesced batches and individual events without becoming a second execution controller or actor checkpoint store.
1818
- **Theme definition**: a versioned, data-only description of LibreChat semantic colors and shared appearance roles, optionally specialized by light or dark mode. The theme module validates and resolves partial definitions against bundled defaults before adapters apply them. A theme definition does not contain arbitrary CSS, application behavior, or alternate feature layouts.

‎api/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"@azure/storage-blob": "^12.30.0",
4747
"@google/genai": "^2.8.0",
4848
"@keyv/redis": "5.1.6",
49-
"@librechat/agents": "^3.7.6",
49+
"@librechat/agents": "^3.7.7",
5050
"@librechat/api": "*",
5151
"@librechat/data-schemas": "*",
5252
"@microsoft/microsoft-graph-client": "^3.0.7",
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
const mockPause = jest.fn();
2+
const mockGetJob = jest.fn();
3+
4+
jest.mock('@librechat/api', () => ({
5+
...jest.requireActual('@librechat/api'),
6+
GenerationJobManager: {
7+
approvals: { pause: (...args) => mockPause(...args) },
8+
getJob: (...args) => mockGetJob(...args),
9+
},
10+
}));
11+
12+
const AgentClient = require('../client');
13+
14+
function clientForProjection() {
15+
const pendingAction = { actionId: 'action-1', expiresAt: Date.now() + 60_000 };
16+
return {
17+
stagedApproval: {
18+
streamId: 'conversation-1',
19+
pendingAction,
20+
discoveredTools: [],
21+
activityPhaseSnapshot: null,
22+
},
23+
pendingApproval: null,
24+
jobCreatedAt: 123,
25+
};
26+
}
27+
28+
describe('AgentClient Event Actor pause projection', () => {
29+
beforeEach(() => {
30+
jest.clearAllMocks();
31+
});
32+
33+
it('confirms the exact durable projection when Redis loses the pause reply', async () => {
34+
const self = clientForProjection();
35+
const suspension = { version: 1, suspensionId: 'suspension-1', attempt: 2 };
36+
mockPause.mockRejectedValue(new Error('reply lost'));
37+
mockGetJob.mockResolvedValue({
38+
createdAt: 123,
39+
status: 'requires_action',
40+
metadata: {
41+
pendingAction: self.stagedApproval.pendingAction,
42+
agentEventSuspension: suspension,
43+
},
44+
});
45+
46+
await expect(AgentClient.prototype.publishStagedApproval.call(self, suspension)).resolves.toBe(
47+
true,
48+
);
49+
expect(self.pendingApproval).toBe(self.stagedApproval.pendingAction);
50+
});
51+
52+
it('propagates an ambiguous failure when the durable projection does not match', async () => {
53+
const self = clientForProjection();
54+
const error = new Error('reply lost');
55+
mockPause.mockRejectedValue(error);
56+
mockGetJob.mockResolvedValue({
57+
createdAt: 123,
58+
status: 'requires_action',
59+
metadata: {
60+
pendingAction: self.stagedApproval.pendingAction,
61+
agentEventSuspension: { version: 1, suspensionId: 'different', attempt: 2 },
62+
},
63+
});
64+
65+
await expect(
66+
AgentClient.prototype.publishStagedApproval.call(self, {
67+
version: 1,
68+
suspensionId: 'suspension-1',
69+
attempt: 2,
70+
}),
71+
).rejects.toBe(error);
72+
expect(self.pendingApproval).toBeNull();
73+
});
74+
});

‎api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js‎

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -85,8 +85,8 @@ const mockResolveAgentTurnExecutionPlan = jest.fn((input) => {
8585
input.event?.binding != null &&
8686
input.event?.expectedAction != null &&
8787
input.checkpointerType !== 'memory' &&
88-
!input.canPause &&
89-
!input.expectedActionMayDetach;
88+
!input.expectedActionMayDetach &&
89+
(!input.canPause || input.durableEventActorSuspensions);
9090
let strategy = 'history';
9191
if (input.isNewConversation) {
9292
strategy = 'fresh';
@@ -3042,10 +3042,12 @@ describe('ResumableAgentController resume metadata', () => {
30423042
});
30433043
let observedHookResult;
30443044
const completedResponseWrite = jest.fn();
3045+
const exposePendingApproval = jest.fn().mockResolvedValue(undefined);
30453046
const client = {
30463047
options: {},
30473048
jobCreatedAt: 1000,
30483049
pendingApproval: { actionId: 'action-pause-barrier' },
3050+
exposePendingApproval,
30493051
skipSaveUserMessage: false,
30503052
skipSaveConvo: false,
30513053
getSaveOptions: jest.fn(() => ({ endpoint: 'agents' })),
@@ -3125,6 +3127,9 @@ describe('ResumableAgentController resume metadata', () => {
31253127
expect(mockSaveMessage.mock.invocationCallOrder[0]).toBeLessThan(
31263128
mockGenerationJobManager.approvals.finishPausePersistence.mock.invocationCallOrder[0],
31273129
);
3130+
expect(exposePendingApproval.mock.invocationCallOrder[0]).toBeLessThan(
3131+
mockGenerationJobManager.approvals.finishPausePersistence.mock.invocationCallOrder[0],
3132+
);
31283133
expect(mockGenerationJobManager.claimTerminalJob).not.toHaveBeenCalled();
31293134
});
31303135

@@ -4482,15 +4487,36 @@ describe('ResumableAgentController resume metadata', () => {
44824487
undefined,
44834488
undefined,
44844489
],
4490+
[
4491+
'pre-cutover pause-capable fleet',
4492+
{ toolDefinitions: [] },
4493+
{ toolApproval: { enabled: true } },
4494+
undefined,
4495+
undefined,
4496+
1,
4497+
],
44854498
])(
4486-
'keeps %s event actors on the existing resumable path',
4487-
async (_label, agent, config, agentConfigs, clientOptions) => {
4499+
'routes %s event actors through the compatible continuation path',
4500+
async (_label, agent, config, agentConfigs, clientOptions, generationProtocolVersion = 2) => {
44884501
mockGenerationJobManager.claimGeneration.mockResolvedValue(
44894502
wonGenerationClaim({
44904503
streamId: 'child-conversation',
44914504
conversationId: 'child-conversation',
4505+
generationProtocolVersion,
44924506
}),
44934507
);
4508+
mockGenerationJobManager.createJob.mockResolvedValueOnce({
4509+
createdAt: 1000,
4510+
metadata: {
4511+
checkpointNamespace: '1000',
4512+
providerExecutionId: 'provider-segment-1',
4513+
providerDrained: true,
4514+
generationProtocolVersion,
4515+
},
4516+
readyPromise: Promise.resolve(),
4517+
abortController: new AbortController(),
4518+
emitter: { on: jest.fn() },
4519+
});
44944520
mockGetConvo.mockResolvedValue({
44954521
conversationId: 'parent-conversation',
44964522
agent_id: 'parent-agent',
@@ -4503,12 +4529,28 @@ describe('ResumableAgentController resume metadata', () => {
45034529
throw new Error('stop after legacy event invocation started');
45044530
}),
45054531
};
4532+
const shouldCheckpoint =
4533+
_label !== 'memory-checkpointer' &&
4534+
_label !== 'background-capable expected action' &&
4535+
_label !== 'pre-cutover pause-capable fleet';
4536+
if (shouldCheckpoint) {
4537+
mockExecuteAgentEventActor.mockImplementationOnce(async (input) => {
4538+
await input.invoke({
4539+
checkpointNamespace: 'event-actor/pause-capable',
4540+
checkpointId: 'checkpoint-pause-capable',
4541+
invocationId: 'req-event-hitl',
4542+
continuation: 'warm',
4543+
signal: input.signal,
4544+
});
4545+
});
4546+
}
45064547
const req = {
45074548
user: { id: 'user-123', tenantId: 'tenant-1' },
45084549
body: {
45094550
text: 'Continue with a pause-capable actor.',
45104551
clientRequestId: 'req-event-hitl',
45114552
conversationId: 'child-conversation',
4553+
generationProtocolVersion,
45124554
endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } },
45134555
agentEventDelivery: {
45144556
deliveryKey: 'req-event-hitl',
@@ -4541,6 +4583,18 @@ describe('ResumableAgentController resume metadata', () => {
45414583
);
45424584
await nextTick();
45434585

4586+
if (shouldCheckpoint) {
4587+
expect(mockExecuteAgentEventActor).toHaveBeenCalled();
4588+
expect(mockGetMessages).not.toHaveBeenCalled();
4589+
expect(mockBeginAgentEventActorLegacyTurn).not.toHaveBeenCalled();
4590+
expect(mockGenerationJobManager.updateMetadata).not.toHaveBeenCalledWith(
4591+
'child-conversation',
4592+
expect.objectContaining({ agentEventLegacyTurnToken: expect.any(String) }),
4593+
1000,
4594+
);
4595+
expect(client.sendMessage).toHaveBeenCalledTimes(1);
4596+
return;
4597+
}
45444598
expect(mockExecuteAgentEventActor).not.toHaveBeenCalled();
45454599
expect(mockBeginAgentEventActorLegacyTurn).toHaveBeenCalledWith({
45464600
user: 'user-123',

0 commit comments

Comments
 (0)