Skip to content

Commit 2085ee8

Browse files
authored
Keep agent chat turns alive across websocket disconnects, replay on reconnect (#4711)
1 parent e3714e9 commit 2085ee8

9 files changed

Lines changed: 1153 additions & 16 deletions

File tree

packages/protocol/src/messages.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,7 @@ export type UnifiedCommandType =
657657
| "stream_input"
658658
| "end_input_stream"
659659
| "chat_message"
660+
| "resume_chat"
660661
| "inference"
661662
| "stop"
662663
| "list_workflows"

packages/protocol/src/ws-commands.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ export const UNIFIED_COMMAND_TYPES = [
4646
"stream_input",
4747
"end_input_stream",
4848
"chat_message",
49+
"resume_chat",
4950
"inference",
5051
"stop",
5152
"list_workflows",
@@ -170,6 +171,14 @@ export const chatMessageDataSchema = z
170171
})
171172
.passthrough();
172173

174+
export const resumeChatDataSchema = z
175+
.object({
176+
thread_id: z.string().optional(),
177+
/** Highest `chat_seq` the client has already received for this thread. */
178+
last_seq: z.number().optional()
179+
})
180+
.passthrough();
181+
173182
export const stopDataSchema = z
174183
.object({
175184
job_id: z.string().optional(),
@@ -236,6 +245,7 @@ export const commandDataSchemas: Record<UnifiedCommandType, z.ZodTypeAny> = {
236245
stream_input: looseDataSchema,
237246
end_input_stream: looseDataSchema,
238247
chat_message: looseDataSchema,
248+
resume_chat: resumeChatDataSchema,
239249
inference: looseDataSchema,
240250
stop: looseDataSchema,
241251
list_workflows: looseDataSchema,
@@ -395,10 +405,33 @@ export const resourceChangeMessageOutSchema = z
395405
})
396406
.passthrough();
397407

408+
/**
409+
* Reply to a `resume_chat` command. Sent before any replayed frames.
410+
*
411+
* `status` reports what the server holds for the thread's latest turn:
412+
* `"running"` (turn still executing; replay is followed by live frames),
413+
* `"finished"` (turn completed while the client was away; replay is the
414+
* whole tail), or `"unknown"` (nothing to replay — either no turn ran, or
415+
* the retention window elapsed; the client should refetch thread history).
416+
* `replay_incomplete` is true when the requested `last_seq` predates what
417+
* the bounded buffer still holds.
418+
*/
419+
export const chatResumedMessageOutSchema = z
420+
.object({
421+
type: z.literal("chat_resumed"),
422+
thread_id: z.string(),
423+
status: z.enum(["running", "finished", "unknown"]),
424+
last_seq: z.number(),
425+
replay_count: z.number(),
426+
replay_incomplete: z.boolean()
427+
})
428+
.passthrough();
429+
398430
/** Non-`ProcessingMessage` outbound frame schemas, keyed by `type`. */
399431
export const outboundControlMessageSchemas = {
400432
pong: pongMessageOutSchema,
401433
rpc_response: rpcResponseMessageOutSchema,
402434
system_stats: systemStatsMessageOutSchema,
403-
resource_change: resourceChangeMessageOutSchema
435+
resource_change: resourceChangeMessageOutSchema,
436+
chat_resumed: chatResumedMessageOutSchema
404437
} as const;
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
/**
2+
* Detachable chat/agent turns.
3+
*
4+
* The WebSocket runner is per-connection, so before this module existed a
5+
* dropped socket aborted the in-flight chat turn (`disconnect()` →
6+
* `cancelChatTurn()`): a laptop lid-close killed the agent mid-work and the
7+
* client had nothing to reattach to. A {@link ChatTurnSession} decouples the
8+
* turn from the socket: every frame the turn emits is stamped with a
9+
* monotonically increasing `chat_seq` and appended to a bounded buffer, and
10+
* delivery goes to whichever connection is currently attached — or nowhere,
11+
* while the client is away. On reconnect the client sends
12+
* `{command: "resume_chat", data: {thread_id, last_seq}}` and gets the missed
13+
* tail replayed, followed by live frames if the turn is still running.
14+
*
15+
* Sessions are keyed by user+thread in a process-wide registry (one turn per
16+
* thread; a new turn supersedes and aborts the previous one, matching the
17+
* single-turn semantics `beginChatTurn` already enforces per connection).
18+
* Two timers bound a session's life:
19+
* - detach grace: a running turn nobody is attached to is aborted after
20+
* `NODETOOL_CHAT_DETACH_GRACE_MS` (default 10 min) so an abandoned client
21+
* cannot leave an agent working forever;
22+
* - retention: a finished session is kept for
23+
* `NODETOOL_CHAT_REPLAY_RETENTION_MS` (default 5 min) so a client that
24+
* reconnects just after the turn ended still gets the tail, then dropped.
25+
*
26+
* Assistant/tool messages are persisted to the DB independently of this
27+
* buffer, so an expired or truncated replay degrades to the client refetching
28+
* thread history over REST — nothing is lost except unpersisted stream chunks.
29+
*/
30+
31+
import { createLogger } from "@nodetool-ai/config";
32+
33+
const log = createLogger("nodetool.websocket.chat-turn-registry");
34+
35+
function envInt(name: string, fallback: number): number {
36+
const raw = process.env[name];
37+
if (!raw) return fallback;
38+
const parsed = Number.parseInt(raw, 10);
39+
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
40+
}
41+
42+
const MAX_BUFFERED_EVENTS = () =>
43+
envInt("NODETOOL_CHAT_REPLAY_BUFFER_EVENTS", 2000);
44+
const DETACH_GRACE_MS = () =>
45+
envInt("NODETOOL_CHAT_DETACH_GRACE_MS", 10 * 60 * 1000);
46+
const RETENTION_MS = () =>
47+
envInt("NODETOOL_CHAT_REPLAY_RETENTION_MS", 5 * 60 * 1000);
48+
49+
/** A connection that can deliver frames to its client. */
50+
export interface ChatTurnDeliveryTarget {
51+
deliver(message: Record<string, unknown>): Promise<void>;
52+
}
53+
54+
/**
55+
* The executing connection's per-turn hooks. Kept on the session so a
56+
* different connection (post-reconnect) can route a client's `tool_result` /
57+
* approval / `stop` back to the runner that actually owns the turn.
58+
*/
59+
export interface ChatTurnExecutionHooks {
60+
resolveToolResult(toolCallId: string, payload: Record<string, unknown>): void;
61+
resolveApproval(approvalId: string, payload: Record<string, unknown>): void;
62+
cancelPendingCalls(threadId: string): void;
63+
}
64+
65+
export interface ChatTurnAttachResult {
66+
/** Frames with `chat_seq` greater than the requested `last_seq`. */
67+
replay: Array<Record<string, unknown>>;
68+
/** True when `last_seq` predates what the bounded buffer still holds. */
69+
incomplete: boolean;
70+
}
71+
72+
interface BufferedEvent {
73+
seq: number;
74+
message: Record<string, unknown>;
75+
}
76+
77+
export class ChatTurnSession {
78+
readonly userId: string;
79+
readonly threadId: string;
80+
status: "running" | "finished" = "running";
81+
82+
private seq: number;
83+
private buffer: BufferedEvent[] = [];
84+
/** Highest seq evicted from the bounded buffer (0 = nothing evicted). */
85+
private evictedThroughSeq: number;
86+
private target: ChatTurnDeliveryTarget | null = null;
87+
/**
88+
* Serializes delivery: replayed frames enqueue before any live frame that
89+
* arrives after attach, so the client always sees seq order.
90+
*/
91+
private deliveryChain: Promise<void> = Promise.resolve();
92+
private detachTimer: NodeJS.Timeout | null = null;
93+
private retentionTimer: NodeJS.Timeout | null = null;
94+
95+
constructor(
96+
userId: string,
97+
threadId: string,
98+
private readonly controller: AbortController,
99+
readonly hooks: ChatTurnExecutionHooks,
100+
private readonly onDrop: (session: ChatTurnSession) => void,
101+
startSeq: number
102+
) {
103+
this.userId = userId;
104+
this.threadId = threadId;
105+
this.seq = startSeq;
106+
this.evictedThroughSeq = startSeq;
107+
}
108+
109+
get lastSeq(): number {
110+
return this.seq;
111+
}
112+
113+
/**
114+
* Stamp, buffer, and (when a connection is attached) deliver one frame.
115+
* Returns the stamped copy.
116+
*/
117+
emit(message: Record<string, unknown>): Record<string, unknown> {
118+
this.seq += 1;
119+
const stamped = { ...message, chat_seq: this.seq };
120+
this.buffer.push({ seq: this.seq, message: stamped });
121+
const max = MAX_BUFFERED_EVENTS();
122+
while (this.buffer.length > max) {
123+
const evicted = this.buffer.shift();
124+
if (evicted) this.evictedThroughSeq = evicted.seq;
125+
}
126+
const target = this.target;
127+
if (target) {
128+
this.enqueueDelivery(() => target.deliver(stamped));
129+
}
130+
return stamped;
131+
}
132+
133+
/**
134+
* Attach a connection and hand back the frames it missed. Live frames
135+
* emitted after this call are delivered to the new target, strictly after
136+
* the returned replay is delivered (both ride {@link deliveryChain} when
137+
* sent via {@link deliverReplay}).
138+
*/
139+
attach(target: ChatTurnDeliveryTarget, lastSeq: number): ChatTurnAttachResult {
140+
this.clearDetachTimer();
141+
this.target = target;
142+
const replay = this.buffer
143+
.filter((e) => e.seq > lastSeq)
144+
.map((e) => e.message);
145+
return { replay, incomplete: lastSeq < this.evictedThroughSeq };
146+
}
147+
148+
/** Deliver frames on the session's ordered delivery chain. */
149+
deliverReplay(
150+
target: ChatTurnDeliveryTarget,
151+
frames: Array<Record<string, unknown>>
152+
): Promise<void> {
153+
for (const frame of frames) {
154+
this.enqueueDelivery(() => target.deliver(frame));
155+
}
156+
return this.deliveryChain;
157+
}
158+
159+
/**
160+
* The attached connection went away. A running turn keeps executing and
161+
* buffering; if nobody reattaches within the grace window the turn is
162+
* aborted so it cannot run unattended forever.
163+
*/
164+
detach(target?: ChatTurnDeliveryTarget): void {
165+
if (target && this.target !== target) return;
166+
this.target = null;
167+
if (this.status !== "running") return;
168+
this.clearDetachTimer();
169+
this.detachTimer = setTimeout(() => {
170+
log.info("Detached chat turn expired, aborting", {
171+
threadId: this.threadId
172+
});
173+
this.abort();
174+
}, DETACH_GRACE_MS());
175+
this.detachTimer.unref?.();
176+
}
177+
178+
/** Abort the turn (superseded, stopped, or detach grace elapsed). */
179+
abort(): void {
180+
this.controller.abort();
181+
this.hooks.cancelPendingCalls(this.threadId);
182+
}
183+
184+
/**
185+
* The turn's promise settled. The session sticks around (still replayable)
186+
* for the retention window, then drops out of the registry.
187+
*/
188+
finish(): void {
189+
if (this.status === "finished") return;
190+
this.status = "finished";
191+
this.clearDetachTimer();
192+
this.retentionTimer = setTimeout(() => this.onDrop(this), RETENTION_MS());
193+
this.retentionTimer.unref?.();
194+
}
195+
196+
/** Release timers when the registry drops the session. */
197+
dispose(): void {
198+
this.clearDetachTimer();
199+
if (this.retentionTimer) {
200+
clearTimeout(this.retentionTimer);
201+
this.retentionTimer = null;
202+
}
203+
}
204+
205+
private clearDetachTimer(): void {
206+
if (this.detachTimer) {
207+
clearTimeout(this.detachTimer);
208+
this.detachTimer = null;
209+
}
210+
}
211+
212+
private enqueueDelivery(fn: () => Promise<void>): void {
213+
this.deliveryChain = this.deliveryChain.then(fn).catch((err) => {
214+
log.warn("Chat turn delivery failed", {
215+
threadId: this.threadId,
216+
error: err instanceof Error ? err.message : String(err)
217+
});
218+
});
219+
}
220+
}
221+
222+
export class ChatTurnRegistry {
223+
private sessions = new Map<string, ChatTurnSession>();
224+
/**
225+
* Per-thread seq high-water marks, so a new turn continues numbering where
226+
* the previous one left off and a client's `last_seq` from an older turn
227+
* can never accidentally skip a newer turn's frames. Bounded: oldest
228+
* entries are evicted past {@link MAX_SEQ_ENTRIES}.
229+
*/
230+
private lastSeqByThread = new Map<string, number>();
231+
private static readonly MAX_SEQ_ENTRIES = 10_000;
232+
233+
private key(userId: string, threadId: string): string {
234+
return `${userId}\u0000${threadId}`;
235+
}
236+
237+
/**
238+
* Open a session for a new turn. An existing session for the same thread is
239+
* superseded: aborted (if still running) and dropped, exactly as a new
240+
* `chat_message` on a live connection cancels the previous turn.
241+
*/
242+
open(
243+
userId: string,
244+
threadId: string,
245+
controller: AbortController,
246+
hooks: ChatTurnExecutionHooks
247+
): ChatTurnSession {
248+
const key = this.key(userId, threadId);
249+
const existing = this.sessions.get(key);
250+
if (existing) {
251+
if (existing.status === "running") existing.abort();
252+
this.drop(existing);
253+
}
254+
const session = new ChatTurnSession(
255+
userId,
256+
threadId,
257+
controller,
258+
hooks,
259+
(s) => this.drop(s),
260+
this.lastSeqByThread.get(key) ?? 0
261+
);
262+
this.sessions.set(key, session);
263+
return session;
264+
}
265+
266+
get(userId: string, threadId: string): ChatTurnSession | null {
267+
return this.sessions.get(this.key(userId, threadId)) ?? null;
268+
}
269+
270+
drop(session: ChatTurnSession): void {
271+
const key = this.key(session.userId, session.threadId);
272+
if (this.sessions.get(key) === session) {
273+
this.sessions.delete(key);
274+
}
275+
// max(): a superseded session's late retention-drop must not lower the
276+
// high-water mark below what its successor already emitted.
277+
this.lastSeqByThread.set(
278+
key,
279+
Math.max(this.lastSeqByThread.get(key) ?? 0, session.lastSeq)
280+
);
281+
while (this.lastSeqByThread.size > ChatTurnRegistry.MAX_SEQ_ENTRIES) {
282+
const oldest = this.lastSeqByThread.keys().next().value;
283+
if (oldest === undefined) break;
284+
this.lastSeqByThread.delete(oldest);
285+
}
286+
session.dispose();
287+
}
288+
}
289+
290+
/** Process-wide registry: sessions survive their originating connection. */
291+
export const chatTurnRegistry = new ChatTurnRegistry();

0 commit comments

Comments
 (0)