agent-relay-server 0.102.0 → 0.102.2

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/docs/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Relay API",
5
- "version": "0.102.0",
5
+ "version": "0.102.2",
6
6
  "description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
7
7
  "license": {
8
8
  "name": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.102.0",
3
+ "version": "0.102.2",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.102.0",
4
+ "version": "0.102.2",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
@@ -284,7 +284,35 @@ function channelTargetHealth(binding: ChannelBinding, now: number = Date.now()):
284
284
  };
285
285
  }
286
286
 
287
+ // #797 — outbound (egress-only) channel liveness. Bidirectional channels (e.g. Telegram)
288
+ // have a backing pool agent that heartbeats, so their agent `lastSeen`/`status` track
289
+ // reality. Outbound-only channels (e.g. ntfy) are just a channels-host daemon SSE
290
+ // subscription with NO agent behind them to heartbeat — so the registration's `lastSeen`
291
+ // goes stale and the offline sweep flips it offline even while the daemon's subscription is
292
+ // live and delivery works. For these, liveness is "is the channels-host subscribed for this
293
+ // channel agent on the SSE bus?", not heartbeat freshness. A late-bound probe (not a direct
294
+ // import) because sse.ts imports the db barrel — sse registers this at startup, mirroring
295
+ // `onWorkspaceChange`.
296
+ type ChannelSubscriberProbe = (channelAgentId: string) => boolean;
297
+ let channelSubscriberProbe: ChannelSubscriberProbe | null = null;
298
+ export function setChannelSubscriberProbe(probe: ChannelSubscriberProbe | null): void {
299
+ channelSubscriberProbe = probe;
300
+ }
301
+ function hasActiveChannelSubscriber(channelAgentId: string): boolean {
302
+ try {
303
+ return channelSubscriberProbe ? channelSubscriberProbe(channelAgentId) : false;
304
+ } catch {
305
+ return false;
306
+ }
307
+ }
308
+
287
309
  function rowToChannelSummary(row: any): ChannelSummary {
310
+ // Outbound channels report online iff their channels-host SSE subscription is active,
311
+ // overriding the heartbeat-derived agent status/ready (which is meaningless for an
312
+ // agentless egress channel). Inbound/bidirectional keep the backing agent's signal.
313
+ const subscriberLive = row.direction === "outbound" ? hasActiveChannelSubscriber(row.agent_id) : null;
314
+ const status: ChannelSummary["status"] = subscriberLive === null ? row.agent_status : (subscriberLive ? "online" : "offline");
315
+ const ready = subscriberLive === null ? row.agent_ready === 1 : subscriberLive;
288
316
  const binding = row.binding_id ? rowToChannelBinding({
289
317
  id: row.binding_id,
290
318
  channel_id: row.id,
@@ -308,8 +336,8 @@ function rowToChannelSummary(row: any): ChannelSummary {
308
336
  transport: row.transport,
309
337
  agentId: row.agent_id,
310
338
  accountId: row.account_id,
311
- status: row.agent_status,
312
- ready: row.agent_ready === 1,
339
+ status,
340
+ ready,
313
341
  direction: row.direction,
314
342
  target: binding ? bindingTargetToLegacyTarget(binding.target) : undefined,
315
343
  binding,
@@ -15,6 +15,17 @@ const WINDOW_NOMINAL_MS: Record<ProviderQuotaBurnRole, number> = {
15
15
  // observed rate (and burnRatio) back toward 0 instead of freezing the endpoint slope.
16
16
  const RECENCY_HALFLIFE_FRACTION = 0.25;
17
17
 
18
+ // #798 — idle de-bias: once a provider's utilization has held flat for this long, any residual
19
+ // forward-burn rate is a stale-burst artifact (the recency half-life decays it, but for the 7d
20
+ // window — half-life ~42h — far too slowly: codex idle ~24h still projected ~1.07 at reset,
21
+ // walking the band boundary tick-to-tick). When idle this long the forward-burn term is collapsed
22
+ // so projectedUtilAtReset → current utilization. The threshold is absolute (NOT window-scaled): a
23
+ // 5h window resets long before reaching it, so this only de-biases the slow-decaying 7d window;
24
+ // shorter idle stretches keep the graceful #760 recency decay. The over-target avoid is preserved
25
+ // by the position penalty in spawn-targets (a stable, position-based floor), not by this rate.
26
+ const IDLE_FORWARD_COLLAPSE_MS = 6 * 3_600_000;
27
+ const IDLE_UTILIZATION_EPSILON = 1e-9;
28
+
18
29
  type QuotaWindow = QuotaState["windows"][number];
19
30
 
20
31
  // Provider-agnostic: window role is expressed by name only. Each adapter uses the
@@ -80,6 +91,19 @@ function recencyWeightedRatePerMs(
80
91
  return Math.max(0, weightedDelta / weightedDt);
81
92
  }
82
93
 
94
+ // #798 — when did utilization last differ from its current value? Scanning back from the newest
95
+ // sample, the first sample that differs marks the last change; everything after it (up to now) is
96
+ // the flat idle stretch. If every sample equals the current value the window has been flat since
97
+ // the oldest sample. Samples are assumed sorted ascending by capturedAt.
98
+ function lastUtilizationChangeAt(samples: Array<{ capturedAt: number; utilization: number }>, current: number): number {
99
+ for (let i = samples.length - 1; i >= 0; i--) {
100
+ if (Math.abs(samples[i]!.utilization - current) > IDLE_UTILIZATION_EPSILON) {
101
+ return samples[i + 1]?.capturedAt ?? samples[i]!.capturedAt;
102
+ }
103
+ }
104
+ return samples[0]?.capturedAt ?? 0;
105
+ }
106
+
83
107
  function deriveProviderQuotaBurnWindow(input: {
84
108
  provider: string;
85
109
  currentWindow: QuotaWindow;
@@ -137,17 +161,25 @@ function deriveProviderQuotaBurnWindow(input: {
137
161
  ? [...samples, { capturedAt: input.now, utilization: utilizationNow }]
138
162
  : samples;
139
163
  const observedRatePerMs = recencyWeightedRatePerMs(rateSamples, input.now, halfLifeMs);
164
+ // #798 — idle de-bias: if utilization has held flat (no change vs. now) for IDLE_FORWARD_COLLAPSE_MS,
165
+ // treat the forward-burn term as a stale-burst artifact and collapse it to 0 so the projection
166
+ // pins to current utilization instead of walking the band boundary. lastChangeAt is the moment
167
+ // utilization last differed from the current value (the synthetic "still X as of now" interval is
168
+ // what makes a deduped idle stretch observable here).
169
+ const lastChangeAt = lastUtilizationChangeAt(samples, utilizationNow);
170
+ const effectiveRate = input.now - lastChangeAt >= IDLE_FORWARD_COLLAPSE_MS ? 0 : observedRatePerMs;
140
171
  // #760 (Defect 2) — pace against a configurable target (<100%); slack carries the underburn
141
172
  // signal. The query layer (spawn-targets) may re-derive these against a per-provider target;
142
173
  // the ingest-time values use the default so direct burn consumers (dashboard) have a number.
143
174
  const pace = quotaPaceMetrics({
144
175
  utilization: utilizationNow,
145
- observedRatePerMs,
176
+ observedRatePerMs: effectiveRate,
146
177
  timeToResetMs,
147
178
  target: DEFAULT_QUOTA_TARGET_UTILIZATION,
148
179
  });
149
- if (observedRatePerMs <= 0) {
150
- // Idle (or decayed-to-idle): no projected wall, but slack still flags the headroom.
180
+ if (effectiveRate <= 0) {
181
+ // Idle (decayed-to-idle, or #798 idle-collapsed): no projected wall, but slack still flags the
182
+ // headroom (positive = underburn) or, when over target, the position the band-floor avoids.
151
183
  return {
152
184
  ...low,
153
185
  confidence: "high",
@@ -158,13 +190,13 @@ function deriveProviderQuotaBurnWindow(input: {
158
190
  target: pace.target,
159
191
  };
160
192
  }
161
- const timeToWallMs = remainingQuota / observedRatePerMs;
193
+ const timeToWallMs = remainingQuota / effectiveRate;
162
194
  const projectedWallAt = input.now + timeToWallMs;
163
195
  return {
164
196
  ...low,
165
197
  confidence: "high",
166
198
  burnRatio: pace.burnRatio,
167
- observedRatePerMs,
199
+ observedRatePerMs: effectiveRate,
168
200
  timeToWallMs,
169
201
  projectedWallAt,
170
202
  // #760 (Defect 2) — symmetric: negative means the wall lands AFTER reset (headroom left).
@@ -13,6 +13,7 @@
13
13
  import { MAX_BODY_BYTES } from "./config";
14
14
  import { getChannel, sendMessage, ValidationError } from "./db";
15
15
  import { validateChannelEnvelope } from "./routes/integrations";
16
+ import { emitNewMessage } from "./sse";
16
17
  import { optionalEnum } from "./validation";
17
18
  import type { ToolDefinition } from "./mcp/types";
18
19
  import { optionalString, optionalStringArray, recordField, stringField } from "./mcp/validation";
@@ -69,7 +70,7 @@ function emitChannelEgress(channelId: string, event: Record<string, unknown>): {
69
70
  event,
70
71
  };
71
72
  validateChannelEnvelope(payload, channel);
72
- sendMessage({
73
+ const msg = sendMessage({
73
74
  from: "system",
74
75
  to: channel.agentId,
75
76
  kind: "channel.event",
@@ -77,6 +78,7 @@ function emitChannelEgress(channelId: string, event: Record<string, unknown>): {
77
78
  payload,
78
79
  replyExpected: false,
79
80
  });
81
+ emitNewMessage(msg);
80
82
  return { accepted: true, channelId, to: channel.agentId };
81
83
  }
82
84
 
@@ -14,17 +14,36 @@
14
14
  // at most one per RECOVERY_MIN_INTERVAL_MS — degradation is the actionable
15
15
  // signal; fleet-wake on recovery churn is suppressed.
16
16
  //
17
+ // #798 — principled noisy-signal → discrete-state pipeline so a provider in a
18
+ // steady state stops re-emitting band-change notifications:
19
+ // 1. LOW-PASS (EMA): the raw penalty/slack jitters tick-to-tick (burnRatio
20
+ // recompute, shrinking timeToReset). An exponential moving average with a
21
+ // few-minute time constant is applied to the signal BEFORE it is thresholded
22
+ // into a band — killing the noise at the source, so a transient single-tick
23
+ // excursion never reaches the opposite band.
24
+ // 2. SCHMITT (applyHysteresis): enter/exit thresholds with a gap on the band
25
+ // STATE (not just the routing weight — that's where #756 fell short).
26
+ // 3. MIN-DWELL DEBOUNCE: the new band must hold DEBOUNCE_SAMPLES ticks before
27
+ // the human-facing notification fires.
28
+ // The idle-projection de-bias + position penalty (db/provider-quota-burn.ts,
29
+ // spawn-targets.ts) attack the same flap from the other end: an idle provider's
30
+ // band is now position-pinned, so the input the pipeline sees is already stable.
31
+ //
17
32
  // Called from handleProviderQuotaAdvisory in quota-advisory.ts so the check
18
33
  // runs on the same event hook as the utilization-threshold advisory — no
19
34
  // parallel delivery channel.
20
35
  import { emitRelayEvent } from "./events";
21
36
  import { routeNotification } from "./notification-router";
22
37
  import { getSpawnCapableAgentIds } from "./routing-policy-push";
23
- import { getProviderBandInfo, windowRecommendedAction } from "./spawn-targets";
38
+ import { getProviderBandInfo, quotaBandFromSignal, windowRecommendedAction } from "./spawn-targets";
24
39
  import type { ProviderBandInfo, ProviderBandWindow, QuotaBand } from "./spawn-targets";
25
40
  import type { ProviderQuotaAdvisoryInput } from "./db/provider-quotas";
26
41
 
27
42
  const DEBOUNCE_SAMPLES = 3;
43
+ // #798 — EMA time constant for the low-pass. "A few minutes": longer than the ~5-min poll-cadence
44
+ // tick jitter (so noise is smoothed), far shorter than the 5h/7d utilization trend (so a genuine
45
+ // quota move still surfaces within a poll or two). dt-based, so it is correct at any cadence.
46
+ const EMA_TAU_MS = 6 * 60_000;
28
47
  // Penalty must drop this far below the band entry threshold before recovery is recognised.
29
48
  // #760: widened 0.05 → 0.10. Live evidence (Claude 7d parked at avoidLargeAt with burn×1.4)
30
49
  // showed the 0.05 deadband was too tight for real penalty jitter — it still ping-ponged
@@ -42,6 +61,22 @@ interface TransitionState {
42
61
  pendingBand: QuotaBand | null;
43
62
  pendingCount: number;
44
63
  lastTransitionAt: number;
64
+ // #798 — low-pass state: EMA of the noisy penalty/slack signal, plus the tick time it was last
65
+ // updated (to size the dt-based smoothing factor). null until the first high-confidence sample.
66
+ emaPenalty: number | null;
67
+ emaSlack: number | null;
68
+ emaUpdatedAt: number;
69
+ }
70
+
71
+ // #798 — dt-based EMA blend factor: 1 − e^(−dt/τ). dt = 0 (or first sample) → seed verbatim.
72
+ function emaAlpha(dtMs: number): number {
73
+ if (!(dtMs > 0)) return 1;
74
+ return 1 - Math.exp(-dtMs / EMA_TAU_MS);
75
+ }
76
+
77
+ function emaStep(prev: number | null, value: number, dtMs: number): number {
78
+ if (prev === null) return value;
79
+ return prev + emaAlpha(dtMs) * (value - prev);
45
80
  }
46
81
 
47
82
  const bandState = new Map<string, TransitionState>();
@@ -73,17 +108,33 @@ export function processBandTransitions(input: ProviderQuotaAdvisoryInput): void
73
108
  pendingBand: null,
74
109
  pendingCount: 0,
75
110
  lastTransitionAt: 0,
111
+ emaPenalty: null,
112
+ emaSlack: null,
113
+ emaUpdatedAt: 0,
76
114
  };
77
115
 
78
- // #756: asymmetric hysteresisrecovery needs penalty HYSTERESIS_MARGIN below the entry
79
- // threshold of the current band to prevent ping-pong at a boundary. #760 extends this with a
80
- // slack deadband for the "prefer" boundary.
81
- const effectiveBand = applyHysteresis(window.band, window.penalty, window.slack, state.lastNotifiedBand, info.quotaBands, info.preferSlackAt);
116
+ // #798 (layer 1)low-pass the noisy penalty/slack BEFORE thresholding, then re-classify the
117
+ // smoothed signal into a band. A transient one-tick excursion is averaged out, so it never
118
+ // reaches (and re-emits) the opposite band; a sustained move still crosses within a poll or two.
119
+ const dtMs = state.emaUpdatedAt > 0 ? input.now - state.emaUpdatedAt : 0;
120
+ const emaPenalty = emaStep(state.emaPenalty, window.penalty, dtMs);
121
+ const emaSlack = window.slack === null ? state.emaSlack : emaStep(state.emaSlack, window.slack, dtMs);
122
+ state.emaPenalty = emaPenalty;
123
+ state.emaSlack = emaSlack;
124
+ state.emaUpdatedAt = input.now;
125
+ // Persist immediately so the low-pass state survives every early-continue below (the state is
126
+ // then mutated in place by reference — the EMA must carry across ticks to smooth excursions).
127
+ bandState.set(key, state);
128
+ const smoothedBand = quotaBandFromSignal(emaPenalty, emaSlack, info.quotaBands, info.preferSlackAt);
129
+
130
+ // #756 (layer 2): asymmetric hysteresis — recovery needs penalty HYSTERESIS_MARGIN below the
131
+ // entry threshold of the current band to prevent ping-pong at a boundary. #760 extends this with
132
+ // a slack deadband for the "prefer" boundary. Operates on the smoothed signal.
133
+ const effectiveBand = applyHysteresis(smoothedBand, emaPenalty, emaSlack, state.lastNotifiedBand, info.quotaBands, info.preferSlackAt);
82
134
 
83
135
  if (effectiveBand === state.lastNotifiedBand) {
84
- if (state.pendingBand !== null) {
85
- bandState.set(key, { ...state, pendingBand: null, pendingCount: 0 });
86
- }
136
+ state.pendingBand = null;
137
+ state.pendingCount = 0;
87
138
  continue;
88
139
  }
89
140
 
@@ -231,8 +231,7 @@ function spawnQuotaWindow(
231
231
  // recency-weighted rate projects no early wall) replaces the blunt utilization floor, which
232
232
  // used to hide a legitimate low-util-high-burst alarm. Operators may still opt back in.
233
233
  const utilizationFloor = providerCfg?.burnPenaltyUtilizationFloor ?? 0;
234
- const rawPenalty = (earlyFraction ?? 0) * weight;
235
- const penalty = quotaWindow.utilization < utilizationFloor ? 0 : rawPenalty;
234
+ const pacePenalty = (earlyFraction ?? 0) * weight;
236
235
 
237
236
  // #760 — re-derive the pace projection against the PER-PROVIDER target (the stored burn used
238
237
  // the default). Slack is the symmetric underburn signal; prefer flags a window with no
@@ -249,6 +248,19 @@ function spawnQuotaWindow(
249
248
  projectedUtilAtReset = pace.projectedUtilAtReset;
250
249
  slack = pace.slack;
251
250
  }
251
+
252
+ // #798 — position penalty: an idle provider whose (de-biased) projection lands AT/OVER target is
253
+ // already over-extended, but its forward-wall pace penalty is 0 (no burn) — so without this the
254
+ // band would flip to "normal" and route big jobs onto a near-exhausted provider. Derive a STABLE
255
+ // penalty from the overshoot magnitude (slack < 0 ⇒ projectedUtilAtReset − target), decoupled
256
+ // from the jittery forward-burn term. Gated to idle (observedRatePerMs collapsed to 0): an ACTIVE
257
+ // provider is still governed entirely by the pace penalty (effective = max of the two), so this
258
+ // adds a floor for the parked-high case WITHOUT changing active routing. Under-target idle ⇒ 0.
259
+ const idle = burnWindow.observedRatePerMs !== null && burnWindow.observedRatePerMs <= 0;
260
+ const overshoot = slack !== null ? Math.max(0, -slack) : 0;
261
+ const positionPenalty = idle ? Math.min(1, overshoot / Math.max(1e-9, 1 - target)) * weight : 0;
262
+ const rawPenalty = Math.max(pacePenalty, positionPenalty);
263
+ const penalty = quotaWindow.utilization < utilizationFloor ? 0 : rawPenalty;
252
264
  const prefer = !blockedNow && penalty === 0
253
265
  && quotaPenaltyBandFor(penalty) === "normal"
254
266
  && slack !== null && slack >= preferSlackAt;
@@ -349,6 +361,22 @@ function quotaPenaltyBandFor(
349
361
  return "normal";
350
362
  }
351
363
 
364
+ // #798 — classify a (penalty, slack) signal into a band. The single home for the
365
+ // penalty-ladder + prefer-overlay mapping, shared by getProviderBandInfo's per-window band and the
366
+ // band-transition notifier, which re-classifies the EMA-smoothed signal before thresholding. The
367
+ // prefer overlay mirrors spawnQuotaWindow: a normal-band window projected ≥ preferSlackAt under
368
+ // target reads "prefer".
369
+ export function quotaBandFromSignal(
370
+ penalty: number,
371
+ slack: number | null,
372
+ quotaBands: Partial<{ cautionAt: number; avoidLargeAt: number; hardAvoidAt: number }> | undefined,
373
+ preferSlackAt: number,
374
+ ): QuotaBand {
375
+ const base = quotaPenaltyBandFor(penalty, quotaBands);
376
+ if (base === "normal" && slack !== null && slack >= preferSlackAt) return "prefer";
377
+ return base;
378
+ }
379
+
352
380
  function recommendedAction(input: { quotaPenaltyBand: SpawnQuotaBurnBand; blockedNow: boolean; prefer: boolean; slack: number | null; windows: SpawnQuotaBurnWindow[] }): string {
353
381
  if (input.blockedNow) {
354
382
  const hasSevenDayBlock = input.windows.some((window) => window.role === "7d" && window.blockedNow);
package/src/sse.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { getAgent, getOrchestrator, matchingDeliveryAgents, onWorkspaceChange, sendMessageWithResult } from "./db";
1
+ import { getAgent, getOrchestrator, matchingDeliveryAgents, onWorkspaceChange, sendMessageWithResult, setChannelSubscriberProbe } from "./db";
2
2
  import { projectAgentStatusData } from "./agent-card-projection";
3
3
  import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
4
4
  import { messageMatchesAgent, targetMatchesAgent } from "./agent-ref";
@@ -20,6 +20,17 @@ const SSE_RETRY_MS = 5_000;
20
20
 
21
21
  subscribeRelayEvents((event) => fanout(event));
22
22
 
23
+ // #797 — let the db layer derive outbound-channel liveness from live SSE subscriptions.
24
+ // A channel agent is "subscribed" iff the channels-host daemon holds an open
25
+ // GET /api/events?for=<channelAgentId> stream (createSSEStream stores `for` as agentId).
26
+ export function hasActiveSubscriber(agentId: string): boolean {
27
+ for (const conn of connections.values()) {
28
+ if (conn.agentId === agentId) return true;
29
+ }
30
+ return false;
31
+ }
32
+ setChannelSubscriberProbe(hasActiveSubscriber);
33
+
23
34
  export function createSSEStream(agentId: string | null): Response {
24
35
  let connId: string;
25
36