dsh-interconnect 0.6.3 → 0.7.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/README.md CHANGED
@@ -17,6 +17,7 @@
17
17
  - `interconnect_send`:向对端实例的指定 session 投递消息;可选 `delivery` 选投递模式、`resume` 唤醒离线 session
18
18
  - `interconnect_list`:列出对端实例的 live session(id + 标题 + 状态),用于在不预先知道 session id 时寻址
19
19
  - `interconnect_ping`:探测对端实例活性与身份
20
+ - `interconnect_reply`:向记录过的发送方回传消息,只需本机 session id + 文本,无需再次寻址
20
21
 
21
22
  ## 用法
22
23
 
@@ -37,6 +38,29 @@ session-b07326da-… [running]
37
38
  只列 live session 是有意的:`send` 能到达的正好是这些。对端存在但没有运行 agent 的 session
38
39
  不会出现在列表里,也收不到消息。
39
40
 
41
+ ### 回复(`reply`)
42
+
43
+ `send` 的线负载带一个可选的 `sender` 身份(`baseUrl` + `instanceId` + `sessionId`),收到
44
+ 消息的 instance 会按「本地 session id → 该 sender」记下这份身份。之后那个 session 可以只凭
45
+ **自己的 session id + 文本**把消息回传给发送方,不用再次给出对端 origin 或远程 session id。
46
+
47
+ ```text
48
+ # 源实例 A 指定目标 B 的 session,并带上自己的身份
49
+ interconnect_send(baseUrl=B, sessionId=B-sess, text="…", sender={baseUrl:A, instanceId:A, sessionId:A-sess})
50
+
51
+ # B 回传:只给本地 session id + 文本,目标从记录的 sender 解析
52
+ interconnect_reply(sessionId=B-sess, text="reply")
53
+ ```
54
+
55
+ - `sender` 是**自报**的,只用于 reply 归因与寻址,**不是**路由或鉴权依据——连接本身仍由
56
+ 共享密钥在每个 origin 上独立鉴权。
57
+ - 回复的消息也带 `sender`(当本机配置了 `selfBaseUrl`),所以对话可双向多轮延续。
58
+ - 只有当入站 send **带了 `sender`** 时 reply 才有目标;对端版本没带、或本 session 从未收过
59
+ 互联消息时,`reply` 返回 `delivered: false, reason: "no-sender-known"`。
60
+ - `sender` **不进模型上下文**(和 `source` 一样只落到持久化日志与 UI 归因)——这条消息的
61
+ 内容字面就是 wire 上传来的 `text`,模型看到的仍是普通 user 文本,只是不带任何结构化的
62
+ 发送方标记。
63
+
40
64
  ### 投递失败的原因
41
65
 
42
66
  `delivered: false` 单独一个布尔值无法据以行动,因为不同失败需要不同应对,所以
@@ -49,6 +73,7 @@ session-b07326da-… [running]
49
73
  | `resume-refused` | 请求了唤醒,但对端不允许(`allowResume: false`) | 再带 `resume` 也没用 |
50
74
  | `resume-failed` | 允许唤醒且尝试了,但没得到 live agent(无此持久化 session,或被别的 owner 持有) | 换目标 |
51
75
  | `session-owned-by-subagent` | 该 session 属于 subagent 路由,投递权在它的父 agent | 通过父 agent 触达,别直接投 |
76
+ | `no-sender-known` | `reply` 指向的本地 session 从未记录过发送方(它没收到过带 `sender` 的消息,或对端版本过旧没带 `sender`) | 先用 `interconnect_send` 主动建立联系,或确认对端配置了 `selfBaseUrl` |
52
77
 
53
78
  `reason` 恰好在 `delivered` 为 false 时出现。
54
79
 
