agent-relay-sdk 0.1.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.
@@ -0,0 +1,286 @@
1
+ export interface BusFrame {
2
+ type: string;
3
+ id?: string;
4
+ payload: unknown;
5
+ }
6
+
7
+ export type BusRole = "provider" | "channel" | "orchestrator" | "integration";
8
+ export type BusAgentStatus = "idle" | "busy" | "online" | "offline";
9
+
10
+ export interface RegisterFrame extends BusFrame {
11
+ type: "register";
12
+ id: string;
13
+ payload: {
14
+ role: BusRole;
15
+ componentId: string;
16
+ agentId?: string;
17
+ instanceId: string;
18
+ capabilities: string[];
19
+ tags: string[];
20
+ machine: string;
21
+ meta: Record<string, unknown>;
22
+ };
23
+ }
24
+
25
+ export interface HeartbeatFrame extends BusFrame {
26
+ type: "heartbeat";
27
+ payload: {
28
+ status: Exclude<BusAgentStatus, "offline">;
29
+ meta?: Record<string, unknown>;
30
+ };
31
+ }
32
+
33
+ export interface CommandFrame extends BusFrame {
34
+ type: "command";
35
+ id: string;
36
+ payload: {
37
+ commandType: string;
38
+ target: string;
39
+ params: Record<string, unknown>;
40
+ };
41
+ }
42
+
43
+ export interface SubscribeFrame extends BusFrame {
44
+ type: "subscribe";
45
+ id: string;
46
+ payload: {
47
+ events: string[];
48
+ scopes?: string[];
49
+ };
50
+ }
51
+
52
+ export interface StatusFrame extends BusFrame {
53
+ type: "status";
54
+ payload: {
55
+ agentStatus: BusAgentStatus;
56
+ ready?: boolean;
57
+ meta?: Record<string, unknown>;
58
+ };
59
+ }
60
+
61
+ export interface AckFrame extends BusFrame {
62
+ type: "ack";
63
+ payload: {
64
+ frameId: string;
65
+ };
66
+ }
67
+
68
+ export interface ResumeFrame extends BusFrame {
69
+ type: "resume";
70
+ id: string;
71
+ payload: {
72
+ since: number;
73
+ };
74
+ }
75
+
76
+ export interface RegisteredFrame extends BusFrame {
77
+ type: "registered";
78
+ payload: {
79
+ epoch: number;
80
+ cursor: number;
81
+ sessionId: string;
82
+ };
83
+ }
84
+
85
+ export interface EventPayload {
86
+ seq: number;
87
+ eventType: string;
88
+ source: string;
89
+ subject?: string;
90
+ data: Record<string, unknown>;
91
+ timestamp: number;
92
+ }
93
+
94
+ export interface EventFrame extends BusFrame {
95
+ type: "event";
96
+ id?: string;
97
+ payload: EventPayload;
98
+ }
99
+
100
+ export interface CommandResultFrame extends BusFrame {
101
+ type: "command.result";
102
+ payload: {
103
+ commandId: string;
104
+ status: "succeeded" | "failed" | "rejected" | "timed_out";
105
+ result?: Record<string, unknown>;
106
+ error?: string;
107
+ };
108
+ }
109
+
110
+ export interface ResumedFrame extends BusFrame {
111
+ type: "resumed";
112
+ payload: {
113
+ events: EventPayload[];
114
+ fromSeq: number;
115
+ toSeq: number;
116
+ };
117
+ }
118
+
119
+ export interface ErrorFrame extends BusFrame {
120
+ type: "error";
121
+ payload: {
122
+ frameId?: string;
123
+ code: string;
124
+ message: string;
125
+ };
126
+ }
127
+
128
+ export type ClientBusFrame =
129
+ | RegisterFrame
130
+ | HeartbeatFrame
131
+ | CommandFrame
132
+ | SubscribeFrame
133
+ | StatusFrame
134
+ | AckFrame
135
+ | ResumeFrame;
136
+
137
+ export type ServerBusFrame =
138
+ | RegisteredFrame
139
+ | EventFrame
140
+ | CommandResultFrame
141
+ | ResumedFrame
142
+ | ErrorFrame;
143
+
144
+ export class BusProtocolError extends Error {
145
+ constructor(
146
+ readonly code: string,
147
+ message: string,
148
+ ) {
149
+ super(message);
150
+ }
151
+ }
152
+
153
+ export function isRegisterFrame(f: BusFrame): f is RegisterFrame { return f.type === "register"; }
154
+ export function isHeartbeatFrame(f: BusFrame): f is HeartbeatFrame { return f.type === "heartbeat"; }
155
+ export function isCommandFrame(f: BusFrame): f is CommandFrame { return f.type === "command"; }
156
+ export function isSubscribeFrame(f: BusFrame): f is SubscribeFrame { return f.type === "subscribe"; }
157
+ export function isStatusFrame(f: BusFrame): f is StatusFrame { return f.type === "status"; }
158
+ export function isAckFrame(f: BusFrame): f is AckFrame { return f.type === "ack"; }
159
+ export function isResumeFrame(f: BusFrame): f is ResumeFrame { return f.type === "resume"; }
160
+
161
+ export function parseBusFrame(data: string | ArrayBuffer | Uint8Array): BusFrame {
162
+ const text = typeof data === "string" ? data : new TextDecoder().decode(data);
163
+ let parsed: unknown;
164
+ try {
165
+ parsed = JSON.parse(text);
166
+ } catch {
167
+ throw new BusProtocolError("INVALID_JSON", "frame must be valid JSON");
168
+ }
169
+ if (!isRecord(parsed) || typeof parsed.type !== "string") {
170
+ throw new BusProtocolError("INVALID_FRAME", "frame must be an object with a string type");
171
+ }
172
+ if (!("payload" in parsed)) {
173
+ throw new BusProtocolError("INVALID_FRAME", "frame payload is required");
174
+ }
175
+ if ("id" in parsed && parsed.id !== undefined && typeof parsed.id !== "string") {
176
+ throw new BusProtocolError("INVALID_FRAME", "frame id must be a string");
177
+ }
178
+ return parsed as unknown as BusFrame;
179
+ }
180
+
181
+ export function validateClientFrame(frame: BusFrame): ClientBusFrame {
182
+ switch (frame.type) {
183
+ case "register":
184
+ requireId(frame);
185
+ requireRegisterPayload(frame.payload);
186
+ return frame as RegisterFrame;
187
+ case "heartbeat":
188
+ requireHeartbeatPayload(frame.payload);
189
+ return frame as HeartbeatFrame;
190
+ case "command":
191
+ requireId(frame);
192
+ requireCommandPayload(frame.payload);
193
+ return frame as CommandFrame;
194
+ case "subscribe":
195
+ requireId(frame);
196
+ requireSubscribePayload(frame.payload);
197
+ return frame as SubscribeFrame;
198
+ case "status":
199
+ requireStatusPayload(frame.payload);
200
+ return frame as StatusFrame;
201
+ case "ack":
202
+ requireAckPayload(frame.payload);
203
+ return frame as AckFrame;
204
+ case "resume":
205
+ requireId(frame);
206
+ requireResumePayload(frame.payload);
207
+ return frame as ResumeFrame;
208
+ default:
209
+ throw new BusProtocolError("UNKNOWN_FRAME", `unsupported frame type: ${frame.type}`);
210
+ }
211
+ }
212
+
213
+ function requireId(frame: BusFrame): asserts frame is BusFrame & { id: string } {
214
+ if (!frame.id) throw new BusProtocolError("MISSING_ID", `${frame.type} frame id is required`);
215
+ }
216
+
217
+ function requireRegisterPayload(payload: unknown): void {
218
+ if (!isRecord(payload)) throw invalidPayload("register");
219
+ if (!isRole(payload.role)) throw invalidPayload("register.role");
220
+ requireString(payload.componentId, "register.componentId");
221
+ requireString(payload.instanceId, "register.instanceId");
222
+ if (payload.agentId !== undefined) requireString(payload.agentId, "register.agentId");
223
+ requireStringArray(payload.capabilities, "register.capabilities");
224
+ requireStringArray(payload.tags, "register.tags");
225
+ requireString(payload.machine, "register.machine");
226
+ if (!isRecord(payload.meta)) throw invalidPayload("register.meta");
227
+ }
228
+
229
+ function requireHeartbeatPayload(payload: unknown): void {
230
+ if (!isRecord(payload) || !["idle", "busy", "online"].includes(String(payload.status))) {
231
+ throw invalidPayload("heartbeat");
232
+ }
233
+ if (payload.meta !== undefined && !isRecord(payload.meta)) throw invalidPayload("heartbeat.meta");
234
+ }
235
+
236
+ function requireCommandPayload(payload: unknown): void {
237
+ if (!isRecord(payload)) throw invalidPayload("command");
238
+ requireString(payload.commandType, "command.commandType");
239
+ requireString(payload.target, "command.target");
240
+ if (!isRecord(payload.params)) throw invalidPayload("command.params");
241
+ }
242
+
243
+ function requireSubscribePayload(payload: unknown): void {
244
+ if (!isRecord(payload)) throw invalidPayload("subscribe");
245
+ requireStringArray(payload.events, "subscribe.events");
246
+ if (payload.scopes !== undefined) requireStringArray(payload.scopes, "subscribe.scopes");
247
+ }
248
+
249
+ function requireStatusPayload(payload: unknown): void {
250
+ if (!isRecord(payload) || !["idle", "busy", "online", "offline"].includes(String(payload.agentStatus))) {
251
+ throw invalidPayload("status");
252
+ }
253
+ if (payload.ready !== undefined && typeof payload.ready !== "boolean") throw invalidPayload("status.ready");
254
+ if (payload.meta !== undefined && !isRecord(payload.meta)) throw invalidPayload("status.meta");
255
+ }
256
+
257
+ function requireAckPayload(payload: unknown): void {
258
+ if (!isRecord(payload)) throw invalidPayload("ack");
259
+ requireString(payload.frameId, "ack.frameId");
260
+ }
261
+
262
+ function requireResumePayload(payload: unknown): void {
263
+ if (!isRecord(payload) || typeof payload.since !== "number" || !Number.isSafeInteger(payload.since) || payload.since < 0) {
264
+ throw invalidPayload("resume.since");
265
+ }
266
+ }
267
+
268
+ function requireString(value: unknown, field: string): void {
269
+ if (typeof value !== "string" || value.length === 0) throw invalidPayload(field);
270
+ }
271
+
272
+ function requireStringArray(value: unknown, field: string): void {
273
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) throw invalidPayload(field);
274
+ }
275
+
276
+ function invalidPayload(field: string): BusProtocolError {
277
+ return new BusProtocolError("INVALID_PAYLOAD", `invalid ${field} payload`);
278
+ }
279
+
280
+ function isRole(value: unknown): value is BusRole {
281
+ return value === "provider" || value === "channel" || value === "orchestrator" || value === "integration";
282
+ }
283
+
284
+ function isRecord(value: unknown): value is Record<string, unknown> {
285
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
286
+ }
@@ -0,0 +1,101 @@
1
+ import { RelayBusClient } from "./bus-client.js";
2
+ import type { Message } from "./types.js";
3
+
4
+ export interface ProviderAdapter {
5
+ readonly providerName: string;
6
+ readonly capabilities: string[];
7
+ deliverMessage(agentId: string, messages: Message[]): Promise<void>;
8
+ getAgentStatus(agentId: string): Promise<"idle" | "busy">;
9
+ spawn?(config: SpawnConfig): Promise<SpawnResult>;
10
+ shutdown?(agentId: string): Promise<void>;
11
+ formatDelivery?(messages: Message[]): string;
12
+ }
13
+
14
+ export interface SpawnConfig {
15
+ cwd: string;
16
+ label?: string;
17
+ approvalMode?: string;
18
+ prompt?: string;
19
+ env?: Record<string, string>;
20
+ }
21
+
22
+ export interface SpawnResult {
23
+ agentId: string;
24
+ pid?: number;
25
+ sessionId?: string;
26
+ }
27
+
28
+ export interface ProviderBaseOptions {
29
+ relayUrl: string;
30
+ adapter: ProviderAdapter;
31
+ agentId: string;
32
+ instanceId: string;
33
+ machine: string;
34
+ tags?: string[];
35
+ meta?: Record<string, unknown>;
36
+ token?: string;
37
+ }
38
+
39
+ export class ProviderBase {
40
+ readonly client: RelayBusClient;
41
+ private running = false;
42
+ private readonly pending = new Map<number, Message>();
43
+ private drain?: Timer;
44
+
45
+ constructor(private readonly options: ProviderBaseOptions) {
46
+ this.client = new RelayBusClient({
47
+ url: options.relayUrl,
48
+ role: "provider",
49
+ componentId: `${options.adapter.providerName}:${options.agentId}`,
50
+ agentId: options.agentId,
51
+ instanceId: options.instanceId,
52
+ token: options.token,
53
+ capabilities: options.adapter.capabilities,
54
+ tags: options.tags ?? [options.adapter.providerName],
55
+ machine: options.machine,
56
+ meta: { provider: options.adapter.providerName, ...(options.meta ?? {}) },
57
+ });
58
+ }
59
+
60
+ async run(): Promise<void> {
61
+ if (this.running) return;
62
+ this.running = true;
63
+ this.client.on("message.new", (message) => this.queueMessage(message as Message));
64
+ await this.client.connect();
65
+ this.startStatusLoop();
66
+ }
67
+
68
+ async stop(): Promise<void> {
69
+ this.running = false;
70
+ if (this.drain) clearInterval(this.drain);
71
+ this.drain = undefined;
72
+ await this.client.close();
73
+ }
74
+
75
+ private queueMessage(message: Message): void {
76
+ if (message.to !== this.options.agentId && message.to !== "broadcast" && !message.to.startsWith("tag:") && !message.to.startsWith("cap:") && !message.to.startsWith("label:")) {
77
+ return;
78
+ }
79
+ this.pending.set(message.id, message);
80
+ void this.drainMessages();
81
+ }
82
+
83
+ private async drainMessages(): Promise<void> {
84
+ if (!this.running || this.pending.size === 0) return;
85
+ const messages = [...this.pending.values()].sort((a, b) => a.id - b.id);
86
+ this.pending.clear();
87
+ await this.options.adapter.deliverMessage(this.options.agentId, messages);
88
+ }
89
+
90
+ private startStatusLoop(): void {
91
+ this.drain = setInterval(async () => {
92
+ if (!this.running) return;
93
+ try {
94
+ const status = await this.options.adapter.getAgentStatus(this.options.agentId);
95
+ this.client.status({ agentStatus: status, ready: true });
96
+ } catch {
97
+ this.client.status({ agentStatus: "online", ready: false });
98
+ }
99
+ }, 30_000);
100
+ }
101
+ }
@@ -0,0 +1,62 @@
1
+ import { setTimeout as delay } from "node:timers/promises";
2
+
3
+ export interface ReconnectOptions {
4
+ initialMs: number;
5
+ maxMs: number;
6
+ jitterMs: number;
7
+ }
8
+
9
+ const DEFAULTS: ReconnectOptions = {
10
+ initialMs: 1000,
11
+ maxMs: 30_000,
12
+ jitterMs: 500,
13
+ };
14
+
15
+ export class ReconnectionManager {
16
+ private attempts = 0;
17
+ private abort?: AbortController;
18
+ private readonly options: ReconnectOptions;
19
+
20
+ constructor(options: Partial<ReconnectOptions> = {}) {
21
+ this.options = {
22
+ initialMs: options.initialMs ?? DEFAULTS.initialMs,
23
+ maxMs: options.maxMs ?? DEFAULTS.maxMs,
24
+ jitterMs: options.jitterMs ?? DEFAULTS.jitterMs,
25
+ };
26
+ }
27
+
28
+ get attempt(): number {
29
+ return this.attempts;
30
+ }
31
+
32
+ nextDelay(): number {
33
+ const base = Math.min(
34
+ this.options.maxMs,
35
+ this.options.initialMs * 2 ** this.attempts,
36
+ );
37
+ this.attempts += 1;
38
+ const jitter = this.options.jitterMs > 0
39
+ ? Math.floor(Math.random() * (this.options.jitterMs + 1))
40
+ : 0;
41
+ return Math.min(this.options.maxMs, base + jitter);
42
+ }
43
+
44
+ reset(): void {
45
+ this.attempts = 0;
46
+ this.cancel();
47
+ }
48
+
49
+ async schedule(): Promise<void> {
50
+ this.cancel();
51
+ this.abort = new AbortController();
52
+ const ms = this.nextDelay();
53
+ await delay(ms, undefined, { signal: this.abort.signal }).catch((error) => {
54
+ if ((error as { name?: string }).name !== "AbortError") throw error;
55
+ });
56
+ }
57
+
58
+ cancel(): void {
59
+ this.abort?.abort();
60
+ this.abort = undefined;
61
+ }
62
+ }