@juspay/neurolink 11.30.0 → 12.0.1
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.
- package/CHANGELOG.md +3 -3
- package/dist/browser/neurolink.min.js +334 -334
- package/dist/core/baseProvider.js +20 -1
- package/dist/neurolink.js +30 -2
- package/dist/utils/streamCancellation.d.ts +46 -0
- package/dist/utils/streamCancellation.js +109 -0
- package/dist/utils/ttsStream.js +34 -1
- package/dist/voice/livekit/realtimeEventBridge.js +27 -6
- package/package.json +1 -1
|
@@ -15,6 +15,7 @@ import { hasLifecycleErrorFired, markLifecycleErrorFired, } from "../utils/lifec
|
|
|
15
15
|
import { resolveLifecycleTimeoutMs } from "../utils/lifecycleTimeout.js";
|
|
16
16
|
import { logger } from "../utils/logger.js";
|
|
17
17
|
import { interleaveTTSStream } from "../utils/ttsStream.js";
|
|
18
|
+
import { attachStreamCancel, cancelStream, releaseIterator, } from "../utils/streamCancellation.js";
|
|
18
19
|
import { TimeoutError as AsyncTimeoutError, withTimeoutFn, } from "../utils/async/withTimeout.js";
|
|
19
20
|
import { composeAbortSignals, createTimeoutController, TimeoutError, } from "../utils/timeout.js";
|
|
20
21
|
import { shouldDisableBuiltinTools } from "../utils/toolUtils.js";
|
|
@@ -383,11 +384,20 @@ export class BaseProvider {
|
|
|
383
384
|
// Arrow, like `safeFire` above: the generator is a plain function expression, so
|
|
384
385
|
// `this` is not bound inside it.
|
|
385
386
|
const classifyStreamError = (e) => this.classifyStreamError(e);
|
|
387
|
+
// Hold the upstream iterator rather than letting `for await` create one
|
|
388
|
+
// internally, so the cancel hook below can close it directly. Iterating
|
|
389
|
+
// `upstreamIterable` is equivalent to iterating `originalStream` — same
|
|
390
|
+
// iterator, same early-exit `return()` semantics — it just leaves a handle
|
|
391
|
+
// reachable from outside the generator.
|
|
392
|
+
const upstreamIterator = originalStream[Symbol.asyncIterator]();
|
|
393
|
+
const upstreamIterable = {
|
|
394
|
+
[Symbol.asyncIterator]: () => upstreamIterator,
|
|
395
|
+
};
|
|
386
396
|
const wrappedStream = (async function* () {
|
|
387
397
|
let accumulated = "";
|
|
388
398
|
let seq = 0;
|
|
389
399
|
try {
|
|
390
|
-
for await (const chunk of
|
|
400
|
+
for await (const chunk of upstreamIterable) {
|
|
391
401
|
const textPart = chunk &&
|
|
392
402
|
typeof chunk === "object" &&
|
|
393
403
|
"content" in chunk &&
|
|
@@ -434,6 +444,15 @@ export class BaseProvider {
|
|
|
434
444
|
throw classifyStreamError(err);
|
|
435
445
|
}
|
|
436
446
|
})();
|
|
447
|
+
// A consumer that breaks out of the stream cannot reach this generator
|
|
448
|
+
// through `.return()` while it is parked awaiting the provider — that
|
|
449
|
+
// request queues behind the in-flight `next()`. The hook closes the
|
|
450
|
+
// upstream directly and forwards the request to any wrapper below, so
|
|
451
|
+
// abandoning a stream really does release the provider connection.
|
|
452
|
+
attachStreamCancel(wrappedStream, () => {
|
|
453
|
+
cancelStream(originalStream);
|
|
454
|
+
releaseIterator(upstreamIterator);
|
|
455
|
+
});
|
|
437
456
|
return { ...result, stream: wrappedStream };
|
|
438
457
|
}
|
|
439
458
|
/**
|
package/dist/neurolink.js
CHANGED
|
@@ -1229,9 +1229,23 @@ export class NeuroLink {
|
|
|
1229
1229
|
});
|
|
1230
1230
|
});
|
|
1231
1231
|
// Fire-and-forget: registrations complete before any generate/stream call
|
|
1232
|
-
// because those calls await initializeMCP() which is slower
|
|
1232
|
+
// because those calls await initializeMCP() which is slower.
|
|
1233
|
+
//
|
|
1234
|
+
// The rejection handler is not decoration. registerTool() throws on a
|
|
1235
|
+
// failed name/description validation (mcp/toolRegistry.ts), and that throw
|
|
1236
|
+
// is not inside a try — so a rejected registration on a `void`-detached
|
|
1237
|
+
// one-argument `.then()` would be an unhandled rejection, which terminates
|
|
1238
|
+
// the process. Today the tool names come from createFileTools(), which are
|
|
1239
|
+
// internal constants that pass validation, so this is latent rather than
|
|
1240
|
+
// live; it stops being latent the moment a name is derived from anything
|
|
1241
|
+
// outside this file. Losing one tool registration is the intended failure
|
|
1242
|
+
// mode here — losing the host process is not.
|
|
1233
1243
|
void Promise.all(registrations).then(() => {
|
|
1234
1244
|
logger.debug(`[NeuroLink] Registered ${Object.keys(fileTools).length} file reference tools`);
|
|
1245
|
+
}, (error) => {
|
|
1246
|
+
logger.warn("[NeuroLink] File tool registration failed", {
|
|
1247
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1248
|
+
});
|
|
1235
1249
|
});
|
|
1236
1250
|
}
|
|
1237
1251
|
/**
|
|
@@ -1253,7 +1267,15 @@ export class NeuroLink {
|
|
|
1253
1267
|
// registerTool is async but its core logic is synchronous (Map.set).
|
|
1254
1268
|
// We fire-and-forget here but tools are available immediately after
|
|
1255
1269
|
// the synchronous validation + map insertion completes.
|
|
1256
|
-
|
|
1270
|
+
//
|
|
1271
|
+
// The .catch() at the end of this call is required for the same reason
|
|
1272
|
+
// as in registerFileTools() above: registerTool() throws on failed
|
|
1273
|
+
// validation, and an unhandled rejection off a `void`-detached call
|
|
1274
|
+
// terminates the process rather than failing this one registration.
|
|
1275
|
+
// Latent today (createTaskTools() supplies internal, valid names), and
|
|
1276
|
+
// cheap to keep correct.
|
|
1277
|
+
void this.toolRegistry
|
|
1278
|
+
.registerTool(toolId, toolInfo, {
|
|
1257
1279
|
execute: async (params) => {
|
|
1258
1280
|
try {
|
|
1259
1281
|
const result = await toolDef.execute(params, {
|
|
@@ -1276,6 +1298,12 @@ export class NeuroLink {
|
|
|
1276
1298
|
},
|
|
1277
1299
|
description: toolDef.description,
|
|
1278
1300
|
inputSchema: {},
|
|
1301
|
+
})
|
|
1302
|
+
.catch((error) => {
|
|
1303
|
+
logger.warn("[NeuroLink] Task tool registration failed", {
|
|
1304
|
+
toolId,
|
|
1305
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1306
|
+
});
|
|
1279
1307
|
});
|
|
1280
1308
|
}
|
|
1281
1309
|
logger.debug(`[NeuroLink] Registered ${Object.keys(taskTools).length} task tools`);
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Out-of-band cancellation for chained async streams.
|
|
3
|
+
*
|
|
4
|
+
* `AsyncGenerator.prototype.return()` is the obvious way to tell a stream its
|
|
5
|
+
* consumer has gone away, and it does not work through a chain of generators.
|
|
6
|
+
* The request is *queued* behind whatever `next()` is already in flight; it
|
|
7
|
+
* cannot interrupt an `await`. A provider stream is wrapped several times
|
|
8
|
+
* (lifecycle in `baseProvider`, MCP and pool wrappers in `neurolink`), and
|
|
9
|
+
* during a pull every one of those layers is parked inside an `await` on the
|
|
10
|
+
* layer below. So none of them can unwind, the innermost source is never
|
|
11
|
+
* closed, and a teardown that waits for that to happen waits forever.
|
|
12
|
+
*
|
|
13
|
+
* This module is the side channel that does work. A wrapper registers a
|
|
14
|
+
* cancel callback on the stream object it returns; the callback closes its own
|
|
15
|
+
* upstream iterator and forwards the request further down. Because these are
|
|
16
|
+
* ordinary function calls rather than generator protocol, they run immediately
|
|
17
|
+
* — no queue, no dependence on any pending `next()` settling.
|
|
18
|
+
*
|
|
19
|
+
* Registration is optional and reading is defensive, so a stream that knows
|
|
20
|
+
* nothing about this behaves exactly as it did before.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Register `cancel` as `stream`'s teardown hook and return the same object.
|
|
24
|
+
*
|
|
25
|
+
* Non-enumerable so the property never shows up in spreads, `JSON.stringify`
|
|
26
|
+
* or logging of a stream handle.
|
|
27
|
+
*/
|
|
28
|
+
export declare function attachStreamCancel<S extends object>(stream: S, cancel: () => void): S;
|
|
29
|
+
/**
|
|
30
|
+
* Invoke `stream`'s cancel hook if it has one.
|
|
31
|
+
*
|
|
32
|
+
* Never throws. This runs from `finally` blocks during teardown, where the
|
|
33
|
+
* consumer has already stopped listening — an exception here would replace
|
|
34
|
+
* whatever outcome the caller was actually returning with a cleanup error.
|
|
35
|
+
*/
|
|
36
|
+
export declare function cancelStream(stream: unknown): void;
|
|
37
|
+
/**
|
|
38
|
+
* Close `iterator` without waiting for it and without surfacing a rejection.
|
|
39
|
+
*
|
|
40
|
+
* Deliberately not awaited: on a generator that is parked mid-`await` this
|
|
41
|
+
* promise may never settle, which is the whole failure this module exists to
|
|
42
|
+
* avoid. The call still propagates whenever the iterator can act on it.
|
|
43
|
+
*/
|
|
44
|
+
export declare function releaseIterator(iterator: {
|
|
45
|
+
return?: (value?: unknown) => unknown;
|
|
46
|
+
}): void;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Out-of-band cancellation for chained async streams.
|
|
3
|
+
*
|
|
4
|
+
* `AsyncGenerator.prototype.return()` is the obvious way to tell a stream its
|
|
5
|
+
* consumer has gone away, and it does not work through a chain of generators.
|
|
6
|
+
* The request is *queued* behind whatever `next()` is already in flight; it
|
|
7
|
+
* cannot interrupt an `await`. A provider stream is wrapped several times
|
|
8
|
+
* (lifecycle in `baseProvider`, MCP and pool wrappers in `neurolink`), and
|
|
9
|
+
* during a pull every one of those layers is parked inside an `await` on the
|
|
10
|
+
* layer below. So none of them can unwind, the innermost source is never
|
|
11
|
+
* closed, and a teardown that waits for that to happen waits forever.
|
|
12
|
+
*
|
|
13
|
+
* This module is the side channel that does work. A wrapper registers a
|
|
14
|
+
* cancel callback on the stream object it returns; the callback closes its own
|
|
15
|
+
* upstream iterator and forwards the request further down. Because these are
|
|
16
|
+
* ordinary function calls rather than generator protocol, they run immediately
|
|
17
|
+
* — no queue, no dependence on any pending `next()` settling.
|
|
18
|
+
*
|
|
19
|
+
* Registration is optional and reading is defensive, so a stream that knows
|
|
20
|
+
* nothing about this behaves exactly as it did before.
|
|
21
|
+
*/
|
|
22
|
+
const STREAM_CANCEL = Symbol.for("neurolink.streamCancel");
|
|
23
|
+
/**
|
|
24
|
+
* Swallow a rejection from a thenable teardown result.
|
|
25
|
+
*
|
|
26
|
+
* An unhandled rejection **terminates the process** — a caller doing everything
|
|
27
|
+
* right still dies, with the stack pointing at library internals it never
|
|
28
|
+
* called. Teardown runs after the consumer has stopped listening, so there is
|
|
29
|
+
* nobody left to hand the error to; dropping it is the only correct outcome,
|
|
30
|
+
* and dropping it deliberately is what keeps it from becoming fatal.
|
|
31
|
+
*/
|
|
32
|
+
function suppressRejection(result) {
|
|
33
|
+
if (result !== null &&
|
|
34
|
+
typeof result === "object" &&
|
|
35
|
+
typeof result.then === "function" &&
|
|
36
|
+
typeof result.catch === "function") {
|
|
37
|
+
void result.catch(() => { });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Register `cancel` as `stream`'s teardown hook and return the same object.
|
|
42
|
+
*
|
|
43
|
+
* Non-enumerable so the property never shows up in spreads, `JSON.stringify`
|
|
44
|
+
* or logging of a stream handle.
|
|
45
|
+
*/
|
|
46
|
+
export function attachStreamCancel(stream, cancel) {
|
|
47
|
+
Object.defineProperty(stream, STREAM_CANCEL, {
|
|
48
|
+
value: cancel,
|
|
49
|
+
enumerable: false,
|
|
50
|
+
configurable: true,
|
|
51
|
+
writable: true,
|
|
52
|
+
});
|
|
53
|
+
return stream;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Invoke `stream`'s cancel hook if it has one.
|
|
57
|
+
*
|
|
58
|
+
* Never throws. This runs from `finally` blocks during teardown, where the
|
|
59
|
+
* consumer has already stopped listening — an exception here would replace
|
|
60
|
+
* whatever outcome the caller was actually returning with a cleanup error.
|
|
61
|
+
*/
|
|
62
|
+
export function cancelStream(stream) {
|
|
63
|
+
if (stream === null || stream === undefined) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (typeof stream !== "object" && typeof stream !== "function") {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
// The property read is inside the try, not before it. Reading a symbol off
|
|
71
|
+
// an arbitrary object can execute user code — a Proxy trap or a throwing
|
|
72
|
+
// getter — and this function is documented as never throwing because it
|
|
73
|
+
// runs from teardown `finally` blocks, where an exception would replace
|
|
74
|
+
// the outcome the caller was actually returning with a cleanup error.
|
|
75
|
+
const hook = stream[STREAM_CANCEL];
|
|
76
|
+
if (typeof hook !== "function") {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// `attachStreamCancel` types the hook as `() => void`, but nothing stops a
|
|
80
|
+
// caller registering an `async` function — whose rejection would otherwise
|
|
81
|
+
// be unhandled, and therefore fatal to the process.
|
|
82
|
+
suppressRejection(hook());
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// Teardown is best-effort by definition.
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Close `iterator` without waiting for it and without surfacing a rejection.
|
|
90
|
+
*
|
|
91
|
+
* Deliberately not awaited: on a generator that is parked mid-`await` this
|
|
92
|
+
* promise may never settle, which is the whole failure this module exists to
|
|
93
|
+
* avoid. The call still propagates whenever the iterator can act on it.
|
|
94
|
+
*/
|
|
95
|
+
export function releaseIterator(iterator) {
|
|
96
|
+
try {
|
|
97
|
+
// Read the method inside the try, for the same reason `cancelStream` does:
|
|
98
|
+
// `iterator` is whatever an upstream handed us, and a Proxy trap or a
|
|
99
|
+
// throwing getter turns a property access into arbitrary user code.
|
|
100
|
+
const release = iterator.return;
|
|
101
|
+
if (typeof release !== "function") {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
suppressRejection(release.call(iterator, undefined));
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Best-effort, as above.
|
|
108
|
+
}
|
|
109
|
+
}
|
package/dist/utils/ttsStream.js
CHANGED
|
@@ -3,6 +3,7 @@ import { sanitizeErrorCause } from "./logSanitize.js";
|
|
|
3
3
|
import { logger } from "./logger.js";
|
|
4
4
|
import { IncrementalTTSSynthesisError, TTS_ERROR_CODES, TTSProcessor, } from "./ttsProcessor.js";
|
|
5
5
|
import { TimeoutError as AsyncTimeoutError } from "./async/withTimeout.js";
|
|
6
|
+
import { cancelStream } from "./streamCancellation.js";
|
|
6
7
|
function getStreamingTTSErrorDetails(error) {
|
|
7
8
|
const incrementalFailure = error instanceof IncrementalTTSSynthesisError ? error : undefined;
|
|
8
9
|
const cause = incrementalFailure?.firstError ?? error;
|
|
@@ -122,6 +123,20 @@ function aggregateTTSChunks(chunks) {
|
|
|
122
123
|
sampleRate: last.sampleRate,
|
|
123
124
|
};
|
|
124
125
|
}
|
|
126
|
+
/**
|
|
127
|
+
* Upper bound on how long stream teardown may wait for upstream iterators to
|
|
128
|
+
* acknowledge `.return()`. Closing an iterator is bookkeeping, not work, so a
|
|
129
|
+
* responsive upstream finishes far inside this; an unresponsive one is exactly
|
|
130
|
+
* the case that must not block the consumer. The timer is unref'd so a pending
|
|
131
|
+
* grace period never by itself keeps the process alive.
|
|
132
|
+
*/
|
|
133
|
+
const RELEASE_GRACE_MS = 5_000;
|
|
134
|
+
function releaseGraceTimer() {
|
|
135
|
+
return new Promise((resolve) => {
|
|
136
|
+
const timer = setTimeout(resolve, RELEASE_GRACE_MS);
|
|
137
|
+
timer.unref?.();
|
|
138
|
+
});
|
|
139
|
+
}
|
|
125
140
|
/**
|
|
126
141
|
* Preserve source-stream backpressure while interleaving incremental TTS audio.
|
|
127
142
|
* Source chunks are yielded before their derived audio, and TTS failures degrade
|
|
@@ -208,6 +223,12 @@ export async function* interleaveTTSStream(params) {
|
|
|
208
223
|
finally {
|
|
209
224
|
if (!completed) {
|
|
210
225
|
cancelled = true;
|
|
226
|
+
// Tell the upstream chain out of band, before asking politely via
|
|
227
|
+
// `.return()` below. When the consumer breaks mid-pull every wrapper
|
|
228
|
+
// beneath is parked inside an `await` and cannot process a queued
|
|
229
|
+
// `return()`, so this side channel is the only thing that actually
|
|
230
|
+
// reaches — and closes — the provider stream at the bottom.
|
|
231
|
+
cancelStream(stream);
|
|
211
232
|
}
|
|
212
233
|
textQueue.end();
|
|
213
234
|
const releases = [];
|
|
@@ -217,7 +238,19 @@ export async function* interleaveTTSStream(params) {
|
|
|
217
238
|
if (nextAudio && audioIterator.return) {
|
|
218
239
|
releases.push(Promise.resolve().then(() => audioIterator.return?.(undefined)));
|
|
219
240
|
}
|
|
220
|
-
|
|
241
|
+
// Cleanup must not be able to outlive the consumer that abandoned this
|
|
242
|
+
// stream. `.return()` on an async generator parked inside an `await` is
|
|
243
|
+
// queued behind the in-flight `next()` and cannot interrupt it, so when an
|
|
244
|
+
// upstream pull never settles these promises settle *never* — not late.
|
|
245
|
+
// Awaiting them unbounded turned a consumer's `break` into a permanent
|
|
246
|
+
// hang: the caller's `for await` never returned, with no error and no way
|
|
247
|
+
// to defend against it from outside this module.
|
|
248
|
+
//
|
|
249
|
+
// The releases are still issued, and still propagate normally whenever the
|
|
250
|
+
// upstream is responsive — which is every case where awaiting them was
|
|
251
|
+
// doing anything useful. We only stop making the consumer's exit depend on
|
|
252
|
+
// an upstream that may never answer.
|
|
253
|
+
await Promise.race([Promise.allSettled(releases), releaseGraceTimer()]);
|
|
221
254
|
if (!completed) {
|
|
222
255
|
onComplete?.(undefined);
|
|
223
256
|
}
|
|
@@ -52,18 +52,39 @@ export async function attachRealtimeEventBridge(params) {
|
|
|
52
52
|
via,
|
|
53
53
|
bytes: bytes.byteLength,
|
|
54
54
|
});
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
// Both calls are detached on purpose — the bridge must never make the
|
|
56
|
+
// caller wait on the UI. But `void` alone does NOT make a rejection
|
|
57
|
+
// harmless, and the try/catch below cannot help: a synchronous catch
|
|
58
|
+
// never sees a rejected promise. Measured — a rejecting publish with
|
|
59
|
+
// this exact shape leaves the catch untouched and, with no
|
|
60
|
+
// unhandledRejection handler installed, terminates the process:
|
|
61
|
+
// SYNC_CATCH_FIRED = false · UNHANDLED_COUNT = 1 · exit 1
|
|
62
|
+
// These are network calls to a peer that can disconnect mid-publish, so
|
|
63
|
+
// the rejection is ordinary, not exotic. Attaching a handler is what
|
|
64
|
+
// actually makes the bridge best-effort, which is what the catch below
|
|
65
|
+
// only claimed. Same class as the detached-pump crash in anthropic.
|
|
66
|
+
const publishFailed = (error) => {
|
|
67
|
+
logger.debug("realtime.bridge.publishFailed", {
|
|
68
|
+
seq,
|
|
69
|
+
type,
|
|
70
|
+
via,
|
|
71
|
+
error: error instanceof Error ? error.message : String(error),
|
|
59
72
|
});
|
|
73
|
+
};
|
|
74
|
+
if (via === "data") {
|
|
75
|
+
void participant
|
|
76
|
+
.publishData(bytes, { reliable: true, topic: eventsTopic })
|
|
77
|
+
.catch(publishFailed);
|
|
60
78
|
}
|
|
61
79
|
else {
|
|
62
|
-
void participant
|
|
80
|
+
void participant
|
|
81
|
+
.sendText(json, { topic: eventsTopic })
|
|
82
|
+
.catch(publishFailed);
|
|
63
83
|
}
|
|
64
84
|
}
|
|
65
85
|
catch {
|
|
66
|
-
/* non-fatal — the
|
|
86
|
+
/* non-fatal — covers the synchronous half only (encode/stringify);
|
|
87
|
+
the detached publishes carry their own handler above. */
|
|
67
88
|
}
|
|
68
89
|
};
|
|
69
90
|
// HITL: WRITE-labeled tools pause for user confirmation. We publish a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juspay/neurolink",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "12.0.1",
|
|
4
4
|
"packageManager": "pnpm@10.15.1",
|
|
5
5
|
"description": "TypeScript AI SDK with 24+ LLM providers behind one consistent API. MCP-native (connect any MCP server), voice TTS/STT/realtime, RAG, agents, memory, context compaction. OpenAI · Anthropic · Gemini · Bedrock · Azure · Ollama · DeepSeek · NVIDIA NIM and more.",
|
|
6
6
|
"author": {
|