@mulmobridge/chat-service 1.3.0 → 1.3.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.
- package/dist/relay.d.ts +2 -0
- package/dist/relay.js +26 -15
- package/dist/reply-deadline.d.ts +2 -0
- package/dist/reply-deadline.js +9 -0
- package/dist/start-when-idle.d.ts +11 -0
- package/dist/start-when-idle.js +52 -0
- package/package.json +1 -1
package/dist/relay.d.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface RelayDeps {
|
|
|
34
34
|
getRole: (roleId: string) => Role;
|
|
35
35
|
defaultRoleId: string;
|
|
36
36
|
logger: Logger;
|
|
37
|
+
/** Monotonic milliseconds for the reply limit. Defaults to `performance.now()`. */
|
|
38
|
+
now?: () => number;
|
|
37
39
|
}
|
|
38
40
|
export declare function createRelay(deps: RelayDeps): RelayFn;
|
|
39
41
|
export declare function resolveDefaultRole(bridgeOptions: Readonly<Record<string, string | number | boolean>> | undefined, getRole: (roleId: string) => Role, fallbackRoleId: string, logger: Logger, transportId: string): string;
|
package/dist/relay.js
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
// host.
|
|
9
9
|
import { EVENT_TYPES, resolveReplyTimeoutMs } from "@mulmobridge/protocol";
|
|
10
10
|
import { createKeyedSerializer } from "./keyed-serializer.js";
|
|
11
|
+
import { remainingReplyMs } from "./reply-deadline.js";
|
|
12
|
+
import { startChatWhenIdle } from "./start-when-idle.js";
|
|
11
13
|
// ── Factory ──────────────────────────────────────────────────
|
|
12
14
|
export function createRelay(deps) {
|
|
13
15
|
const serialize = createKeyedSerializer();
|
|
@@ -17,10 +19,19 @@ export function createRelay(deps) {
|
|
|
17
19
|
// sessions, and split the conversation across them (#1878). The
|
|
18
20
|
// JSON pair is an unambiguous composite key for the two ids.
|
|
19
21
|
const key = JSON.stringify([params.transportId, params.externalChatId]);
|
|
20
|
-
|
|
22
|
+
const budget = {
|
|
23
|
+
receivedAtMs: clockOf(deps)(),
|
|
24
|
+
replyTimeoutMs: resolveBridgeReplyTimeout(params.bridgeOptions, deps.logger, params.transportId),
|
|
25
|
+
};
|
|
26
|
+
return serialize.run(key, () => processRelayMessage(deps, params, budget));
|
|
21
27
|
};
|
|
22
28
|
}
|
|
23
|
-
|
|
29
|
+
// Neutral about the cause: a queue behind an earlier message, or slow setup on an idle chat.
|
|
30
|
+
const EXPIRED_BEFORE_START_REPLY = "The request timed out before the agent could start on it. Please send it again.";
|
|
31
|
+
// Monotonic by default, so a wall-clock step between receipt and processing cannot move the limit.
|
|
32
|
+
const clockOf = (deps) => deps.now ?? (() => performance.now());
|
|
33
|
+
const remainingOf = (budget, deps) => remainingReplyMs(budget.receivedAtMs, budget.replyTimeoutMs, clockOf(deps)());
|
|
34
|
+
async function processRelayMessage(deps, params, budget) {
|
|
24
35
|
const { store, handleCommand, startChat, onSessionEvent, getRole, defaultRoleId, logger } = deps;
|
|
25
36
|
const { transportId, externalChatId, attachments, bridgeOptions } = params;
|
|
26
37
|
let { text } = params;
|
|
@@ -60,7 +71,14 @@ async function processRelayMessage(deps, params) {
|
|
|
60
71
|
chatState = commandResult.nextState;
|
|
61
72
|
text = commandResult.forwardAs;
|
|
62
73
|
}
|
|
63
|
-
|
|
74
|
+
// Checked here, not on entry: a command above still runs and answers at once, but an agent turn
|
|
75
|
+
// started after the limit would produce a reply nobody is waiting for.
|
|
76
|
+
if (remainingOf(budget, deps) === 0) {
|
|
77
|
+
logger.info("chat-service", "message expired before its turn started", { transportId, externalChatId });
|
|
78
|
+
return { kind: "ok", reply: EXPIRED_BEFORE_START_REPLY };
|
|
79
|
+
}
|
|
80
|
+
const idleStartDeps = { startChat, onSessionEvent, remainingMs: () => remainingOf(budget, deps) };
|
|
81
|
+
const result = await startChatWhenIdle(idleStartDeps, {
|
|
64
82
|
message: text,
|
|
65
83
|
roleId: chatState.roleId,
|
|
66
84
|
chatSessionId: chatState.sessionId,
|
|
@@ -70,18 +88,12 @@ async function processRelayMessage(deps, params) {
|
|
|
70
88
|
// we forward the whole bag untouched.
|
|
71
89
|
bridgeOptions,
|
|
72
90
|
});
|
|
91
|
+
if (result.kind === "expired") {
|
|
92
|
+
logger.info("chat-service", "message expired waiting for the session to finish", { transportId, externalChatId });
|
|
93
|
+
return { kind: "ok", reply: EXPIRED_BEFORE_START_REPLY };
|
|
94
|
+
}
|
|
73
95
|
if (result.kind === "error") {
|
|
74
96
|
const status = result.status ?? 500;
|
|
75
|
-
if (status === 409) {
|
|
76
|
-
// Session busy — tell the bridge to retry. Keep the HTTP
|
|
77
|
-
// response shape the old handler returned (status 409 on
|
|
78
|
-
// the HTTP side, "ok" reply text on the socket side — both
|
|
79
|
-
// layers decide how to serialise).
|
|
80
|
-
return {
|
|
81
|
-
kind: "ok",
|
|
82
|
-
reply: "A previous message is still being processed. Please wait.",
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
97
|
logger.error("chat-service", "startChat failed", {
|
|
86
98
|
transportId,
|
|
87
99
|
externalChatId,
|
|
@@ -94,8 +106,7 @@ async function processRelayMessage(deps, params) {
|
|
|
94
106
|
};
|
|
95
107
|
}
|
|
96
108
|
try {
|
|
97
|
-
const
|
|
98
|
-
const reply = await collectAgentReply(onSessionEvent, chatState.sessionId, replyTimeoutMs, params.onChunk);
|
|
109
|
+
const reply = await collectAgentReply(onSessionEvent, chatState.sessionId, remainingOf(budget, deps), params.onChunk);
|
|
99
110
|
await store.setChatState(transportId, {
|
|
100
111
|
...chatState,
|
|
101
112
|
updatedAt: new Date().toISOString(),
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// A turn's reply limit is counted from when the relay RECEIVED the message,
|
|
2
|
+
// not from when the turn reached the front of its chat's queue. The bridge
|
|
3
|
+
// client starts its ack timer when it sends, so a limit counted from the start
|
|
4
|
+
// of collection let a queued or slow-to-start turn outlive the client's wait
|
|
5
|
+
// and have its reply dropped (#3312).
|
|
6
|
+
/** Milliseconds of the reply limit still left at `nowMs`; never negative. */
|
|
7
|
+
export function remainingReplyMs(receivedAtMs, replyTimeoutMs, nowMs) {
|
|
8
|
+
return Math.max(0, receivedAtMs + replyTimeoutMs - nowMs);
|
|
9
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { OnSessionEventFn, StartChatFn, StartChatParams, StartChatResult } from "./types.js";
|
|
2
|
+
export type IdleStartResult = StartChatResult | {
|
|
3
|
+
kind: "expired";
|
|
4
|
+
};
|
|
5
|
+
export interface IdleStartDeps {
|
|
6
|
+
startChat: StartChatFn;
|
|
7
|
+
onSessionEvent: OnSessionEventFn;
|
|
8
|
+
/** Milliseconds this message may still wait; 0 once its limit has passed. */
|
|
9
|
+
remainingMs: () => number;
|
|
10
|
+
}
|
|
11
|
+
export declare function startChatWhenIdle(deps: IdleStartDeps, params: StartChatParams): Promise<IdleStartResult>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Start a bridge turn even when the session is still busy with an earlier run.
|
|
2
|
+
//
|
|
3
|
+
// A turn cut off at its reply limit leaves its agent running, so the next
|
|
4
|
+
// turn's `startChat()` used to get 409 and the message was dropped with
|
|
5
|
+
// "please wait" (#3320). Instead, wait for that run to finish — within the
|
|
6
|
+
// message's own remaining time — and start then.
|
|
7
|
+
import { EVENT_TYPES } from "@mulmobridge/protocol";
|
|
8
|
+
// `startChat()` answers 409 before it saves or broadcasts anything, so trying again is safe.
|
|
9
|
+
const isBusy = (result) => result.kind === "error" && result.status === 409;
|
|
10
|
+
function waitForSessionFinished(onSessionEvent, sessionId, timeoutMs) {
|
|
11
|
+
const handles = {};
|
|
12
|
+
const finished = new Promise((resolve) => {
|
|
13
|
+
handles.settle = (value) => {
|
|
14
|
+
handles.done = true;
|
|
15
|
+
clearTimeout(handles.timer);
|
|
16
|
+
handles.unsubscribe?.();
|
|
17
|
+
resolve(value);
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
const settle = (value) => handles.settle?.(value);
|
|
21
|
+
handles.timer = setTimeout(() => settle(false), timeoutMs);
|
|
22
|
+
handles.unsubscribe = onSessionEvent(sessionId, (event) => {
|
|
23
|
+
if (event.type === EVENT_TYPES.sessionFinished)
|
|
24
|
+
settle(true);
|
|
25
|
+
});
|
|
26
|
+
// A listener fired during subscription settled before `unsubscribe` existed.
|
|
27
|
+
if (handles.done)
|
|
28
|
+
handles.unsubscribe();
|
|
29
|
+
// A cancelled wait still settles, so nothing is left pending behind it.
|
|
30
|
+
return { finished, cancel: () => settle(false) };
|
|
31
|
+
}
|
|
32
|
+
async function retryWhenFinished(deps, params) {
|
|
33
|
+
const remainingMs = deps.remainingMs();
|
|
34
|
+
if (remainingMs === 0)
|
|
35
|
+
return { kind: "expired" };
|
|
36
|
+
// Subscribe BEFORE trying again: a run that ends between the 409 and the
|
|
37
|
+
// subscription would otherwise never be seen, and this would wait out the limit.
|
|
38
|
+
const wait = waitForSessionFinished(deps.onSessionEvent, params.chatSessionId, remainingMs);
|
|
39
|
+
const retried = await deps.startChat(params).catch((err) => {
|
|
40
|
+
wait.cancel();
|
|
41
|
+
throw err;
|
|
42
|
+
});
|
|
43
|
+
if (!isBusy(retried)) {
|
|
44
|
+
wait.cancel();
|
|
45
|
+
return retried;
|
|
46
|
+
}
|
|
47
|
+
return (await wait.finished) ? retryWhenFinished(deps, params) : { kind: "expired" };
|
|
48
|
+
}
|
|
49
|
+
export async function startChatWhenIdle(deps, params) {
|
|
50
|
+
const first = await deps.startChat(params);
|
|
51
|
+
return isBusy(first) ? retryWhenFinished(deps, params) : first;
|
|
52
|
+
}
|
package/package.json
CHANGED