@dudousxd/nestjs-agent 1.36.1 → 1.36.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,6 +30,20 @@ var import_nestjs_agent_core = require("@dudousxd/nestjs-agent-core");
30
30
  var CHUNK_FIELD = "c";
31
31
  var END_FIELD = "e";
32
32
  var FAIL_FIELD = "f";
33
+ var decoder = new TextDecoder();
34
+ function textOf(chunk) {
35
+ const line = decoder.decode(chunk).trimEnd();
36
+ if (!line.startsWith('{"kind":"text"')) return null;
37
+ try {
38
+ const parsed = JSON.parse(line);
39
+ if (typeof parsed === "object" && parsed !== null && Object.keys(parsed).length === 2 && parsed.kind === "text" && typeof parsed.text === "string") {
40
+ return parsed.text;
41
+ }
42
+ } catch {
43
+ }
44
+ return null;
45
+ }
46
+ __name(textOf, "textOf");
33
47
  var RedisTokenStreamSink = class {
34
48
  static {
35
49
  __name(this, "RedisTokenStreamSink");
@@ -38,6 +52,7 @@ var RedisTokenStreamSink = class {
38
52
  keyPrefix;
39
53
  ttlSeconds;
40
54
  blockMs;
55
+ writes = /* @__PURE__ */ new Map();
41
56
  constructor(redis, options = {}) {
42
57
  this.redis = redis;
43
58
  this.keyPrefix = options.keyPrefix ?? "agent:sink:";
@@ -47,35 +62,165 @@ var RedisTokenStreamSink = class {
47
62
  key(runId) {
48
63
  return `${this.keyPrefix}${runId}`;
49
64
  }
50
- open(runId) {
65
+ runWrites(runId) {
66
+ let state = this.writes.get(runId);
67
+ if (state === void 0) {
68
+ state = {
69
+ text: null,
70
+ flushQueued: false,
71
+ tail: Promise.resolve(),
72
+ pending: 0,
73
+ failure: void 0
74
+ };
75
+ this.writes.set(runId, state);
76
+ }
77
+ return state;
78
+ }
79
+ /** Forget a run's write state once nothing is gathered, queued or left to report. */
80
+ release(runId, state) {
81
+ if (state.pending === 0 && state.text === null && state.failure === void 0 && this.writes.get(runId) === state) {
82
+ this.writes.delete(runId);
83
+ }
84
+ }
85
+ /** Queue `work` behind every send already queued for the run, so entries land in call order. */
86
+ enqueue(runId, state, work) {
87
+ state.pending += 1;
88
+ const next = state.tail.then(work);
89
+ state.tail = next.then(() => void 0, () => void 0).then(() => {
90
+ state.pending -= 1;
91
+ this.release(runId, state);
92
+ });
93
+ return next;
94
+ }
95
+ /**
96
+ * One round trip: the entries, in order, then the TTL re-armed — sliding from the run's last
97
+ * write. `EXPIRE 0` would delete, so 0 skips it.
98
+ */
99
+ async send(runId, entries) {
51
100
  const key = this.key(runId);
101
+ const pipeline = this.redis.pipeline();
102
+ for (const [field, value] of entries) {
103
+ pipeline.xadd(key, "*", field, value);
104
+ }
105
+ if (this.ttlSeconds > 0) pipeline.expire(key, this.ttlSeconds);
106
+ const results = await pipeline.exec() ?? [];
107
+ for (const [error] of results) {
108
+ if (error) throw error;
109
+ }
110
+ }
111
+ /** The gathered text as one entry (nothing if there is none), followed by `entries`. */
112
+ drain(state) {
113
+ if (state.text === null) return [];
114
+ const text = state.text;
115
+ state.text = null;
116
+ return [
117
+ [
118
+ CHUNK_FIELD,
119
+ Buffer.from((0, import_nestjs_agent_core.encodeStreamEvent)({
120
+ kind: "text",
121
+ text
122
+ }))
123
+ ]
124
+ ];
125
+ }
126
+ /** Queue a send of the gathered text, unless one is already queued and has not started. */
127
+ queueText(runId, state) {
128
+ if (state.flushQueued) return;
129
+ state.flushQueued = true;
130
+ this.enqueue(runId, state, async () => {
131
+ state.flushQueued = false;
132
+ const entries = this.drain(state);
133
+ if (entries.length > 0) await this.send(runId, entries);
134
+ }).catch((error) => {
135
+ state.failure ??= {
136
+ error
137
+ };
138
+ });
139
+ }
140
+ /** Raise, once, a gathered send that failed with nobody awaiting it. */
141
+ raiseFailure(state) {
142
+ const failure = state.failure;
143
+ if (failure !== void 0) {
144
+ state.failure = void 0;
145
+ throw failure.error;
146
+ }
147
+ }
148
+ /** Queue the gathered text and then `entry`, as one send; raise a failure nobody saw. */
149
+ async write(runId, entry) {
150
+ const state = this.runWrites(runId);
151
+ this.raiseFailure(state);
152
+ await this.enqueue(runId, state, () => this.send(runId, [
153
+ ...this.drain(state),
154
+ entry
155
+ ]));
156
+ this.raiseFailure(state);
157
+ this.release(runId, state);
158
+ }
159
+ /**
160
+ * The run's writer. Writers opened for the same run in one process share what they are gathering,
161
+ * so a delegated run's text (forwarded through a child writer) and its parent's next frame keep
162
+ * the order they were written in.
163
+ */
164
+ open(runId) {
52
165
  return {
53
166
  write: /* @__PURE__ */ __name(async (chunk) => {
54
- await this.redis.xadd(key, "*", CHUNK_FIELD, Buffer.from(chunk));
55
- await this.expire(key);
167
+ const text = textOf(chunk);
168
+ if (text === null) {
169
+ await this.write(runId, [
170
+ CHUNK_FIELD,
171
+ Buffer.from(chunk)
172
+ ]);
173
+ return;
174
+ }
175
+ const state = this.runWrites(runId);
176
+ this.raiseFailure(state);
177
+ state.text = (state.text ?? "") + text;
178
+ this.queueText(runId, state);
56
179
  }, "write"),
57
- end: /* @__PURE__ */ __name(async () => {
58
- await this.redis.xadd(key, "*", END_FIELD, "1");
59
- await this.expire(key);
60
- }, "end"),
61
- fail: /* @__PURE__ */ __name(async (error) => {
62
- await this.redis.xadd(key, "*", FAIL_FIELD, JSON.stringify(error));
63
- await this.expire(key);
64
- }, "fail")
180
+ flush: /* @__PURE__ */ __name(async () => {
181
+ const state = this.runWrites(runId);
182
+ this.queueText(runId, state);
183
+ await state.tail;
184
+ this.raiseFailure(state);
185
+ this.release(runId, state);
186
+ }, "flush"),
187
+ end: /* @__PURE__ */ __name(() => this.write(runId, [
188
+ END_FIELD,
189
+ "1"
190
+ ]), "end"),
191
+ fail: /* @__PURE__ */ __name((error) => this.write(runId, [
192
+ FAIL_FIELD,
193
+ JSON.stringify(error)
194
+ ]), "fail")
65
195
  };
66
196
  }
67
- /** (Re)arm the TTL — sliding from the run's last write. `EXPIRE 0` would delete, so 0 skips. */
68
- async expire(key) {
69
- if (this.ttlSeconds > 0) await this.redis.expire(key, this.ttlSeconds);
70
- }
71
- async *subscribe(runId) {
197
+ async *subscribe(runId, options = {}) {
198
+ const { signal } = options;
199
+ if (signal?.aborted) return;
72
200
  const key = this.key(runId);
73
201
  const reader = this.redis.duplicate();
202
+ const onAbort = /* @__PURE__ */ __name(() => reader.disconnect(), "onAbort");
203
+ signal?.addEventListener("abort", onAbort, {
204
+ once: true
205
+ });
206
+ const giveUpAt = this.ttlSeconds > 0 ? Date.now() + this.ttlSeconds * 1e3 : Number.POSITIVE_INFINITY;
74
207
  try {
75
208
  let lastId = "0";
76
209
  while (true) {
77
- const response = await reader.xreadBuffer("BLOCK", this.blockMs, "STREAMS", key, lastId);
78
- for (const entry of readEntries(response)) {
210
+ let response;
211
+ try {
212
+ response = await reader.xreadBuffer("BLOCK", this.blockMs, "STREAMS", key, lastId);
213
+ } catch (error) {
214
+ if (signal?.aborted) return;
215
+ throw error;
216
+ }
217
+ const entries = readEntries(response);
218
+ if (entries.length === 0) {
219
+ if (lastId !== "0" && await this.redis.exists(key) === 0) return;
220
+ if (lastId === "0" && Date.now() >= giveUpAt) return;
221
+ continue;
222
+ }
223
+ for (const entry of entries) {
79
224
  lastId = entry.id;
80
225
  if (entry.field === CHUNK_FIELD && entry.value !== void 0) {
81
226
  yield new Uint8Array(entry.value);
@@ -87,6 +232,7 @@ var RedisTokenStreamSink = class {
87
232
  }
88
233
  }
89
234
  } finally {
235
+ signal?.removeEventListener("abort", onAbort);
90
236
  reader.disconnect();
91
237
  }
92
238
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/sink-redis/index.ts","../../src/sink-redis/redis-token-stream-sink.ts"],"sourcesContent":["export {\n RedisTokenStreamSink,\n type RedisTokenStreamSinkOptions,\n} from './redis-token-stream-sink.js';\n","import {\n AgentStreamError,\n type SinkWriter,\n type StreamError,\n type TokenStreamSink,\n} from '@dudousxd/nestjs-agent-core';\nimport type { Redis } from 'ioredis';\n\nexport interface RedisTokenStreamSinkOptions {\n /** Key namespace for the per-run streams. Default `agent:sink:`. */\n keyPrefix?: string;\n /**\n * How long (seconds) a run's stream outlives its last write, so streams self-expire instead of\n * accumulating in Redis — ended runs past their replay window and runs that crashed without\n * ending alike (nothing calls `close()` on its own). A subscriber that reconnects within the window\n * still replays. Default 3600 (1h). `0` keeps the stream until `close()`.\n */\n ttlSeconds?: number;\n /** `XREAD BLOCK` timeout (ms) between follow polls. Default 5000. Purely an internal cadence. */\n blockMs?: number;\n}\n\n// One-character field names keep each stream entry's payload minimal (one XADD per token delta).\nconst CHUNK_FIELD = 'c';\nconst END_FIELD = 'e';\nconst FAIL_FIELD = 'f';\n\n/**\n * A cross-process {@link TokenStreamSink} backed by Redis Streams — the \"data plane\" for durable\n * agent turns whose model call runs on a WORKER pod while the SSE connection is served by an API\n * pod. The turn `XADD`s each delta to `agent:sink:<runId>`; a subscriber `XREAD`s from `0` so it\n * REPLAYS everything buffered so far (a reconnect or a subscriber that attaches after the run\n * started misses nothing), then blocks for live entries until an `end`/`fail` sentinel.\n *\n * The consumer supplies the ioredis client (so the lib stays Redis-driver-agnostic and reuses the\n * app's connection config). Blocking reads monopolise a connection, so each `subscribe` runs on a\n * short-lived `duplicate()` that is disconnected when the subscriber stops.\n */\nexport class RedisTokenStreamSink implements TokenStreamSink {\n private readonly keyPrefix: string;\n private readonly ttlSeconds: number;\n private readonly blockMs: number;\n\n constructor(\n private readonly redis: Redis,\n options: RedisTokenStreamSinkOptions = {},\n ) {\n this.keyPrefix = options.keyPrefix ?? 'agent:sink:';\n this.ttlSeconds = options.ttlSeconds ?? 3600;\n this.blockMs = options.blockMs ?? 5000;\n }\n\n private key(runId: string): string {\n return `${this.keyPrefix}${runId}`;\n }\n\n open(runId: string): SinkWriter {\n const key = this.key(runId);\n return {\n write: async (chunk: Uint8Array) => {\n await this.redis.xadd(key, '*', CHUNK_FIELD, Buffer.from(chunk));\n await this.expire(key);\n },\n end: async () => {\n await this.redis.xadd(key, '*', END_FIELD, '1');\n await this.expire(key);\n },\n fail: async (error: StreamError) => {\n await this.redis.xadd(key, '*', FAIL_FIELD, JSON.stringify(error));\n await this.expire(key);\n },\n };\n }\n\n /** (Re)arm the TTL — sliding from the run's last write. `EXPIRE 0` would delete, so 0 skips. */\n private async expire(key: string): Promise<void> {\n if (this.ttlSeconds > 0) await this.redis.expire(key, this.ttlSeconds);\n }\n\n async *subscribe(runId: string): AsyncIterable<Uint8Array> {\n const key = this.key(runId);\n // Blocking XREAD holds the connection for the whole poll, so follow on a dedicated one.\n const reader = this.redis.duplicate();\n try {\n let lastId = '0';\n while (true) {\n const response = await reader.xreadBuffer('BLOCK', this.blockMs, 'STREAMS', key, lastId);\n for (const entry of readEntries(response)) {\n lastId = entry.id;\n if (entry.field === CHUNK_FIELD && entry.value !== undefined) {\n yield new Uint8Array(entry.value);\n } else if (entry.field === END_FIELD) {\n return;\n } else if (entry.field === FAIL_FIELD && entry.value !== undefined) {\n throw new AgentStreamError(parseStreamError(entry.value.toString()));\n }\n }\n }\n } finally {\n reader.disconnect();\n }\n }\n\n async close(runId: string): Promise<void> {\n await this.redis.del(this.key(runId));\n }\n}\n\ninterface StreamEntry {\n id: string;\n field: string;\n value: Buffer | undefined;\n}\n\n/**\n * The per-stream entry lists of an `XREAD` reply. ioredis 5 (and ioredis 6 under its default\n * `replyMapping: \"legacy\"`) returns `[[key, entries], …]`; ioredis 6 with `replyMapping: \"resp3\"`\n * surfaces the RESP3 map as a plain object `{ [key]: entries }`. Accept both, so the sink works with\n * whatever client the app hands it.\n */\nfunction streamEntryLists(response: unknown): unknown[] {\n if (Array.isArray(response)) {\n return response.map((stream) => (Array.isArray(stream) ? stream[1] : undefined));\n }\n if (typeof response === 'object' && response !== null && !Buffer.isBuffer(response)) {\n return Object.values(response);\n }\n return [];\n}\n\n/**\n * Flatten ioredis's `XREAD` buffer response — `[[key, [[id, [field, value, …]], …]], …] | null`,\n * or the RESP3 map form (see {@link streamEntryLists}) — into the entries we care about. Validated\n * defensively at runtime (it crosses the Redis boundary) rather than trusting the driver's loose\n * typing.\n */\nfunction readEntries(response: unknown): StreamEntry[] {\n const entries: StreamEntry[] = [];\n for (const list of streamEntryLists(response)) {\n if (!Array.isArray(list)) {\n continue;\n }\n for (const item of list) {\n if (!Array.isArray(item) || item.length < 2) {\n continue;\n }\n const [id, fields] = item;\n if (!Buffer.isBuffer(id) || !Array.isArray(fields)) {\n continue;\n }\n const field = Buffer.isBuffer(fields[0]) ? fields[0].toString() : undefined;\n if (field === undefined) {\n continue;\n }\n entries.push({\n id: id.toString(),\n field,\n value: Buffer.isBuffer(fields[1]) ? fields[1] : undefined,\n });\n }\n }\n return entries;\n}\n\n/** Parse a `fail` sentinel's payload back into a {@link StreamError}, tolerating malformed JSON. */\nfunction parseStreamError(raw: string): StreamError {\n try {\n const parsed: unknown = JSON.parse(raw);\n if (\n typeof parsed === 'object' &&\n parsed !== null &&\n 'code' in parsed &&\n 'message' in parsed &&\n typeof parsed.code === 'string' &&\n typeof parsed.message === 'string'\n ) {\n return { code: parsed.code, message: parsed.message };\n }\n } catch {\n // fall through to the generic terminal below\n }\n return { code: 'stream_failed', message: raw };\n}\n"],"mappings":"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","names":["CHUNK_FIELD","END_FIELD","FAIL_FIELD","RedisTokenStreamSink","keyPrefix","ttlSeconds","blockMs","redis","options","key","runId","open","write","chunk","xadd","Buffer","from","expire","end","fail","error","JSON","stringify","subscribe","reader","duplicate","lastId","response","xreadBuffer","entry","readEntries","id","field","value","undefined","Uint8Array","AgentStreamError","parseStreamError","toString","disconnect","close","del","streamEntryLists","Array","isArray","map","stream","isBuffer","Object","values","entries","list","item","length","fields","push","raw","parsed","parse","code","message"]}
1
+ {"version":3,"sources":["../../src/sink-redis/index.ts","../../src/sink-redis/redis-token-stream-sink.ts"],"sourcesContent":["export {\n RedisTokenStreamSink,\n type RedisTokenStreamSinkOptions,\n} from './redis-token-stream-sink.js';\n","import {\n AgentStreamError,\n type SinkWriter,\n type StreamError,\n type SubscribeOptions,\n type TokenStreamSink,\n encodeStreamEvent,\n} from '@dudousxd/nestjs-agent-core';\nimport type { Redis } from 'ioredis';\n\nexport interface RedisTokenStreamSinkOptions {\n /** Key namespace for the per-run streams. Default `agent:sink:`. */\n keyPrefix?: string;\n /**\n * How long (seconds) a run's stream outlives its last write, so streams self-expire instead of\n * accumulating in Redis — ended runs past their replay window and runs that crashed without\n * ending alike (nothing calls `close()` on its own). A subscriber that reconnects within the window\n * still replays; a subscriber of a run that never writes within the window stops following it.\n * Default 3600 (1h). `0` keeps the stream until `close()`.\n */\n ttlSeconds?: number;\n /** `XREAD BLOCK` timeout (ms) between follow polls. Default 5000. Purely an internal cadence. */\n blockMs?: number;\n}\n\n// One-character field names keep each stream entry's payload minimal.\nconst CHUNK_FIELD = 'c';\nconst END_FIELD = 'e';\nconst FAIL_FIELD = 'f';\n\nconst decoder = new TextDecoder();\n\n/** The text of a chunk that is exactly one `{\"kind\":\"text\",\"text\":…}` line, else `null`. */\nfunction textOf(chunk: Uint8Array): string | null {\n const line = decoder.decode(chunk).trimEnd();\n if (!line.startsWith('{\"kind\":\"text\"')) return null;\n try {\n const parsed: unknown = JSON.parse(line);\n if (\n typeof parsed === 'object' &&\n parsed !== null &&\n Object.keys(parsed).length === 2 &&\n (parsed as { kind?: unknown }).kind === 'text' &&\n typeof (parsed as { text?: unknown }).text === 'string'\n ) {\n return (parsed as { text: string }).text;\n }\n } catch {\n /* not one event: written as it came */\n }\n return null;\n}\n\n/** One run's write side in this process: the text being gathered, and the sends already queued. */\ninterface RunWrites {\n text: string | null;\n /** A send of the gathered text is queued and has not started yet. */\n flushQueued: boolean;\n /** Settles when every send queued so far has been attempted. Never rejects. */\n tail: Promise<void>;\n pending: number;\n /** A gathered send that failed with nobody awaiting it — raised by the next call. */\n failure: { error: unknown } | undefined;\n}\n\n/**\n * A cross-process {@link TokenStreamSink} backed by Redis Streams — the \"data plane\" for durable\n * agent turns whose model call runs on a WORKER pod while the SSE connection is served by an API\n * pod. The turn `XADD`s its frames to `agent:sink:<runId>`; a subscriber `XREAD`s from `0` so it\n * REPLAYS everything buffered so far (a reconnect or a subscriber that attaches after the run\n * started misses nothing), then blocks for live entries until an `end`/`fail` sentinel — or until\n * the stream is gone: expired or closed after it had entries, or never created within the TTL.\n *\n * Consecutive `text` frames are gathered while the previous send is still on the wire and go out as\n * ONE `text` entry, so a model streaming faster than a Redis round trip costs one round trip per\n * batch rather than per token, and a slow stream still goes out a token at a time. Every subscriber\n * reads the same stored entries in the same order, so frame numbering stays exact across replicas.\n *\n * The consumer supplies the ioredis client (so the lib stays Redis-driver-agnostic and reuses the\n * app's connection config). Blocking reads monopolise a connection, so each `subscribe` runs on a\n * short-lived `duplicate()` that is disconnected when the subscriber stops.\n */\nexport class RedisTokenStreamSink implements TokenStreamSink {\n private readonly keyPrefix: string;\n private readonly ttlSeconds: number;\n private readonly blockMs: number;\n private readonly writes = new Map<string, RunWrites>();\n\n constructor(\n private readonly redis: Redis,\n options: RedisTokenStreamSinkOptions = {},\n ) {\n this.keyPrefix = options.keyPrefix ?? 'agent:sink:';\n this.ttlSeconds = options.ttlSeconds ?? 3600;\n this.blockMs = options.blockMs ?? 5000;\n }\n\n private key(runId: string): string {\n return `${this.keyPrefix}${runId}`;\n }\n\n private runWrites(runId: string): RunWrites {\n let state = this.writes.get(runId);\n if (state === undefined) {\n state = {\n text: null,\n flushQueued: false,\n tail: Promise.resolve(),\n pending: 0,\n failure: undefined,\n };\n this.writes.set(runId, state);\n }\n return state;\n }\n\n /** Forget a run's write state once nothing is gathered, queued or left to report. */\n private release(runId: string, state: RunWrites): void {\n if (\n state.pending === 0 &&\n state.text === null &&\n state.failure === undefined &&\n this.writes.get(runId) === state\n ) {\n this.writes.delete(runId);\n }\n }\n\n /** Queue `work` behind every send already queued for the run, so entries land in call order. */\n private enqueue(runId: string, state: RunWrites, work: () => Promise<void>): Promise<void> {\n state.pending += 1;\n const next = state.tail.then(work);\n state.tail = next\n .then(\n () => undefined,\n () => undefined,\n )\n .then(() => {\n state.pending -= 1;\n this.release(runId, state);\n });\n return next;\n }\n\n /**\n * One round trip: the entries, in order, then the TTL re-armed — sliding from the run's last\n * write. `EXPIRE 0` would delete, so 0 skips it.\n */\n private async send(\n runId: string,\n entries: ReadonlyArray<readonly [string, string | Buffer]>,\n ): Promise<void> {\n const key = this.key(runId);\n const pipeline = this.redis.pipeline();\n for (const [field, value] of entries) {\n pipeline.xadd(key, '*', field, value);\n }\n if (this.ttlSeconds > 0) pipeline.expire(key, this.ttlSeconds);\n const results = (await pipeline.exec()) ?? [];\n for (const [error] of results) {\n if (error) throw error;\n }\n }\n\n /** The gathered text as one entry (nothing if there is none), followed by `entries`. */\n private drain(state: RunWrites): Array<readonly [string, Buffer]> {\n if (state.text === null) return [];\n const text = state.text;\n state.text = null;\n return [[CHUNK_FIELD, Buffer.from(encodeStreamEvent({ kind: 'text', text }))]];\n }\n\n /** Queue a send of the gathered text, unless one is already queued and has not started. */\n private queueText(runId: string, state: RunWrites): void {\n if (state.flushQueued) return;\n state.flushQueued = true;\n this.enqueue(runId, state, async () => {\n state.flushQueued = false;\n const entries = this.drain(state);\n if (entries.length > 0) await this.send(runId, entries);\n }).catch((error: unknown) => {\n state.failure ??= { error };\n });\n }\n\n /** Raise, once, a gathered send that failed with nobody awaiting it. */\n private raiseFailure(state: RunWrites): void {\n const failure = state.failure;\n if (failure !== undefined) {\n state.failure = undefined;\n throw failure.error;\n }\n }\n\n /** Queue the gathered text and then `entry`, as one send; raise a failure nobody saw. */\n private async write(runId: string, entry: readonly [string, string | Buffer]): Promise<void> {\n const state = this.runWrites(runId);\n this.raiseFailure(state);\n await this.enqueue(runId, state, () => this.send(runId, [...this.drain(state), entry]));\n this.raiseFailure(state);\n this.release(runId, state);\n }\n\n /**\n * The run's writer. Writers opened for the same run in one process share what they are gathering,\n * so a delegated run's text (forwarded through a child writer) and its parent's next frame keep\n * the order they were written in.\n */\n open(runId: string): SinkWriter {\n return {\n write: async (chunk: Uint8Array) => {\n const text = textOf(chunk);\n if (text === null) {\n await this.write(runId, [CHUNK_FIELD, Buffer.from(chunk)]);\n return;\n }\n const state = this.runWrites(runId);\n this.raiseFailure(state);\n state.text = (state.text ?? '') + text;\n this.queueText(runId, state);\n },\n flush: async () => {\n const state = this.runWrites(runId);\n this.queueText(runId, state);\n await state.tail;\n this.raiseFailure(state);\n this.release(runId, state);\n },\n end: () => this.write(runId, [END_FIELD, '1']),\n fail: (error: StreamError) => this.write(runId, [FAIL_FIELD, JSON.stringify(error)]),\n };\n }\n\n async *subscribe(runId: string, options: SubscribeOptions = {}): AsyncIterable<Uint8Array> {\n const { signal } = options;\n if (signal?.aborted) return;\n const key = this.key(runId);\n // Blocking XREAD holds the connection for the whole poll, so follow on a dedicated one — and\n // drop it on abort, which is the only way to cut a blocked read short.\n const reader = this.redis.duplicate();\n const onAbort = () => reader.disconnect();\n signal?.addEventListener('abort', onAbort, { once: true });\n // A run writes within its TTL or its stream would have expired, so one silent that long is gone.\n const giveUpAt =\n this.ttlSeconds > 0 ? Date.now() + this.ttlSeconds * 1000 : Number.POSITIVE_INFINITY;\n try {\n let lastId = '0';\n while (true) {\n let response: unknown;\n try {\n response = await reader.xreadBuffer('BLOCK', this.blockMs, 'STREAMS', key, lastId);\n } catch (error) {\n if (signal?.aborted) return;\n throw error;\n }\n const entries = readEntries(response);\n if (entries.length === 0) {\n // A stream that had entries and is now gone expired or was closed without a terminal\n // (its run crashed, or is parked past the TTL): nothing will ever be appended to it.\n if (lastId !== '0' && (await this.redis.exists(key)) === 0) return;\n if (lastId === '0' && Date.now() >= giveUpAt) return;\n continue;\n }\n for (const entry of entries) {\n lastId = entry.id;\n if (entry.field === CHUNK_FIELD && entry.value !== undefined) {\n yield new Uint8Array(entry.value);\n } else if (entry.field === END_FIELD) {\n return;\n } else if (entry.field === FAIL_FIELD && entry.value !== undefined) {\n throw new AgentStreamError(parseStreamError(entry.value.toString()));\n }\n }\n }\n } finally {\n signal?.removeEventListener('abort', onAbort);\n reader.disconnect();\n }\n }\n\n async close(runId: string): Promise<void> {\n await this.redis.del(this.key(runId));\n }\n}\n\ninterface StreamEntry {\n id: string;\n field: string;\n value: Buffer | undefined;\n}\n\n/**\n * The per-stream entry lists of an `XREAD` reply. ioredis 5 (and ioredis 6 under its default\n * `replyMapping: \"legacy\"`) returns `[[key, entries], …]`; ioredis 6 with `replyMapping: \"resp3\"`\n * surfaces the RESP3 map as a plain object `{ [key]: entries }`. Accept both, so the sink works with\n * whatever client the app hands it.\n */\nfunction streamEntryLists(response: unknown): unknown[] {\n if (Array.isArray(response)) {\n return response.map((stream) => (Array.isArray(stream) ? stream[1] : undefined));\n }\n if (typeof response === 'object' && response !== null && !Buffer.isBuffer(response)) {\n return Object.values(response);\n }\n return [];\n}\n\n/**\n * Flatten ioredis's `XREAD` buffer response — `[[key, [[id, [field, value, …]], …]], …] | null`,\n * or the RESP3 map form (see {@link streamEntryLists}) — into the entries we care about. Validated\n * defensively at runtime (it crosses the Redis boundary) rather than trusting the driver's loose\n * typing.\n */\nfunction readEntries(response: unknown): StreamEntry[] {\n const entries: StreamEntry[] = [];\n for (const list of streamEntryLists(response)) {\n if (!Array.isArray(list)) {\n continue;\n }\n for (const item of list) {\n if (!Array.isArray(item) || item.length < 2) {\n continue;\n }\n const [id, fields] = item;\n if (!Buffer.isBuffer(id) || !Array.isArray(fields)) {\n continue;\n }\n const field = Buffer.isBuffer(fields[0]) ? fields[0].toString() : undefined;\n if (field === undefined) {\n continue;\n }\n entries.push({\n id: id.toString(),\n field,\n value: Buffer.isBuffer(fields[1]) ? fields[1] : undefined,\n });\n }\n }\n return entries;\n}\n\n/** Parse a `fail` sentinel's payload back into a {@link StreamError}, tolerating malformed JSON. */\nfunction parseStreamError(raw: string): StreamError {\n try {\n const parsed: unknown = JSON.parse(raw);\n if (\n typeof parsed === 'object' &&\n parsed !== null &&\n 'code' in parsed &&\n 'message' in parsed &&\n typeof parsed.code === 'string' &&\n typeof parsed.message === 'string'\n ) {\n return { code: parsed.code, message: parsed.message };\n }\n } catch {\n // fall through to the generic terminal below\n }\n return { code: 'stream_failed', message: raw };\n}\n"],"mappings":"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","names":["CHUNK_FIELD","END_FIELD","FAIL_FIELD","decoder","TextDecoder","textOf","chunk","line","decode","trimEnd","startsWith","parsed","JSON","parse","Object","keys","length","kind","text","RedisTokenStreamSink","keyPrefix","ttlSeconds","blockMs","writes","Map","redis","options","key","runId","runWrites","state","get","undefined","flushQueued","tail","Promise","resolve","pending","failure","set","release","delete","enqueue","work","next","then","send","entries","pipeline","field","value","xadd","expire","results","exec","error","drain","Buffer","from","encodeStreamEvent","queueText","catch","raiseFailure","write","entry","open","flush","end","fail","stringify","subscribe","signal","aborted","reader","duplicate","onAbort","disconnect","addEventListener","once","giveUpAt","Date","now","Number","POSITIVE_INFINITY","lastId","response","xreadBuffer","readEntries","exists","id","Uint8Array","AgentStreamError","parseStreamError","toString","removeEventListener","close","del","streamEntryLists","Array","isArray","map","stream","isBuffer","values","list","item","fields","push","raw","code","message"]}
@@ -1,4 +1,4 @@
1
- import { TokenStreamSink, SinkWriter } from '@dudousxd/nestjs-agent-core';
1
+ import { TokenStreamSink, SinkWriter, SubscribeOptions } from '@dudousxd/nestjs-agent-core';
2
2
  import { Redis } from 'ioredis';
3
3
 
4
4
  interface RedisTokenStreamSinkOptions {
@@ -8,7 +8,8 @@ interface RedisTokenStreamSinkOptions {
8
8
  * How long (seconds) a run's stream outlives its last write, so streams self-expire instead of
9
9
  * accumulating in Redis — ended runs past their replay window and runs that crashed without
10
10
  * ending alike (nothing calls `close()` on its own). A subscriber that reconnects within the window
11
- * still replays. Default 3600 (1h). `0` keeps the stream until `close()`.
11
+ * still replays; a subscriber of a run that never writes within the window stops following it.
12
+ * Default 3600 (1h). `0` keeps the stream until `close()`.
12
13
  */
13
14
  ttlSeconds?: number;
14
15
  /** `XREAD BLOCK` timeout (ms) between follow polls. Default 5000. Purely an internal cadence. */
@@ -17,9 +18,15 @@ interface RedisTokenStreamSinkOptions {
17
18
  /**
18
19
  * A cross-process {@link TokenStreamSink} backed by Redis Streams — the "data plane" for durable
19
20
  * agent turns whose model call runs on a WORKER pod while the SSE connection is served by an API
20
- * pod. The turn `XADD`s each delta to `agent:sink:<runId>`; a subscriber `XREAD`s from `0` so it
21
+ * pod. The turn `XADD`s its frames to `agent:sink:<runId>`; a subscriber `XREAD`s from `0` so it
21
22
  * REPLAYS everything buffered so far (a reconnect or a subscriber that attaches after the run
22
- * started misses nothing), then blocks for live entries until an `end`/`fail` sentinel.
23
+ * started misses nothing), then blocks for live entries until an `end`/`fail` sentinel — or until
24
+ * the stream is gone: expired or closed after it had entries, or never created within the TTL.
25
+ *
26
+ * Consecutive `text` frames are gathered while the previous send is still on the wire and go out as
27
+ * ONE `text` entry, so a model streaming faster than a Redis round trip costs one round trip per
28
+ * batch rather than per token, and a slow stream still goes out a token at a time. Every subscriber
29
+ * reads the same stored entries in the same order, so frame numbering stays exact across replicas.
23
30
  *
24
31
  * The consumer supplies the ioredis client (so the lib stays Redis-driver-agnostic and reuses the
25
32
  * app's connection config). Blocking reads monopolise a connection, so each `subscribe` runs on a
@@ -30,12 +37,34 @@ declare class RedisTokenStreamSink implements TokenStreamSink {
30
37
  private readonly keyPrefix;
31
38
  private readonly ttlSeconds;
32
39
  private readonly blockMs;
40
+ private readonly writes;
33
41
  constructor(redis: Redis, options?: RedisTokenStreamSinkOptions);
34
42
  private key;
43
+ private runWrites;
44
+ /** Forget a run's write state once nothing is gathered, queued or left to report. */
45
+ private release;
46
+ /** Queue `work` behind every send already queued for the run, so entries land in call order. */
47
+ private enqueue;
48
+ /**
49
+ * One round trip: the entries, in order, then the TTL re-armed — sliding from the run's last
50
+ * write. `EXPIRE 0` would delete, so 0 skips it.
51
+ */
52
+ private send;
53
+ /** The gathered text as one entry (nothing if there is none), followed by `entries`. */
54
+ private drain;
55
+ /** Queue a send of the gathered text, unless one is already queued and has not started. */
56
+ private queueText;
57
+ /** Raise, once, a gathered send that failed with nobody awaiting it. */
58
+ private raiseFailure;
59
+ /** Queue the gathered text and then `entry`, as one send; raise a failure nobody saw. */
60
+ private write;
61
+ /**
62
+ * The run's writer. Writers opened for the same run in one process share what they are gathering,
63
+ * so a delegated run's text (forwarded through a child writer) and its parent's next frame keep
64
+ * the order they were written in.
65
+ */
35
66
  open(runId: string): SinkWriter;
36
- /** (Re)arm the TTL — sliding from the run's last write. `EXPIRE 0` would delete, so 0 skips. */
37
- private expire;
38
- subscribe(runId: string): AsyncIterable<Uint8Array>;
67
+ subscribe(runId: string, options?: SubscribeOptions): AsyncIterable<Uint8Array>;
39
68
  close(runId: string): Promise<void>;
40
69
  }
41
70
 
@@ -1,4 +1,4 @@
1
- import { TokenStreamSink, SinkWriter } from '@dudousxd/nestjs-agent-core';
1
+ import { TokenStreamSink, SinkWriter, SubscribeOptions } from '@dudousxd/nestjs-agent-core';
2
2
  import { Redis } from 'ioredis';
3
3
 
4
4
  interface RedisTokenStreamSinkOptions {
@@ -8,7 +8,8 @@ interface RedisTokenStreamSinkOptions {
8
8
  * How long (seconds) a run's stream outlives its last write, so streams self-expire instead of
9
9
  * accumulating in Redis — ended runs past their replay window and runs that crashed without
10
10
  * ending alike (nothing calls `close()` on its own). A subscriber that reconnects within the window
11
- * still replays. Default 3600 (1h). `0` keeps the stream until `close()`.
11
+ * still replays; a subscriber of a run that never writes within the window stops following it.
12
+ * Default 3600 (1h). `0` keeps the stream until `close()`.
12
13
  */
13
14
  ttlSeconds?: number;
14
15
  /** `XREAD BLOCK` timeout (ms) between follow polls. Default 5000. Purely an internal cadence. */
@@ -17,9 +18,15 @@ interface RedisTokenStreamSinkOptions {
17
18
  /**
18
19
  * A cross-process {@link TokenStreamSink} backed by Redis Streams — the "data plane" for durable
19
20
  * agent turns whose model call runs on a WORKER pod while the SSE connection is served by an API
20
- * pod. The turn `XADD`s each delta to `agent:sink:<runId>`; a subscriber `XREAD`s from `0` so it
21
+ * pod. The turn `XADD`s its frames to `agent:sink:<runId>`; a subscriber `XREAD`s from `0` so it
21
22
  * REPLAYS everything buffered so far (a reconnect or a subscriber that attaches after the run
22
- * started misses nothing), then blocks for live entries until an `end`/`fail` sentinel.
23
+ * started misses nothing), then blocks for live entries until an `end`/`fail` sentinel — or until
24
+ * the stream is gone: expired or closed after it had entries, or never created within the TTL.
25
+ *
26
+ * Consecutive `text` frames are gathered while the previous send is still on the wire and go out as
27
+ * ONE `text` entry, so a model streaming faster than a Redis round trip costs one round trip per
28
+ * batch rather than per token, and a slow stream still goes out a token at a time. Every subscriber
29
+ * reads the same stored entries in the same order, so frame numbering stays exact across replicas.
23
30
  *
24
31
  * The consumer supplies the ioredis client (so the lib stays Redis-driver-agnostic and reuses the
25
32
  * app's connection config). Blocking reads monopolise a connection, so each `subscribe` runs on a
@@ -30,12 +37,34 @@ declare class RedisTokenStreamSink implements TokenStreamSink {
30
37
  private readonly keyPrefix;
31
38
  private readonly ttlSeconds;
32
39
  private readonly blockMs;
40
+ private readonly writes;
33
41
  constructor(redis: Redis, options?: RedisTokenStreamSinkOptions);
34
42
  private key;
43
+ private runWrites;
44
+ /** Forget a run's write state once nothing is gathered, queued or left to report. */
45
+ private release;
46
+ /** Queue `work` behind every send already queued for the run, so entries land in call order. */
47
+ private enqueue;
48
+ /**
49
+ * One round trip: the entries, in order, then the TTL re-armed — sliding from the run's last
50
+ * write. `EXPIRE 0` would delete, so 0 skips it.
51
+ */
52
+ private send;
53
+ /** The gathered text as one entry (nothing if there is none), followed by `entries`. */
54
+ private drain;
55
+ /** Queue a send of the gathered text, unless one is already queued and has not started. */
56
+ private queueText;
57
+ /** Raise, once, a gathered send that failed with nobody awaiting it. */
58
+ private raiseFailure;
59
+ /** Queue the gathered text and then `entry`, as one send; raise a failure nobody saw. */
60
+ private write;
61
+ /**
62
+ * The run's writer. Writers opened for the same run in one process share what they are gathering,
63
+ * so a delegated run's text (forwarded through a child writer) and its parent's next frame keep
64
+ * the order they were written in.
65
+ */
35
66
  open(runId: string): SinkWriter;
36
- /** (Re)arm the TTL — sliding from the run's last write. `EXPIRE 0` would delete, so 0 skips. */
37
- private expire;
38
- subscribe(runId: string): AsyncIterable<Uint8Array>;
67
+ subscribe(runId: string, options?: SubscribeOptions): AsyncIterable<Uint8Array>;
39
68
  close(runId: string): Promise<void>;
40
69
  }
41
70
 
@@ -2,10 +2,24 @@ var __defProp = Object.defineProperty;
2
2
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
3
 
4
4
  // src/sink-redis/redis-token-stream-sink.ts
5
- import { AgentStreamError } from "@dudousxd/nestjs-agent-core";
5
+ import { AgentStreamError, encodeStreamEvent } from "@dudousxd/nestjs-agent-core";
6
6
  var CHUNK_FIELD = "c";
7
7
  var END_FIELD = "e";
8
8
  var FAIL_FIELD = "f";
9
+ var decoder = new TextDecoder();
10
+ function textOf(chunk) {
11
+ const line = decoder.decode(chunk).trimEnd();
12
+ if (!line.startsWith('{"kind":"text"')) return null;
13
+ try {
14
+ const parsed = JSON.parse(line);
15
+ if (typeof parsed === "object" && parsed !== null && Object.keys(parsed).length === 2 && parsed.kind === "text" && typeof parsed.text === "string") {
16
+ return parsed.text;
17
+ }
18
+ } catch {
19
+ }
20
+ return null;
21
+ }
22
+ __name(textOf, "textOf");
9
23
  var RedisTokenStreamSink = class {
10
24
  static {
11
25
  __name(this, "RedisTokenStreamSink");
@@ -14,6 +28,7 @@ var RedisTokenStreamSink = class {
14
28
  keyPrefix;
15
29
  ttlSeconds;
16
30
  blockMs;
31
+ writes = /* @__PURE__ */ new Map();
17
32
  constructor(redis, options = {}) {
18
33
  this.redis = redis;
19
34
  this.keyPrefix = options.keyPrefix ?? "agent:sink:";
@@ -23,35 +38,165 @@ var RedisTokenStreamSink = class {
23
38
  key(runId) {
24
39
  return `${this.keyPrefix}${runId}`;
25
40
  }
26
- open(runId) {
41
+ runWrites(runId) {
42
+ let state = this.writes.get(runId);
43
+ if (state === void 0) {
44
+ state = {
45
+ text: null,
46
+ flushQueued: false,
47
+ tail: Promise.resolve(),
48
+ pending: 0,
49
+ failure: void 0
50
+ };
51
+ this.writes.set(runId, state);
52
+ }
53
+ return state;
54
+ }
55
+ /** Forget a run's write state once nothing is gathered, queued or left to report. */
56
+ release(runId, state) {
57
+ if (state.pending === 0 && state.text === null && state.failure === void 0 && this.writes.get(runId) === state) {
58
+ this.writes.delete(runId);
59
+ }
60
+ }
61
+ /** Queue `work` behind every send already queued for the run, so entries land in call order. */
62
+ enqueue(runId, state, work) {
63
+ state.pending += 1;
64
+ const next = state.tail.then(work);
65
+ state.tail = next.then(() => void 0, () => void 0).then(() => {
66
+ state.pending -= 1;
67
+ this.release(runId, state);
68
+ });
69
+ return next;
70
+ }
71
+ /**
72
+ * One round trip: the entries, in order, then the TTL re-armed — sliding from the run's last
73
+ * write. `EXPIRE 0` would delete, so 0 skips it.
74
+ */
75
+ async send(runId, entries) {
27
76
  const key = this.key(runId);
77
+ const pipeline = this.redis.pipeline();
78
+ for (const [field, value] of entries) {
79
+ pipeline.xadd(key, "*", field, value);
80
+ }
81
+ if (this.ttlSeconds > 0) pipeline.expire(key, this.ttlSeconds);
82
+ const results = await pipeline.exec() ?? [];
83
+ for (const [error] of results) {
84
+ if (error) throw error;
85
+ }
86
+ }
87
+ /** The gathered text as one entry (nothing if there is none), followed by `entries`. */
88
+ drain(state) {
89
+ if (state.text === null) return [];
90
+ const text = state.text;
91
+ state.text = null;
92
+ return [
93
+ [
94
+ CHUNK_FIELD,
95
+ Buffer.from(encodeStreamEvent({
96
+ kind: "text",
97
+ text
98
+ }))
99
+ ]
100
+ ];
101
+ }
102
+ /** Queue a send of the gathered text, unless one is already queued and has not started. */
103
+ queueText(runId, state) {
104
+ if (state.flushQueued) return;
105
+ state.flushQueued = true;
106
+ this.enqueue(runId, state, async () => {
107
+ state.flushQueued = false;
108
+ const entries = this.drain(state);
109
+ if (entries.length > 0) await this.send(runId, entries);
110
+ }).catch((error) => {
111
+ state.failure ??= {
112
+ error
113
+ };
114
+ });
115
+ }
116
+ /** Raise, once, a gathered send that failed with nobody awaiting it. */
117
+ raiseFailure(state) {
118
+ const failure = state.failure;
119
+ if (failure !== void 0) {
120
+ state.failure = void 0;
121
+ throw failure.error;
122
+ }
123
+ }
124
+ /** Queue the gathered text and then `entry`, as one send; raise a failure nobody saw. */
125
+ async write(runId, entry) {
126
+ const state = this.runWrites(runId);
127
+ this.raiseFailure(state);
128
+ await this.enqueue(runId, state, () => this.send(runId, [
129
+ ...this.drain(state),
130
+ entry
131
+ ]));
132
+ this.raiseFailure(state);
133
+ this.release(runId, state);
134
+ }
135
+ /**
136
+ * The run's writer. Writers opened for the same run in one process share what they are gathering,
137
+ * so a delegated run's text (forwarded through a child writer) and its parent's next frame keep
138
+ * the order they were written in.
139
+ */
140
+ open(runId) {
28
141
  return {
29
142
  write: /* @__PURE__ */ __name(async (chunk) => {
30
- await this.redis.xadd(key, "*", CHUNK_FIELD, Buffer.from(chunk));
31
- await this.expire(key);
143
+ const text = textOf(chunk);
144
+ if (text === null) {
145
+ await this.write(runId, [
146
+ CHUNK_FIELD,
147
+ Buffer.from(chunk)
148
+ ]);
149
+ return;
150
+ }
151
+ const state = this.runWrites(runId);
152
+ this.raiseFailure(state);
153
+ state.text = (state.text ?? "") + text;
154
+ this.queueText(runId, state);
32
155
  }, "write"),
33
- end: /* @__PURE__ */ __name(async () => {
34
- await this.redis.xadd(key, "*", END_FIELD, "1");
35
- await this.expire(key);
36
- }, "end"),
37
- fail: /* @__PURE__ */ __name(async (error) => {
38
- await this.redis.xadd(key, "*", FAIL_FIELD, JSON.stringify(error));
39
- await this.expire(key);
40
- }, "fail")
156
+ flush: /* @__PURE__ */ __name(async () => {
157
+ const state = this.runWrites(runId);
158
+ this.queueText(runId, state);
159
+ await state.tail;
160
+ this.raiseFailure(state);
161
+ this.release(runId, state);
162
+ }, "flush"),
163
+ end: /* @__PURE__ */ __name(() => this.write(runId, [
164
+ END_FIELD,
165
+ "1"
166
+ ]), "end"),
167
+ fail: /* @__PURE__ */ __name((error) => this.write(runId, [
168
+ FAIL_FIELD,
169
+ JSON.stringify(error)
170
+ ]), "fail")
41
171
  };
42
172
  }
43
- /** (Re)arm the TTL — sliding from the run's last write. `EXPIRE 0` would delete, so 0 skips. */
44
- async expire(key) {
45
- if (this.ttlSeconds > 0) await this.redis.expire(key, this.ttlSeconds);
46
- }
47
- async *subscribe(runId) {
173
+ async *subscribe(runId, options = {}) {
174
+ const { signal } = options;
175
+ if (signal?.aborted) return;
48
176
  const key = this.key(runId);
49
177
  const reader = this.redis.duplicate();
178
+ const onAbort = /* @__PURE__ */ __name(() => reader.disconnect(), "onAbort");
179
+ signal?.addEventListener("abort", onAbort, {
180
+ once: true
181
+ });
182
+ const giveUpAt = this.ttlSeconds > 0 ? Date.now() + this.ttlSeconds * 1e3 : Number.POSITIVE_INFINITY;
50
183
  try {
51
184
  let lastId = "0";
52
185
  while (true) {
53
- const response = await reader.xreadBuffer("BLOCK", this.blockMs, "STREAMS", key, lastId);
54
- for (const entry of readEntries(response)) {
186
+ let response;
187
+ try {
188
+ response = await reader.xreadBuffer("BLOCK", this.blockMs, "STREAMS", key, lastId);
189
+ } catch (error) {
190
+ if (signal?.aborted) return;
191
+ throw error;
192
+ }
193
+ const entries = readEntries(response);
194
+ if (entries.length === 0) {
195
+ if (lastId !== "0" && await this.redis.exists(key) === 0) return;
196
+ if (lastId === "0" && Date.now() >= giveUpAt) return;
197
+ continue;
198
+ }
199
+ for (const entry of entries) {
55
200
  lastId = entry.id;
56
201
  if (entry.field === CHUNK_FIELD && entry.value !== void 0) {
57
202
  yield new Uint8Array(entry.value);
@@ -63,6 +208,7 @@ var RedisTokenStreamSink = class {
63
208
  }
64
209
  }
65
210
  } finally {
211
+ signal?.removeEventListener("abort", onAbort);
66
212
  reader.disconnect();
67
213
  }
68
214
  }