@@ -127,6 +152,7 @@ Host 自己的 resume 路径对同一个 session 报 `SessionFormatUnsupportedEr
127
152
  | 字段 | 默认 | 说明 |
128
153
  |---|---|---|
129
154
  | `instanceId` | `'dsh'` | 本实例自报的 id,出现在 `ping`/`send`/`list` 的回包里。仅诊断用,**不作为路由依据** |
155
+ | `selfBaseUrl` | 无 | 本实例对外可达的 origin(如 `http://my-box:3080`)。设置后,出站 `send`/`reply` 会带上本实例的 `sender` 身份,使对端能回信;不设置则不归因、对端 `reply` 报 `no-sender-known` |
130
156
  | `requestTimeoutMs` | `10000` | 出站请求超时,上限 60000 |
131
157
  | `peers` | `[]` | 启动时的事件 fan-out 目标;运行时可用 `subscribe()` 增补 |
132
158
  | `delivery` | `'followup'` | 入站消息未带 `delivery` 时的默认投递模式 |
@@ -136,6 +162,7 @@ Host 自己的 resume 路径对同一个 session 报 `SessionFormatUnsupportedEr
136
162
  - id: interconnect
137
163
  config:
138
164
  instanceId: my-box
165
+ selfBaseUrl: http://my-box:3080 # 让对端能 reply 回本机
139
166
  peers: ['http://peer-host:3080']
140
167
  delivery: followup
141
168
  allowResume: false # 拒绝一切唤醒请求
@@ -187,7 +214,7 @@ pnpm run build # esbuild → lib/
187
214
 
188
215
  ## 验证
189
216
 
190
- - 50/50 单测通过(服务 + 工具);类型检查、构建均干净。
217
+ - 67/67 单测通过(服务 + 工具);类型检查、构建均干净。
191
218
  - 已在两台机器之间实测双向互通:消息投递、WebSocket 事件推流、以及 agent 经
192
219
  `interconnect_send` 工具反向回发,均验证通过。
193
220
  - CI(GitHub Actions):clone 公开 DSH 仓库作为 sibling,跑 `pnpm run check`。
@@ -198,9 +225,12 @@ pnpm run build # esbuild → lib/
198
225
  `{ kind: 'user' }`。负例:把该 source 改回 `kind: 'user'`,对应断言转红。
199
226
  **注意 `source` 不进模型上下文**——只有 `role` 和 `content` 会,而 `role` 是 `user`。
200
227
  所以这个字段的价值在持久化日志与 UI 归因,接收方的**模型本身分辨不出**消息来自插件。
228
+ 同理,reply 的记录与寻址依赖 `sender`,它同样不进模型上下文。
229
+ - reply 双向多轮延续在**同一测试进程内用真实 HTTP 双向回环**验证(A→B→A→B),不是 mockout。
201
230
  - 每个行为改动都配负例对照(删掉实现使对应断言转红),而不只是「测试通过」。
202
231
  例如:去掉请求处理器的 `await` 会让 25 个测试转红;去掉 `resume` 的发送方 opt-in
203
- 门或接收方否决门,各让 1 个转红。
232
+ 门或接收方否决门,各让 1 个转红;去掉入站 send 的 sender 记录(`senders.set`),
233
+ 两条 reply 回环测试转红。
204
234
 
205
235
  ## 许可
206
236
 
package/lib/index.js CHANGED
@@ -847,6 +847,17 @@ var linkFrameSchema = Schema.union([
847
847
  Schema.object({ type: Schema.const("event"), notification: notificationSchema })
848
848
  ]);
849
849
  var sendPayloadSchema = Schema.object({
850
+ sessionId: Schema.string(),
851
+ text: Schema.string(),
852
+ sender: Schema.object({
853
+ baseUrl: Schema.string(),
854
+ instanceId: Schema.string(),
855
+ sessionId: Schema.string()
856
+ }),
857
+ delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]),
858
+ resume: Schema.boolean()
859
+ });
860
+ var replyPayloadSchema = Schema.object({
850
861
  sessionId: Schema.string(),
851
862
  text: Schema.string(),
852
863
  delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]),
@@ -860,12 +871,14 @@ var InterconnectService = class extends Service {
860
871
  static inject = ["webServer", "agents", "credentials"];
861
872
  static Config = Schema.object({
862
873
  instanceId: Schema.string().default("dsh"),
874
+ selfBaseUrl: Schema.string(),
863
875
  requestTimeoutMs: Schema.natural().max(6e4).default(1e4),
864
876
  peers: Schema.array(Schema.string()).default([]),
865
877
  delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]).default("followup"),
866
878
  allowResume: Schema.boolean().default(true)
867
879
  });
868
880
  instanceId;
881
+ selfBaseUrl;
869
882
  requestTimeoutMs;
870
883
  delivery;
871
884
  allowResume;
@@ -877,9 +890,12 @@ var InterconnectService = class extends Service {
877
890
  heartbeatTimer;
878
891
  /** Peer identity each live socket announced via its `hello` frame, if any. */
879
892
  peerOf = /* @__PURE__ */ new WeakMap();
893
+ /** Sender each local session last received a send from, keyed by local session id. */
894
+ senders = /* @__PURE__ */ new Map();
880
895
  constructor(ctx, config) {
881
896
  super(ctx, "interconnect");
882
897
  this.instanceId = config.instanceId;
898
+ this.selfBaseUrl = config.selfBaseUrl;
883
899
  this.requestTimeoutMs = config.requestTimeoutMs;
884
900
  this.delivery = config.delivery ?? "followup";
885
901
  this.allowResume = config.allowResume ?? true;
@@ -950,16 +966,49 @@ var InterconnectService = class extends Service {
950
966
  this.server.close();
951
967
  }, "interconnect: websocket teardown");
952
968
  }
969
+ /**
970
+ * This instance's identity to attach to outbound messages, when configured.
971
+ * Absent (`selfBaseUrl` unset) outbound sends are not attributable and the
972
+ * peer cannot reply to them.
973
+ * @param sessionId - the local session that is sending, used as the reply target.
974
+ */
975
+ selfSender(sessionId) {
976
+ return this.selfBaseUrl === void 0 ? void 0 : { baseUrl: this.selfBaseUrl, instanceId: this.instanceId, sessionId };
977
+ }
953
978
  async send(request) {
954
979
  const payload = {
955
980
  sessionId: request.sessionId,
956
981
  text: request.text,
982
+ ...request.sender === void 0 ? {} : { sender: request.sender },
957
983
  ...request.delivery === void 0 ? {} : { delivery: request.delivery },
958
984
  ...request.resume === void 0 ? {} : { resume: request.resume }
959
985
  };
960
986
  const result = await this.post(request.baseUrl, "send", payload);
961
987
  return result ?? { delivered: false, instance: this.instanceId, reason: "unreachable" };
962
988
  }
989
+ /**
990
+ * Deliver one text message back to the peer that a local session last
991
+ * received a send from. `sessionId` names the LOCAL replying session; the
992
+ * outbound target is the `sender` that session recorded, resolved to a
993
+ * `send` against the recalled baseUrl and remote session id.
994
+ */
995
+ async reply(request) {
996
+ const sender = this.senders.get(request.sessionId);
997
+ if (sender === void 0) {
998
+ return { delivered: false, instance: this.instanceId, reason: "no-sender-known" };
999
+ }
1000
+ const payload = {
1001
+ sessionId: sender.sessionId,
1002
+ text: request.text,
1003
+ ...request.delivery === void 0 ? {} : { delivery: request.delivery },
1004
+ ...request.resume === void 0 ? {} : { resume: request.resume },
1005
+ // Attribute the reply to this instance so the peer, in turn, can reply
1006
+ // back — chaining the conversation. Absent when we have no self origin.
1007
+ ...this.selfBaseUrl === void 0 ? {} : { sender: this.selfSender(request.sessionId) }
1008
+ };
1009
+ const result = await this.post(sender.baseUrl, "send", payload);
1010
+ return result ?? { delivered: false, instance: this.instanceId, reason: "unreachable" };
1011
+ }
963
1012
  async ping(baseUrl) {
964
1013
  return this.post(baseUrl, "ping", {});
965
1014
  }
@@ -1134,6 +1183,15 @@ var InterconnectService = class extends Service {
1134
1183
  }
1135
1184
  return this.deliver(parsed);
1136
1185
  }
1186
+ if (endpoint === "reply") {
1187
+ let parsed;
1188
+ try {
1189
+ parsed = Schema.resolve(payload, replyPayloadSchema, {})[0];
1190
+ } catch (error) {
1191
+ throw new InvalidPayloadError(`reply payload invalid: ${error instanceof Error ? error.message : String(error)}`);
1192
+ }
1193
+ return this.replyForSession(parsed);
1194
+ }
1137
1195
  if (endpoint === "list") {
1138
1196
  return this.listSessions();
1139
1197
  }
@@ -1226,12 +1284,21 @@ var InterconnectService = class extends Service {
1226
1284
  if (hasApiRemoteSubagentOwner(this.ctx, agent.session, agent)) {
1227
1285
  return { delivered: false, instance: this.instanceId, reason: "session-owned-by-subagent" };
1228
1286
  }
1287
+ const sender = (() => {
1288
+ const candidate = payload.sender;
1289
+ return candidate === void 0 || typeof candidate.baseUrl !== "string" ? void 0 : candidate;
1290
+ })();
1291
+ if (sender !== void 0) {
1292
+ this.senders.set(payload.sessionId, sender);
1293
+ }
1229
1294
  const message = createUserMessage({
1230
1295
  source: {
1231
1296
  kind: "plugin",
1232
1297
  plugin: PLUGIN_SOURCE,
1233
1298
  form: "notice",
1234
- summary: boundContextSummary(`interconnect handoff delivered on instance ${this.instanceId}`)
1299
+ summary: boundContextSummary(
1300
+ sender === void 0 ? `interconnect handoff delivered on instance ${this.instanceId}` : `interconnect handoff from ${sender.instanceId} (session ${sender.sessionId}) delivered on instance ${this.instanceId}`
1301
+ )
1235
1302
  },
1236
1303
  content: [{ type: "text", text: payload.text }]
1237
1304
  });
@@ -1249,7 +1316,19 @@ var InterconnectService = class extends Service {
1249
1316
  }
1250
1317
  return { delivered: true, instance: this.instanceId, delivery: mode };
1251
1318
  }
1252
- /** POST one business call to a peer instance; undefined on transport/auth failure. */
1319
+ /**
1320
+ * Inbound `reply` dispatch: forward a request to {link reply}, resolving the
1321
+ * outbound sender for the named local session. Kept separate from {link
1322
+ * deliver} so the two wire endpoints stay distinct in the request pipeline.
1323
+ */
1324
+ async replyForSession(payload) {
1325
+ return this.reply({
1326
+ sessionId: payload.sessionId,
1327
+ text: payload.text,
1328
+ ...payload.delivery === void 0 ? {} : { delivery: payload.delivery },
1329
+ ...payload.resume === void 0 ? {} : { resume: payload.resume }
1330
+ });
1331
+ }
1253
1332
  async post(baseUrl, endpoint, payload) {
1254
1333
  const token = await this.resolveToken();
1255
1334
  if (token === void 0) return void 0;
@@ -847,6 +847,17 @@ var linkFrameSchema = Schema.union([
847
847
  Schema.object({ type: Schema.const("event"), notification: notificationSchema })
848
848
  ]);
849
849
  var sendPayloadSchema = Schema.object({
850
+ sessionId: Schema.string(),
851
+ text: Schema.string(),
852
+ sender: Schema.object({
853
+ baseUrl: Schema.string(),
854
+ instanceId: Schema.string(),
855
+ sessionId: Schema.string()
856
+ }),
857
+ delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]),
858
+ resume: Schema.boolean()
859
+ });
860
+ var replyPayloadSchema = Schema.object({
850
861
  sessionId: Schema.string(),
851
862
  text: Schema.string(),
852
863
  delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]),
@@ -860,12 +871,14 @@ var InterconnectService = class extends Service {
860
871
  static inject = ["webServer", "agents", "credentials"];
861
872
  static Config = Schema.object({
862
873
  instanceId: Schema.string().default("dsh"),
874
+ selfBaseUrl: Schema.string(),
863
875
  requestTimeoutMs: Schema.natural().max(6e4).default(1e4),
864
876
  peers: Schema.array(Schema.string()).default([]),
865
877
  delivery: Schema.union([Schema.const("followup"), Schema.const("steer"), Schema.const("inject")]).default("followup"),
866
878
  allowResume: Schema.boolean().default(true)
867
879
  });
868
880
  instanceId;
881
+ selfBaseUrl;
869
882
  requestTimeoutMs;
870
883
  delivery;
871
884
  allowResume;
@@ -877,9 +890,12 @@ var InterconnectService = class extends Service {
877
890
  heartbeatTimer;
878
891
  /** Peer identity each live socket announced via its `hello` frame, if any. */
879
892
  peerOf = /* @__PURE__ */ new WeakMap();
893
+ /** Sender each local session last received a send from, keyed by local session id. */
894
+ senders = /* @__PURE__ */ new Map();
880
895
  constructor(ctx, config) {
881
896
  super(ctx, "interconnect");
882
897
  this.instanceId = config.instanceId;
898
+ this.selfBaseUrl = config.selfBaseUrl;
883
899
  this.requestTimeoutMs = config.requestTimeoutMs;
884
900
  this.delivery = config.delivery ?? "followup";
885
901
  this.allowResume = config.allowResume ?? true;
@@ -950,16 +966,49 @@ var InterconnectService = class extends Service {
950
966
  this.server.close();
951
967
  }, "interconnect: websocket teardown");
952
968
  }
969
+ /**
970
+ * This instance's identity to attach to outbound messages, when configured.
971
+ * Absent (`selfBaseUrl` unset) outbound sends are not attributable and the
972
+ * peer cannot reply to them.
973
+ * @param sessionId - the local session that is sending, used as the reply target.
974
+ */
975
+ selfSender(sessionId) {
976
+ return this.selfBaseUrl === void 0 ? void 0 : { baseUrl: this.selfBaseUrl, instanceId: this.instanceId, sessionId };
977
+ }
953
978
  async send(request) {
954
979
  const payload = {
955
980
  sessionId: request.sessionId,
956
981
  text: request.text,
982
+ ...request.sender === void 0 ? {} : { sender: request.sender },
957
983
  ...request.delivery === void 0 ? {} : { delivery: request.delivery },
958
984
  ...request.resume === void 0 ? {} : { resume: request.resume }
959
985
  };
960
986
  const result = await this.post(request.baseUrl, "send", payload);
961
987
  return result ?? { delivered: false, instance: this.instanceId, reason: "unreachable" };
962
988
  }
989
+ /**
990
+ * Deliver one text message back to the peer that a local session last
991
+ * received a send from. `sessionId` names the LOCAL replying session; the
992
+ * outbound target is the `sender` that session recorded, resolved to a
993
+ * `send` against the recalled baseUrl and remote session id.
994
+ */
995
+ async reply(request) {
996
+ const sender = this.senders.get(request.sessionId);
997
+ if (sender === void 0) {
998
+ return { delivered: false, instance: this.instanceId, reason: "no-sender-known" };
999
+ }
1000
+ const payload = {
1001
+ sessionId: sender.sessionId,
1002
+ text: request.text,
1003
+ ...request.delivery === void 0 ? {} : { delivery: request.delivery },
1004
+ ...request.resume === void 0 ? {} : { resume: request.resume },
1005
+ // Attribute the reply to this instance so the peer, in turn, can reply
1006
+ // back — chaining the conversation. Absent when we have no self origin.
1007
+ ...this.selfBaseUrl === void 0 ? {} : { sender: this.selfSender(request.sessionId) }
1008
+ };
1009
+ const result = await this.post(sender.baseUrl, "send", payload);
1010
+ return result ?? { delivered: false, instance: this.instanceId, reason: "unreachable" };
1011
+ }
963
1012
  async ping(baseUrl) {
964
1013
  return this.post(baseUrl, "ping", {});
965
1014
  }
@@ -1134,6 +1183,15 @@ var InterconnectService = class extends Service {
1134
1183
  }
1135
1184
  return this.deliver(parsed);
1136
1185
  }
1186
+ if (endpoint === "reply") {
1187
+ let parsed;
1188
+ try {
1189
+ parsed = Schema.resolve(payload, replyPayloadSchema, {})[0];
1190
+ } catch (error) {
1191
+ throw new InvalidPayloadError(`reply payload invalid: ${error instanceof Error ? error.message : String(error)}`);
1192
+ }
1193
+ return this.replyForSession(parsed);
1194
+ }
1137
1195
  if (endpoint === "list") {
1138
1196
  return this.listSessions();
1139
1197
  }
@@ -1226,12 +1284,21 @@ var InterconnectService = class extends Service {
1226
1284
  if (hasApiRemoteSubagentOwner(this.ctx, agent.session, agent)) {
1227
1285
  return { delivered: false, instance: this.instanceId, reason: "session-owned-by-subagent" };
1228
1286
  }
1287
+ const sender = (() => {
1288
+ const candidate = payload.sender;
1289
+ return candidate === void 0 || typeof candidate.baseUrl !== "string" ? void 0 : candidate;
1290
+ })();
1291
+ if (sender !== void 0) {
1292
+ this.senders.set(payload.sessionId, sender);
1293
+ }
1229
1294
  const message = createUserMessage({
1230
1295
  source: {
1231
1296
  kind: "plugin",
1232
1297
  plugin: PLUGIN_SOURCE,
1233
1298
  form: "notice",
1234
- summary: boundContextSummary(`interconnect handoff delivered on instance ${this.instanceId}`)
1299
+ summary: boundContextSummary(
1300
+ sender === void 0 ? `interconnect handoff delivered on instance ${this.instanceId}` : `interconnect handoff from ${sender.instanceId} (session ${sender.sessionId}) delivered on instance ${this.instanceId}`
1301
+ )
1235
1302
  },
1236
1303
  content: [{ type: "text", text: payload.text }]
1237
1304
  });
@@ -1249,7 +1316,19 @@ var InterconnectService = class extends Service {
1249
1316
  }
1250
1317
  return { delivered: true, instance: this.instanceId, delivery: mode };
1251
1318
  }
1252
- /** POST one business call to a peer instance; undefined on transport/auth failure. */
1319
+ /**
1320
+ * Inbound `reply` dispatch: forward a request to {link reply}, resolving the
1321
+ * outbound sender for the named local session. Kept separate from {link
1322
+ * deliver} so the two wire endpoints stay distinct in the request pipeline.
1323
+ */
1324
+ async replyForSession(payload) {
1325
+ return this.reply({
1326
+ sessionId: payload.sessionId,
1327
+ text: payload.text,
1328
+ ...payload.delivery === void 0 ? {} : { delivery: payload.delivery },
1329
+ ...payload.resume === void 0 ? {} : { resume: payload.resume }
1330
+ });
1331
+ }
1253
1332
  async post(baseUrl, endpoint, payload) {
1254
1333
  const token = await this.resolveToken();
1255
1334
  if (token === void 0) return void 0;