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 +1 -1
- package/src/agent-ref.ts +30 -3
- package/src/mcp.ts +5 -3
- package/src/routes.ts +3 -1
package/package.json
CHANGED
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[],
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|