@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.
package/src/tools.ts ADDED
@@ -0,0 +1,352 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { v4 as uuid } from "uuid";
3
+ import type { AgentLinkConfig, AgentStatus } from "./types.js";
4
+ import { createEnvelope, TOPICS } from "./types.js";
5
+ import type { StateManager } from "./state.js";
6
+ import type { ContactsManager } from "./contacts.js";
7
+ import type { MqttService } from "./mqtt-service.js";
8
+ import type { InviteManager } from "./invite.js";
9
+ import type { JobManager } from "./jobs.js";
10
+ import type { Logger } from "./mqtt-client.js";
11
+
12
+ // ---------------------------------------------------------------------------
13
+ // Tool result helper
14
+ // ---------------------------------------------------------------------------
15
+
16
+ function json(data: Record<string, unknown>) {
17
+ return {
18
+ content: [{ type: "text" as const, text: JSON.stringify(data, null, 2) }],
19
+ details: data,
20
+ };
21
+ }
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Publish agent status to group
25
+ // ---------------------------------------------------------------------------
26
+
27
+ async function publishStatus(
28
+ config: AgentLinkConfig,
29
+ mqtt: MqttService,
30
+ groupId: string,
31
+ ): Promise<void> {
32
+ const status: AgentStatus = {
33
+ agent_id: config.agent.id,
34
+ owner: config.agent.description?.split("'s")[0] ?? config.agent.id,
35
+ status: "online",
36
+ capabilities: config.agent.capabilities.map((c) => ({
37
+ name: c.name,
38
+ description: c.description ?? c.name,
39
+ input_hint: c.input_hint ?? "",
40
+ })),
41
+ description: config.agent.description,
42
+ ts: new Date().toISOString(),
43
+ };
44
+
45
+ await mqtt.publish(
46
+ TOPICS.groupStatus(groupId, config.agent.id),
47
+ JSON.stringify(status),
48
+ { retain: true },
49
+ );
50
+ }
51
+
52
+ // ---------------------------------------------------------------------------
53
+ // TypeBox Schemas
54
+ // ---------------------------------------------------------------------------
55
+
56
+ const CoordinateSchema = Type.Object({
57
+ goal: Type.String({ description: "What the user wants to accomplish" }),
58
+ done_when: Type.Optional(
59
+ Type.String({ description: "How to know when this is complete" }),
60
+ ),
61
+ participants: Type.Array(Type.String(), {
62
+ description: "Names or agent IDs of people to coordinate with",
63
+ }),
64
+ });
65
+
66
+ const SubmitJobSchema = Type.Object({
67
+ group_id: Type.String({ description: "The active group/coordination ID" }),
68
+ capability: Type.String({
69
+ description: "The capability to request (e.g. 'check_calendar')",
70
+ }),
71
+ target_agent: Type.Optional(
72
+ Type.String({
73
+ description: "Specific agent ID to target (optional — if omitted, routes by capability)",
74
+ }),
75
+ ),
76
+ text: Type.String({
77
+ description: "Natural language description of what you need",
78
+ }),
79
+ });
80
+
81
+ const InviteAgentSchema = Type.Object({
82
+ group_id: Type.String({ description: "The active group/coordination ID" }),
83
+ name_or_agent_id: Type.String({
84
+ description: "Contact name (e.g. 'Sara') or agent ID (e.g. 'sara-macbook')",
85
+ }),
86
+ });
87
+
88
+ const JoinGroupSchema = Type.Object({
89
+ invite_code: Type.String({
90
+ description: "The 6-character invite code (e.g. 'AB3X7K')",
91
+ }),
92
+ });
93
+
94
+ const CompleteSchema = Type.Object({
95
+ group_id: Type.String({ description: "The active group/coordination ID" }),
96
+ summary: Type.String({ description: "Final outcome summary" }),
97
+ success: Type.Optional(
98
+ Type.Boolean({
99
+ description: "Whether the goal was achieved",
100
+ default: true,
101
+ }),
102
+ ),
103
+ });
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // createTools
107
+ // ---------------------------------------------------------------------------
108
+
109
+ export function createTools(
110
+ config: AgentLinkConfig,
111
+ state: StateManager,
112
+ contacts: ContactsManager,
113
+ mqtt: MqttService,
114
+ invites: InviteManager,
115
+ jobs: JobManager,
116
+ logger: Logger,
117
+ ) {
118
+ function log(msg: string) {
119
+ if (config.outputMode === "debug") {
120
+ logger.info(`[AgentLink] ${msg}`);
121
+ }
122
+ }
123
+
124
+ // -----------------------------------------------------------------------
125
+ // agentlink_coordinate
126
+ // -----------------------------------------------------------------------
127
+ const coordinateTool = {
128
+ name: "agentlink_coordinate",
129
+ label: "Coordinate",
130
+ description:
131
+ "Start coordinating with other people's agents. Use this when the user wants to do something that involves other people.",
132
+ parameters: CoordinateSchema,
133
+ async execute(_id: string, params: Record<string, unknown>) {
134
+ const goal = params.goal as string;
135
+ const doneWhen = (params.done_when as string) ?? `${goal} — completed to user's satisfaction`;
136
+ const participants = params.participants as string[];
137
+
138
+ const resolved = participants.map((p) => ({
139
+ name: p,
140
+ agentId: contacts.resolve(p),
141
+ }));
142
+
143
+ const unresolved = resolved.filter((r) => !r.agentId);
144
+ if (unresolved.length > 0) {
145
+ return json({
146
+ error: `Unknown contacts: ${unresolved.map((u) => u.name).join(", ")}. Ask the user for their agent ID, or use agentlink_invite_agent with an agent_id.`,
147
+ });
148
+ }
149
+
150
+ const participantIds = resolved.map((r) => r.agentId!);
151
+ const groupId = uuid();
152
+ const intentId = uuid();
153
+
154
+ state.addGroup({
155
+ group_id: groupId,
156
+ driver: config.agent.id,
157
+ goal,
158
+ done_when: doneWhen,
159
+ intent_id: intentId,
160
+ participants: participantIds,
161
+ status: "active",
162
+ idle_turns: 0,
163
+ created_at: new Date().toISOString(),
164
+ });
165
+
166
+ await mqtt.subscribeGroup(groupId);
167
+ await publishStatus(config, mqtt, groupId);
168
+
169
+ for (const pid of participantIds) {
170
+ await invites.sendDirectInvite(pid, groupId, goal, doneWhen);
171
+ log(`Invite sent to ${contacts.getNameByAgentId(pid) ?? pid}`);
172
+ }
173
+
174
+ return json({
175
+ group_id: groupId,
176
+ intent_id: intentId,
177
+ participants: participantIds,
178
+ status: "invites_sent",
179
+ message: `Coordination started. Waiting for ${participantIds.length} agent(s) to join.`,
180
+ });
181
+ },
182
+ };
183
+
184
+ // -----------------------------------------------------------------------
185
+ // agentlink_submit_job
186
+ // -----------------------------------------------------------------------
187
+ const submitJobTool = {
188
+ name: "agentlink_submit_job",
189
+ label: "Submit Job",
190
+ description:
191
+ "Send a specific task to another agent. Use this to request actions like checking a calendar, searching for restaurants, etc.",
192
+ parameters: SubmitJobSchema,
193
+ async execute(_id: string, params: Record<string, unknown>) {
194
+ const groupId = params.group_id as string;
195
+ const group = state.getGroup(groupId);
196
+ if (!group) return json({ error: "Group not found or already closed" });
197
+
198
+ const correlationId = await jobs.submitJob({
199
+ groupId,
200
+ intentId: group.intent_id,
201
+ targetAgentId: params.target_agent as string | undefined,
202
+ capability: params.capability as string,
203
+ text: params.text as string,
204
+ });
205
+
206
+ state.resetIdleTurns(groupId);
207
+
208
+ return json({
209
+ correlation_id: correlationId,
210
+ status: "requested",
211
+ message: `Job sent: ${params.capability}. Waiting for response (timeout: ${config.jobTimeoutMs / 1000}s).`,
212
+ });
213
+ },
214
+ };
215
+
216
+ // -----------------------------------------------------------------------
217
+ // agentlink_invite_agent
218
+ // -----------------------------------------------------------------------
219
+ const inviteAgentTool = {
220
+ name: "agentlink_invite_agent",
221
+ label: "Invite Agent",
222
+ description:
223
+ "Invite someone to join a coordination group. Use when adding new participants mid-coordination.",
224
+ parameters: InviteAgentSchema,
225
+ async execute(_id: string, params: Record<string, unknown>) {
226
+ const groupId = params.group_id as string;
227
+ const group = state.getGroup(groupId);
228
+ if (!group) return json({ error: "Group not found" });
229
+
230
+ const nameOrId = params.name_or_agent_id as string;
231
+ const agentId = contacts.resolve(nameOrId);
232
+ if (!agentId) {
233
+ return json({ error: `Unknown contact: ${nameOrId}. Ask the user for their agent ID.` });
234
+ }
235
+
236
+ await invites.sendDirectInvite(agentId, groupId, group.goal, group.done_when);
237
+ log(`Invite sent to ${nameOrId}`);
238
+
239
+ return json({
240
+ group_id: groupId,
241
+ invited: agentId,
242
+ status: "invite_sent",
243
+ });
244
+ },
245
+ };
246
+
247
+ // -----------------------------------------------------------------------
248
+ // agentlink_join_group
249
+ // -----------------------------------------------------------------------
250
+ const joinGroupTool = {
251
+ name: "agentlink_join_group",
252
+ label: "Join Group",
253
+ description: "Join a coordination group using an invite code that was shared with you.",
254
+ parameters: JoinGroupSchema,
255
+ async execute(_id: string, params: Record<string, unknown>) {
256
+ const code = params.invite_code as string;
257
+ const invite = await invites.resolveInviteCode(code);
258
+ if (!invite) return json({ error: "Invalid or expired invite code" });
259
+
260
+ const groupId = invite.group_id;
261
+
262
+ await mqtt.subscribeGroup(groupId);
263
+
264
+ state.addGroup({
265
+ group_id: groupId,
266
+ driver: invite.from,
267
+ goal: invite.goal,
268
+ done_when: "",
269
+ intent_id: "",
270
+ participants: [invite.from],
271
+ status: "active",
272
+ idle_turns: 0,
273
+ created_at: new Date().toISOString(),
274
+ });
275
+
276
+ await publishStatus(config, mqtt, groupId);
277
+
278
+ const joinMsg = createEnvelope(config.agent.id, {
279
+ group_id: groupId,
280
+ to: "group",
281
+ type: "join",
282
+ payload: { text: `${config.agent.id} joined the group` },
283
+ });
284
+ await mqtt.publishEnvelope(TOPICS.groupSystem(groupId), joinMsg);
285
+
286
+ if (!contacts.resolve(invite.from)) {
287
+ contacts.add(invite.from, invite.from);
288
+ }
289
+
290
+ return json({
291
+ group_id: groupId,
292
+ driver: invite.from,
293
+ goal: invite.goal,
294
+ status: "joined",
295
+ });
296
+ },
297
+ };
298
+
299
+ // -----------------------------------------------------------------------
300
+ // agentlink_complete
301
+ // -----------------------------------------------------------------------
302
+ const completeTool = {
303
+ name: "agentlink_complete",
304
+ label: "Complete",
305
+ description:
306
+ "Declare that the coordination is complete. Only call this when the goal has been achieved or explicitly abandoned.",
307
+ parameters: CompleteSchema,
308
+ async execute(_id: string, params: Record<string, unknown>) {
309
+ const groupId = params.group_id as string;
310
+ const summary = params.summary as string;
311
+ const success = (params.success as boolean) ?? true;
312
+
313
+ const group = state.getGroup(groupId);
314
+ if (!group) return json({ error: "Group not found" });
315
+
316
+ if (group.driver !== config.agent.id) {
317
+ return json({ error: "Only the driver agent can complete a coordination" });
318
+ }
319
+
320
+ const completionMsg = createEnvelope(config.agent.id, {
321
+ group_id: groupId,
322
+ to: "group",
323
+ type: "leave",
324
+ payload: {
325
+ text: summary,
326
+ status: success ? "completed" : "failed",
327
+ },
328
+ });
329
+ await mqtt.publishEnvelope(TOPICS.groupSystem(groupId), completionMsg);
330
+
331
+ await mqtt.unsubscribeGroup(groupId);
332
+
333
+ await mqtt.publish(
334
+ TOPICS.groupStatus(groupId, config.agent.id),
335
+ "",
336
+ { retain: true },
337
+ );
338
+
339
+ state.removeGroup(groupId);
340
+
341
+ log(`Coordination complete: ${summary}`);
342
+
343
+ return json({
344
+ group_id: groupId,
345
+ status: success ? "completed" : "failed",
346
+ summary,
347
+ });
348
+ },
349
+ };
350
+
351
+ return [coordinateTool, submitJobTool, inviteAgentTool, joinGroupTool, completeTool];
352
+ }
package/src/types.ts ADDED
@@ -0,0 +1,258 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { v4 as uuid } from "uuid";
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Config types
8
+ // ---------------------------------------------------------------------------
9
+
10
+ export interface AgentLinkConfig {
11
+ brokerUrl: string;
12
+ brokerUsername?: string;
13
+ brokerPassword?: string;
14
+ agent: {
15
+ id: string;
16
+ description?: string;
17
+ capabilities: Capability[];
18
+ };
19
+ outputMode: "user" | "debug";
20
+ jobTimeoutMs: number;
21
+ dataDir: string;
22
+ }
23
+
24
+ export interface Capability {
25
+ name: string;
26
+ tool: string;
27
+ description?: string;
28
+ input_hint?: string;
29
+ }
30
+
31
+ export function resolveConfig(rawConfig: Record<string, unknown>): AgentLinkConfig {
32
+ const cfg = rawConfig as Record<string, unknown>;
33
+ const agent = cfg.agent as Record<string, unknown> | undefined;
34
+ const capabilities = (agent?.capabilities as Capability[] | undefined) ?? [];
35
+
36
+ // Resolve dataDir first — needed for persistent agent ID lookup
37
+ const dataDir = (cfg.data_dir as string) ?? path.join(os.homedir(), ".agentlink");
38
+
39
+ // 3-tier agent ID resolution:
40
+ // 1. Explicit agent.id from config (user-configured)
41
+ // 2. Persistent agent_id from <dataDir>/state.json (set by CLI setup)
42
+ // 3. Temp fallback agent-HOSTNAME-PID (dev without setup)
43
+ let agentId = agent?.id as string | undefined;
44
+ if (!agentId) {
45
+ try {
46
+ const stateFile = path.join(dataDir, "state.json");
47
+ if (fs.existsSync(stateFile)) {
48
+ const stateData = JSON.parse(fs.readFileSync(stateFile, "utf-8"));
49
+ if (stateData.agent_id && typeof stateData.agent_id === "string") {
50
+ agentId = stateData.agent_id;
51
+ }
52
+ }
53
+ } catch {
54
+ // state.json unreadable — fall through to temp ID
55
+ }
56
+ }
57
+ if (!agentId) {
58
+ agentId = `agent-${os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, "")}-${process.pid}`;
59
+ }
60
+
61
+ return {
62
+ brokerUrl: (cfg.brokerUrl as string) ?? "mqtt://broker.emqx.io:1883",
63
+ brokerUsername: cfg.brokerUsername as string | undefined,
64
+ brokerPassword: cfg.brokerPassword as string | undefined,
65
+ agent: {
66
+ id: agentId,
67
+ description: agent?.description as string | undefined,
68
+ capabilities,
69
+ },
70
+ outputMode: (cfg.output_mode as "user" | "debug") ?? "user",
71
+ jobTimeoutMs: (cfg.job_timeout_ms as number) ?? 60_000,
72
+ dataDir,
73
+ };
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Message envelope
78
+ // ---------------------------------------------------------------------------
79
+
80
+ export type MessageType = "chat" | "job_request" | "job_response" | "join" | "leave";
81
+
82
+ export type JobStatus = "requested" | "completed" | "failed" | "awaiting_approval";
83
+
84
+ export interface CoordinationHeader {
85
+ driver_agent_id: string;
86
+ goal: string;
87
+ done_when: string;
88
+ }
89
+
90
+ export interface ProposalPayload {
91
+ summary: string;
92
+ requires_approval: boolean;
93
+ }
94
+
95
+ export interface MessagePayload {
96
+ text?: string;
97
+ capability?: string;
98
+ status?: JobStatus;
99
+ result?: string;
100
+ proposal?: ProposalPayload;
101
+ [key: string]: unknown;
102
+ }
103
+
104
+ export interface MessageEnvelope {
105
+ v: 1;
106
+ id: string;
107
+ group_id: string;
108
+ intent_id: string;
109
+ from: string;
110
+ to: "group" | string;
111
+ type: MessageType;
112
+ correlation_id?: string;
113
+ coordination?: CoordinationHeader;
114
+ payload: MessagePayload;
115
+ ts: string;
116
+ }
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Agent status (retained message)
120
+ // ---------------------------------------------------------------------------
121
+
122
+ export interface CapabilityAdvertisement {
123
+ name: string;
124
+ description: string;
125
+ input_hint: string;
126
+ }
127
+
128
+ export interface AgentStatus {
129
+ agent_id: string;
130
+ owner: string;
131
+ status: "online" | "offline";
132
+ capabilities: CapabilityAdvertisement[];
133
+ description?: string;
134
+ ts: string;
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Invite types
139
+ // ---------------------------------------------------------------------------
140
+
141
+ export interface InviteMessage {
142
+ type: "invite";
143
+ group_id: string;
144
+ from: string;
145
+ goal: string;
146
+ done_when: string;
147
+ ts: string;
148
+ }
149
+
150
+ export interface InviteCodePayload {
151
+ group_id: string;
152
+ from: string;
153
+ goal: string;
154
+ created_at: string;
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // State types
159
+ // ---------------------------------------------------------------------------
160
+
161
+ export interface GroupState {
162
+ group_id: string;
163
+ driver: string;
164
+ goal: string;
165
+ done_when: string;
166
+ intent_id: string;
167
+ participants: string[];
168
+ status: "active" | "closing";
169
+ idle_turns: number;
170
+ created_at: string;
171
+ }
172
+
173
+ export interface PendingJob {
174
+ correlation_id: string;
175
+ group_id: string;
176
+ target: string;
177
+ capability: string;
178
+ status: JobStatus;
179
+ sent_at: string;
180
+ text?: string;
181
+ }
182
+
183
+ export interface TimedOutJob extends PendingJob {
184
+ timed_out: true;
185
+ }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Helper: message construction
189
+ // ---------------------------------------------------------------------------
190
+
191
+ export function createEnvelope(
192
+ from: string,
193
+ overrides: Partial<MessageEnvelope> & Pick<MessageEnvelope, "group_id" | "to" | "type" | "payload">,
194
+ ): MessageEnvelope {
195
+ return {
196
+ v: 1,
197
+ id: uuid(),
198
+ intent_id: overrides.intent_id ?? uuid(),
199
+ from,
200
+ ts: new Date().toISOString(),
201
+ ...overrides,
202
+ };
203
+ }
204
+
205
+ // ---------------------------------------------------------------------------
206
+ // Topic helpers
207
+ // ---------------------------------------------------------------------------
208
+
209
+ export const TOPICS = {
210
+ inbox: (agentId: string) =>
211
+ `agentlink/agents/${agentId}/inbox`,
212
+
213
+ groupMessages: (groupId: string, agentId: string) =>
214
+ `agentlink/${groupId}/messages/${agentId}`,
215
+
216
+ groupMessagesWildcard: (groupId: string) =>
217
+ `agentlink/${groupId}/messages/+`,
218
+
219
+ groupStatus: (groupId: string, agentId: string) =>
220
+ `agentlink/${groupId}/status/${agentId}`,
221
+
222
+ groupStatusWildcard: (groupId: string) =>
223
+ `agentlink/${groupId}/status/+`,
224
+
225
+ groupSystem: (groupId: string) =>
226
+ `agentlink/${groupId}/system`,
227
+
228
+ groupAll: (groupId: string) =>
229
+ `agentlink/${groupId}/#`,
230
+
231
+ inviteCode: (code: string) =>
232
+ `agentlink/invites/${code}`,
233
+ } as const;
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Type guards
237
+ // ---------------------------------------------------------------------------
238
+
239
+ export function isInviteMessage(msg: unknown): msg is InviteMessage {
240
+ return (
241
+ typeof msg === "object" &&
242
+ msg !== null &&
243
+ (msg as Record<string, unknown>).type === "invite" &&
244
+ typeof (msg as Record<string, unknown>).group_id === "string" &&
245
+ typeof (msg as Record<string, unknown>).from === "string"
246
+ );
247
+ }
248
+
249
+ export function isMessageEnvelope(msg: unknown): msg is MessageEnvelope {
250
+ if (typeof msg !== "object" || msg === null) return false;
251
+ const m = msg as Record<string, unknown>;
252
+ return (
253
+ m.v === 1 &&
254
+ typeof m.id === "string" &&
255
+ typeof m.from === "string" &&
256
+ typeof m.type === "string"
257
+ );
258
+ }