@relaymessenger/openclaw-plugin 0.2.0
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/LICENSE +21 -0
- package/README.md +141 -0
- package/dist/index.js +11 -0
- package/dist/setup-entry.js +5 -0
- package/dist/src/account-lock.js +91 -0
- package/dist/src/accounts.js +67 -0
- package/dist/src/channel.js +606 -0
- package/dist/src/client.js +219 -0
- package/dist/src/cursor-store.js +136 -0
- package/dist/src/inbound-dedupe.js +175 -0
- package/dist/src/inbound.js +94 -0
- package/dist/src/lifecycle.js +35 -0
- package/dist/src/outbound.js +98 -0
- package/dist/src/poll-loop.js +125 -0
- package/dist/src/runtime.js +8 -0
- package/dist/src/security.js +26 -0
- package/dist/src/state-files.js +167 -0
- package/dist/src/types.js +4 -0
- package/index.ts +12 -0
- package/openclaw.plugin.json +101 -0
- package/package.json +97 -0
- package/setup-entry.ts +6 -0
- package/src/account-lock.ts +108 -0
- package/src/accounts.ts +98 -0
- package/src/channel.ts +669 -0
- package/src/client.ts +313 -0
- package/src/cursor-store.ts +186 -0
- package/src/inbound-dedupe.ts +241 -0
- package/src/inbound.ts +128 -0
- package/src/lifecycle.ts +42 -0
- package/src/outbound.ts +136 -0
- package/src/poll-loop.ts +161 -0
- package/src/runtime.ts +13 -0
- package/src/security.ts +36 -0
- package/src/state-files.ts +212 -0
- package/src/types.ts +173 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// Relay inbound replay protection. The long-poll cursor acknowledges batches,
|
|
2
|
+
// so a crash between dispatch and cursor advance
|
|
3
|
+
// replays events. Each (canonical origin, agent, event) is claimed, safely
|
|
4
|
+
// preflighted, and durably committed immediately before agent dispatch. That
|
|
5
|
+
// gives engine/tool side effects at-most-once semantics:
|
|
6
|
+
// an interrupted turn may need the user to resend, but it is never silently
|
|
7
|
+
// executed twice.
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
import {
|
|
10
|
+
assertRelayStateDocument,
|
|
11
|
+
emptyRelayStateDocument,
|
|
12
|
+
openRelayStateDocument,
|
|
13
|
+
} from "./state-files.js";
|
|
14
|
+
|
|
15
|
+
// One shared namespace with stable Relay identity baked into each key so local
|
|
16
|
+
// account renames cannot reset safety state or partition the row budget.
|
|
17
|
+
const RELAY_INBOUND_DEDUPE_SCOPE = "global";
|
|
18
|
+
// 30d window: a long outage can replay a deep cursor backlog.
|
|
19
|
+
export const RELAY_INBOUND_DEDUPE_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
20
|
+
export const RELAY_INBOUND_DEDUPE_STATE_MAX_ENTRIES = 20_000;
|
|
21
|
+
|
|
22
|
+
type MaybePromise<T> = T | Promise<T>;
|
|
23
|
+
|
|
24
|
+
/** Minimal claim/commit/release slice of the SDK's ClaimableDedupe. */
|
|
25
|
+
export type RelayClaimableGuard = {
|
|
26
|
+
claim: (key: string, opts: { namespace: string }) => Promise<{ kind: string }>;
|
|
27
|
+
commit: (key: string, opts: { namespace: string }) => Promise<unknown>;
|
|
28
|
+
release: (key: string, opts: { namespace: string }) => void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type RelayAttemptStateStore = {
|
|
32
|
+
lookup(key: string): MaybePromise<{ attemptedAt: number } | undefined>;
|
|
33
|
+
register(
|
|
34
|
+
key: string,
|
|
35
|
+
value: { attemptedAt: number },
|
|
36
|
+
opts?: { ttlMs?: number },
|
|
37
|
+
): MaybePromise<void>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type RelayInboundDeduper = {
|
|
41
|
+
/** True when the caller now owns the event; false for committed or in-flight duplicates. */
|
|
42
|
+
claimEvent: (eventId: string) => Promise<boolean>;
|
|
43
|
+
/** Records an attempted event at the agent-dispatch boundary so restart cannot run it again. */
|
|
44
|
+
commitEvent: (eventId: string) => Promise<void>;
|
|
45
|
+
/** Drops an uncommitted claim so a failed dispatch can retry the event. */
|
|
46
|
+
releaseEvent: (eventId: string) => void;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
export function buildRelayInboundDedupeKey(params: {
|
|
50
|
+
baseUrl: string;
|
|
51
|
+
agentId: string;
|
|
52
|
+
eventId: string;
|
|
53
|
+
}): string | null {
|
|
54
|
+
const eventId = params.eventId.trim();
|
|
55
|
+
if (!eventId) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
// NUL separator: event ids are opaque strings, so a printable separator
|
|
59
|
+
// could collide two distinct (account, event) pairs.
|
|
60
|
+
return `${new URL(params.baseUrl).origin}\0${params.agentId}\0${eventId}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createRelayInboundDeduper(params: {
|
|
64
|
+
guard: RelayClaimableGuard;
|
|
65
|
+
baseUrl: string;
|
|
66
|
+
agentId: string;
|
|
67
|
+
}): RelayInboundDeduper {
|
|
68
|
+
const namespace = RELAY_INBOUND_DEDUPE_SCOPE;
|
|
69
|
+
return {
|
|
70
|
+
claimEvent: async (eventId) => {
|
|
71
|
+
const key = buildRelayInboundDedupeKey({ baseUrl: params.baseUrl, agentId: params.agentId, eventId });
|
|
72
|
+
if (!key) {
|
|
73
|
+
// Fail closed: an event without a durable identity cannot safely
|
|
74
|
+
// cross the at-most-once agent/tool side-effect boundary.
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
return (await params.guard.claim(key, { namespace })).kind === "claimed";
|
|
78
|
+
},
|
|
79
|
+
commitEvent: async (eventId) => {
|
|
80
|
+
const key = buildRelayInboundDedupeKey({ baseUrl: params.baseUrl, agentId: params.agentId, eventId });
|
|
81
|
+
if (!key) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
await params.guard.commit(key, { namespace });
|
|
85
|
+
},
|
|
86
|
+
releaseEvent: (eventId) => {
|
|
87
|
+
const key = buildRelayInboundDedupeKey({ baseUrl: params.baseUrl, agentId: params.agentId, eventId });
|
|
88
|
+
if (key) {
|
|
89
|
+
params.guard.release(key, { namespace });
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function durableAttemptKey(key: string, namespace: string): string {
|
|
96
|
+
return createHash("sha256").update(`${namespace}\0${key}`).digest("hex");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Strict Relay-owned guard used by the channel runtime. Unlike a normal
|
|
101
|
+
* message dedupe cache, persistence is not best effort: a failed attempt write
|
|
102
|
+
* must stop before agent dispatch or a crash could execute local tools twice.
|
|
103
|
+
*/
|
|
104
|
+
export function createRelayInboundDedupeGuard(params?: {
|
|
105
|
+
env?: NodeJS.ProcessEnv;
|
|
106
|
+
onDiskError?: (error: unknown) => void;
|
|
107
|
+
store?: RelayAttemptStateStore;
|
|
108
|
+
maxEntries?: number;
|
|
109
|
+
ttlMs?: number;
|
|
110
|
+
now?: () => number;
|
|
111
|
+
}): RelayClaimableGuard {
|
|
112
|
+
type PersistedAttempt = { attemptedAt: number; expiresAt: number };
|
|
113
|
+
const now = params?.now ?? Date.now;
|
|
114
|
+
const ttlMs = params?.ttlMs ?? RELAY_INBOUND_DEDUPE_TTL_MS;
|
|
115
|
+
const maxEntries = params?.maxEntries ?? RELAY_INBOUND_DEDUPE_STATE_MAX_ENTRIES;
|
|
116
|
+
if (!Number.isSafeInteger(ttlMs) || ttlMs < 1) {
|
|
117
|
+
throw new Error("relay inbound dedupe ttlMs must be a positive safe integer");
|
|
118
|
+
}
|
|
119
|
+
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) {
|
|
120
|
+
throw new Error("relay inbound dedupe maxEntries must be a positive safe integer");
|
|
121
|
+
}
|
|
122
|
+
const readNow = (): number => {
|
|
123
|
+
const timestamp = now();
|
|
124
|
+
if (!Number.isSafeInteger(timestamp) || timestamp < 0) {
|
|
125
|
+
throw new Error("relay inbound dedupe clock returned an invalid timestamp");
|
|
126
|
+
}
|
|
127
|
+
return timestamp;
|
|
128
|
+
};
|
|
129
|
+
const isPersistedAttempt = (key: string, value: unknown): value is PersistedAttempt => {
|
|
130
|
+
if (!/^[a-f0-9]{64}$/u.test(key) || !value || typeof value !== "object") return false;
|
|
131
|
+
const entry = value as Partial<PersistedAttempt>;
|
|
132
|
+
return (
|
|
133
|
+
typeof entry.attemptedAt === "number" &&
|
|
134
|
+
Number.isSafeInteger(entry.attemptedAt) &&
|
|
135
|
+
entry.attemptedAt >= 0 &&
|
|
136
|
+
typeof entry.expiresAt === "number" &&
|
|
137
|
+
Number.isSafeInteger(entry.expiresAt) &&
|
|
138
|
+
entry.expiresAt > entry.attemptedAt
|
|
139
|
+
);
|
|
140
|
+
};
|
|
141
|
+
const state = params?.store
|
|
142
|
+
? undefined
|
|
143
|
+
: openRelayStateDocument<PersistedAttempt>({
|
|
144
|
+
fileName: "inbound-attempts.json",
|
|
145
|
+
...(params?.env ? { env: params.env } : {}),
|
|
146
|
+
});
|
|
147
|
+
const store =
|
|
148
|
+
params?.store ??
|
|
149
|
+
{
|
|
150
|
+
lookup: async (key: string) => {
|
|
151
|
+
const current = await state!.read();
|
|
152
|
+
if (current === undefined) return undefined;
|
|
153
|
+
assertRelayStateDocument(current, "inbound attempt", isPersistedAttempt);
|
|
154
|
+
const entry = current.entries[key];
|
|
155
|
+
if (!entry || entry.expiresAt <= readNow()) return undefined;
|
|
156
|
+
return { attemptedAt: entry.attemptedAt };
|
|
157
|
+
},
|
|
158
|
+
register: async (
|
|
159
|
+
key: string,
|
|
160
|
+
value: { attemptedAt: number },
|
|
161
|
+
opts?: { ttlMs?: number },
|
|
162
|
+
) => {
|
|
163
|
+
await state!.updateOr(emptyRelayStateDocument<PersistedAttempt>(), (current) => {
|
|
164
|
+
assertRelayStateDocument(current, "inbound attempt", isPersistedAttempt);
|
|
165
|
+
const timestamp = readNow();
|
|
166
|
+
const entryTtlMs = opts?.ttlMs ?? ttlMs;
|
|
167
|
+
if (!Number.isSafeInteger(entryTtlMs) || entryTtlMs < 1) {
|
|
168
|
+
throw new Error("relay inbound dedupe ttlMs must be a positive safe integer");
|
|
169
|
+
}
|
|
170
|
+
const expiresAt = value.attemptedAt + entryTtlMs;
|
|
171
|
+
if (!Number.isSafeInteger(expiresAt)) {
|
|
172
|
+
throw new Error("relay inbound dedupe expiration exceeds safe integer range");
|
|
173
|
+
}
|
|
174
|
+
const liveEntries = Object.fromEntries(
|
|
175
|
+
Object.entries(current.entries).filter(([, entry]) => entry.expiresAt > timestamp),
|
|
176
|
+
);
|
|
177
|
+
liveEntries[key] = {
|
|
178
|
+
attemptedAt: value.attemptedAt,
|
|
179
|
+
expiresAt,
|
|
180
|
+
};
|
|
181
|
+
const ordered = Object.entries(liveEntries).sort(
|
|
182
|
+
([leftKey, left], [rightKey, right]) =>
|
|
183
|
+
left.attemptedAt - right.attemptedAt || leftKey.localeCompare(rightKey),
|
|
184
|
+
);
|
|
185
|
+
const retained = ordered.slice(Math.max(ordered.length - maxEntries, 0));
|
|
186
|
+
return {
|
|
187
|
+
version: current.version,
|
|
188
|
+
entries: Object.fromEntries(retained),
|
|
189
|
+
};
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
} satisfies RelayAttemptStateStore;
|
|
193
|
+
const inflight = new Set<string>();
|
|
194
|
+
const claiming = new Set<string>();
|
|
195
|
+
|
|
196
|
+
const withDiskError = async <T>(operation: () => MaybePromise<T>): Promise<T> => {
|
|
197
|
+
try {
|
|
198
|
+
return await operation();
|
|
199
|
+
} catch (error) {
|
|
200
|
+
params?.onDiskError?.(error);
|
|
201
|
+
throw error;
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
return {
|
|
206
|
+
claim: async (key, opts) => {
|
|
207
|
+
const storageKey = durableAttemptKey(key, opts.namespace);
|
|
208
|
+
if (inflight.has(storageKey) || claiming.has(storageKey)) {
|
|
209
|
+
return { kind: "inflight" };
|
|
210
|
+
}
|
|
211
|
+
claiming.add(storageKey);
|
|
212
|
+
try {
|
|
213
|
+
if ((await withDiskError(() => store.lookup(storageKey))) !== undefined) {
|
|
214
|
+
return { kind: "duplicate" };
|
|
215
|
+
}
|
|
216
|
+
inflight.add(storageKey);
|
|
217
|
+
return { kind: "claimed" };
|
|
218
|
+
} finally {
|
|
219
|
+
claiming.delete(storageKey);
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
commit: async (key, opts) => {
|
|
223
|
+
const storageKey = durableAttemptKey(key, opts.namespace);
|
|
224
|
+
try {
|
|
225
|
+
await withDiskError(() =>
|
|
226
|
+
store.register(
|
|
227
|
+
storageKey,
|
|
228
|
+
{ attemptedAt: readNow() },
|
|
229
|
+
{ ttlMs },
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
return true;
|
|
233
|
+
} finally {
|
|
234
|
+
inflight.delete(storageKey);
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
release: (key, opts) => {
|
|
238
|
+
inflight.delete(durableAttemptKey(key, opts.namespace));
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
package/src/inbound.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Pure inbound mapping: Relay events -> normalized fact bundles.
|
|
2
|
+
// No SDK imports so the mapping is unit-testable without an OpenClaw runtime;
|
|
3
|
+
// the runtime dispatch wiring lives in channel.ts.
|
|
4
|
+
import type { RelayEvent, RelayMessage, RelayPart } from "./types.js";
|
|
5
|
+
|
|
6
|
+
/** Coarse classification deciding whether an event can start an agent turn. */
|
|
7
|
+
export type RelayEventClass =
|
|
8
|
+
| "message" // message.received -> can start an agent turn
|
|
9
|
+
| "reaction" // reaction.added/removed -> observe-only at v1
|
|
10
|
+
| "lifecycle" // message.delivered/read -> bookkeeping, never dispatch
|
|
11
|
+
| "unknown"; // forward-compatible: ignore quietly
|
|
12
|
+
|
|
13
|
+
export function classifyRelayEvent(event: Pick<RelayEvent, "event_type">): RelayEventClass {
|
|
14
|
+
switch (event.event_type) {
|
|
15
|
+
case "message.received":
|
|
16
|
+
return "message";
|
|
17
|
+
case "reaction.added":
|
|
18
|
+
case "reaction.removed":
|
|
19
|
+
return "reaction";
|
|
20
|
+
case "message.delivered":
|
|
21
|
+
case "message.read":
|
|
22
|
+
return "lifecycle";
|
|
23
|
+
default:
|
|
24
|
+
return "unknown";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Render typed parts into agent-facing text: text parts joined, link URLs
|
|
30
|
+
* inlined, `data` parts as a compact JSON fence, media/voice as a labeled
|
|
31
|
+
* fetchable URL. The URL is a capability link: it is the authorization, so
|
|
32
|
+
* any HTTP client can fetch the bytes without an Agent Token.
|
|
33
|
+
*/
|
|
34
|
+
export function renderRelayPartsText(parts: readonly RelayPart[]): string {
|
|
35
|
+
const lines: string[] = [];
|
|
36
|
+
for (const part of parts) {
|
|
37
|
+
switch (part.type) {
|
|
38
|
+
case "text":
|
|
39
|
+
if (part.text) {
|
|
40
|
+
lines.push(part.text);
|
|
41
|
+
}
|
|
42
|
+
break;
|
|
43
|
+
case "link_preview":
|
|
44
|
+
lines.push(part.url);
|
|
45
|
+
break;
|
|
46
|
+
case "data": {
|
|
47
|
+
let rendered: string;
|
|
48
|
+
try {
|
|
49
|
+
rendered = JSON.stringify(part.data);
|
|
50
|
+
} catch {
|
|
51
|
+
rendered = String(part.data);
|
|
52
|
+
}
|
|
53
|
+
lines.push("```json\n" + rendered + "\n```");
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
case "media":
|
|
57
|
+
lines.push(`[attachment] ${part.url}`);
|
|
58
|
+
break;
|
|
59
|
+
case "voice_memo":
|
|
60
|
+
lines.push(
|
|
61
|
+
part.duration_ms
|
|
62
|
+
? `[voice memo, ${Math.round(part.duration_ms / 1000)}s] ${part.url}`
|
|
63
|
+
: `[voice memo] ${part.url}`,
|
|
64
|
+
);
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return lines.join("\n");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Drop the agent's own sends echoed back on the event stream. */
|
|
72
|
+
export function isRelayEchoMessage(
|
|
73
|
+
message: Pick<RelayMessage, "sender">,
|
|
74
|
+
agentId: string,
|
|
75
|
+
): boolean {
|
|
76
|
+
return message.sender.kind === "agent" && message.sender.id === agentId;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Normalized facts for one dispatchable inbound message. */
|
|
80
|
+
export type RelayInboundFacts = {
|
|
81
|
+
eventId: string;
|
|
82
|
+
messageId: string;
|
|
83
|
+
conversationId: string;
|
|
84
|
+
senderId: string;
|
|
85
|
+
senderKind: "user" | "agent";
|
|
86
|
+
replyToId?: string;
|
|
87
|
+
text: string;
|
|
88
|
+
timestamp?: number;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Build the dispatchable fact bundle for a message.received event. Returns
|
|
93
|
+
* null when the event should not start a turn: echoes of our own agent,
|
|
94
|
+
* non-message events, or messages with no renderable content.
|
|
95
|
+
*/
|
|
96
|
+
export function buildRelayInboundFacts(
|
|
97
|
+
event: RelayEvent,
|
|
98
|
+
params: { agentId: string },
|
|
99
|
+
): RelayInboundFacts | null {
|
|
100
|
+
if (classifyRelayEvent(event) !== "message") {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
const message = event.data.message;
|
|
104
|
+
if (!message || !message.id || !message.conversation_id) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
// Agent-authored messages never start a local agent turn. This drops our
|
|
108
|
+
// own event echo and prevents agent-to-agent loops even if an id is
|
|
109
|
+
// mistakenly added to the user allowlist.
|
|
110
|
+
if (message.sender.kind !== "user" || isRelayEchoMessage(message, params.agentId)) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
const text = renderRelayPartsText(message.parts) || message.fallback_text || "";
|
|
114
|
+
if (!text.trim()) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
const createdAtMs = Date.parse(message.created_at);
|
|
118
|
+
return {
|
|
119
|
+
eventId: event.event_id,
|
|
120
|
+
messageId: message.id,
|
|
121
|
+
conversationId: message.conversation_id,
|
|
122
|
+
senderId: message.sender.id,
|
|
123
|
+
senderKind: message.sender.kind,
|
|
124
|
+
...(message.reply_to?.message_id ? { replyToId: message.reply_to.message_id } : {}),
|
|
125
|
+
text,
|
|
126
|
+
...(Number.isFinite(createdAtMs) ? { timestamp: createdAtMs } : {}),
|
|
127
|
+
};
|
|
128
|
+
}
|
package/src/lifecycle.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type RelayAccountLifecycleLease = {
|
|
2
|
+
signal: AbortSignal;
|
|
3
|
+
release: () => void;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
/** Owns exactly one abortable long-poll lifecycle per configured account. */
|
|
7
|
+
export function createRelayAccountLifecycleRegistry() {
|
|
8
|
+
const controllers = new Map<string, AbortController>();
|
|
9
|
+
|
|
10
|
+
return {
|
|
11
|
+
acquire(accountId: string, parentSignal: AbortSignal): RelayAccountLifecycleLease {
|
|
12
|
+
if (controllers.has(accountId)) {
|
|
13
|
+
throw new Error(`relay: account "${accountId}" already has an active consumer`);
|
|
14
|
+
}
|
|
15
|
+
const controller = new AbortController();
|
|
16
|
+
controllers.set(accountId, controller);
|
|
17
|
+
const signal = AbortSignal.any([parentSignal, controller.signal]);
|
|
18
|
+
let released = false;
|
|
19
|
+
return {
|
|
20
|
+
signal,
|
|
21
|
+
release: () => {
|
|
22
|
+
if (released) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
released = true;
|
|
26
|
+
if (controllers.get(accountId) === controller) {
|
|
27
|
+
controllers.delete(accountId);
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
stop(accountId: string): boolean {
|
|
34
|
+
const controller = controllers.get(accountId);
|
|
35
|
+
if (!controller) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
controller.abort();
|
|
39
|
+
return true;
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
package/src/outbound.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Durable outbound sends: every logical send carries an
|
|
2
|
+
// Idempotency-Key; internal retries and unknown-send reconciliation replay
|
|
3
|
+
// the same key, so a retry can never duplicate a visible message
|
|
4
|
+
// (server contract: commitMessage.ts idempotent replay).
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { RelayApiError } from "./client.js";
|
|
7
|
+
import type { RelayClient } from "./client.js";
|
|
8
|
+
import type { RelayMessage } from "./types.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Per-part text ceiling declared to core's renderer so long agent replies are
|
|
12
|
+
* split into multiple messages instead of truncated. Server caps a text part at 8 KiB UTF-8
|
|
13
|
+
* (server/src/domain/commitMessage.ts MAX_TEXT_BYTES); 2000 chars is safe for
|
|
14
|
+
* any UTF-8 content (4 bytes/char worst case).
|
|
15
|
+
*/
|
|
16
|
+
export const RELAY_TEXT_CHUNK_LIMIT = 2_000;
|
|
17
|
+
|
|
18
|
+
const IDEMPOTENCY_KEY_MAX = 255;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Idempotency key for one logical send. When core supplies a durable delivery
|
|
22
|
+
* queue id, the key is a stable function of (queueId, partIndex) so internal
|
|
23
|
+
* retries and reconciliation replay the exact same key. Without a queue id a
|
|
24
|
+
* fresh key is minted: identical intentional sends must remain distinct.
|
|
25
|
+
*/
|
|
26
|
+
export function deriveRelayIdempotencyKey(params: {
|
|
27
|
+
deliveryQueueId?: string;
|
|
28
|
+
deliveryPartIndex?: number;
|
|
29
|
+
random?: () => string;
|
|
30
|
+
}): string {
|
|
31
|
+
const queueId = params.deliveryQueueId?.trim();
|
|
32
|
+
const key = queueId
|
|
33
|
+
? `relay-send:${queueId}:${params.deliveryPartIndex ?? 0}`
|
|
34
|
+
: `relay-send:${(params.random ?? (() => crypto.randomUUID()))()}`;
|
|
35
|
+
// Server accepts 8-255 chars; the prefix guarantees the minimum.
|
|
36
|
+
if (key.length <= IDEMPOTENCY_KEY_MAX) {
|
|
37
|
+
return key;
|
|
38
|
+
}
|
|
39
|
+
// Preserve uniqueness when an opaque core queue id is unusually long; a
|
|
40
|
+
// simple prefix slice could erase the part index and collapse two chunks.
|
|
41
|
+
return `relay-send:h:${createHash("sha256").update(key).digest("hex")}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type RelayOutboundSendResult = {
|
|
45
|
+
/** Id of the first committed message; core's receipt APIs name one id. */
|
|
46
|
+
messageId: string;
|
|
47
|
+
/**
|
|
48
|
+
* Every message the send committed, in display order. A single text part
|
|
49
|
+
* commits exactly one, but the 202 is always an array and the receipt
|
|
50
|
+
* should name everything the server stored.
|
|
51
|
+
*/
|
|
52
|
+
messages: RelayMessage[];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export async function sendRelayText(params: {
|
|
56
|
+
client: RelayClient;
|
|
57
|
+
conversationId: string;
|
|
58
|
+
text: string;
|
|
59
|
+
replyToId?: string | null;
|
|
60
|
+
idempotencyKey: string;
|
|
61
|
+
signal?: AbortSignal;
|
|
62
|
+
}): Promise<RelayOutboundSendResult> {
|
|
63
|
+
let lastError: unknown;
|
|
64
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
65
|
+
try {
|
|
66
|
+
const result = await params.client.sendMessage({
|
|
67
|
+
conversationId: params.conversationId,
|
|
68
|
+
parts: [{ type: "text", text: params.text }],
|
|
69
|
+
...(params.replyToId ? { replyTo: { message_id: params.replyToId } } : {}),
|
|
70
|
+
idempotencyKey: params.idempotencyKey,
|
|
71
|
+
...(params.signal ? { signal: params.signal } : {}),
|
|
72
|
+
});
|
|
73
|
+
const first = result.messages[0];
|
|
74
|
+
if (!first) {
|
|
75
|
+
throw new RelayApiError("relay: 202 carried no messages", { kind: "retryable" });
|
|
76
|
+
}
|
|
77
|
+
return { messageId: first.id, messages: result.messages };
|
|
78
|
+
} catch (error) {
|
|
79
|
+
lastError = error;
|
|
80
|
+
if (!(error instanceof RelayApiError) || !error.retryable || params.signal?.aborted) {
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
throw lastError;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export type RelayUnknownSendVerdict =
|
|
89
|
+
| { status: "sent"; messageId: string; messages: RelayMessage[] }
|
|
90
|
+
| { status: "not_sent" }
|
|
91
|
+
| { status: "unresolved"; error?: string; retryable?: boolean };
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Reconcile a send whose platform outcome is unknown: replay the POST with the
|
|
95
|
+
* same idempotency key and body. By server contract the replay either performs
|
|
96
|
+
* the send exactly once or returns the originally committed messages — either
|
|
97
|
+
* way the visible outcome is the one set of messages the key names, never a
|
|
98
|
+
* duplicate.
|
|
99
|
+
*/
|
|
100
|
+
export async function reconcileRelayUnknownSend(params: {
|
|
101
|
+
client: RelayClient;
|
|
102
|
+
conversationId: string;
|
|
103
|
+
text: string;
|
|
104
|
+
replyToId?: string | null;
|
|
105
|
+
idempotencyKey: string;
|
|
106
|
+
}): Promise<RelayUnknownSendVerdict> {
|
|
107
|
+
try {
|
|
108
|
+
const result = await sendRelayText({
|
|
109
|
+
client: params.client,
|
|
110
|
+
conversationId: params.conversationId,
|
|
111
|
+
text: params.text,
|
|
112
|
+
replyToId: params.replyToId ?? null,
|
|
113
|
+
idempotencyKey: params.idempotencyKey,
|
|
114
|
+
});
|
|
115
|
+
return { status: "sent", messageId: result.messageId, messages: result.messages };
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error instanceof RelayApiError) {
|
|
118
|
+
if (error.kind === "conflict") {
|
|
119
|
+
// Key already used with a different request body: the original send
|
|
120
|
+
// reached the server but we cannot recover its receipt. Do not retry —
|
|
121
|
+
// a retry with a fresh key would duplicate the visible message.
|
|
122
|
+
return { status: "unresolved", error: error.message, retryable: false };
|
|
123
|
+
}
|
|
124
|
+
if (error.retryable) {
|
|
125
|
+
return { status: "unresolved", error: error.message, retryable: true };
|
|
126
|
+
}
|
|
127
|
+
if (error.kind === "auth") {
|
|
128
|
+
return { status: "unresolved", error: error.message, retryable: false };
|
|
129
|
+
}
|
|
130
|
+
// Deterministic rejection (403/404/422): the original request would have
|
|
131
|
+
// been rejected identically, so nothing reached the conversation.
|
|
132
|
+
return { status: "not_sent" };
|
|
133
|
+
}
|
|
134
|
+
return { status: "unresolved", error: String(error), retryable: true };
|
|
135
|
+
}
|
|
136
|
+
}
|