@relaymessenger/openclaw-plugin 0.4.7-staging.35 → 0.4.7-staging.37
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/README.md +26 -5
- package/contracts/relay-v1.lock.json +5 -5
- package/dist/src/dispatch.js +42 -6
- package/dist/src/gateway.js +3 -0
- package/dist/src/inbound.js +16 -2
- package/dist/src/turns.js +69 -0
- package/package.json +3 -3
- package/src/dispatch.ts +49 -6
- package/src/gateway.ts +3 -0
- package/src/inbound.ts +16 -2
- package/src/turns.ts +94 -0
- package/src/types.ts +7 -0
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Relay for OpenClaw
|
|
2
2
|
|
|
3
3
|
`@relaymessenger/openclaw-plugin` is the native Relay channel for OpenClaw
|
|
4
|
-
`2026.8.1
|
|
4
|
+
`2026.8.1` through `2026.9.6`, the versions its gateway harness runs against.
|
|
5
5
|
|
|
6
6
|
Source is maintained in
|
|
7
7
|
[`RelayMessenger/Relay-SDK`](https://github.com/RelayMessenger/Relay-SDK/tree/main/packages/openclaw)
|
|
@@ -42,6 +42,15 @@ go first and the payment card follows as its own Message.
|
|
|
42
42
|
openclaw plugins install @relaymessenger/openclaw-plugin
|
|
43
43
|
```
|
|
44
44
|
|
|
45
|
+
OpenClaw asks two questions for a plugin from npm: whether you trust a source
|
|
46
|
+
outside ClawHub, and whether to accept the capabilities the plugin declares.
|
|
47
|
+
This plugin declares one capability, the `relay` channel. Where no terminal
|
|
48
|
+
can answer, pass both answers:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
openclaw plugins install @relaymessenger/openclaw-plugin --force --accept-capabilities
|
|
52
|
+
```
|
|
53
|
+
|
|
45
54
|
Configure the default account:
|
|
46
55
|
|
|
47
56
|
```json
|
|
@@ -130,6 +139,16 @@ Without `allowFrom`, any user or agent Contact whose Message Relay delivers
|
|
|
130
139
|
to this agent can start a direct turn, while the group activation rules above
|
|
131
140
|
still apply.
|
|
132
141
|
|
|
142
|
+
## Messages from another agent
|
|
143
|
+
|
|
144
|
+
Another agent's call reaches this agent as a Message, and Relay gives the
|
|
145
|
+
caller the answer whose `reply_to` names its Message. So every answer to
|
|
146
|
+
another agent names the Message it answers. When the same agent sends a second
|
|
147
|
+
Message while a turn is still running in that Chat, the plugin holds it until
|
|
148
|
+
the turn ends, then gives it a turn of its own. OpenClaw would otherwise steer
|
|
149
|
+
it into the running turn, and the second caller would get no answer. A
|
|
150
|
+
person's Messages keep OpenClaw's own queue and reply behavior.
|
|
151
|
+
|
|
133
152
|
## Durable delivery
|
|
134
153
|
|
|
135
154
|
For every WebSocket event, the plugin:
|
|
@@ -177,10 +196,12 @@ exact SHA selected from the `staging` branch, the matching
|
|
|
177
196
|
validated tarball and publishes that same digest with npm provenance; its
|
|
178
197
|
publish job is also bound to the `staging` GitHub environment.
|
|
179
198
|
|
|
180
|
-
`gateway:harness` packs the plugin, installs the tarball with OpenClaw
|
|
181
|
-
`
|
|
182
|
-
connects to a loopback Relay WebSocket, receives one Message,
|
|
183
|
-
durable ACK and idempotent REST reply.
|
|
199
|
+
`gateway:harness` packs the plugin, installs the tarball with the OpenClaw
|
|
200
|
+
version in `devDependencies`, inspects the managed installation, starts a real
|
|
201
|
+
OpenClaw gateway, connects to a loopback Relay WebSocket, receives one Message,
|
|
202
|
+
and proves the durable ACK and idempotent REST reply. Its `--overlap` run sends
|
|
203
|
+
two Messages from one agent, the second while the model still answers the
|
|
204
|
+
first, and requires two answers, each naming its own Message.
|
|
184
205
|
|
|
185
206
|
## Contract lock
|
|
186
207
|
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"relayServer": {
|
|
3
3
|
"repository": "RelayMessenger/Relay-Server",
|
|
4
|
-
"commit": "
|
|
4
|
+
"commit": "d511deefd9e07953f910759e37d662cafa3e6c14",
|
|
5
5
|
"openapiPath": "contracts/developer/openapi.yaml",
|
|
6
|
-
"sha256": "
|
|
6
|
+
"sha256": "02e42cd6efdac39c4f3a894ec9251bc8560bbfbabe539e4eb06d9e0c6c55d0a6"
|
|
7
7
|
},
|
|
8
8
|
"relaySdk": {
|
|
9
9
|
"package": "@relaymessenger/sdk",
|
|
10
|
-
"version": "0.3.6-staging.
|
|
11
|
-
"integrity": "sha512-
|
|
10
|
+
"version": "0.3.6-staging.43",
|
|
11
|
+
"integrity": "sha512-I3QTO65wJlEWolmdazHoFEDwYBl22xq326j0iWJsjgGv+NnS1DT8DmKYYydi1KzyRnJlCGPSur/FiRC9A7RJaQ==",
|
|
12
12
|
"operationsSha256": "bb36ba7380042a0d7ceb5064fd15e883e1e6fe11a28fc0d5157bb0df501111e3",
|
|
13
|
-
"workspaceOpenapiSha256": "
|
|
13
|
+
"workspaceOpenapiSha256": "02e42cd6efdac39c4f3a894ec9251bc8560bbfbabe539e4eb06d9e0c6c55d0a6",
|
|
14
14
|
"usedOperations": [
|
|
15
15
|
{
|
|
16
16
|
"method": "GET",
|
package/dist/src/dispatch.js
CHANGED
|
@@ -3,6 +3,7 @@ import { buildChannelInboundEventContext, resolveChannelInboundRouteEnvelope, }
|
|
|
3
3
|
import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
|
4
4
|
import { bindIngressLifecycleToReplyOptions } from "openclaw/plugin-sdk/channel-outbound";
|
|
5
5
|
import { buildRelayInboundFacts } from "./inbound.js";
|
|
6
|
+
import { waitForIdleChat } from "./turns.js";
|
|
6
7
|
function isReplyToAgentMessage(message, chatId) {
|
|
7
8
|
return message.chat_id === chatId && message.is_from_me === true;
|
|
8
9
|
}
|
|
@@ -38,6 +39,21 @@ export async function resolveRelayTurnActivation(params) {
|
|
|
38
39
|
implicitMentionKinds: ["reply_to_bot"],
|
|
39
40
|
};
|
|
40
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* The answer to another agent names the Message it answers: the model's own
|
|
44
|
+
* reply target when it chose one, else the agent's Message. Where no reply may
|
|
45
|
+
* point (a Message opening with buttons or a selection), OpenClaw's implicit
|
|
46
|
+
* current-message reply is removed.
|
|
47
|
+
*/
|
|
48
|
+
export function agentReplyPayload(payload, facts) {
|
|
49
|
+
if (facts.agentReplyLink) {
|
|
50
|
+
return { ...payload, replyToId: payload.replyToId ?? facts.agentReplyLink };
|
|
51
|
+
}
|
|
52
|
+
if (payload.replyToId !== facts.messageId)
|
|
53
|
+
return payload;
|
|
54
|
+
const { replyToId: _unlinked, ...rest } = payload;
|
|
55
|
+
return rest;
|
|
56
|
+
}
|
|
41
57
|
export async function dispatchRelayEvent(params) {
|
|
42
58
|
const facts = buildRelayInboundFacts(params.event);
|
|
43
59
|
if (!facts) {
|
|
@@ -137,6 +153,11 @@ export async function dispatchRelayEvent(params) {
|
|
|
137
153
|
params.warn?.(`relay: Contact @${facts.handle} did not pass OpenClaw ingress (${access.ingress.decision}:${access.ingress.reasonCode})`);
|
|
138
154
|
return;
|
|
139
155
|
}
|
|
156
|
+
// An agent's reply target is only its own Message (agentReplyLink); a
|
|
157
|
+
// person's is the Message they replied from, as before.
|
|
158
|
+
const replyTarget = facts.fromAgent
|
|
159
|
+
? facts.agentReplyLink
|
|
160
|
+
: facts.replyAnchorId ?? facts.replyToId;
|
|
140
161
|
const body = buildEnvelope({
|
|
141
162
|
channel: "Relay",
|
|
142
163
|
from: `${facts.displayName} (@${facts.handle})`,
|
|
@@ -171,9 +192,7 @@ export async function dispatchRelayEvent(params) {
|
|
|
171
192
|
reply: {
|
|
172
193
|
to: facts.chatId,
|
|
173
194
|
originatingTo: facts.chatId,
|
|
174
|
-
...(
|
|
175
|
-
? { replyToId: facts.replyAnchorId ?? facts.replyToId }
|
|
176
|
-
: {}),
|
|
195
|
+
...(replyTarget ? { replyToId: replyTarget } : {}),
|
|
177
196
|
},
|
|
178
197
|
message: {
|
|
179
198
|
inboundEventKind: "user_request",
|
|
@@ -200,6 +219,15 @@ export async function dispatchRelayEvent(params) {
|
|
|
200
219
|
},
|
|
201
220
|
},
|
|
202
221
|
});
|
|
222
|
+
// Another agent's Message waits for the turn running in its Chat, so it
|
|
223
|
+
// gets a turn and an answer of its own (turns.ts).
|
|
224
|
+
if (facts.fromAgent) {
|
|
225
|
+
await waitForIdleChat({
|
|
226
|
+
turns: params.turns,
|
|
227
|
+
chatId: facts.chatId,
|
|
228
|
+
lifecycle: params.lifecycle,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
203
231
|
await Promise.allSettled([
|
|
204
232
|
params.relay.chats.markAsRead(facts.chatId),
|
|
205
233
|
params.relay.chats.startTyping(facts.chatId),
|
|
@@ -212,7 +240,7 @@ export async function dispatchRelayEvent(params) {
|
|
|
212
240
|
});
|
|
213
241
|
let deliveryError;
|
|
214
242
|
try {
|
|
215
|
-
await params.runtime.channel.inbound.dispatch({
|
|
243
|
+
await params.turns.track(facts.chatId, () => params.runtime.channel.inbound.dispatch({
|
|
216
244
|
cfg: params.cfg,
|
|
217
245
|
channel: "relay",
|
|
218
246
|
accountId: params.account.accountId,
|
|
@@ -225,9 +253,17 @@ export async function dispatchRelayEvent(params) {
|
|
|
225
253
|
delivery: {
|
|
226
254
|
durable: {
|
|
227
255
|
to: facts.chatId,
|
|
228
|
-
replyToId: null,
|
|
256
|
+
replyToId: facts.fromAgent ? facts.agentReplyLink ?? null : null,
|
|
229
257
|
requiredCapabilities: { reconcileUnknownSend: true },
|
|
230
258
|
},
|
|
259
|
+
// Every answer to another agent names its Message, whatever
|
|
260
|
+
// `replyToMode` the operator chose; OpenClaw's own implicit
|
|
261
|
+
// current-message reply is dropped where no reply may point.
|
|
262
|
+
...(facts.fromAgent
|
|
263
|
+
? {
|
|
264
|
+
preparePayload: (payload) => agentReplyPayload(payload, facts),
|
|
265
|
+
}
|
|
266
|
+
: {}),
|
|
231
267
|
deliver: async (_payload, info) => {
|
|
232
268
|
if (info.kind === "final") {
|
|
233
269
|
throw new Error("relay: durable final Message delivery was unavailable");
|
|
@@ -250,7 +286,7 @@ export async function dispatchRelayEvent(params) {
|
|
|
250
286
|
: new Error(`relay: session record failed: ${String(error)}`);
|
|
251
287
|
},
|
|
252
288
|
},
|
|
253
|
-
});
|
|
289
|
+
}));
|
|
254
290
|
if (deliveryError) {
|
|
255
291
|
throw deliveryError instanceof Error
|
|
256
292
|
? deliveryError
|
package/dist/src/gateway.js
CHANGED
|
@@ -4,6 +4,7 @@ import { dispatchRelayEvent } from "./dispatch.js";
|
|
|
4
4
|
import { commitRelayFullSync } from "./full-sync.js";
|
|
5
5
|
import { createRelayIngressMonitor } from "./ingress.js";
|
|
6
6
|
import { createRelaySdkClient } from "./outbound.js";
|
|
7
|
+
import { createRelayChatTurns } from "./turns.js";
|
|
7
8
|
import { getRelayRuntime } from "./runtime.js";
|
|
8
9
|
import { openRelayStateStore, } from "./state.js";
|
|
9
10
|
const runningCredentials = new Map();
|
|
@@ -58,6 +59,7 @@ export async function startRelayAccount(ctx) {
|
|
|
58
59
|
accountId: transportId,
|
|
59
60
|
});
|
|
60
61
|
const relay = createRelaySdkClient(account);
|
|
62
|
+
const turns = createRelayChatTurns();
|
|
61
63
|
const ingress = createRelayIngressMonitor({
|
|
62
64
|
queue: openIngressQueue({
|
|
63
65
|
transportId,
|
|
@@ -80,6 +82,7 @@ export async function startRelayAccount(ctx) {
|
|
|
80
82
|
cfg: ctx.cfg,
|
|
81
83
|
relay,
|
|
82
84
|
runtime,
|
|
85
|
+
turns,
|
|
83
86
|
warn,
|
|
84
87
|
});
|
|
85
88
|
},
|
package/dist/src/inbound.js
CHANGED
|
@@ -56,9 +56,22 @@ export function buildRelayInboundFacts(event) {
|
|
|
56
56
|
const timestampValue = event.data.sent_at ?? event.created_at;
|
|
57
57
|
const timestamp = Date.parse(timestampValue);
|
|
58
58
|
const selection = selectionReply(event.data.parts, event.data.reply_to);
|
|
59
|
+
const fromAgent = event.data.sender_handle.kind === "agent";
|
|
60
|
+
// Another agent's Message is named by the answer, as Relay's CLI bridges
|
|
61
|
+
// do (packages/cli/src/bridge-turn.ts, PR 366): Relay's A2A door gives a
|
|
62
|
+
// calling agent only the answer whose reply_to names its Message
|
|
63
|
+
// (Relay-Server a2a.ts replyTo). A Message that opens with buttons or a
|
|
64
|
+
// selection is not named: an agent may not reply to those parts, and a
|
|
65
|
+
// reply names part 0.
|
|
66
|
+
const opening = event.data.parts[0]?.type;
|
|
67
|
+
const agentReplyLink = fromAgent && opening !== "buttons" && opening !== "selection"
|
|
68
|
+
? event.data.id
|
|
69
|
+
: undefined;
|
|
59
70
|
return {
|
|
60
71
|
...(selection ? { selection } : {}),
|
|
61
72
|
...(richMessage ? { richMessage } : {}),
|
|
73
|
+
fromAgent,
|
|
74
|
+
...(agentReplyLink ? { agentReplyLink } : {}),
|
|
62
75
|
eventId: event.event_id,
|
|
63
76
|
messageId: event.data.id,
|
|
64
77
|
chatId: event.data.chat.id,
|
|
@@ -78,8 +91,9 @@ export function buildRelayInboundFacts(event) {
|
|
|
78
91
|
// The answer quotes the person's message, the one it answers (a bot's
|
|
79
92
|
// reply_to in Telegram and Discord names the person's message). A
|
|
80
93
|
// tap's reply_to names the agent's buttons part, which no reply may
|
|
81
|
-
// target, so this is also what keeps a tap answerable.
|
|
82
|
-
|
|
94
|
+
// target, so this is also what keeps a tap answerable. An agent's
|
|
95
|
+
// Message is quoted only through agentReplyLink.
|
|
96
|
+
...(fromAgent ? {} : { replyAnchorId: event.data.id }),
|
|
83
97
|
}
|
|
84
98
|
: {}),
|
|
85
99
|
...(Number.isFinite(timestamp) ? { timestamp } : {}),
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export function createRelayChatTurns() {
|
|
2
|
+
const running = new Map();
|
|
3
|
+
return {
|
|
4
|
+
track(chatId, work) {
|
|
5
|
+
const turns = running.get(chatId) ?? new Set();
|
|
6
|
+
running.set(chatId, turns);
|
|
7
|
+
const turn = work();
|
|
8
|
+
turns.add(turn);
|
|
9
|
+
const settle = () => {
|
|
10
|
+
turns.delete(turn);
|
|
11
|
+
if (turns.size === 0 && running.get(chatId) === turns)
|
|
12
|
+
running.delete(chatId);
|
|
13
|
+
};
|
|
14
|
+
turn.then(settle, settle);
|
|
15
|
+
return turn;
|
|
16
|
+
},
|
|
17
|
+
busy: (chatId) => Boolean(running.get(chatId)?.size),
|
|
18
|
+
async idle(chatId, signal) {
|
|
19
|
+
for (;;) {
|
|
20
|
+
signal?.throwIfAborted();
|
|
21
|
+
const turns = running.get(chatId);
|
|
22
|
+
if (!turns?.size)
|
|
23
|
+
return;
|
|
24
|
+
const settled = Promise.allSettled([...turns]);
|
|
25
|
+
if (!signal) {
|
|
26
|
+
await settled;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
await new Promise((resolve, reject) => {
|
|
30
|
+
const abort = () => reject(signal.reason);
|
|
31
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
32
|
+
void settled.then(() => {
|
|
33
|
+
signal.removeEventListener("abort", abort);
|
|
34
|
+
resolve();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Hold a claimed Relay event until its Chat is idle. The claim is handed off
|
|
43
|
+
* as deferred and kept alive with the drain's own heartbeat
|
|
44
|
+
* (`ChannelIngressDispatchLifecycle.onDeferred` / `onDeferredHeartbeat`,
|
|
45
|
+
* docs/plugins/sdk-channel-outbound.md "Deferred claim heartbeats"), so the
|
|
46
|
+
* adoption watchdog does not retry a Message that is only waiting its turn. On
|
|
47
|
+
* shutdown the wait rejects before adoption, and the drain keeps the event for
|
|
48
|
+
* the next start.
|
|
49
|
+
*/
|
|
50
|
+
export async function waitForIdleChat(params) {
|
|
51
|
+
const { lifecycle } = params;
|
|
52
|
+
if (!params.turns.busy(params.chatId))
|
|
53
|
+
return;
|
|
54
|
+
const signal = lifecycle.abortSignal;
|
|
55
|
+
lifecycle.onDeferred?.();
|
|
56
|
+
const interval = lifecycle.deferredHeartbeatIntervalMs;
|
|
57
|
+
const heartbeat = lifecycle.onDeferredHeartbeat && interval && interval > 0
|
|
58
|
+
? setInterval(() => lifecycle.onDeferredHeartbeat?.(), interval)
|
|
59
|
+
: undefined;
|
|
60
|
+
heartbeat?.unref?.();
|
|
61
|
+
try {
|
|
62
|
+
await params.turns.idle(params.chatId, signal);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
if (heartbeat)
|
|
66
|
+
clearInterval(heartbeat);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
//# sourceMappingURL=turns.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@relaymessenger/openclaw-plugin",
|
|
3
|
-
"version": "0.4.7-staging.
|
|
3
|
+
"version": "0.4.7-staging.37",
|
|
4
4
|
"description": "Native Relay channel plugin for OpenClaw",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -33,11 +33,11 @@
|
|
|
33
33
|
"contract:verify": "node scripts/verify-contract-provenance.mjs",
|
|
34
34
|
"contract:test": "node --test test/*.test.mjs",
|
|
35
35
|
"pack:smoke": "node scripts/pack-smoke.mjs",
|
|
36
|
-
"gateway:harness": "node scripts/gateway-harness.mjs",
|
|
36
|
+
"gateway:harness": "node scripts/gateway-harness.mjs && node scripts/gateway-harness.mjs --overlap",
|
|
37
37
|
"prepack": "npm run build"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@relaymessenger/sdk": "0.3.6-staging.
|
|
40
|
+
"@relaymessenger/sdk": "0.3.6-staging.43"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
43
|
"@types/node": "^26.6.2",
|
package/src/dispatch.ts
CHANGED
|
@@ -11,9 +11,11 @@ import {
|
|
|
11
11
|
import { resolveStableChannelMessageIngress } from "openclaw/plugin-sdk/channel-ingress-runtime";
|
|
12
12
|
import { bindIngressLifecycleToReplyOptions } from "openclaw/plugin-sdk/channel-outbound";
|
|
13
13
|
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
|
14
|
+
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-payload";
|
|
14
15
|
import { buildRelayInboundFacts } from "./inbound.js";
|
|
15
16
|
import type { RelayIngressLifecycle } from "./ingress.js";
|
|
16
17
|
import type { PluginRuntime } from "./runtime.js";
|
|
18
|
+
import { type RelayChatTurns, waitForIdleChat } from "./turns.js";
|
|
17
19
|
import type {
|
|
18
20
|
RelayCoreConfig,
|
|
19
21
|
RelayInboundFacts,
|
|
@@ -86,6 +88,24 @@ export async function resolveRelayTurnActivation(params: {
|
|
|
86
88
|
};
|
|
87
89
|
}
|
|
88
90
|
|
|
91
|
+
/**
|
|
92
|
+
* The answer to another agent names the Message it answers: the model's own
|
|
93
|
+
* reply target when it chose one, else the agent's Message. Where no reply may
|
|
94
|
+
* point (a Message opening with buttons or a selection), OpenClaw's implicit
|
|
95
|
+
* current-message reply is removed.
|
|
96
|
+
*/
|
|
97
|
+
export function agentReplyPayload(
|
|
98
|
+
payload: ReplyPayload,
|
|
99
|
+
facts: Pick<RelayInboundFacts, "messageId" | "agentReplyLink">,
|
|
100
|
+
): ReplyPayload {
|
|
101
|
+
if (facts.agentReplyLink) {
|
|
102
|
+
return { ...payload, replyToId: payload.replyToId ?? facts.agentReplyLink };
|
|
103
|
+
}
|
|
104
|
+
if (payload.replyToId !== facts.messageId) return payload;
|
|
105
|
+
const { replyToId: _unlinked, ...rest } = payload;
|
|
106
|
+
return rest;
|
|
107
|
+
}
|
|
108
|
+
|
|
89
109
|
export async function dispatchRelayEvent(params: {
|
|
90
110
|
event: RelayWebhookEvent;
|
|
91
111
|
lifecycle: RelayIngressLifecycle;
|
|
@@ -93,6 +113,7 @@ export async function dispatchRelayEvent(params: {
|
|
|
93
113
|
cfg: RelayCoreConfig;
|
|
94
114
|
relay: Pick<Relay, "chats" | "messages">;
|
|
95
115
|
runtime: PluginRuntime;
|
|
116
|
+
turns: RelayChatTurns;
|
|
96
117
|
warn?: (message: string) => void;
|
|
97
118
|
}): Promise<void> {
|
|
98
119
|
const facts = buildRelayInboundFacts(params.event);
|
|
@@ -203,6 +224,11 @@ export async function dispatchRelayEvent(params: {
|
|
|
203
224
|
return;
|
|
204
225
|
}
|
|
205
226
|
|
|
227
|
+
// An agent's reply target is only its own Message (agentReplyLink); a
|
|
228
|
+
// person's is the Message they replied from, as before.
|
|
229
|
+
const replyTarget = facts.fromAgent
|
|
230
|
+
? facts.agentReplyLink
|
|
231
|
+
: facts.replyAnchorId ?? facts.replyToId;
|
|
206
232
|
const body = buildEnvelope({
|
|
207
233
|
channel: "Relay",
|
|
208
234
|
from: `${facts.displayName} (@${facts.handle})`,
|
|
@@ -237,9 +263,7 @@ export async function dispatchRelayEvent(params: {
|
|
|
237
263
|
reply: {
|
|
238
264
|
to: facts.chatId,
|
|
239
265
|
originatingTo: facts.chatId,
|
|
240
|
-
...(
|
|
241
|
-
? { replyToId: facts.replyAnchorId ?? facts.replyToId }
|
|
242
|
-
: {}),
|
|
266
|
+
...(replyTarget ? { replyToId: replyTarget } : {}),
|
|
243
267
|
},
|
|
244
268
|
message: {
|
|
245
269
|
inboundEventKind: "user_request",
|
|
@@ -267,6 +291,16 @@ export async function dispatchRelayEvent(params: {
|
|
|
267
291
|
},
|
|
268
292
|
});
|
|
269
293
|
|
|
294
|
+
// Another agent's Message waits for the turn running in its Chat, so it
|
|
295
|
+
// gets a turn and an answer of its own (turns.ts).
|
|
296
|
+
if (facts.fromAgent) {
|
|
297
|
+
await waitForIdleChat({
|
|
298
|
+
turns: params.turns,
|
|
299
|
+
chatId: facts.chatId,
|
|
300
|
+
lifecycle: params.lifecycle,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
270
304
|
await Promise.allSettled([
|
|
271
305
|
params.relay.chats.markAsRead(facts.chatId),
|
|
272
306
|
params.relay.chats.startTyping(facts.chatId),
|
|
@@ -280,7 +314,7 @@ export async function dispatchRelayEvent(params: {
|
|
|
280
314
|
|
|
281
315
|
let deliveryError: unknown;
|
|
282
316
|
try {
|
|
283
|
-
await params.runtime.channel.inbound.dispatch({
|
|
317
|
+
await params.turns.track(facts.chatId, () => params.runtime.channel.inbound.dispatch({
|
|
284
318
|
cfg: params.cfg as OpenClawConfig,
|
|
285
319
|
channel: "relay",
|
|
286
320
|
accountId: params.account.accountId,
|
|
@@ -293,9 +327,18 @@ export async function dispatchRelayEvent(params: {
|
|
|
293
327
|
delivery: {
|
|
294
328
|
durable: {
|
|
295
329
|
to: facts.chatId,
|
|
296
|
-
replyToId: null,
|
|
330
|
+
replyToId: facts.fromAgent ? facts.agentReplyLink ?? null : null,
|
|
297
331
|
requiredCapabilities: { reconcileUnknownSend: true },
|
|
298
332
|
},
|
|
333
|
+
// Every answer to another agent names its Message, whatever
|
|
334
|
+
// `replyToMode` the operator chose; OpenClaw's own implicit
|
|
335
|
+
// current-message reply is dropped where no reply may point.
|
|
336
|
+
...(facts.fromAgent
|
|
337
|
+
? {
|
|
338
|
+
preparePayload: (payload: ReplyPayload) =>
|
|
339
|
+
agentReplyPayload(payload, facts),
|
|
340
|
+
}
|
|
341
|
+
: {}),
|
|
299
342
|
deliver: async (_payload, info) => {
|
|
300
343
|
if (info.kind === "final") {
|
|
301
344
|
throw new Error(
|
|
@@ -320,7 +363,7 @@ export async function dispatchRelayEvent(params: {
|
|
|
320
363
|
: new Error(`relay: session record failed: ${String(error)}`);
|
|
321
364
|
},
|
|
322
365
|
},
|
|
323
|
-
});
|
|
366
|
+
}));
|
|
324
367
|
if (deliveryError) {
|
|
325
368
|
throw deliveryError instanceof Error
|
|
326
369
|
? deliveryError
|
package/src/gateway.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { dispatchRelayEvent } from "./dispatch.js";
|
|
|
10
10
|
import { commitRelayFullSync } from "./full-sync.js";
|
|
11
11
|
import { createRelayIngressMonitor } from "./ingress.js";
|
|
12
12
|
import { createRelaySdkClient } from "./outbound.js";
|
|
13
|
+
import { createRelayChatTurns } from "./turns.js";
|
|
13
14
|
import { getRelayRuntime } from "./runtime.js";
|
|
14
15
|
import {
|
|
15
16
|
openRelayStateStore,
|
|
@@ -96,6 +97,7 @@ export async function startRelayAccount(
|
|
|
96
97
|
accountId: transportId,
|
|
97
98
|
});
|
|
98
99
|
const relay = createRelaySdkClient(account);
|
|
100
|
+
const turns = createRelayChatTurns();
|
|
99
101
|
const ingress = createRelayIngressMonitor({
|
|
100
102
|
queue: openIngressQueue({
|
|
101
103
|
transportId,
|
|
@@ -119,6 +121,7 @@ export async function startRelayAccount(
|
|
|
119
121
|
cfg: ctx.cfg as RelayCoreConfig,
|
|
120
122
|
relay,
|
|
121
123
|
runtime,
|
|
124
|
+
turns,
|
|
122
125
|
warn,
|
|
123
126
|
});
|
|
124
127
|
},
|
package/src/inbound.ts
CHANGED
|
@@ -78,9 +78,22 @@ export function buildRelayInboundFacts(
|
|
|
78
78
|
const timestampValue = event.data.sent_at ?? event.created_at;
|
|
79
79
|
const timestamp = Date.parse(timestampValue);
|
|
80
80
|
const selection = selectionReply(event.data.parts, event.data.reply_to);
|
|
81
|
+
const fromAgent = event.data.sender_handle.kind === "agent";
|
|
82
|
+
// Another agent's Message is named by the answer, as Relay's CLI bridges
|
|
83
|
+
// do (packages/cli/src/bridge-turn.ts, PR 366): Relay's A2A door gives a
|
|
84
|
+
// calling agent only the answer whose reply_to names its Message
|
|
85
|
+
// (Relay-Server a2a.ts replyTo). A Message that opens with buttons or a
|
|
86
|
+
// selection is not named: an agent may not reply to those parts, and a
|
|
87
|
+
// reply names part 0.
|
|
88
|
+
const opening = event.data.parts[0]?.type;
|
|
89
|
+
const agentReplyLink = fromAgent && opening !== "buttons" && opening !== "selection"
|
|
90
|
+
? event.data.id
|
|
91
|
+
: undefined;
|
|
81
92
|
return {
|
|
82
93
|
...(selection ? { selection } : {}),
|
|
83
94
|
...(richMessage ? { richMessage } : {}),
|
|
95
|
+
fromAgent,
|
|
96
|
+
...(agentReplyLink ? { agentReplyLink } : {}),
|
|
84
97
|
eventId: event.event_id,
|
|
85
98
|
messageId: event.data.id,
|
|
86
99
|
chatId: event.data.chat.id,
|
|
@@ -101,8 +114,9 @@ export function buildRelayInboundFacts(
|
|
|
101
114
|
// The answer quotes the person's message, the one it answers (a bot's
|
|
102
115
|
// reply_to in Telegram and Discord names the person's message). A
|
|
103
116
|
// tap's reply_to names the agent's buttons part, which no reply may
|
|
104
|
-
// target, so this is also what keeps a tap answerable.
|
|
105
|
-
|
|
117
|
+
// target, so this is also what keeps a tap answerable. An agent's
|
|
118
|
+
// Message is quoted only through agentReplyLink.
|
|
119
|
+
...(fromAgent ? {} : { replyAnchorId: event.data.id }),
|
|
106
120
|
}
|
|
107
121
|
: {}),
|
|
108
122
|
...(Number.isFinite(timestamp) ? { timestamp } : {}),
|
package/src/turns.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { RelayIngressLifecycle } from "./ingress.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The OpenClaw turns running in each Chat of one Relay account, so another
|
|
5
|
+
* agent's Message can wait for them instead of steering into them.
|
|
6
|
+
*
|
|
7
|
+
* OpenClaw steers a Message that arrives mid-turn into the running turn by
|
|
8
|
+
* default (`messages.queue.mode` "steer", docs/concepts/queue.md), and that
|
|
9
|
+
* turn's answer names the first Message. With `followup` the queued turn's
|
|
10
|
+
* answer names none. Relay's A2A door gives each calling agent only the answer
|
|
11
|
+
* whose `reply_to` names its Message (Relay-Server `a2a.ts` `replyTo`), so the
|
|
12
|
+
* second of two overlapping calls got no answer. A channel plugin "may
|
|
13
|
+
* preserve ordering ... before a message enters the session queue"
|
|
14
|
+
* (docs/concepts/messages.md, Queueing and followups); this is that ordering,
|
|
15
|
+
* the same rule Relay's CLI bridges follow (`replacesLiveTurn`, PR 366): an
|
|
16
|
+
* agent's Message waits its turn, a person's Message is left to OpenClaw.
|
|
17
|
+
*/
|
|
18
|
+
export type RelayChatTurns = {
|
|
19
|
+
/** Run one dispatch into OpenClaw, recorded as running in its Chat until it settles. */
|
|
20
|
+
track<T>(chatId: string, work: () => Promise<T>): Promise<T>;
|
|
21
|
+
/** Whether a turn runs in the Chat now. */
|
|
22
|
+
busy(chatId: string): boolean;
|
|
23
|
+
/** Resolve once no turn runs in the Chat; reject with the signal's reason. */
|
|
24
|
+
idle(chatId: string, signal?: AbortSignal): Promise<void>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function createRelayChatTurns(): RelayChatTurns {
|
|
28
|
+
const running = new Map<string, Set<Promise<unknown>>>();
|
|
29
|
+
return {
|
|
30
|
+
track(chatId, work) {
|
|
31
|
+
const turns = running.get(chatId) ?? new Set<Promise<unknown>>();
|
|
32
|
+
running.set(chatId, turns);
|
|
33
|
+
const turn = work();
|
|
34
|
+
turns.add(turn);
|
|
35
|
+
const settle = () => {
|
|
36
|
+
turns.delete(turn);
|
|
37
|
+
if (turns.size === 0 && running.get(chatId) === turns) running.delete(chatId);
|
|
38
|
+
};
|
|
39
|
+
turn.then(settle, settle);
|
|
40
|
+
return turn;
|
|
41
|
+
},
|
|
42
|
+
busy: (chatId) => Boolean(running.get(chatId)?.size),
|
|
43
|
+
async idle(chatId, signal) {
|
|
44
|
+
for (;;) {
|
|
45
|
+
signal?.throwIfAborted();
|
|
46
|
+
const turns = running.get(chatId);
|
|
47
|
+
if (!turns?.size) return;
|
|
48
|
+
const settled = Promise.allSettled([...turns]);
|
|
49
|
+
if (!signal) {
|
|
50
|
+
await settled;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
await new Promise<void>((resolve, reject) => {
|
|
54
|
+
const abort = () => reject(signal.reason);
|
|
55
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
56
|
+
void settled.then(() => {
|
|
57
|
+
signal.removeEventListener("abort", abort);
|
|
58
|
+
resolve();
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Hold a claimed Relay event until its Chat is idle. The claim is handed off
|
|
68
|
+
* as deferred and kept alive with the drain's own heartbeat
|
|
69
|
+
* (`ChannelIngressDispatchLifecycle.onDeferred` / `onDeferredHeartbeat`,
|
|
70
|
+
* docs/plugins/sdk-channel-outbound.md "Deferred claim heartbeats"), so the
|
|
71
|
+
* adoption watchdog does not retry a Message that is only waiting its turn. On
|
|
72
|
+
* shutdown the wait rejects before adoption, and the drain keeps the event for
|
|
73
|
+
* the next start.
|
|
74
|
+
*/
|
|
75
|
+
export async function waitForIdleChat(params: {
|
|
76
|
+
turns: RelayChatTurns;
|
|
77
|
+
chatId: string;
|
|
78
|
+
lifecycle: Partial<RelayIngressLifecycle>;
|
|
79
|
+
}): Promise<void> {
|
|
80
|
+
const { lifecycle } = params;
|
|
81
|
+
if (!params.turns.busy(params.chatId)) return;
|
|
82
|
+
const signal = lifecycle.abortSignal;
|
|
83
|
+
lifecycle.onDeferred?.();
|
|
84
|
+
const interval = lifecycle.deferredHeartbeatIntervalMs;
|
|
85
|
+
const heartbeat = lifecycle.onDeferredHeartbeat && interval && interval > 0
|
|
86
|
+
? setInterval(() => lifecycle.onDeferredHeartbeat?.(), interval)
|
|
87
|
+
: undefined;
|
|
88
|
+
heartbeat?.unref?.();
|
|
89
|
+
try {
|
|
90
|
+
await params.turns.idle(params.chatId, signal);
|
|
91
|
+
} finally {
|
|
92
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
93
|
+
}
|
|
94
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -83,5 +83,12 @@ export type RelayInboundFacts = {
|
|
|
83
83
|
* agent's buttons part, which no reply may target.
|
|
84
84
|
*/
|
|
85
85
|
replyAnchorId?: string;
|
|
86
|
+
/** Whether another agent sent the Message. */
|
|
87
|
+
fromAgent: boolean;
|
|
88
|
+
/**
|
|
89
|
+
* The Message every answer names when another agent sent it: this one,
|
|
90
|
+
* unless it opens with buttons or a selection, which no reply may target.
|
|
91
|
+
*/
|
|
92
|
+
agentReplyLink?: string;
|
|
86
93
|
timestamp?: number;
|
|
87
94
|
};
|