agent-relay-server 0.31.0 → 0.31.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.31.0",
3
+ "version": "0.31.1",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
package/src/agent-ref.ts CHANGED
@@ -105,6 +105,24 @@ export function matchAgents(ref: string, agents: AgentCard[], opts: ResolveOptio
105
105
  return pool.filter((a) => idMatchesSegment(a.id, trimmed));
106
106
  }
107
107
 
108
+ /** A channel/bridge agent (Telegram, Slack, …) — the canonical endpoint for its provider name. */
109
+ export function isChannelAgent(agent: AgentCard): boolean {
110
+ return agent.kind === "channel" || (agent.meta?.kind as unknown) === "channel";
111
+ }
112
+
113
+ /**
114
+ * A channel agent is the canonical OWNER of its provider/account name, so a bare ref that
115
+ * names a channel ("telegram") must resolve to that channel even when provider agents in the
116
+ * channel's pool carry the same string as a label/tag. Without this, the bare channel name
117
+ * collides with its own pool and resolves to an arbitrary live pool member — worst case the
118
+ * sender itself — so the bridge never receives the message and it never reaches the chat
119
+ * (#290). Returns the single channel in `candidates`, or null if there isn't exactly one.
120
+ */
121
+ function soleChannel(candidates: AgentCard[]): AgentCard | null {
122
+ const channels = candidates.filter(isChannelAgent);
123
+ return channels.length === 1 ? channels[0]! : null;
124
+ }
125
+
108
126
  /**
109
127
  * Resolve `ref` to a SINGLE agent. Prefers online matches; reports ambiguity rather
110
128
  * than guessing. On not_found, returns any offline matches so callers can say "exists
@@ -116,8 +134,15 @@ export function resolveAgentRef(ref: string, agents: AgentCard[], opts: ResolveO
116
134
  if (matches.length === 0) return { status: "not_found", offlineMatches: [] };
117
135
 
118
136
  const online = matches.filter((a) => isAgentOnline(a, now));
137
+ // Channel precedence: when a bare ref matches a channel, the channel wins over provider
138
+ // agents that merely share its name — both when live and (so the caller reports "bridge
139
+ // offline" rather than misrouting to a pool member) when momentarily down.
140
+ const onlineChannel = soleChannel(online);
141
+ if (onlineChannel) return { status: "resolved", agent: onlineChannel };
119
142
  if (online.length === 1) return { status: "resolved", agent: online[0]! };
120
143
  if (online.length > 1) return { status: "ambiguous", candidates: online };
144
+ const offlineChannel = soleChannel(matches);
145
+ if (offlineChannel) return { status: "not_found", offlineMatches: [offlineChannel] };
121
146
  return { status: "not_found", offlineMatches: matches };
122
147
  }
123
148
 
@@ -193,7 +218,8 @@ function notFoundMessage(ref: string, agents: AgentCard[]): string {
193
218
  * a live recipient exists; fan-out targets report how many online members they reach;
194
219
  * reserved/policy targets pass through unchanged.
195
220
  */
196
- export function planSend(to: string, agents: AgentCard[], now: number = Date.now()): SendPlan {
221
+ export function planSend(to: string, agents: AgentCard[], opts: ResolveOptions = {}): SendPlan {
222
+ const now = opts.now ?? Date.now();
197
223
  const target = to.trim();
198
224
 
199
225
  if (target === "broadcast") {
@@ -209,8 +235,9 @@ export function planSend(to: string, agents: AgentCard[], now: number = Date.now
209
235
  return { kind: "passthrough", to: target, receipt: { delivered: true, expectReply: true, recipients: [target] } };
210
236
  }
211
237
 
212
- // Direct single-agent reference.
213
- const resolved = resolveAgentRef(target, agents, { now });
238
+ // Direct single-agent reference. `excludeId` (the sender) keeps a bare ref from ever
239
+ // resolving back to its own author — a self-loop silently swallows the message (#290).
240
+ const resolved = resolveAgentRef(target, agents, opts);
214
241
  if (resolved.status === "resolved") {
215
242
  return { kind: "direct", to: resolved.agent.id, receipt: { delivered: true, expectReply: true, recipients: [resolved.agent.id] } };
216
243
  }
package/src/mcp.ts CHANGED
@@ -564,13 +564,15 @@ function relaySendMessage(auth: McpAuthContext, args: Record<string, unknown>):
564
564
  const attachments = optionalAttachments(args.attachments);
565
565
  const payload = payloadWithAttachments(optionalRecord(args.payload, "payload"), attachments);
566
566
  const requestedTo = stringField(args.to, "to", { required: true, max: 200 });
567
+ const sender = resolveSender(auth, args.from);
567
568
  // Resolve the target to a canonical agent id (so poll-time matching works) and refuse
568
569
  // up front when it's unknown or ambiguous — never store a message no one will receive.
569
- const plan = planSend(requestedTo, listAgents());
570
+ // Exclude the sender so a bare ref can't loop back to its own author (#290).
571
+ const plan = planSend(requestedTo, listAgents(), { excludeId: sender });
570
572
  if (plan.kind === "not_found") throw new McpNotFoundError(plan.message);
571
573
  if (plan.kind === "ambiguous") throw new ValidationError(plan.message);
572
574
  const input: SendMessageInput = {
573
- from: resolveSender(auth, args.from),
575
+ from: sender,
574
576
  to: plan.to,
575
577
  body: stringField(args.body, "body", { required: true, maxBytes: MAX_BODY_BYTES }),
576
578
  subject: optionalString(args.subject, "subject", 200),
@@ -617,7 +619,7 @@ function relayReply(auth: McpAuthContext, args: Record<string, unknown>): Messag
617
619
  emitMessage(result.message, result.created);
618
620
  // Reply routing is fixed to the parent's sender — never reject, but report whether
619
621
  // that original sender is still reachable so the agent doesn't wait forever.
620
- const plan = planSend(input.to, listAgents());
622
+ const plan = planSend(input.to, listAgents(), { excludeId: input.from });
621
623
  const delivery: DeliveryReceipt = plan.kind === "not_found" || plan.kind === "ambiguous"
622
624
  ? { delivered: false, expectReply: false, recipients: [], reason: "original sender no longer reachable" }
623
625
  : plan.receipt;
package/src/routes.ts CHANGED
@@ -5222,7 +5222,9 @@ const postMessage: Handler = async (req) => {
5222
5222
  // from the provider transcript and stored for the dashboard chat; it must persist
5223
5223
  // regardless of target liveness and never be re-delivered into a session.
5224
5224
  if (!isMechanicalMessageKind(input.kind)) {
5225
- const plan = planSend(input.to, listAgents());
5225
+ // excludeId: a bare ref must never resolve back to its own author — that self-loop
5226
+ // silently swallows the message (the reddit-briefing → telegram bridge break, #290).
5227
+ const plan = planSend(input.to, listAgents(), { excludeId: input.from });
5226
5228
  if (plan.kind === "ambiguous") return error(plan.message, 409);
5227
5229
  if (plan.kind !== "not_found") input.to = plan.to;
5228
5230
  // Long-standing guard: refuse a direct send to a known-offline agent (now also