Skip to content

Commit 8020dd3

Browse files
authored
fix(reef): discover trusted peers as conversations (#109905)
* fix(reef): discover trusted peer conversations * test(reef): document temporary state cleanup * test(gateway): include conversation discovery in method order * chore: leave changelog to release automation * fix(gateway): preserve configured conversation discovery * fix(gateway): validate turns before session binding * fix(gateway): preserve directory conversation identity
1 parent 62407b3 commit 8020dd3

26 files changed

Lines changed: 1337 additions & 119 deletions

apps/android/app/src/main/java/ai/openclaw/app/gateway/GatewayProtocol.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,7 @@ enum class GatewayMethod(
386386
UiCommand("ui.command"),
387387
ApprovalHistory("approval.history"),
388388
PluginSurfaceRefresh("plugin.surface.refresh"),
389+
ConversationsList("conversations.list"),
389390
}
390391

391392
enum class GatewayEvent(

apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1216,6 +1216,92 @@ public struct ConversationSendResult: Codable, Sendable {
12161216
}
12171217
}
12181218

1219+
public struct ConversationListItem: Codable, Sendable {
1220+
public let conversationref: String
1221+
public let channel: String
1222+
public let accountid: String
1223+
public let kind: AnyCodable
1224+
public let target: String
1225+
public let threadid: String?
1226+
public let label: String?
1227+
public let firstseenat: Int
1228+
public let lastseenat: Int
1229+
1230+
public init(
1231+
conversationref: String,
1232+
channel: String,
1233+
accountid: String,
1234+
kind: AnyCodable,
1235+
target: String,
1236+
threadid: String? = nil,
1237+
label: String? = nil,
1238+
firstseenat: Int,
1239+
lastseenat: Int)
1240+
{
1241+
self.conversationref = conversationref
1242+
self.channel = channel
1243+
self.accountid = accountid
1244+
self.kind = kind
1245+
self.target = target
1246+
self.threadid = threadid
1247+
self.label = label
1248+
self.firstseenat = firstseenat
1249+
self.lastseenat = lastseenat
1250+
}
1251+
1252+
private enum CodingKeys: String, CodingKey {
1253+
case conversationref = "conversationRef"
1254+
case channel
1255+
case accountid = "accountId"
1256+
case kind
1257+
case target
1258+
case threadid = "threadId"
1259+
case label
1260+
case firstseenat = "firstSeenAt"
1261+
case lastseenat = "lastSeenAt"
1262+
}
1263+
}
1264+
1265+
public struct ConversationListParams: Codable, Sendable {
1266+
public let agentid: String
1267+
public let channel: String?
1268+
public let query: String?
1269+
public let limit: Int?
1270+
1271+
public init(
1272+
agentid: String,
1273+
channel: String? = nil,
1274+
query: String? = nil,
1275+
limit: Int? = nil)
1276+
{
1277+
self.agentid = agentid
1278+
self.channel = channel
1279+
self.query = query
1280+
self.limit = limit
1281+
}
1282+
1283+
private enum CodingKeys: String, CodingKey {
1284+
case agentid = "agentId"
1285+
case channel
1286+
case query
1287+
case limit
1288+
}
1289+
}
1290+
1291+
public struct ConversationListResult: Codable, Sendable {
1292+
public let conversations: [ConversationListItem]
1293+
1294+
public init(
1295+
conversations: [ConversationListItem])
1296+
{
1297+
self.conversations = conversations
1298+
}
1299+
1300+
private enum CodingKeys: String, CodingKey {
1301+
case conversations
1302+
}
1303+
}
1304+
12191305
public struct ConversationTurnCancelParams: Codable, Sendable {
12201306
public let agentid: String
12211307
public let turnid: String

docs/concepts/session-tool.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ If you need the exact raw transcript, inspect the scoped SQLite transcript rows
6565

6666
A **session** is local model context. A **conversation** is an exact external address such as one peer, channel, or thread. The two are linked, but they are not interchangeable: direct messages can share one `main` session while retaining separate conversation addresses.
6767

68-
`conversations_list` returns opaque `conversationRef` values for the active agent. Conversation discovery and delivery are owner-only because they use the Gateway's channel credentials. Use `conversations_send` for fire-and-forget delivery. Use `conversations_turn` when the remote reply belongs to the current model turn: the Gateway reserves one transport message ID, persists a delivery operation and queue intent before transport I/O, and returns the correlated reply from the tool instead of starting a second local agent turn. Delivery operations live outside model transcripts; a captured reply is retained only as a side artifact while the tool result owns model context. If the Gateway restarts after queueing, delivery can recover but a later reply follows ordinary inbound dispatch because the process-local waiter is gone. Unsolicited inbound messages always continue through the normal channel dispatch path.
68+
`conversations_list` returns opaque `conversationRef` values for the active agent. With an explicit `channel`, the Gateway also refreshes addresses from that channel's local directory, such as approved Reef peers; use `query` to find a specific peer beyond the current result page. Discovery catalogs the address without creating a model-context session; the backing session is created only when delivery or inbound context needs it. Conversation discovery and delivery are owner-only because they use the Gateway's channel credentials. Use `conversations_send` for fire-and-forget delivery. Use `conversations_turn` when the remote reply belongs to the current model turn: the Gateway reserves one transport message ID, persists a delivery operation and queue intent before transport I/O, and returns the correlated reply from the tool instead of starting a second local agent turn. Delivery operations live outside model transcripts; a captured reply is retained only as a side artifact while the tool result owns model context. If the Gateway restarts after queueing, delivery can recover but a later reply follows ordinary inbound dispatch because the process-local waiter is gone. Unsolicited inbound messages always continue through the normal channel dispatch path.
6969

7070
Use the shared `message` tool when you already have an explicit raw channel target or need a channel-specific action. Conversation references are scoped to the active agent and should be obtained through `conversations_list`, not constructed from session keys.
7171

extensions/reef/src/channel.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
1-
import { describe, expect, it } from "vitest";
1+
import fs from "node:fs";
2+
import os from "node:os";
3+
import path from "node:path";
4+
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
5+
import {
6+
createPluginStateSyncKeyedStoreForTests,
7+
resetPluginStateStoreForTests,
8+
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
9+
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/plugin-test-runtime";
10+
import { defaultRuntime } from "openclaw/plugin-sdk/runtime";
11+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
12+
import { generateIdentity } from "../protocol/index.js";
13+
import { reefPlugin } from "./channel.js";
14+
import { resolveReefConfig } from "./config-schema.js";
215
import { resolveReefInboundDispatchContent } from "./inbound.js";
16+
import { setReefRuntime } from "./runtime.js";
17+
import { openReefTrustStore } from "./trust-store.js";
318

419
describe("Reef inbound dispatch content", () => {
520
it("keeps provenance model-visible without storing it in the transcript body", () => {
@@ -41,3 +56,50 @@ describe("Reef inbound dispatch content", () => {
4156
});
4257
});
4358
});
59+
60+
describe("Reef conversation directory", () => {
61+
let stateDir = "";
62+
63+
beforeEach(() => {
64+
resetPluginStateStoreForTests();
65+
// openclaw-temp-dir: allow Reef directory tests need an on-disk state root; afterEach removes it.
66+
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "reef-directory-"));
67+
const runtime = createPluginRuntimeMock();
68+
runtime.state.openSyncKeyedStore = <T>(options: OpenKeyedStoreOptions) =>
69+
createPluginStateSyncKeyedStoreForTests<T>("reef", {
70+
...options,
71+
env: { OPENCLAW_STATE_DIR: stateDir },
72+
});
73+
setReefRuntime(runtime);
74+
const identity = generateIdentity();
75+
openReefTrustStore(runtime, resolveReefConfig({ channels: { reef: { handle: "clawd" } } })).set(
76+
"molty",
77+
{
78+
autonomy: "bounded",
79+
ed25519PublicKey: identity.signing.publicKey,
80+
x25519PublicKey: identity.encryption.publicKey,
81+
keyEpoch: 1,
82+
safetyNumberChanged: false,
83+
approvedAt: 1_752_537_600_000,
84+
},
85+
);
86+
});
87+
88+
afterEach(() => {
89+
resetPluginStateStoreForTests();
90+
fs.rmSync(stateDir, { recursive: true, force: true });
91+
});
92+
93+
it("exposes locally trusted peers as routable directory entries", async () => {
94+
const cfg = { channels: { reef: { handle: "clawd" } } };
95+
await expect(
96+
reefPlugin.directory?.listPeers?.({
97+
cfg,
98+
accountId: "default",
99+
query: "@molty",
100+
limit: 10,
101+
runtime: defaultRuntime,
102+
}),
103+
).resolves.toEqual([{ kind: "user", id: "molty", name: "@molty's agent", handle: "@molty" }]);
104+
});
105+
});

extensions/reef/src/channel.ts

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
buildChannelOutboundSessionRoute,
1010
type ChannelPlugin,
1111
} from "openclaw/plugin-sdk/core";
12-
import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
12+
import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
1313
import {
1414
ReefChannelConfigSchema,
1515
autonomyBudget,
@@ -64,6 +64,24 @@ function listTrustedPeers(config: ReefAccount["config"]): string[] {
6464
: [];
6565
}
6666

67+
function listTrustedPeerDirectoryEntries(params: {
68+
config: ReefAccount["config"];
69+
query: string | null | undefined;
70+
limit: number | null | undefined;
71+
}) {
72+
const query = normalizeReefTarget(params.query ?? "") ?? params.query?.trim().toLowerCase();
73+
const peers = listTrustedPeers(params.config).filter(
74+
(peer) => !query || peer === query || peer.includes(query),
75+
);
76+
const limit = params.limit == null ? peers.length : Math.max(0, params.limit);
77+
return peers.slice(0, limit).map((peer) => ({
78+
kind: "user" as const,
79+
id: peer,
80+
name: `@${peer}'s agent`,
81+
handle: `@${peer}`,
82+
}));
83+
}
84+
6785
function replyText(payload: unknown): string {
6886
if (!payload || typeof payload !== "object" || !("text" in payload)) {
6987
return "";
@@ -147,7 +165,15 @@ export const reefPlugin: ChannelPlugin<ReefAccount> = {
147165
: null;
148166
},
149167
},
150-
directory: createEmptyChannelDirectoryAdapter(),
168+
directory: createChannelDirectoryAdapter({
169+
listPeers: async ({ cfg, query, limit }) =>
170+
listTrustedPeerDirectoryEntries({
171+
config: resolveReefConfig(cfg as ReefCoreConfig),
172+
query,
173+
limit,
174+
}),
175+
listGroups: async () => [],
176+
}),
151177
message: reefMessageAdapter,
152178
outbound: reefOutboundAdapter,
153179
pairing: {

packages/gateway-protocol/src/index.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,9 @@ import {
5858
AgentsListParamsSchema,
5959
AgentsListResultSchema,
6060
AgentWaitParamsSchema,
61+
ConversationListItemSchema,
62+
ConversationListParamsSchema,
63+
ConversationListResultSchema,
6164
ConversationSendParamsSchema,
6265
ConversationSendResultSchema,
6366
ConversationTurnCancelParamsSchema,
@@ -573,6 +576,8 @@ export const validateResponseFrame = lazyCompile(ResponseFrameSchema);
573576
export const validateEventFrame = lazyCompile(EventFrameSchema);
574577
export const validateMessageActionParams = lazyCompile(MessageActionParamsSchema);
575578
export const validateSendParams = lazyCompile(SendParamsSchema);
579+
export const validateConversationListParams = lazyCompile(ConversationListParamsSchema);
580+
export const validateConversationListResult = lazyCompile(ConversationListResultSchema);
576581
export const validateConversationSendParams = lazyCompile(ConversationSendParamsSchema);
577582
export const validateConversationSendResult = lazyCompile(ConversationSendResultSchema);
578583
export const validateConversationTurnCancelParams = lazyCompile(ConversationTurnCancelParamsSchema);
@@ -944,6 +949,9 @@ export {
944949
SystemInfoResultSchema,
945950
StateVersionSchema,
946951
AgentEventSchema,
952+
ConversationListItemSchema,
953+
ConversationListParamsSchema,
954+
ConversationListResultSchema,
947955
ConversationSendParamsSchema,
948956
ConversationSendResultSchema,
949957
ConversationTurnCancelParamsSchema,
@@ -1368,6 +1376,9 @@ export type {
13681376
ErrorShape,
13691377
StateVersion,
13701378
AgentEvent,
1379+
ConversationListItem,
1380+
ConversationListParams,
1381+
ConversationListResult,
13711382
ConversationSendParams,
13721383
ConversationSendResult,
13731384
ConversationTurnCancelParams,

packages/gateway-protocol/src/schema/agent.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { Value } from "typebox/value";
33
import { describe, expect, it } from "vitest";
44
import {
55
AgentParamsSchema,
6+
ConversationListParamsSchema,
7+
ConversationListResultSchema,
68
ConversationSendParamsSchema,
79
ConversationSendResultSchema,
810
ConversationTurnCancelParamsSchema,
@@ -150,6 +152,33 @@ describe("MessageActionParamsSchema", () => {
150152
});
151153

152154
describe("Conversation schemas", () => {
155+
it("accepts Gateway-owned address discovery without session internals", () => {
156+
expect(
157+
Value.Check(ConversationListParamsSchema, {
158+
agentId: "main",
159+
channel: "reef",
160+
query: "@molty",
161+
limit: 50,
162+
}),
163+
).toBe(true);
164+
expect(
165+
Value.Check(ConversationListResultSchema, {
166+
conversations: [
167+
{
168+
conversationRef: "conv_0123456789abcdef0123456789abcdef",
169+
channel: "reef",
170+
accountId: "default",
171+
kind: "direct",
172+
target: "reef:molty",
173+
label: "@molty's agent",
174+
firstSeenAt: 100,
175+
lastSeenAt: 100,
176+
},
177+
],
178+
}),
179+
).toBe(true);
180+
});
181+
153182
it("accepts a Gateway-owned durable send and result", () => {
154183
expect(
155184
Value.Check(ConversationSendParamsSchema, {

packages/gateway-protocol/src/schema/agent.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,30 @@ export const SendParamsSchema = closedObject({
145145
idempotencyKey: NonEmptyString,
146146
});
147147

148+
/** Gateway-owned request that lists persisted and channel-directory addresses. */
149+
export const ConversationListParamsSchema = closedObject({
150+
agentId: NonEmptyString,
151+
channel: Type.Optional(NonEmptyString),
152+
query: Type.Optional(NonEmptyString),
153+
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
154+
});
155+
156+
export const ConversationListItemSchema = closedObject({
157+
conversationRef: Type.String({ pattern: CONVERSATION_REF_PATTERN }),
158+
channel: NonEmptyString,
159+
accountId: NonEmptyString,
160+
kind: Type.Union([Type.Literal("direct"), Type.Literal("group"), Type.Literal("channel")]),
161+
target: NonEmptyString,
162+
threadId: Type.Optional(NonEmptyString),
163+
label: Type.Optional(NonEmptyString),
164+
firstSeenAt: Type.Integer({ minimum: 0 }),
165+
lastSeenAt: Type.Integer({ minimum: 0 }),
166+
});
167+
168+
export const ConversationListResultSchema = closedObject({
169+
conversations: Type.Array(ConversationListItemSchema),
170+
});
171+
148172
/** Gateway-owned request that sends to one durable external conversation. */
149173
export const ConversationSendParamsSchema = closedObject({
150174
agentId: NonEmptyString,
@@ -356,6 +380,9 @@ export const WakeParamsSchema = Type.Object(
356380
export type AgentEvent = Static<typeof AgentEventSchema>;
357381
export type AgentIdentityParams = Static<typeof AgentIdentityParamsSchema>;
358382
export type AgentIdentityResult = Static<typeof AgentIdentityResultSchema>;
383+
export type ConversationListParams = Static<typeof ConversationListParamsSchema>;
384+
export type ConversationListItem = Static<typeof ConversationListItemSchema>;
385+
export type ConversationListResult = Static<typeof ConversationListResultSchema>;
359386
export type ConversationSendParams = Static<typeof ConversationSendParamsSchema>;
360387
export type ConversationSendResult = Static<typeof ConversationSendResultSchema>;
361388
export type ConversationTurnParams = Static<typeof ConversationTurnParamsSchema>;

packages/gateway-protocol/src/schema/protocol-schemas.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import {
99
AgentIdentityResultSchema,
1010
AgentParamsSchema,
1111
AgentWaitParamsSchema,
12+
ConversationListItemSchema,
13+
ConversationListParamsSchema,
14+
ConversationListResultSchema,
1215
ConversationSendParamsSchema,
1316
ConversationSendResultSchema,
1417
ConversationTurnCancelParamsSchema,
@@ -558,6 +561,9 @@ export const ProtocolSchemas = {
558561
AgentEvent: AgentEventSchema,
559562
ConversationSendParams: ConversationSendParamsSchema,
560563
ConversationSendResult: ConversationSendResultSchema,
564+
ConversationListItem: ConversationListItemSchema,
565+
ConversationListParams: ConversationListParamsSchema,
566+
ConversationListResult: ConversationListResultSchema,
561567
ConversationTurnCancelParams: ConversationTurnCancelParamsSchema,
562568
ConversationTurnCancelResult: ConversationTurnCancelResultSchema,
563569
ConversationTurnParams: ConversationTurnParamsSchema,

0 commit comments

Comments
 (0)