@dudousxd/nestjs-agent 0.3.2 → 0.3.3

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.
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+ var __copyProps = (to, from, except, desc) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from))
14
+ if (!__hasOwnProp.call(to, key) && key !== except)
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
16
+ }
17
+ return to;
18
+ };
19
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
20
+
21
+ // src/sink-redis/index.ts
22
+ var sink_redis_exports = {};
23
+ __export(sink_redis_exports, {
24
+ RedisTokenStreamSink: () => RedisTokenStreamSink
25
+ });
26
+ module.exports = __toCommonJS(sink_redis_exports);
27
+
28
+ // src/sink-redis/redis-token-stream-sink.ts
29
+ var import_nestjs_agent_core = require("@dudousxd/nestjs-agent-core");
30
+ var CHUNK_FIELD = "c";
31
+ var END_FIELD = "e";
32
+ var FAIL_FIELD = "f";
33
+ var RedisTokenStreamSink = class {
34
+ static {
35
+ __name(this, "RedisTokenStreamSink");
36
+ }
37
+ redis;
38
+ keyPrefix;
39
+ ttlSeconds;
40
+ blockMs;
41
+ constructor(redis, options = {}) {
42
+ this.redis = redis;
43
+ this.keyPrefix = options.keyPrefix ?? "agent:sink:";
44
+ this.ttlSeconds = options.ttlSeconds ?? 3600;
45
+ this.blockMs = options.blockMs ?? 5e3;
46
+ }
47
+ key(runId) {
48
+ return `${this.keyPrefix}${runId}`;
49
+ }
50
+ open(runId) {
51
+ const key = this.key(runId);
52
+ return {
53
+ write: /* @__PURE__ */ __name(async (chunk) => {
54
+ await this.redis.xadd(key, "*", CHUNK_FIELD, Buffer.from(chunk));
55
+ }, "write"),
56
+ end: /* @__PURE__ */ __name(async () => {
57
+ await this.redis.xadd(key, "*", END_FIELD, "1");
58
+ await this.redis.expire(key, this.ttlSeconds);
59
+ }, "end"),
60
+ fail: /* @__PURE__ */ __name(async (error) => {
61
+ await this.redis.xadd(key, "*", FAIL_FIELD, JSON.stringify(error));
62
+ await this.redis.expire(key, this.ttlSeconds);
63
+ }, "fail")
64
+ };
65
+ }
66
+ async *subscribe(runId) {
67
+ const key = this.key(runId);
68
+ const reader = this.redis.duplicate();
69
+ try {
70
+ let lastId = "0";
71
+ while (true) {
72
+ const response = await reader.xreadBuffer("BLOCK", this.blockMs, "STREAMS", key, lastId);
73
+ for (const entry of readEntries(response)) {
74
+ lastId = entry.id;
75
+ if (entry.field === CHUNK_FIELD && entry.value !== void 0) {
76
+ yield new Uint8Array(entry.value);
77
+ } else if (entry.field === END_FIELD) {
78
+ return;
79
+ } else if (entry.field === FAIL_FIELD && entry.value !== void 0) {
80
+ throw new import_nestjs_agent_core.AgentStreamError(parseStreamError(entry.value.toString()));
81
+ }
82
+ }
83
+ }
84
+ } finally {
85
+ reader.disconnect();
86
+ }
87
+ }
88
+ async close(runId) {
89
+ await this.redis.del(this.key(runId));
90
+ }
91
+ };
92
+ function readEntries(response) {
93
+ if (!Array.isArray(response)) {
94
+ return [];
95
+ }
96
+ const entries = [];
97
+ for (const stream of response) {
98
+ if (!Array.isArray(stream) || !Array.isArray(stream[1])) {
99
+ continue;
100
+ }
101
+ for (const item of stream[1]) {
102
+ if (!Array.isArray(item) || item.length < 2) {
103
+ continue;
104
+ }
105
+ const [id, fields] = item;
106
+ if (!Buffer.isBuffer(id) || !Array.isArray(fields)) {
107
+ continue;
108
+ }
109
+ const field = Buffer.isBuffer(fields[0]) ? fields[0].toString() : void 0;
110
+ if (field === void 0) {
111
+ continue;
112
+ }
113
+ entries.push({
114
+ id: id.toString(),
115
+ field,
116
+ value: Buffer.isBuffer(fields[1]) ? fields[1] : void 0
117
+ });
118
+ }
119
+ }
120
+ return entries;
121
+ }
122
+ __name(readEntries, "readEntries");
123
+ function parseStreamError(raw) {
124
+ try {
125
+ const parsed = JSON.parse(raw);
126
+ if (typeof parsed === "object" && parsed !== null && "code" in parsed && "message" in parsed && typeof parsed.code === "string" && typeof parsed.message === "string") {
127
+ return {
128
+ code: parsed.code,
129
+ message: parsed.message
130
+ };
131
+ }
132
+ } catch {
133
+ }
134
+ return {
135
+ code: "stream_failed",
136
+ message: raw
137
+ };
138
+ }
139
+ __name(parseStreamError, "parseStreamError");
140
+ // Annotate the CommonJS export names for ESM import in node:
141
+ 0 && (module.exports = {
142
+ RedisTokenStreamSink
143
+ });
144
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +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 * TTL (seconds) applied to a run's stream once it ends/fails, so completed streams self-expire\n * instead of accumulating in Redis. A subscriber that reconnects within the window still replays.\n * Default 3600 (1h).\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 },\n end: async () => {\n await this.redis.xadd(key, '*', END_FIELD, '1');\n await this.redis.expire(key, this.ttlSeconds);\n },\n fail: async (error: StreamError) => {\n await this.redis.xadd(key, '*', FAIL_FIELD, JSON.stringify(error));\n await this.redis.expire(key, this.ttlSeconds);\n },\n };\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 * Flatten ioredis's `XREAD` buffer response — `[[key, [[id, [field, value, …]], …]], …] | null` —\n * into the entries we care about. Validated defensively at runtime (it crosses the Redis boundary)\n * rather than trusting the driver's loose typing.\n */\nfunction readEntries(response: unknown): StreamEntry[] {\n if (!Array.isArray(response)) {\n return [];\n }\n const entries: StreamEntry[] = [];\n for (const stream of response) {\n if (!Array.isArray(stream) || !Array.isArray(stream[1])) {\n continue;\n }\n for (const item of stream[1]) {\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","end","expire","fail","error","JSON","stringify","subscribe","reader","duplicate","lastId","response","xreadBuffer","entry","readEntries","id","field","value","undefined","Uint8Array","AgentStreamError","parseStreamError","toString","disconnect","close","del","Array","isArray","entries","stream","item","length","fields","isBuffer","push","raw","parsed","parse","code","message"]}
@@ -0,0 +1,39 @@
1
+ import { TokenStreamSink, SinkWriter } from '@dudousxd/nestjs-agent-core';
2
+ import { Redis } from 'ioredis';
3
+
4
+ interface RedisTokenStreamSinkOptions {
5
+ /** Key namespace for the per-run streams. Default `agent:sink:`. */
6
+ keyPrefix?: string;
7
+ /**
8
+ * TTL (seconds) applied to a run's stream once it ends/fails, so completed streams self-expire
9
+ * instead of accumulating in Redis. A subscriber that reconnects within the window still replays.
10
+ * Default 3600 (1h).
11
+ */
12
+ ttlSeconds?: number;
13
+ /** `XREAD BLOCK` timeout (ms) between follow polls. Default 5000. Purely an internal cadence. */
14
+ blockMs?: number;
15
+ }
16
+ /**
17
+ * A cross-process {@link TokenStreamSink} backed by Redis Streams — the "data plane" for durable
18
+ * agent turns whose model call runs on a WORKER pod while the SSE connection is served by an API
19
+ * pod. The turn `XADD`s each delta to `agent:sink:<runId>`; a subscriber `XREAD`s from `0` so it
20
+ * REPLAYS everything buffered so far (a reconnect or a subscriber that attaches after the run
21
+ * started misses nothing), then blocks for live entries until an `end`/`fail` sentinel.
22
+ *
23
+ * The consumer supplies the ioredis client (so the lib stays Redis-driver-agnostic and reuses the
24
+ * app's connection config). Blocking reads monopolise a connection, so each `subscribe` runs on a
25
+ * short-lived `duplicate()` that is disconnected when the subscriber stops.
26
+ */
27
+ declare class RedisTokenStreamSink implements TokenStreamSink {
28
+ private readonly redis;
29
+ private readonly keyPrefix;
30
+ private readonly ttlSeconds;
31
+ private readonly blockMs;
32
+ constructor(redis: Redis, options?: RedisTokenStreamSinkOptions);
33
+ private key;
34
+ open(runId: string): SinkWriter;
35
+ subscribe(runId: string): AsyncIterable<Uint8Array>;
36
+ close(runId: string): Promise<void>;
37
+ }
38
+
39
+ export { RedisTokenStreamSink, type RedisTokenStreamSinkOptions };
@@ -0,0 +1,39 @@
1
+ import { TokenStreamSink, SinkWriter } from '@dudousxd/nestjs-agent-core';
2
+ import { Redis } from 'ioredis';
3
+
4
+ interface RedisTokenStreamSinkOptions {
5
+ /** Key namespace for the per-run streams. Default `agent:sink:`. */
6
+ keyPrefix?: string;
7
+ /**
8
+ * TTL (seconds) applied to a run's stream once it ends/fails, so completed streams self-expire
9
+ * instead of accumulating in Redis. A subscriber that reconnects within the window still replays.
10
+ * Default 3600 (1h).
11
+ */
12
+ ttlSeconds?: number;
13
+ /** `XREAD BLOCK` timeout (ms) between follow polls. Default 5000. Purely an internal cadence. */
14
+ blockMs?: number;
15
+ }
16
+ /**
17
+ * A cross-process {@link TokenStreamSink} backed by Redis Streams — the "data plane" for durable
18
+ * agent turns whose model call runs on a WORKER pod while the SSE connection is served by an API
19
+ * pod. The turn `XADD`s each delta to `agent:sink:<runId>`; a subscriber `XREAD`s from `0` so it
20
+ * REPLAYS everything buffered so far (a reconnect or a subscriber that attaches after the run
21
+ * started misses nothing), then blocks for live entries until an `end`/`fail` sentinel.
22
+ *
23
+ * The consumer supplies the ioredis client (so the lib stays Redis-driver-agnostic and reuses the
24
+ * app's connection config). Blocking reads monopolise a connection, so each `subscribe` runs on a
25
+ * short-lived `duplicate()` that is disconnected when the subscriber stops.
26
+ */
27
+ declare class RedisTokenStreamSink implements TokenStreamSink {
28
+ private readonly redis;
29
+ private readonly keyPrefix;
30
+ private readonly ttlSeconds;
31
+ private readonly blockMs;
32
+ constructor(redis: Redis, options?: RedisTokenStreamSinkOptions);
33
+ private key;
34
+ open(runId: string): SinkWriter;
35
+ subscribe(runId: string): AsyncIterable<Uint8Array>;
36
+ close(runId: string): Promise<void>;
37
+ }
38
+
39
+ export { RedisTokenStreamSink, type RedisTokenStreamSinkOptions };
@@ -0,0 +1,119 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
+
4
+ // src/sink-redis/redis-token-stream-sink.ts
5
+ import { AgentStreamError } from "@dudousxd/nestjs-agent-core";
6
+ var CHUNK_FIELD = "c";
7
+ var END_FIELD = "e";
8
+ var FAIL_FIELD = "f";
9
+ var RedisTokenStreamSink = class {
10
+ static {
11
+ __name(this, "RedisTokenStreamSink");
12
+ }
13
+ redis;
14
+ keyPrefix;
15
+ ttlSeconds;
16
+ blockMs;
17
+ constructor(redis, options = {}) {
18
+ this.redis = redis;
19
+ this.keyPrefix = options.keyPrefix ?? "agent:sink:";
20
+ this.ttlSeconds = options.ttlSeconds ?? 3600;
21
+ this.blockMs = options.blockMs ?? 5e3;
22
+ }
23
+ key(runId) {
24
+ return `${this.keyPrefix}${runId}`;
25
+ }
26
+ open(runId) {
27
+ const key = this.key(runId);
28
+ return {
29
+ write: /* @__PURE__ */ __name(async (chunk) => {
30
+ await this.redis.xadd(key, "*", CHUNK_FIELD, Buffer.from(chunk));
31
+ }, "write"),
32
+ end: /* @__PURE__ */ __name(async () => {
33
+ await this.redis.xadd(key, "*", END_FIELD, "1");
34
+ await this.redis.expire(key, this.ttlSeconds);
35
+ }, "end"),
36
+ fail: /* @__PURE__ */ __name(async (error) => {
37
+ await this.redis.xadd(key, "*", FAIL_FIELD, JSON.stringify(error));
38
+ await this.redis.expire(key, this.ttlSeconds);
39
+ }, "fail")
40
+ };
41
+ }
42
+ async *subscribe(runId) {
43
+ const key = this.key(runId);
44
+ const reader = this.redis.duplicate();
45
+ try {
46
+ let lastId = "0";
47
+ while (true) {
48
+ const response = await reader.xreadBuffer("BLOCK", this.blockMs, "STREAMS", key, lastId);
49
+ for (const entry of readEntries(response)) {
50
+ lastId = entry.id;
51
+ if (entry.field === CHUNK_FIELD && entry.value !== void 0) {
52
+ yield new Uint8Array(entry.value);
53
+ } else if (entry.field === END_FIELD) {
54
+ return;
55
+ } else if (entry.field === FAIL_FIELD && entry.value !== void 0) {
56
+ throw new AgentStreamError(parseStreamError(entry.value.toString()));
57
+ }
58
+ }
59
+ }
60
+ } finally {
61
+ reader.disconnect();
62
+ }
63
+ }
64
+ async close(runId) {
65
+ await this.redis.del(this.key(runId));
66
+ }
67
+ };
68
+ function readEntries(response) {
69
+ if (!Array.isArray(response)) {
70
+ return [];
71
+ }
72
+ const entries = [];
73
+ for (const stream of response) {
74
+ if (!Array.isArray(stream) || !Array.isArray(stream[1])) {
75
+ continue;
76
+ }
77
+ for (const item of stream[1]) {
78
+ if (!Array.isArray(item) || item.length < 2) {
79
+ continue;
80
+ }
81
+ const [id, fields] = item;
82
+ if (!Buffer.isBuffer(id) || !Array.isArray(fields)) {
83
+ continue;
84
+ }
85
+ const field = Buffer.isBuffer(fields[0]) ? fields[0].toString() : void 0;
86
+ if (field === void 0) {
87
+ continue;
88
+ }
89
+ entries.push({
90
+ id: id.toString(),
91
+ field,
92
+ value: Buffer.isBuffer(fields[1]) ? fields[1] : void 0
93
+ });
94
+ }
95
+ }
96
+ return entries;
97
+ }
98
+ __name(readEntries, "readEntries");
99
+ function parseStreamError(raw) {
100
+ try {
101
+ const parsed = JSON.parse(raw);
102
+ if (typeof parsed === "object" && parsed !== null && "code" in parsed && "message" in parsed && typeof parsed.code === "string" && typeof parsed.message === "string") {
103
+ return {
104
+ code: parsed.code,
105
+ message: parsed.message
106
+ };
107
+ }
108
+ } catch {
109
+ }
110
+ return {
111
+ code: "stream_failed",
112
+ message: raw
113
+ };
114
+ }
115
+ __name(parseStreamError, "parseStreamError");
116
+ export {
117
+ RedisTokenStreamSink
118
+ };
119
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/sink-redis/redis-token-stream-sink.ts"],"sourcesContent":["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 * TTL (seconds) applied to a run's stream once it ends/fails, so completed streams self-expire\n * instead of accumulating in Redis. A subscriber that reconnects within the window still replays.\n * Default 3600 (1h).\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 },\n end: async () => {\n await this.redis.xadd(key, '*', END_FIELD, '1');\n await this.redis.expire(key, this.ttlSeconds);\n },\n fail: async (error: StreamError) => {\n await this.redis.xadd(key, '*', FAIL_FIELD, JSON.stringify(error));\n await this.redis.expire(key, this.ttlSeconds);\n },\n };\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 * Flatten ioredis's `XREAD` buffer response — `[[key, [[id, [field, value, …]], …]], …] | null` —\n * into the entries we care about. Validated defensively at runtime (it crosses the Redis boundary)\n * rather than trusting the driver's loose typing.\n */\nfunction readEntries(response: unknown): StreamEntry[] {\n if (!Array.isArray(response)) {\n return [];\n }\n const entries: StreamEntry[] = [];\n for (const stream of response) {\n if (!Array.isArray(stream) || !Array.isArray(stream[1])) {\n continue;\n }\n for (const item of stream[1]) {\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":["AgentStreamError","CHUNK_FIELD","END_FIELD","FAIL_FIELD","RedisTokenStreamSink","keyPrefix","ttlSeconds","blockMs","redis","options","key","runId","open","write","chunk","xadd","Buffer","from","end","expire","fail","error","JSON","stringify","subscribe","reader","duplicate","lastId","response","xreadBuffer","entry","readEntries","id","field","value","undefined","Uint8Array","AgentStreamError","parseStreamError","toString","disconnect","close","del","Array","isArray","entries","stream","item","length","fields","isBuffer","push","raw","parsed","parse","code","message"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dudousxd/nestjs-agent",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "Governed, durable-backed AI agent for NestJS — @AiTool, discovery, /agent controllers",
5
5
  "license": "MIT",
