agent-relay-server 0.102.1 → 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 +1 -1
- package/package.json +1 -1
- package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
- package/src/db/channels.ts +30 -2
- package/src/db/provider-quota-burn.ts +37 -5
- package/src/quota-band-transition.ts +59 -8
- package/src/spawn-targets.ts +30 -2
- package/src/sse.ts +12 -1
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.
|
|
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
package/src/db/channels.ts
CHANGED
|
@@ -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
|
|
312
|
-
ready
|
|
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 (
|
|
150
|
-
// Idle (
|
|
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 /
|
|
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).
|
|
@@ -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
|
-
// #
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
const
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
}
|
|
136
|
+
state.pendingBand = null;
|
|
137
|
+
state.pendingCount = 0;
|
|
87
138
|
continue;
|
|
88
139
|
}
|
|
89
140
|
|
package/src/spawn-targets.ts
CHANGED
|
@@ -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
|
|
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
|
|