@relaymessenger/openclaw-plugin 0.3.3 → 0.3.4
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/src/channel.js +109 -74
- package/dist/src/client.js +13 -229
- package/dist/src/inbound.js +5 -0
- package/dist/src/invocations.js +47 -0
- package/dist/src/outbound.js +2 -0
- package/dist/src/poll-loop.js +12 -0
- package/dist/src/responding.js +31 -8
- package/dist/src/state-files.js +114 -38
- package/dist/src/vendor/relay-sdk/client.js +163 -0
- package/dist/src/vendor/relay-sdk/errors.js +45 -0
- package/dist/src/vendor/relay-sdk/types.js +2 -0
- package/dist/src/vendor/relay-sdk/url.js +39 -0
- package/package.json +1 -1
- package/src/channel.ts +110 -75
- package/src/client.ts +38 -317
- package/src/inbound.ts +11 -0
- package/src/invocations.ts +58 -0
- package/src/outbound.ts +11 -4
- package/src/poll-loop.ts +12 -0
- package/src/responding.ts +41 -10
- package/src/state-files.ts +130 -44
- package/src/types.ts +15 -1
- package/src/vendor/relay-sdk/README.md +28 -0
- package/src/vendor/relay-sdk/client.ts +293 -0
- package/src/vendor/relay-sdk/errors.ts +61 -0
- package/src/vendor/relay-sdk/types.ts +82 -0
- package/src/vendor/relay-sdk/url.ts +43 -0
package/dist/src/channel.js
CHANGED
|
@@ -24,6 +24,7 @@ import { createRelayClient, isAbortError, isRelayWebhookConflict, RelayApiError,
|
|
|
24
24
|
import { createRelayCursorStore, openRelayCursorStateStore } from "./cursor-store.js";
|
|
25
25
|
import { createRelayInboundDedupeGuard, createRelayInboundDeduper } from "./inbound-dedupe.js";
|
|
26
26
|
import { buildRelayInboundFacts } from "./inbound.js";
|
|
27
|
+
import { relayInvocationFor, rememberRelayInvocation } from "./invocations.js";
|
|
27
28
|
import { createRelayAccountLifecycleRegistry } from "./lifecycle.js";
|
|
28
29
|
import { deriveRelayIdempotencyKey, RELAY_TEXT_CHUNK_LIMIT, reconcileRelayUnknownSend, sendRelayText, } from "./outbound.js";
|
|
29
30
|
import { runRelayPollLoop } from "./poll-loop.js";
|
|
@@ -105,11 +106,16 @@ const relayMessageAdapter = defineChannelMessageAdapter({
|
|
|
105
106
|
if (text === null) {
|
|
106
107
|
return null;
|
|
107
108
|
}
|
|
109
|
+
const invocationId = relayInvocationFor({
|
|
110
|
+
accountId: account.accountId,
|
|
111
|
+
conversationId: ctx.to,
|
|
112
|
+
});
|
|
108
113
|
const verdict = await reconcileRelayUnknownSend({
|
|
109
114
|
client: relayClientForAccount(account),
|
|
110
115
|
conversationId: ctx.to,
|
|
111
116
|
text,
|
|
112
117
|
replyToId: ctx.effectiveReplyToId ?? ctx.replyToId ?? null,
|
|
118
|
+
...(invocationId ? { invocationId } : {}),
|
|
113
119
|
idempotencyKey: deriveRelayIdempotencyKey({ deliveryQueueId: ctx.queueId }),
|
|
114
120
|
});
|
|
115
121
|
if (verdict.status === "sent") {
|
|
@@ -138,11 +144,19 @@ const relayMessageAdapter = defineChannelMessageAdapter({
|
|
|
138
144
|
if (!account.configured) {
|
|
139
145
|
throw new Error(`relay: account "${account.accountId}" has no Agent Token configured`);
|
|
140
146
|
}
|
|
147
|
+
// A group reply must name the invocation it answers. Core's send context
|
|
148
|
+
// has no field for it, so the turn parks it in the invocation registry
|
|
149
|
+
// under (accountId, conversationId) and it is read back here.
|
|
150
|
+
const invocationId = relayInvocationFor({
|
|
151
|
+
accountId: account.accountId,
|
|
152
|
+
conversationId: ctx.to,
|
|
153
|
+
});
|
|
141
154
|
const result = await sendRelayText({
|
|
142
155
|
client: relayClientForAccount(account),
|
|
143
156
|
conversationId: ctx.to,
|
|
144
157
|
text: ctx.text,
|
|
145
158
|
replyToId: ctx.replyToId ?? null,
|
|
159
|
+
...(invocationId ? { invocationId } : {}),
|
|
146
160
|
// Stable per (queueId, part): internal retries replay the same key,
|
|
147
161
|
// so the server-side idempotent commit makes duplicates impossible by
|
|
148
162
|
// contract. On a core with no part index the text names the part.
|
|
@@ -242,81 +256,97 @@ async function dispatchRelayInbound(params) {
|
|
|
242
256
|
const recordDeliveryError = (error) => {
|
|
243
257
|
deliveryError ??= error;
|
|
244
258
|
};
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
:
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
deliveryQueueId: logicalBlockId,
|
|
301
|
-
deliveryPartIndex: chunkIndex,
|
|
302
|
-
}),
|
|
303
|
-
});
|
|
304
|
-
chunkIndex += 1;
|
|
259
|
+
// Park the group invocation for the life of the turn. Core's durable send
|
|
260
|
+
// adapter is a separate entry point with no inbound context, so this is how
|
|
261
|
+
// the reply learns which invocation it answers.
|
|
262
|
+
const releaseInvocation = facts.invocationId
|
|
263
|
+
? rememberRelayInvocation({
|
|
264
|
+
accountId: account.accountId,
|
|
265
|
+
conversationId: facts.conversationId,
|
|
266
|
+
invocationId: facts.invocationId,
|
|
267
|
+
})
|
|
268
|
+
: () => { };
|
|
269
|
+
try {
|
|
270
|
+
// Admission, runtime resolution, route/session lookup, envelope building,
|
|
271
|
+
// and context finalization above are replay-safe. The durable attempt starts
|
|
272
|
+
// immediately before OpenClaw can invoke the agent or its tools.
|
|
273
|
+
await markRespondingBeforeAttempt({
|
|
274
|
+
client: params.client,
|
|
275
|
+
facts,
|
|
276
|
+
label: "OpenClaw",
|
|
277
|
+
markAttempt: params.markAttempt,
|
|
278
|
+
...(params.warn ? { onReceiptFailure: params.warn } : {}),
|
|
279
|
+
});
|
|
280
|
+
await runtime.channel.inbound.dispatchReply({
|
|
281
|
+
cfg: params.cfg,
|
|
282
|
+
channel: RELAY_CHANNEL_ID,
|
|
283
|
+
accountId: account.accountId,
|
|
284
|
+
agentId: route.agentId,
|
|
285
|
+
routeSessionKey: route.sessionKey,
|
|
286
|
+
storePath,
|
|
287
|
+
ctxPayload,
|
|
288
|
+
recordInboundSession: runtime.channel.session.recordInboundSession,
|
|
289
|
+
dispatchReplyWithBufferedBlockDispatcher: runtime.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
|
290
|
+
delivery: {
|
|
291
|
+
// Final replies go through the durable message adapter: core renders
|
|
292
|
+
// and chunks them (chunker + textChunkLimit) and tracks the send as a
|
|
293
|
+
// durable queue intent. Requiring reconcileUnknownSend forces
|
|
294
|
+
// `durability: "required"`, so single-payload finals carry a stable
|
|
295
|
+
// deliveryQueueId into send.text (stable idempotency key + exact
|
|
296
|
+
// replay), and multi-chunk finals get core's queue-level crash
|
|
297
|
+
// recovery. Replies land as plain messages, not quotes
|
|
298
|
+
// (`replyToId: null`).
|
|
299
|
+
durable: {
|
|
300
|
+
to: facts.conversationId,
|
|
301
|
+
replyToId: null,
|
|
302
|
+
requiredCapabilities: { reconcileUnknownSend: true },
|
|
303
|
+
},
|
|
304
|
+
// Fallback for payloads the durable path does not carry (non-final
|
|
305
|
+
// visible blocks). The event id + block/chunk ordinals identify each
|
|
306
|
+
// logical send: retries reuse it while identical intentional blocks and
|
|
307
|
+
// chunks remain distinct.
|
|
308
|
+
deliver: async (payload) => {
|
|
309
|
+
const text = payload && typeof payload === "object" && "text" in payload
|
|
310
|
+
? (payload.text ?? "")
|
|
311
|
+
: "";
|
|
312
|
+
if (!text.trim()) {
|
|
313
|
+
return;
|
|
305
314
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
315
|
+
const logicalBlockId = `${facts.eventId}:block:${fallbackDeliveryIndex}`;
|
|
316
|
+
fallbackDeliveryIndex += 1;
|
|
317
|
+
try {
|
|
318
|
+
let chunkIndex = 0;
|
|
319
|
+
for (const chunk of chunkText(text, RELAY_TEXT_CHUNK_LIMIT)) {
|
|
320
|
+
await sendRelayText({
|
|
321
|
+
client: params.client,
|
|
322
|
+
conversationId: facts.conversationId,
|
|
323
|
+
text: chunk,
|
|
324
|
+
...(facts.invocationId ? { invocationId: facts.invocationId } : {}),
|
|
325
|
+
idempotencyKey: deriveRelayIdempotencyKey({
|
|
326
|
+
deliveryQueueId: logicalBlockId,
|
|
327
|
+
deliveryPartIndex: chunkIndex,
|
|
328
|
+
}),
|
|
329
|
+
});
|
|
330
|
+
chunkIndex += 1;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
recordDeliveryError(error);
|
|
335
|
+
throw error;
|
|
336
|
+
}
|
|
337
|
+
},
|
|
338
|
+
onError: recordDeliveryError,
|
|
311
339
|
},
|
|
312
|
-
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
340
|
+
replyPipeline: {},
|
|
341
|
+
});
|
|
342
|
+
if (deliveryError) {
|
|
343
|
+
throw deliveryError instanceof Error
|
|
344
|
+
? deliveryError
|
|
345
|
+
: new Error(`relay reply delivery failed: ${String(deliveryError)}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
finally {
|
|
349
|
+
releaseInvocation();
|
|
320
350
|
}
|
|
321
351
|
}
|
|
322
352
|
// ---------------------------------------------------------------------------
|
|
@@ -440,6 +470,7 @@ async function startRelayAccount(ctx) {
|
|
|
440
470
|
client,
|
|
441
471
|
allowedSenderIds,
|
|
442
472
|
markAttempt,
|
|
473
|
+
warn,
|
|
443
474
|
});
|
|
444
475
|
},
|
|
445
476
|
});
|
|
@@ -448,7 +479,11 @@ async function startRelayAccount(ctx) {
|
|
|
448
479
|
if (abortSignal.aborted || isAbortError(error)) {
|
|
449
480
|
return;
|
|
450
481
|
}
|
|
451
|
-
|
|
482
|
+
// Named as `kind === "auth"`, not `error.terminal`. The SDK client counts
|
|
483
|
+
// every non-retryable kind as terminal, which would swallow the 409 cases
|
|
484
|
+
// below — including `terminated_by_other_consumer`, whose whole point is
|
|
485
|
+
// to fall through to the supervisor's restart arbitration.
|
|
486
|
+
if (error instanceof RelayApiError && error.kind === "auth") {
|
|
452
487
|
markTerminalDisconnect(error);
|
|
453
488
|
}
|
|
454
489
|
else if (isRelayWebhookConflict(error)) {
|
package/dist/src/client.js
CHANGED
|
@@ -1,229 +1,13 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
return (normalized === "localhost" ||
|
|
16
|
-
normalized.endsWith(".localhost"));
|
|
17
|
-
}
|
|
18
|
-
/**
|
|
19
|
-
* Validate and canonicalize the API origin before a bearer token can be sent
|
|
20
|
-
* to it. Production/custom remote origins must use HTTPS. Plain HTTP remains
|
|
21
|
-
* available only for an explicit loopback development server.
|
|
22
|
-
*/
|
|
23
|
-
export function normalizeRelayBaseUrl(raw) {
|
|
24
|
-
const candidate = raw?.trim() || DEFAULT_RELAY_BASE_URL;
|
|
25
|
-
let url;
|
|
26
|
-
try {
|
|
27
|
-
url = new URL(candidate);
|
|
28
|
-
}
|
|
29
|
-
catch {
|
|
30
|
-
throw new Error(`relay: invalid baseUrl ${JSON.stringify(candidate)}`);
|
|
31
|
-
}
|
|
32
|
-
if (url.username || url.password) {
|
|
33
|
-
throw new Error("relay: baseUrl must not contain credentials");
|
|
34
|
-
}
|
|
35
|
-
if (url.search || url.hash) {
|
|
36
|
-
throw new Error("relay: baseUrl must not contain a query or fragment");
|
|
37
|
-
}
|
|
38
|
-
if (!/^\/+$/u.test(url.pathname)) {
|
|
39
|
-
throw new Error("relay: baseUrl must be an origin without a path");
|
|
40
|
-
}
|
|
41
|
-
if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopbackHostname(url.hostname))) {
|
|
42
|
-
throw new Error("relay: baseUrl must use HTTPS (HTTP is allowed only for loopback development)");
|
|
43
|
-
}
|
|
44
|
-
return url.origin;
|
|
45
|
-
}
|
|
46
|
-
/** Classified Relay API failure. `terminal` means operator action (bad token). */
|
|
47
|
-
export class RelayApiError extends Error {
|
|
48
|
-
status;
|
|
49
|
-
kind;
|
|
50
|
-
/** Server error code from the response body (`error.code`), when present. */
|
|
51
|
-
code;
|
|
52
|
-
constructor(message, params) {
|
|
53
|
-
super(message);
|
|
54
|
-
this.name = "RelayApiError";
|
|
55
|
-
this.status = params.status;
|
|
56
|
-
this.kind = params.kind;
|
|
57
|
-
this.code = params.code;
|
|
58
|
-
}
|
|
59
|
-
get terminal() {
|
|
60
|
-
return this.kind === "auth";
|
|
61
|
-
}
|
|
62
|
-
get retryable() {
|
|
63
|
-
return this.kind === "retryable";
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
/**
|
|
67
|
-
* 409 from the webhook XOR rule: an enabled webhook endpoint makes long
|
|
68
|
-
* polling unavailable until the operator disables it (server code
|
|
69
|
-
* `conflict`, distinct from `terminated_by_other_consumer`).
|
|
70
|
-
*/
|
|
71
|
-
export function isRelayWebhookConflict(error) {
|
|
72
|
-
return (error instanceof RelayApiError &&
|
|
73
|
-
error.status === 409 &&
|
|
74
|
-
error.code !== "terminated_by_other_consumer");
|
|
75
|
-
}
|
|
76
|
-
export function classifyRelayHttpStatus(status) {
|
|
77
|
-
if (status === 401) {
|
|
78
|
-
return "auth";
|
|
79
|
-
}
|
|
80
|
-
if (status === 409) {
|
|
81
|
-
return "conflict";
|
|
82
|
-
}
|
|
83
|
-
if (status === 408 || status === 429 || status >= 500) {
|
|
84
|
-
return "retryable";
|
|
85
|
-
}
|
|
86
|
-
return "rejected";
|
|
87
|
-
}
|
|
88
|
-
export function isAbortError(error) {
|
|
89
|
-
return error instanceof Error && error.name === "AbortError";
|
|
90
|
-
}
|
|
91
|
-
async function readErrorDetail(response) {
|
|
92
|
-
try {
|
|
93
|
-
const body = (await response.json());
|
|
94
|
-
return {
|
|
95
|
-
...(body?.error?.code ? { code: body.error.code } : {}),
|
|
96
|
-
message: body?.error?.message ?? body?.message ?? "",
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
catch {
|
|
100
|
-
return { message: "" };
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
export function createRelayClient(options) {
|
|
104
|
-
const baseUrl = normalizeRelayBaseUrl(options.baseUrl);
|
|
105
|
-
const fetchImpl = options.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
106
|
-
const requestTimeoutMs = options.requestTimeoutMs ?? 15_000;
|
|
107
|
-
const request = async (params) => {
|
|
108
|
-
const url = new URL(`${baseUrl}${params.path}`);
|
|
109
|
-
for (const [key, value] of Object.entries(params.query ?? {})) {
|
|
110
|
-
if (value !== undefined) {
|
|
111
|
-
url.searchParams.set(key, String(value));
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
let response;
|
|
115
|
-
const timeoutSignal = AbortSignal.timeout(params.timeoutMs ?? requestTimeoutMs);
|
|
116
|
-
const signal = params.signal
|
|
117
|
-
? AbortSignal.any([params.signal, timeoutSignal])
|
|
118
|
-
: timeoutSignal;
|
|
119
|
-
try {
|
|
120
|
-
response = await fetchImpl(url.toString(), {
|
|
121
|
-
method: params.method,
|
|
122
|
-
headers: {
|
|
123
|
-
authorization: `Bearer ${options.token}`,
|
|
124
|
-
...(params.body === undefined ? {} : { "content-type": "application/json" }),
|
|
125
|
-
...params.headers,
|
|
126
|
-
},
|
|
127
|
-
...(params.body === undefined ? {} : { body: JSON.stringify(params.body) }),
|
|
128
|
-
signal,
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
catch (error) {
|
|
132
|
-
if (timeoutSignal.aborted && !params.signal?.aborted) {
|
|
133
|
-
throw new RelayApiError(`relay: ${params.method} ${params.path} timed out after ${params.timeoutMs ?? requestTimeoutMs}ms`, { kind: "retryable" });
|
|
134
|
-
}
|
|
135
|
-
if (isAbortError(error)) {
|
|
136
|
-
throw error;
|
|
137
|
-
}
|
|
138
|
-
// Network-level failure (DNS, reset, offline): always retryable.
|
|
139
|
-
throw new RelayApiError(`relay: network error: ${String(error)}`, { kind: "retryable" });
|
|
140
|
-
}
|
|
141
|
-
if (!response.ok) {
|
|
142
|
-
const detail = await readErrorDetail(response);
|
|
143
|
-
throw new RelayApiError(`relay: ${params.method} ${params.path} failed with ${response.status}${detail.message ? `: ${detail.message}` : ""}`, {
|
|
144
|
-
status: response.status,
|
|
145
|
-
kind: classifyRelayHttpStatus(response.status),
|
|
146
|
-
...(detail.code ? { code: detail.code } : {}),
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
return response;
|
|
150
|
-
};
|
|
151
|
-
return {
|
|
152
|
-
getMe: async (params) => {
|
|
153
|
-
const response = await request({
|
|
154
|
-
method: "GET",
|
|
155
|
-
path: "/v1/agents/me",
|
|
156
|
-
signal: params?.signal,
|
|
157
|
-
});
|
|
158
|
-
const body = (await response.json());
|
|
159
|
-
return body.agent;
|
|
160
|
-
},
|
|
161
|
-
pollEvents: async (params) => {
|
|
162
|
-
const timeoutSeconds = Math.min(Math.max(params.timeoutSeconds ?? 30, 1), 30);
|
|
163
|
-
// Guard against a wedged connection: the server holds <= timeout seconds,
|
|
164
|
-
// so anything past timeout + slack is a dead socket, not a slow poll.
|
|
165
|
-
const response = await request({
|
|
166
|
-
method: "GET",
|
|
167
|
-
path: "/v1/events",
|
|
168
|
-
query: {
|
|
169
|
-
cursor: params.cursor,
|
|
170
|
-
timeout: timeoutSeconds,
|
|
171
|
-
...(params.limit === undefined ? {} : { limit: params.limit }),
|
|
172
|
-
},
|
|
173
|
-
signal: params.signal,
|
|
174
|
-
timeoutMs: (timeoutSeconds + 15) * 1_000,
|
|
175
|
-
});
|
|
176
|
-
const body = (await response.json());
|
|
177
|
-
const events = Array.isArray(body.events) ? body.events : [];
|
|
178
|
-
const nextCursor = typeof body.next_cursor === "number" && Number.isSafeInteger(body.next_cursor)
|
|
179
|
-
? body.next_cursor
|
|
180
|
-
: params.cursor;
|
|
181
|
-
return { events, nextCursor };
|
|
182
|
-
},
|
|
183
|
-
sendMessage: async (params) => {
|
|
184
|
-
const response = await request({
|
|
185
|
-
method: "POST",
|
|
186
|
-
path: "/v1/messages",
|
|
187
|
-
headers: { "idempotency-key": params.idempotencyKey },
|
|
188
|
-
body: {
|
|
189
|
-
conversation_id: params.conversationId,
|
|
190
|
-
parts: params.parts,
|
|
191
|
-
...(params.replyTo ? { reply_to: params.replyTo } : {}),
|
|
192
|
-
},
|
|
193
|
-
signal: params.signal,
|
|
194
|
-
});
|
|
195
|
-
const body = (await response.json());
|
|
196
|
-
return { messages: body.messages };
|
|
197
|
-
},
|
|
198
|
-
setTyping: async (params) => {
|
|
199
|
-
await request({
|
|
200
|
-
method: "POST",
|
|
201
|
-
path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/typing`,
|
|
202
|
-
body: {
|
|
203
|
-
started: params.started,
|
|
204
|
-
...(params.label ? { label: params.label } : {}),
|
|
205
|
-
},
|
|
206
|
-
signal: params.signal,
|
|
207
|
-
});
|
|
208
|
-
},
|
|
209
|
-
setResponding: async (params) => {
|
|
210
|
-
await request({
|
|
211
|
-
method: "POST",
|
|
212
|
-
path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/responding`,
|
|
213
|
-
body: {
|
|
214
|
-
message_id: params.messageId,
|
|
215
|
-
...(params.label ? { label: params.label } : {}),
|
|
216
|
-
},
|
|
217
|
-
signal: params.signal,
|
|
218
|
-
});
|
|
219
|
-
},
|
|
220
|
-
markRead: async (params) => {
|
|
221
|
-
await request({
|
|
222
|
-
method: "POST",
|
|
223
|
-
path: `/v1/conversations/${encodeURIComponent(params.conversationId)}/read`,
|
|
224
|
-
body: { message_id: params.messageId },
|
|
225
|
-
signal: params.signal,
|
|
226
|
-
});
|
|
227
|
-
},
|
|
228
|
-
};
|
|
229
|
-
}
|
|
1
|
+
// The Relay API client the plugin uses. There is no implementation here: this
|
|
2
|
+
// is `@relaymessenger/sdk`'s client, vendored under `./vendor/relay-sdk`
|
|
3
|
+
// (see that directory's README for why, and for the one-file swap when the
|
|
4
|
+
// package ships).
|
|
5
|
+
//
|
|
6
|
+
// The plugin used to hand-roll its own client beside the SDK's. They drifted,
|
|
7
|
+
// and the drift was a defect: the hand-rolled one had no `invocationId` on
|
|
8
|
+
// `sendMessage`, `setTyping`, or `setResponding`, so an agent's first group
|
|
9
|
+
// mention wedged its entire event stream (REL-167).
|
|
10
|
+
import { createRelayClient as createVendoredRelayClient } from "./vendor/relay-sdk/client.js";
|
|
11
|
+
export { DEFAULT_RELAY_BASE_URL, normalizeRelayBaseUrl } from "./vendor/relay-sdk/url.js";
|
|
12
|
+
export { classifyRelayHttpStatus, isAbortError, isRelayWebhookConflict, RelayApiError, } from "./vendor/relay-sdk/errors.js";
|
|
13
|
+
export const createRelayClient = createVendoredRelayClient;
|
package/dist/src/inbound.js
CHANGED
|
@@ -81,6 +81,10 @@ export function buildRelayInboundFacts(event, params) {
|
|
|
81
81
|
return null;
|
|
82
82
|
}
|
|
83
83
|
const createdAtMs = Date.parse(message.created_at);
|
|
84
|
+
const invocationId = typeof event.data.invocation_id === "string"
|
|
85
|
+
&& event.data.invocation_id.trim()
|
|
86
|
+
? event.data.invocation_id
|
|
87
|
+
: undefined;
|
|
84
88
|
return {
|
|
85
89
|
eventId: event.event_id,
|
|
86
90
|
messageId: message.id,
|
|
@@ -90,5 +94,6 @@ export function buildRelayInboundFacts(event, params) {
|
|
|
90
94
|
...(message.reply_to?.message_id ? { replyToId: message.reply_to.message_id } : {}),
|
|
91
95
|
text,
|
|
92
96
|
...(Number.isFinite(createdAtMs) ? { timestamp: createdAtMs } : {}),
|
|
97
|
+
...(invocationId ? { invocationId } : {}),
|
|
93
98
|
};
|
|
94
99
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which group invocation an in-flight turn belongs to.
|
|
3
|
+
*
|
|
4
|
+
* Relay mints an invocation when a human invokes an agent in a group, and
|
|
5
|
+
* every call the agent then makes about that message has to carry the id back:
|
|
6
|
+
* `/typing` and `/responding` refuse without it, and so does the reply itself.
|
|
7
|
+
*
|
|
8
|
+
* The reply does not leave through this plugin's own code. It leaves through
|
|
9
|
+
* core's durable message adapter, whose send context carries `to`, `text`, and
|
|
10
|
+
* delivery bookkeeping and nothing about the message being answered
|
|
11
|
+
* (`ChannelMessageSendTextContext`). There is no field to thread the id
|
|
12
|
+
* through, so the turn parks it here for the adapter to find.
|
|
13
|
+
*
|
|
14
|
+
* Keyed by (accountId, conversationId) because `to` and `accountId` are all
|
|
15
|
+
* the adapter knows. Two agents in one group get separate slots. Two
|
|
16
|
+
* overlapping turns for ONE agent in ONE group share a slot and the later one
|
|
17
|
+
* wins — bounded by the server, which spends an invocation exactly once and
|
|
18
|
+
* refuses the loser rather than misattributing it.
|
|
19
|
+
*/
|
|
20
|
+
const pendingInvocations = new Map();
|
|
21
|
+
function slotKey(accountId, conversationId) {
|
|
22
|
+
return `${accountId}\0${conversationId}`;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Hold `invocationId` for the life of one turn. Returns the release function;
|
|
26
|
+
* call it in a `finally` so a thrown turn cannot strand the slot.
|
|
27
|
+
*
|
|
28
|
+
* Releasing only clears the slot if this turn still owns it, so a turn that
|
|
29
|
+
* finishes after being superseded cannot delete its successor's id.
|
|
30
|
+
*/
|
|
31
|
+
export function rememberRelayInvocation(params) {
|
|
32
|
+
const key = slotKey(params.accountId, params.conversationId);
|
|
33
|
+
pendingInvocations.set(key, params.invocationId);
|
|
34
|
+
return () => {
|
|
35
|
+
if (pendingInvocations.get(key) === params.invocationId) {
|
|
36
|
+
pendingInvocations.delete(key);
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** The invocation an outbound send in this conversation belongs to, if any. */
|
|
41
|
+
export function relayInvocationFor(params) {
|
|
42
|
+
return pendingInvocations.get(slotKey(params.accountId, params.conversationId));
|
|
43
|
+
}
|
|
44
|
+
/** Test seam: drop every slot. */
|
|
45
|
+
export function resetRelayInvocationsForTest() {
|
|
46
|
+
pendingInvocations.clear();
|
|
47
|
+
}
|
package/dist/src/outbound.js
CHANGED
|
@@ -58,6 +58,7 @@ export async function sendRelayText(params) {
|
|
|
58
58
|
conversationId: params.conversationId,
|
|
59
59
|
parts: [{ type: "text", text: params.text }],
|
|
60
60
|
...(params.replyToId ? { replyTo: { message_id: params.replyToId } } : {}),
|
|
61
|
+
...(params.invocationId ? { invocationId: params.invocationId } : {}),
|
|
61
62
|
idempotencyKey: params.idempotencyKey,
|
|
62
63
|
...(params.signal ? { signal: params.signal } : {}),
|
|
63
64
|
});
|
|
@@ -90,6 +91,7 @@ export async function reconcileRelayUnknownSend(params) {
|
|
|
90
91
|
conversationId: params.conversationId,
|
|
91
92
|
text: params.text,
|
|
92
93
|
replyToId: params.replyToId ?? null,
|
|
94
|
+
...(params.invocationId ? { invocationId: params.invocationId } : {}),
|
|
93
95
|
idempotencyKey: params.idempotencyKey,
|
|
94
96
|
});
|
|
95
97
|
return { status: "sent", messageId: result.messageId, messages: result.messages };
|
package/dist/src/poll-loop.js
CHANGED
|
@@ -94,6 +94,18 @@ export async function runRelayPollLoop(params) {
|
|
|
94
94
|
catch (error) {
|
|
95
95
|
if (!attempted) {
|
|
96
96
|
params.deduper.releaseEvent(event.event_id);
|
|
97
|
+
// A rejection is the server's final answer: replaying the identical
|
|
98
|
+
// request produces the identical refusal. Holding the cursor for it
|
|
99
|
+
// is a livelock, and the cursor is ONE watermark for the whole
|
|
100
|
+
// channel — so a single permanently-refused event would starve every
|
|
101
|
+
// later message, direct ones included (REL-167). Losing one event is
|
|
102
|
+
// strictly better than losing the channel, so log it loudly and let
|
|
103
|
+
// the page cursor move past it.
|
|
104
|
+
if (error instanceof RelayApiError && error.kind === "rejected") {
|
|
105
|
+
log(`[relay] event ${event.event_id} was permanently rejected by the server, ` +
|
|
106
|
+
`skipping it so later messages are not starved: ${String(error)}`);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
97
109
|
log(`[relay] event ${event.event_id} safe preflight failed, will replay: ${String(error)}`);
|
|
98
110
|
batchFailed = true;
|
|
99
111
|
break;
|
package/dist/src/responding.js
CHANGED
|
@@ -1,13 +1,36 @@
|
|
|
1
|
+
import { RelayApiError } from "./client.js";
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* Record the read/responding receipt, then commit the durable attempt marker.
|
|
4
|
+
*
|
|
5
|
+
* The receipt is a courtesy to the person waiting: it turns their message Read
|
|
6
|
+
* and shows that something is composing. It is NOT permission to answer, and
|
|
7
|
+
* it used to be treated as such — a rejected receipt threw here, before
|
|
8
|
+
* `markAttempt`, which sent the poll loop down its replay branch and froze the
|
|
9
|
+
* channel's single delivery cursor. One group mention whose receipt the server
|
|
10
|
+
* refused therefore starved every later message, direct ones included
|
|
11
|
+
* (REL-167).
|
|
12
|
+
*
|
|
13
|
+
* So a failed receipt is reported and the turn continues. The ordering that
|
|
14
|
+
* mattered is kept: the receipt is still attempted BEFORE the attempt marker,
|
|
15
|
+
* so a receipt that succeeds still precedes any agent or tool work.
|
|
5
16
|
*/
|
|
6
17
|
export async function markRespondingBeforeAttempt(params) {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
18
|
+
const { facts } = params;
|
|
19
|
+
try {
|
|
20
|
+
await params.client.setResponding({
|
|
21
|
+
conversationId: facts.conversationId,
|
|
22
|
+
messageId: facts.messageId,
|
|
23
|
+
label: params.label,
|
|
24
|
+
...(facts.invocationId ? { invocationId: facts.invocationId } : {}),
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
// An aborted shutdown is not a receipt failure; let it settle the loop.
|
|
29
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
32
|
+
const detail = error instanceof RelayApiError ? error.message : String(error);
|
|
33
|
+
params.onReceiptFailure?.(`responding receipt for message ${facts.messageId} failed, answering anyway: ${detail}`);
|
|
34
|
+
}
|
|
12
35
|
await params.markAttempt();
|
|
13
36
|
}
|