6
6
  "author": "Davide Carvalho",
@@ -28,6 +28,16 @@
28
28
  "types": "./dist/durable/index.d.cts",
29
29
  "default": "./dist/durable/index.cjs"
30
30
  }
31
+ },
32
+ "./sink-redis": {
33
+ "import": {
34
+ "types": "./dist/sink-redis/index.d.ts",
35
+ "default": "./dist/sink-redis/index.js"
36
+ },
37
+ "require": {
38
+ "types": "./dist/sink-redis/index.d.cts",
39
+ "default": "./dist/sink-redis/index.cjs"
40
+ }
31
41
  }
32
42
  },
33
43
  "files": [
@@ -36,13 +46,14 @@
36
46
  "dependencies": {
37
47
  "@standard-schema/spec": "1.1.0",
38
48
  "zod": "3.25.76",
39
- "@dudousxd/nestjs-agent-core": "0.3.2"
49
+ "@dudousxd/nestjs-agent-core": "0.3.3"
40
50
  },
41
51
  "peerDependencies": {
42
- "@nestjs/common": "^10.0.0 || ^11.0.0",
43
- "@nestjs/core": "^10.0.0 || ^11.0.0",
44
52
  "@dudousxd/nestjs-durable": ">=0.26.0",
45
53
  "@dudousxd/nestjs-durable-core": ">=0.45.0",
54
+ "@nestjs/common": "^10.0.0 || ^11.0.0",
55
+ "@nestjs/core": "^10.0.0 || ^11.0.0",
56
+ "ioredis": "^5.0.0",
46
57
  "reflect-metadata": "^0.2.0"
47
58
  },
48
59
  "peerDependenciesMeta": {
@@ -51,6 +62,9 @@
51
62
  },
52
63
  "@dudousxd/nestjs-durable-core": {
53
64
  "optional": true
65
+ },
66
+ "ioredis": {
67
+ "optional": true
54
68
  }
55
69
  },
56
70
  "devDependencies": {
@@ -65,13 +79,14 @@
65
79
  "@nestjs/testing": "11.1.6",
66
80
  "@types/express": "5.0.0",
67
81
  "@types/supertest": "6.0.2",
82
+ "ioredis": "^5.4.1",
68
83
  "reflect-metadata": "0.2.2",
69
84
  "rxjs": "7.8.1",
70
85
  "supertest": "7.0.0",
71
86
  "tsup": "8.3.5",
72
87
  "typescript": "5.9.3",
73
88
  "zod": "3.25.76",
74
- "@dudousxd/nestjs-agent-testing": "0.3.2"
89
+ "@dudousxd/nestjs-agent-testing": "0.3.3"
75
90
  },
76
91
  "scripts": {
77
92
  "build": "tsup",