@agentlinkdev/agentlink 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,142 @@
1
+ import mqtt from "mqtt";
2
+ import type { AgentLinkConfig } from "./types.js";
3
+
4
+ export interface PublishOptions {
5
+ retain?: boolean;
6
+ qos?: 0 | 1 | 2;
7
+ }
8
+
9
+ export interface MqttClient {
10
+ connect(): Promise<void>;
11
+ disconnect(): Promise<void>;
12
+ subscribe(topic: string): Promise<void>;
13
+ unsubscribe(topic: string): Promise<void>;
14
+ publish(topic: string, payload: string | Buffer, options?: PublishOptions): Promise<void>;
15
+ onMessage(handler: (topic: string, payload: Buffer) => void): void;
16
+ isConnected(): boolean;
17
+ }
18
+
19
+ export interface Logger {
20
+ info: (message: string) => void;
21
+ warn: (message: string) => void;
22
+ error: (message: string) => void;
23
+ }
24
+
25
+ export function createMqttClient(config: AgentLinkConfig, logger: Logger): MqttClient {
26
+ let client: mqtt.MqttClient | null = null;
27
+ const messageHandlers: Array<(topic: string, payload: Buffer) => void> = [];
28
+ let subscribedTopics: Set<string> = new Set();
29
+
30
+ return {
31
+ async connect() {
32
+ client = mqtt.connect(config.brokerUrl, {
33
+ username: config.brokerUsername,
34
+ password: config.brokerPassword,
35
+ clientId: `agentlink-${config.agent.id}-${Date.now()}`,
36
+ clean: true,
37
+ keepalive: 30,
38
+ reconnectPeriod: 5000,
39
+ connectTimeout: 10_000,
40
+ });
41
+
42
+ return new Promise<void>((resolve) => {
43
+ let resolved = false;
44
+
45
+ client!.on("connect", () => {
46
+ if (!resolved) {
47
+ resolved = true;
48
+ logger.info(`[AgentLink] Connected to broker: ${config.brokerUrl}`);
49
+ resolve();
50
+ } else {
51
+ logger.info(`[AgentLink] Reconnected to broker`);
52
+ }
53
+ // Resubscribe on every connect (clean:true means broker forgets subscriptions)
54
+ for (const topic of subscribedTopics) {
55
+ client!.subscribe(topic, { qos: 1 });
56
+ }
57
+ });
58
+
59
+ client!.on("error", (err: Error) => {
60
+ if (!resolved) {
61
+ // First connection attempt failed — start anyway, mqtt.js will keep retrying
62
+ resolved = true;
63
+ logger.warn(`[AgentLink] Broker unavailable (${err.message}), will retry in background`);
64
+ resolve();
65
+ } else {
66
+ logger.warn(`[AgentLink] MQTT error: ${err.message}`);
67
+ }
68
+ });
69
+
70
+ client!.on("message", (topic, payload) => {
71
+ for (const handler of messageHandlers) {
72
+ handler(topic, payload);
73
+ }
74
+ });
75
+
76
+ client!.on("reconnect", () => {
77
+ logger.info("[AgentLink] Reconnecting to broker...");
78
+ });
79
+
80
+ client!.on("offline", () => {
81
+ logger.warn("[AgentLink] Broker connection lost");
82
+ });
83
+ });
84
+ },
85
+
86
+ async disconnect() {
87
+ if (client) {
88
+ subscribedTopics.clear();
89
+ await new Promise<void>((resolve) => client!.end(false, {}, () => resolve()));
90
+ client = null;
91
+ }
92
+ },
93
+
94
+ async subscribe(topic) {
95
+ if (!client) throw new Error("MQTT client not initialized");
96
+ subscribedTopics.add(topic);
97
+ // If not connected, the topic is tracked and will be subscribed on (re)connect
98
+ if (!client.connected) return;
99
+ return new Promise<void>((resolve, reject) => {
100
+ client!.subscribe(topic, { qos: 1 }, (err) => {
101
+ if (err) reject(err);
102
+ else resolve();
103
+ });
104
+ });
105
+ },
106
+
107
+ async unsubscribe(topic) {
108
+ if (!client) throw new Error("MQTT not connected");
109
+ subscribedTopics.delete(topic);
110
+ return new Promise<void>((resolve, reject) => {
111
+ client!.unsubscribe(topic, {}, (err) => {
112
+ if (err) reject(err);
113
+ else resolve();
114
+ });
115
+ });
116
+ },
117
+
118
+ async publish(topic, payload, options = {}) {
119
+ if (!client) throw new Error("MQTT client not initialized");
120
+ if (!client.connected) throw new Error("MQTT broker not connected (retrying in background)");
121
+ return new Promise<void>((resolve, reject) => {
122
+ client!.publish(
123
+ topic,
124
+ payload,
125
+ { qos: options.qos ?? 1, retain: options.retain ?? false },
126
+ (err) => {
127
+ if (err) reject(err);
128
+ else resolve();
129
+ },
130
+ );
131
+ });
132
+ },
133
+
134
+ onMessage(handler) {
135
+ messageHandlers.push(handler);
136
+ },
137
+
138
+ isConnected() {
139
+ return client?.connected ?? false;
140
+ },
141
+ };
142
+ }
@@ -0,0 +1,157 @@
1
+ import { createMqttClient, type MqttClient, type PublishOptions, type Logger } from "./mqtt-client.js";
2
+ import type { AgentLinkConfig, MessageEnvelope } from "./types.js";
3
+ import { TOPICS, isInviteMessage, isMessageEnvelope } from "./types.js";
4
+ import type { StateManager } from "./state.js";
5
+ import type { ContactsManager } from "./contacts.js";
6
+
7
+ export interface MqttService {
8
+ start(): Promise<void>;
9
+ stop(): Promise<void>;
10
+ publish(topic: string, payload: string, options?: PublishOptions): Promise<void>;
11
+ publishEnvelope(topic: string, envelope: MessageEnvelope): Promise<void>;
12
+ subscribeGroup(groupId: string): Promise<void>;
13
+ unsubscribeGroup(groupId: string): Promise<void>;
14
+ getClient(): MqttClient;
15
+ onGroupMessage(handler: (msg: MessageEnvelope) => void): void;
16
+ onInboxMessage(handler: (topic: string, msg: unknown) => void): void;
17
+ onStatusUpdate(handler: (msg: unknown) => void): void;
18
+ onSystemEvent(handler: (msg: unknown) => void): void;
19
+ }
20
+
21
+ export function createMqttService(
22
+ config: AgentLinkConfig,
23
+ state: StateManager,
24
+ contacts: ContactsManager,
25
+ logger: Logger,
26
+ ): MqttService {
27
+ const client = createMqttClient(config, logger);
28
+
29
+ const groupMessageHandlers: Array<(msg: MessageEnvelope) => void> = [];
30
+ const inboxHandlers: Array<(topic: string, msg: unknown) => void> = [];
31
+ const statusHandlers: Array<(msg: unknown) => void> = [];
32
+ const systemHandlers: Array<(msg: unknown) => void> = [];
33
+
34
+ function routeMessage(topic: string, payload: Buffer) {
35
+ let parsed: unknown;
36
+ try {
37
+ parsed = JSON.parse(payload.toString());
38
+ } catch {
39
+ logger.warn(`[AgentLink] Non-JSON message on ${topic}`);
40
+ return;
41
+ }
42
+
43
+ // Inbox: direct invites and jobs
44
+ if (topic === TOPICS.inbox(config.agent.id)) {
45
+ for (const handler of inboxHandlers) {
46
+ handler(topic, parsed);
47
+ }
48
+ return;
49
+ }
50
+
51
+ // Group messages
52
+ const groupMsgMatch = topic.match(/^agentlink\/([^/]+)\/messages\/.+$/);
53
+ if (groupMsgMatch) {
54
+ if (!isMessageEnvelope(parsed)) {
55
+ logger.warn(`[AgentLink] Malformed envelope on ${topic}`);
56
+ return;
57
+ }
58
+ if (parsed.from === config.agent.id) return; // ignore echo
59
+ for (const handler of groupMessageHandlers) {
60
+ handler(parsed);
61
+ }
62
+ return;
63
+ }
64
+
65
+ // Status updates
66
+ if (topic.match(/^agentlink\/[^/]+\/status\/.+$/)) {
67
+ for (const handler of statusHandlers) {
68
+ handler(parsed);
69
+ }
70
+ return;
71
+ }
72
+
73
+ // System events (join/leave/complete)
74
+ if (topic.match(/^agentlink\/[^/]+\/system$/)) {
75
+ for (const handler of systemHandlers) {
76
+ handler(parsed);
77
+ }
78
+ return;
79
+ }
80
+ }
81
+
82
+ return {
83
+ async start() {
84
+ await client.connect();
85
+
86
+ // Always subscribe to personal inbox
87
+ await client.subscribe(TOPICS.inbox(config.agent.id));
88
+
89
+ // Resubscribe to active groups from persisted state
90
+ for (const groupId of state.getActiveGroups()) {
91
+ await client.subscribe(TOPICS.groupAll(groupId));
92
+ }
93
+
94
+ // Route all inbound messages
95
+ client.onMessage(routeMessage);
96
+
97
+ // Check for timed-out jobs from before restart
98
+ const timedOut = state.checkTimeouts(config.jobTimeoutMs);
99
+ if (timedOut.length > 0) {
100
+ logger.info(`[AgentLink] ${timedOut.length} job(s) timed out during downtime`);
101
+ }
102
+ },
103
+
104
+ async stop() {
105
+ // Publish offline status for all active groups
106
+ for (const groupId of state.getActiveGroups()) {
107
+ const statusTopic = TOPICS.groupStatus(groupId, config.agent.id);
108
+ await client.publish(
109
+ statusTopic,
110
+ JSON.stringify({
111
+ agent_id: config.agent.id,
112
+ status: "offline",
113
+ ts: new Date().toISOString(),
114
+ }),
115
+ { retain: true },
116
+ );
117
+ }
118
+ await client.disconnect();
119
+ },
120
+
121
+ async publish(topic, payload, options) {
122
+ await client.publish(topic, payload, options);
123
+ },
124
+
125
+ async publishEnvelope(topic, envelope) {
126
+ await client.publish(topic, JSON.stringify(envelope), { qos: 1 });
127
+ },
128
+
129
+ async subscribeGroup(groupId) {
130
+ await client.subscribe(TOPICS.groupAll(groupId));
131
+ },
132
+
133
+ async unsubscribeGroup(groupId) {
134
+ await client.unsubscribe(TOPICS.groupAll(groupId));
135
+ },
136
+
137
+ getClient() {
138
+ return client;
139
+ },
140
+
141
+ onGroupMessage(handler) {
142
+ groupMessageHandlers.push(handler);
143
+ },
144
+
145
+ onInboxMessage(handler) {
146
+ inboxHandlers.push(handler);
147
+ },
148
+
149
+ onStatusUpdate(handler) {
150
+ statusHandlers.push(handler);
151
+ },
152
+
153
+ onSystemEvent(handler) {
154
+ systemHandlers.push(handler);
155
+ },
156
+ };
157
+ }
package/src/routing.ts ADDED
@@ -0,0 +1,53 @@
1
+ import type { MessageEnvelope, AgentStatus, Capability } from "./types.js";
2
+
3
+ export interface Router {
4
+ resolveTarget(msg: MessageEnvelope, groupParticipants: AgentStatus[]): string[];
5
+ }
6
+
7
+ export function createRouter(): Router {
8
+ return {
9
+ resolveTarget(msg, groupParticipants) {
10
+ // Rule 1: Explicit target — route directly
11
+ if (msg.to !== "group") {
12
+ return [msg.to];
13
+ }
14
+
15
+ // Rule 2: Capability match — filter by capability
16
+ if (msg.payload.capability) {
17
+ const capName = msg.payload.capability;
18
+ return groupParticipants
19
+ .filter((p) => p.agent_id !== msg.from)
20
+ .filter((p) => p.capabilities.some((c) => c.name === capName))
21
+ .map((p) => p.agent_id);
22
+ }
23
+
24
+ // Rule 3: No capability — broadcast to all except sender
25
+ return groupParticipants
26
+ .filter((p) => p.agent_id !== msg.from)
27
+ .map((p) => p.agent_id);
28
+ },
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Receiver-side: should this agent process the incoming message?
34
+ */
35
+ export function shouldProcess(
36
+ msg: MessageEnvelope,
37
+ myAgentId: string,
38
+ myCapabilities: Capability[],
39
+ ): boolean {
40
+ // Always process if addressed to us directly
41
+ if (msg.to === myAgentId) return true;
42
+
43
+ // If broadcast with capability filter: only process if we have the capability
44
+ if (msg.to === "group" && msg.payload.capability) {
45
+ return myCapabilities.some((c) => c.name === msg.payload.capability);
46
+ }
47
+
48
+ // Broadcast without capability: process (group coordination)
49
+ if (msg.to === "group") return true;
50
+
51
+ // Addressed to someone else
52
+ return false;
53
+ }
package/src/state.ts ADDED
@@ -0,0 +1,169 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type { GroupState, JobStatus, PendingJob, TimedOutJob } from "./types.js";
4
+
5
+ export interface StateManager {
6
+ // Identity
7
+ getAgentId(): string | null;
8
+
9
+ // Pending joins (from CLI --join flag)
10
+ getPendingJoins(): string[];
11
+ removePendingJoin(code: string): void;
12
+
13
+ // Groups
14
+ addGroup(group: GroupState): void;
15
+ getGroup(groupId: string): GroupState | null;
16
+ removeGroup(groupId: string): void;
17
+ getActiveGroups(): string[];
18
+ updateGroup(groupId: string, updates: Partial<GroupState>): void;
19
+ incrementIdleTurns(groupId: string): number;
20
+ resetIdleTurns(groupId: string): void;
21
+
22
+ // Jobs
23
+ addJob(job: PendingJob): void;
24
+ getJob(correlationId: string): PendingJob | null;
25
+ completeJob(correlationId: string, status: JobStatus, result?: string): void;
26
+ removeJob(correlationId: string): void;
27
+ hasPendingJob(correlationId: string): boolean;
28
+ getJobsForGroup(groupId: string): PendingJob[];
29
+ checkTimeouts(timeoutMs: number): TimedOutJob[];
30
+ }
31
+
32
+ interface StateData {
33
+ agent_id?: string;
34
+ pending_joins?: string[];
35
+ groups: Record<string, GroupState>;
36
+ pending_jobs: Record<string, PendingJob>;
37
+ }
38
+
39
+ export function createState(dataDir: string): StateManager {
40
+ const filePath = path.join(dataDir, "state.json");
41
+ let data: StateData = {
42
+ groups: {},
43
+ pending_jobs: {},
44
+ };
45
+
46
+ if (fs.existsSync(filePath)) {
47
+ const loaded = JSON.parse(fs.readFileSync(filePath, "utf-8"));
48
+ data = {
49
+ ...data,
50
+ ...loaded,
51
+ groups: loaded.groups ?? data.groups,
52
+ pending_jobs: loaded.pending_jobs ?? data.pending_jobs,
53
+ };
54
+ }
55
+
56
+ function save() {
57
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
58
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
59
+ }
60
+
61
+ return {
62
+ getAgentId() {
63
+ return data.agent_id ?? null;
64
+ },
65
+
66
+ getPendingJoins() {
67
+ return data.pending_joins ? [...data.pending_joins] : [];
68
+ },
69
+
70
+ removePendingJoin(code) {
71
+ if (data.pending_joins) {
72
+ data.pending_joins = data.pending_joins.filter((c) => c !== code);
73
+ save();
74
+ }
75
+ },
76
+
77
+ addGroup(group) {
78
+ data.groups[group.group_id] = group;
79
+ save();
80
+ },
81
+
82
+ getGroup(groupId) {
83
+ return data.groups[groupId] ?? null;
84
+ },
85
+
86
+ removeGroup(groupId) {
87
+ delete data.groups[groupId];
88
+ for (const [id, job] of Object.entries(data.pending_jobs)) {
89
+ if (job.group_id === groupId) delete data.pending_jobs[id];
90
+ }
91
+ save();
92
+ },
93
+
94
+ getActiveGroups() {
95
+ return Object.keys(data.groups).filter(
96
+ (id) => data.groups[id].status === "active",
97
+ );
98
+ },
99
+
100
+ updateGroup(groupId, updates) {
101
+ if (data.groups[groupId]) {
102
+ Object.assign(data.groups[groupId], updates);
103
+ save();
104
+ }
105
+ },
106
+
107
+ incrementIdleTurns(groupId) {
108
+ const group = data.groups[groupId];
109
+ if (!group) return 0;
110
+ group.idle_turns++;
111
+ save();
112
+ return group.idle_turns;
113
+ },
114
+
115
+ resetIdleTurns(groupId) {
116
+ if (data.groups[groupId]) {
117
+ data.groups[groupId].idle_turns = 0;
118
+ save();
119
+ }
120
+ },
121
+
122
+ addJob(job) {
123
+ data.pending_jobs[job.correlation_id] = job;
124
+ save();
125
+ },
126
+
127
+ getJob(correlationId) {
128
+ return data.pending_jobs[correlationId] ?? null;
129
+ },
130
+
131
+ completeJob(correlationId, status) {
132
+ const job = data.pending_jobs[correlationId];
133
+ if (job) {
134
+ job.status = status;
135
+ save();
136
+ }
137
+ },
138
+
139
+ removeJob(correlationId) {
140
+ delete data.pending_jobs[correlationId];
141
+ save();
142
+ },
143
+
144
+ hasPendingJob(correlationId) {
145
+ const job = data.pending_jobs[correlationId];
146
+ return !!job && job.status === "requested";
147
+ },
148
+
149
+ getJobsForGroup(groupId) {
150
+ return Object.values(data.pending_jobs).filter((j) => j.group_id === groupId);
151
+ },
152
+
153
+ checkTimeouts(timeoutMs) {
154
+ const now = Date.now();
155
+ const timedOut: TimedOutJob[] = [];
156
+ for (const job of Object.values(data.pending_jobs)) {
157
+ if (job.status === "requested") {
158
+ const elapsed = now - new Date(job.sent_at).getTime();
159
+ if (elapsed > timeoutMs) {
160
+ job.status = "failed";
161
+ timedOut.push({ ...job, timed_out: true });
162
+ }
163
+ }
164
+ }
165
+ if (timedOut.length > 0) save();
166
+ return timedOut;
167
+ },
168
+ };
169
+ }