oacp-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.
- package/README.md +102 -0
- package/dist/agent.d.ts +201 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +715 -0
- package/dist/agent.js.map +1 -0
- package/dist/config.d.ts +57 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +12 -0
- package/dist/config.js.map +1 -0
- package/dist/crypto.d.ts +56 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +140 -0
- package/dist/crypto.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/trust.d.ts +98 -0
- package/dist/trust.d.ts.map +1 -0
- package/dist/trust.js +123 -0
- package/dist/trust.js.map +1 -0
- package/dist/types.d.ts +147 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -0
- package/package.json +55 -0
package/dist/agent.js
ADDED
|
@@ -0,0 +1,715 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OACP SDK — OACPAgent Class
|
|
3
|
+
*
|
|
4
|
+
* Main entry point for agents using the OACP protocol.
|
|
5
|
+
* Handles registration, heartbeat, and discovery.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* const agent = new OACPAgent({
|
|
9
|
+
* name: 'my-agent',
|
|
10
|
+
* capabilities: ['research-summarization'],
|
|
11
|
+
* supabaseUrl: process.env.SUPABASE_URL!,
|
|
12
|
+
* supabaseAnonKey: process.env.SUPABASE_ANON_KEY!,
|
|
13
|
+
* });
|
|
14
|
+
* await agent.register();
|
|
15
|
+
* agent.startHeartbeat();
|
|
16
|
+
* const agents = await agent.discover({ capability: 'data-extraction' });
|
|
17
|
+
*/
|
|
18
|
+
import { DEFAULT_HEARTBEAT_INTERVAL_MS, DEFAULT_KEYPAIR_PATH, OACP_PUBLIC_REGISTRY_URL, OACP_PUBLIC_REGISTRY_KEY, } from './config.js';
|
|
19
|
+
import { loadOrCreateKeypair, signMessage, verifySignature, canonicalizeMessage, } from './crypto.js';
|
|
20
|
+
import { validatePayload, isSenderAllowed, matchAction, DEFAULT_TRUST_POLICY, } from './trust.js';
|
|
21
|
+
export class OACPAgent {
|
|
22
|
+
config;
|
|
23
|
+
keypair;
|
|
24
|
+
agentId = null;
|
|
25
|
+
registryToken = null;
|
|
26
|
+
heartbeatTimer = null;
|
|
27
|
+
apiBaseUrl;
|
|
28
|
+
registered = false;
|
|
29
|
+
// Messaging — event handlers and Realtime subscription
|
|
30
|
+
// deno-lint-ignore no-explicit-any
|
|
31
|
+
messageHandlers = new Map();
|
|
32
|
+
// deno-lint-ignore no-explicit-any
|
|
33
|
+
realtimeChannel = null;
|
|
34
|
+
// deno-lint-ignore no-explicit-any
|
|
35
|
+
supabaseClient = null;
|
|
36
|
+
// Trust model
|
|
37
|
+
actionSpecs = [];
|
|
38
|
+
trustPolicy;
|
|
39
|
+
// deno-lint-ignore no-explicit-any
|
|
40
|
+
actionHandlers = new Map();
|
|
41
|
+
constructor(config) {
|
|
42
|
+
this.config = config;
|
|
43
|
+
// Load or generate keypair
|
|
44
|
+
const keypairPath = config.keypairPath || DEFAULT_KEYPAIR_PATH;
|
|
45
|
+
this.keypair = loadOrCreateKeypair(keypairPath);
|
|
46
|
+
// Set API base URL
|
|
47
|
+
const supabaseUrl = config.supabaseUrl || OACP_PUBLIC_REGISTRY_URL;
|
|
48
|
+
this.apiBaseUrl =
|
|
49
|
+
config.apiBaseUrl ||
|
|
50
|
+
`${supabaseUrl}/functions/v1/oacp`;
|
|
51
|
+
// Store resolved config for Realtime client
|
|
52
|
+
this.config = {
|
|
53
|
+
...config,
|
|
54
|
+
supabaseUrl: supabaseUrl,
|
|
55
|
+
supabaseAnonKey: config.supabaseAnonKey || OACP_PUBLIC_REGISTRY_KEY,
|
|
56
|
+
};
|
|
57
|
+
// Trust model
|
|
58
|
+
this.actionSpecs = config.actionSpecs || [];
|
|
59
|
+
this.trustPolicy = config.trustPolicy || DEFAULT_TRUST_POLICY;
|
|
60
|
+
console.log(`[OACP] Agent "${config.name}" initialized (pubkey: ${this.keypair.publicKey.substring(0, 16)}...)`);
|
|
61
|
+
if (this.actionSpecs.length > 0) {
|
|
62
|
+
console.log(`[OACP] Actions: ${this.actionSpecs.map((s) => s.name).join(', ')}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// ============================================================
|
|
66
|
+
// Public Accessors
|
|
67
|
+
// ============================================================
|
|
68
|
+
/** Get the agent's unique ID (available after registration) */
|
|
69
|
+
getAgentId() {
|
|
70
|
+
return this.agentId;
|
|
71
|
+
}
|
|
72
|
+
/** Get the agent's Ed25519 public key (hex) */
|
|
73
|
+
getPublicKey() {
|
|
74
|
+
return this.keypair.publicKey;
|
|
75
|
+
}
|
|
76
|
+
/** Check if the agent is registered */
|
|
77
|
+
isRegistered() {
|
|
78
|
+
return this.registered;
|
|
79
|
+
}
|
|
80
|
+
/** Get the API base URL */
|
|
81
|
+
getApiBaseUrl() {
|
|
82
|
+
return this.apiBaseUrl;
|
|
83
|
+
}
|
|
84
|
+
// ============================================================
|
|
85
|
+
// Registration
|
|
86
|
+
// ============================================================
|
|
87
|
+
/**
|
|
88
|
+
* Register this agent with the OACP Registry.
|
|
89
|
+
*
|
|
90
|
+
* On first call: creates a new registration and stores the agent_id + token.
|
|
91
|
+
* On subsequent calls: re-registers (updates) the existing agent card.
|
|
92
|
+
*
|
|
93
|
+
* @returns The agent_id assigned by the registry
|
|
94
|
+
*/
|
|
95
|
+
async register() {
|
|
96
|
+
const body = {
|
|
97
|
+
name: this.config.name,
|
|
98
|
+
capabilities: this.config.capabilities,
|
|
99
|
+
public_key: this.keypair.publicKey,
|
|
100
|
+
};
|
|
101
|
+
if (this.config.ownerHandle)
|
|
102
|
+
body.owner_handle = this.config.ownerHandle;
|
|
103
|
+
if (this.config.description)
|
|
104
|
+
body.description = this.config.description;
|
|
105
|
+
if (this.config.endpointUrl)
|
|
106
|
+
body.endpoint_url = this.config.endpointUrl;
|
|
107
|
+
if (this.config.protocolsSupported)
|
|
108
|
+
body.protocols_supported = this.config.protocolsSupported;
|
|
109
|
+
if (this.config.metadata)
|
|
110
|
+
body.metadata = this.config.metadata;
|
|
111
|
+
// Include structured metadata so other agents can discover our capabilities
|
|
112
|
+
const structuredMeta = {
|
|
113
|
+
...(body.metadata || {}),
|
|
114
|
+
};
|
|
115
|
+
if (this.actionSpecs.length > 0) {
|
|
116
|
+
structuredMeta.action_specs = this.actionSpecs.map((s) => ({
|
|
117
|
+
name: s.name,
|
|
118
|
+
description: s.description,
|
|
119
|
+
price: s.price || 0,
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
if (this.config.model) {
|
|
123
|
+
structuredMeta.model = this.config.model;
|
|
124
|
+
}
|
|
125
|
+
if (this.config.languages) {
|
|
126
|
+
structuredMeta.languages = this.config.languages;
|
|
127
|
+
}
|
|
128
|
+
if (Object.keys(structuredMeta).length > 0) {
|
|
129
|
+
body.metadata = structuredMeta;
|
|
130
|
+
}
|
|
131
|
+
// Re-registration: include agent_id and auth token
|
|
132
|
+
if (this.agentId && this.registryToken) {
|
|
133
|
+
body.agent_id = this.agentId;
|
|
134
|
+
}
|
|
135
|
+
const headers = {
|
|
136
|
+
'Content-Type': 'application/json',
|
|
137
|
+
};
|
|
138
|
+
if (this.registryToken) {
|
|
139
|
+
headers['Authorization'] = `Bearer ${this.registryToken}`;
|
|
140
|
+
}
|
|
141
|
+
const response = await fetch(`${this.apiBaseUrl}/register`, {
|
|
142
|
+
method: 'POST',
|
|
143
|
+
headers,
|
|
144
|
+
body: JSON.stringify(body),
|
|
145
|
+
});
|
|
146
|
+
if (!response.ok) {
|
|
147
|
+
const error = (await response.json());
|
|
148
|
+
throw new Error(`Registration failed (${response.status}): ${error.error}`);
|
|
149
|
+
}
|
|
150
|
+
// deno-lint-ignore no-explicit-any
|
|
151
|
+
const data = (await response.json());
|
|
152
|
+
if ('registry_token' in data) {
|
|
153
|
+
// New registration
|
|
154
|
+
const reg = data;
|
|
155
|
+
this.agentId = reg.agent_id;
|
|
156
|
+
this.registryToken = reg.registry_token;
|
|
157
|
+
this.registered = true;
|
|
158
|
+
console.log(`[OACP] Registered as ${reg.agent_id}`);
|
|
159
|
+
// Start Realtime if handlers were registered before register() was called
|
|
160
|
+
if (this.messageHandlers.size > 0) {
|
|
161
|
+
this.startRealtimeSubscription();
|
|
162
|
+
}
|
|
163
|
+
return reg.agent_id;
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
// Re-registration
|
|
167
|
+
const rereg = data;
|
|
168
|
+
this.registered = true;
|
|
169
|
+
console.log(`[OACP] Re-registered ${rereg.agent_id}`);
|
|
170
|
+
return rereg.agent_id;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// ============================================================
|
|
174
|
+
// Heartbeat
|
|
175
|
+
// ============================================================
|
|
176
|
+
/**
|
|
177
|
+
* Send a single heartbeat to the registry.
|
|
178
|
+
* Updates last_seen_at and confirms online status.
|
|
179
|
+
*/
|
|
180
|
+
async heartbeat() {
|
|
181
|
+
if (!this.agentId || !this.registryToken) {
|
|
182
|
+
throw new Error('Agent must be registered before sending heartbeats');
|
|
183
|
+
}
|
|
184
|
+
const response = await fetch(`${this.apiBaseUrl}/heartbeat`, {
|
|
185
|
+
method: 'POST',
|
|
186
|
+
headers: {
|
|
187
|
+
'Content-Type': 'application/json',
|
|
188
|
+
Authorization: `Bearer ${this.registryToken}`,
|
|
189
|
+
},
|
|
190
|
+
body: JSON.stringify({ agent_id: this.agentId }),
|
|
191
|
+
});
|
|
192
|
+
if (!response.ok) {
|
|
193
|
+
const error = (await response.json());
|
|
194
|
+
throw new Error(`Heartbeat failed (${response.status}): ${error.error}`);
|
|
195
|
+
}
|
|
196
|
+
return (await response.json());
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Start automatic heartbeat at the configured interval.
|
|
200
|
+
* Default: every 30 seconds (matching PRD REQ-HB-01).
|
|
201
|
+
*/
|
|
202
|
+
startHeartbeat(intervalMs) {
|
|
203
|
+
if (this.heartbeatTimer) {
|
|
204
|
+
console.warn('[OACP] Heartbeat already running');
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
const interval = intervalMs || this.config.heartbeatIntervalMs || DEFAULT_HEARTBEAT_INTERVAL_MS;
|
|
208
|
+
// Send immediate heartbeat, then schedule recurring
|
|
209
|
+
this.heartbeat().catch((err) => {
|
|
210
|
+
console.error('[OACP] Initial heartbeat failed:', err.message);
|
|
211
|
+
});
|
|
212
|
+
this.heartbeatTimer = setInterval(async () => {
|
|
213
|
+
try {
|
|
214
|
+
await this.heartbeat();
|
|
215
|
+
}
|
|
216
|
+
catch (err) {
|
|
217
|
+
const msg = err instanceof Error ? err.message : 'Unknown error';
|
|
218
|
+
console.error(`[OACP] Heartbeat failed: ${msg}`);
|
|
219
|
+
}
|
|
220
|
+
}, interval);
|
|
221
|
+
console.log(`[OACP] Heartbeat started (interval: ${interval}ms)`);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Stop the automatic heartbeat.
|
|
225
|
+
*/
|
|
226
|
+
stopHeartbeat() {
|
|
227
|
+
if (this.heartbeatTimer) {
|
|
228
|
+
clearInterval(this.heartbeatTimer);
|
|
229
|
+
this.heartbeatTimer = null;
|
|
230
|
+
console.log('[OACP] Heartbeat stopped');
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// ============================================================
|
|
234
|
+
// Discovery
|
|
235
|
+
// ============================================================
|
|
236
|
+
/**
|
|
237
|
+
* Discover agents in the registry matching the given criteria.
|
|
238
|
+
*
|
|
239
|
+
* @param query - Filter criteria (capability, status, owner_handle, pagination)
|
|
240
|
+
* @returns Discovery results with agents and pagination info
|
|
241
|
+
*/
|
|
242
|
+
async discover(query = {}) {
|
|
243
|
+
const params = new URLSearchParams();
|
|
244
|
+
if (query.capability)
|
|
245
|
+
params.set('capability', query.capability);
|
|
246
|
+
if (query.status)
|
|
247
|
+
params.set('status', query.status);
|
|
248
|
+
if (query.owner_handle)
|
|
249
|
+
params.set('owner_handle', query.owner_handle);
|
|
250
|
+
if (query.page)
|
|
251
|
+
params.set('page', String(query.page));
|
|
252
|
+
if (query.page_size)
|
|
253
|
+
params.set('page_size', String(query.page_size));
|
|
254
|
+
const qs = params.toString();
|
|
255
|
+
const url = `${this.apiBaseUrl}/discover${qs ? '?' + qs : ''}`;
|
|
256
|
+
const response = await fetch(url, {
|
|
257
|
+
method: 'GET',
|
|
258
|
+
headers: {
|
|
259
|
+
'Content-Type': 'application/json',
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
if (!response.ok) {
|
|
263
|
+
const error = (await response.json());
|
|
264
|
+
throw new Error(`Discovery failed (${response.status}): ${error.error}`);
|
|
265
|
+
}
|
|
266
|
+
return (await response.json());
|
|
267
|
+
}
|
|
268
|
+
// ============================================================
|
|
269
|
+
// Token Economy
|
|
270
|
+
// ============================================================
|
|
271
|
+
/**
|
|
272
|
+
* Get this agent's token balance.
|
|
273
|
+
*/
|
|
274
|
+
async getBalance() {
|
|
275
|
+
if (!this.agentId)
|
|
276
|
+
throw new Error('Agent must be registered');
|
|
277
|
+
const response = await fetch(`${this.apiBaseUrl}/balance?agent_id=${this.agentId}`);
|
|
278
|
+
if (!response.ok)
|
|
279
|
+
return 0;
|
|
280
|
+
// deno-lint-ignore no-explicit-any
|
|
281
|
+
const data = (await response.json());
|
|
282
|
+
return data.balance;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Get this agent's transaction history.
|
|
286
|
+
*/
|
|
287
|
+
async getTransactions(options = {}) {
|
|
288
|
+
if (!this.agentId || !this.registryToken)
|
|
289
|
+
throw new Error('Agent must be registered');
|
|
290
|
+
const params = new URLSearchParams();
|
|
291
|
+
params.set('agent_id', this.agentId);
|
|
292
|
+
if (options.limit)
|
|
293
|
+
params.set('limit', String(options.limit));
|
|
294
|
+
if (options.type)
|
|
295
|
+
params.set('type', options.type);
|
|
296
|
+
const response = await fetch(`${this.apiBaseUrl}/transactions?${params.toString()}`, {
|
|
297
|
+
headers: { Authorization: `Bearer ${this.registryToken}` },
|
|
298
|
+
});
|
|
299
|
+
if (!response.ok) {
|
|
300
|
+
const error = (await response.json());
|
|
301
|
+
throw new Error(`Get transactions failed: ${error.error}`);
|
|
302
|
+
}
|
|
303
|
+
// deno-lint-ignore no-explicit-any
|
|
304
|
+
return (await response.json());
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Transfer tokens to another agent.
|
|
308
|
+
* @private — used internally for task payments
|
|
309
|
+
*/
|
|
310
|
+
async transferTokens(toAgentId, amount, messageId, description) {
|
|
311
|
+
if (!this.agentId || !this.registryToken)
|
|
312
|
+
throw new Error('Agent must be registered');
|
|
313
|
+
const response = await fetch(`${this.apiBaseUrl}/transfer`, {
|
|
314
|
+
method: 'POST',
|
|
315
|
+
headers: {
|
|
316
|
+
'Content-Type': 'application/json',
|
|
317
|
+
Authorization: `Bearer ${this.registryToken}`,
|
|
318
|
+
},
|
|
319
|
+
body: JSON.stringify({
|
|
320
|
+
from_agent_id: this.agentId,
|
|
321
|
+
to_agent_id: toAgentId,
|
|
322
|
+
amount,
|
|
323
|
+
type: 'task_payment',
|
|
324
|
+
message_id: messageId || null,
|
|
325
|
+
description: description || `Payment for task`,
|
|
326
|
+
}),
|
|
327
|
+
});
|
|
328
|
+
if (!response.ok) {
|
|
329
|
+
const error = (await response.json());
|
|
330
|
+
throw new Error(`Transfer failed: ${error.error}`);
|
|
331
|
+
}
|
|
332
|
+
// deno-lint-ignore no-explicit-any
|
|
333
|
+
const data = (await response.json());
|
|
334
|
+
return data.transaction_id;
|
|
335
|
+
}
|
|
336
|
+
// ============================================================
|
|
337
|
+
// Messaging
|
|
338
|
+
// ============================================================
|
|
339
|
+
/**
|
|
340
|
+
* Send a message to another agent.
|
|
341
|
+
*
|
|
342
|
+
* Composes the OACP Message Envelope, signs it with this agent's
|
|
343
|
+
* Ed25519 key, and POSTs to the registry message bus.
|
|
344
|
+
*
|
|
345
|
+
* @param recipientId - Target agent's agent_id
|
|
346
|
+
* @param messageType - Message type (task_request, task_ack, task_result, task_error)
|
|
347
|
+
* @param payload - Message payload object
|
|
348
|
+
* @param options - Optional correlation_id and sequence
|
|
349
|
+
* @returns The message_id assigned by the registry
|
|
350
|
+
*/
|
|
351
|
+
async send(recipientId, messageType, payload, options) {
|
|
352
|
+
if (!this.agentId || !this.registryToken) {
|
|
353
|
+
throw new Error('Agent must be registered before sending messages');
|
|
354
|
+
}
|
|
355
|
+
// Canonicalize and sign
|
|
356
|
+
const canonical = canonicalizeMessage({
|
|
357
|
+
sender_agent_id: this.agentId,
|
|
358
|
+
recipient_agent_id: recipientId,
|
|
359
|
+
message_type: messageType,
|
|
360
|
+
payload,
|
|
361
|
+
correlation_id: options?.correlationId,
|
|
362
|
+
sequence: options?.sequence,
|
|
363
|
+
});
|
|
364
|
+
const signature = signMessage(canonical, this.keypair.secretKey);
|
|
365
|
+
const body = {
|
|
366
|
+
sender_agent_id: this.agentId,
|
|
367
|
+
recipient_agent_id: recipientId,
|
|
368
|
+
message_type: messageType,
|
|
369
|
+
payload,
|
|
370
|
+
signature,
|
|
371
|
+
};
|
|
372
|
+
if (options?.correlationId)
|
|
373
|
+
body.correlation_id = options.correlationId;
|
|
374
|
+
if (options?.sequence !== undefined)
|
|
375
|
+
body.sequence = options.sequence;
|
|
376
|
+
const response = await fetch(`${this.apiBaseUrl}/messages/send`, {
|
|
377
|
+
method: 'POST',
|
|
378
|
+
headers: {
|
|
379
|
+
'Content-Type': 'application/json',
|
|
380
|
+
Authorization: `Bearer ${this.registryToken}`,
|
|
381
|
+
},
|
|
382
|
+
body: JSON.stringify(body),
|
|
383
|
+
});
|
|
384
|
+
if (!response.ok) {
|
|
385
|
+
const error = (await response.json());
|
|
386
|
+
throw new Error(`Send failed (${response.status}): ${error.error}`);
|
|
387
|
+
}
|
|
388
|
+
// deno-lint-ignore no-explicit-any
|
|
389
|
+
const result = (await response.json());
|
|
390
|
+
console.log(`[OACP] Sent ${messageType} to ${recipientId.substring(0, 8)}... (msg: ${result.message_id})`);
|
|
391
|
+
return result.message_id;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Get messages for this agent from the registry.
|
|
395
|
+
*
|
|
396
|
+
* @param options - Filter options (direction, messageType, correlationId, since, limit)
|
|
397
|
+
*/
|
|
398
|
+
async getMessages(options = {}) {
|
|
399
|
+
if (!this.agentId || !this.registryToken) {
|
|
400
|
+
throw new Error('Agent must be registered before retrieving messages');
|
|
401
|
+
}
|
|
402
|
+
const params = new URLSearchParams();
|
|
403
|
+
params.set('agent_id', this.agentId);
|
|
404
|
+
if (options.direction)
|
|
405
|
+
params.set('direction', options.direction);
|
|
406
|
+
if (options.messageType)
|
|
407
|
+
params.set('message_type', options.messageType);
|
|
408
|
+
if (options.correlationId)
|
|
409
|
+
params.set('correlation_id', options.correlationId);
|
|
410
|
+
if (options.since)
|
|
411
|
+
params.set('since', options.since);
|
|
412
|
+
if (options.limit)
|
|
413
|
+
params.set('limit', String(options.limit));
|
|
414
|
+
const response = await fetch(`${this.apiBaseUrl}/messages?${params.toString()}`, {
|
|
415
|
+
method: 'GET',
|
|
416
|
+
headers: {
|
|
417
|
+
'Content-Type': 'application/json',
|
|
418
|
+
Authorization: `Bearer ${this.registryToken}`,
|
|
419
|
+
},
|
|
420
|
+
});
|
|
421
|
+
if (!response.ok) {
|
|
422
|
+
const error = (await response.json());
|
|
423
|
+
throw new Error(`Get messages failed (${response.status}): ${error.error}`);
|
|
424
|
+
}
|
|
425
|
+
// deno-lint-ignore no-explicit-any
|
|
426
|
+
const result = (await response.json());
|
|
427
|
+
return {
|
|
428
|
+
messages: result.messages,
|
|
429
|
+
total: result.total,
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Register an event handler for incoming messages.
|
|
434
|
+
*
|
|
435
|
+
* Uses Supabase Realtime to subscribe to the messages table,
|
|
436
|
+
* filtering for messages addressed to this agent.
|
|
437
|
+
*
|
|
438
|
+
* @param eventType - Message type to listen for, or '*' for all
|
|
439
|
+
* @param handler - Callback receiving the message envelope
|
|
440
|
+
*/
|
|
441
|
+
on(eventType, handler) {
|
|
442
|
+
const handlers = this.messageHandlers.get(eventType) || [];
|
|
443
|
+
handlers.push(handler);
|
|
444
|
+
this.messageHandlers.set(eventType, handlers);
|
|
445
|
+
// Start Realtime subscription if not already running
|
|
446
|
+
if (!this.realtimeChannel) {
|
|
447
|
+
this.startRealtimeSubscription();
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Remove event handlers for a message type.
|
|
452
|
+
*/
|
|
453
|
+
off(eventType) {
|
|
454
|
+
this.messageHandlers.delete(eventType);
|
|
455
|
+
// If no handlers left, unsubscribe from Realtime
|
|
456
|
+
if (this.messageHandlers.size === 0 && this.realtimeChannel) {
|
|
457
|
+
this.stopRealtimeSubscription();
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Start Supabase Realtime subscription for incoming messages.
|
|
462
|
+
* Called automatically after register() if handlers are registered.
|
|
463
|
+
* @private
|
|
464
|
+
*/
|
|
465
|
+
async startRealtimeSubscription() {
|
|
466
|
+
if (!this.agentId) {
|
|
467
|
+
// Not registered yet — will be called again after register()
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
// Already subscribed
|
|
471
|
+
if (this.realtimeChannel)
|
|
472
|
+
return;
|
|
473
|
+
try {
|
|
474
|
+
// Dynamic import for ESM compatibility
|
|
475
|
+
const { createClient } = await import('@supabase/supabase-js');
|
|
476
|
+
if (!this.supabaseClient) {
|
|
477
|
+
this.supabaseClient = createClient(this.config.supabaseUrl || OACP_PUBLIC_REGISTRY_URL, this.config.supabaseAnonKey || OACP_PUBLIC_REGISTRY_KEY);
|
|
478
|
+
}
|
|
479
|
+
const channelName = `oacp-messages-${this.agentId}`;
|
|
480
|
+
this.realtimeChannel = this.supabaseClient
|
|
481
|
+
.channel(channelName)
|
|
482
|
+
.on('postgres_changes', {
|
|
483
|
+
event: 'INSERT',
|
|
484
|
+
schema: 'public',
|
|
485
|
+
table: 'messages',
|
|
486
|
+
filter: `recipient_agent_id=eq.${this.agentId}`,
|
|
487
|
+
},
|
|
488
|
+
// deno-lint-ignore no-explicit-any
|
|
489
|
+
(payload) => {
|
|
490
|
+
const msg = payload.new;
|
|
491
|
+
this.dispatchMessage(msg);
|
|
492
|
+
})
|
|
493
|
+
.subscribe((status) => {
|
|
494
|
+
if (status === 'SUBSCRIBED') {
|
|
495
|
+
console.log(`[OACP] Realtime subscription active for ${this.agentId}`);
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
catch (err) {
|
|
500
|
+
console.warn('[OACP] Supabase Realtime not available — use getMessages() for polling instead', err instanceof Error ? err.message : err);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Stop Realtime subscription.
|
|
505
|
+
* @private
|
|
506
|
+
*/
|
|
507
|
+
stopRealtimeSubscription() {
|
|
508
|
+
if (this.realtimeChannel && this.supabaseClient) {
|
|
509
|
+
this.supabaseClient.removeChannel(this.realtimeChannel);
|
|
510
|
+
this.realtimeChannel = null;
|
|
511
|
+
console.log('[OACP] Realtime subscription stopped');
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Register an action with a trust-validated handler.
|
|
516
|
+
*
|
|
517
|
+
* This is the preferred way to handle incoming tasks. The handler
|
|
518
|
+
* only runs if:
|
|
519
|
+
* 1. The sender is allowed by the trust policy
|
|
520
|
+
* 2. The message matches a declared action_spec
|
|
521
|
+
* 3. The payload validates against the action's inputSchema
|
|
522
|
+
*
|
|
523
|
+
* @param spec - The action specification
|
|
524
|
+
* @param handler - Async function receiving validated payload and full message.
|
|
525
|
+
* Return a result object to auto-send task_result back.
|
|
526
|
+
*
|
|
527
|
+
* @example
|
|
528
|
+
* agent.registerAction(
|
|
529
|
+
* {
|
|
530
|
+
* name: 'summarize-text',
|
|
531
|
+
* description: 'Summarize a topic',
|
|
532
|
+
* inputSchema: { type: 'object', properties: { task: { type: 'string' } }, required: ['task'] },
|
|
533
|
+
* },
|
|
534
|
+
* async (payload, msg) => {
|
|
535
|
+
* const summary = await doResearch(payload.task);
|
|
536
|
+
* return { summary, word_count: summary.split(' ').length };
|
|
537
|
+
* },
|
|
538
|
+
* );
|
|
539
|
+
*/
|
|
540
|
+
registerAction(spec, handler) {
|
|
541
|
+
// Add to action specs if not already present
|
|
542
|
+
if (!this.actionSpecs.find((s) => s.name === spec.name)) {
|
|
543
|
+
this.actionSpecs.push(spec);
|
|
544
|
+
}
|
|
545
|
+
this.actionHandlers.set(spec.name, handler);
|
|
546
|
+
// Ensure we're listening for task_requests
|
|
547
|
+
if (!this.messageHandlers.has('__trust_pipeline')) {
|
|
548
|
+
this.messageHandlers.set('__trust_pipeline', []);
|
|
549
|
+
this.on('task_request', (msg) => {
|
|
550
|
+
this.handleTrustedTask(msg).catch((err) => {
|
|
551
|
+
console.error('[OACP] Trust pipeline error:', err);
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
console.log(`[OACP] Registered action: ${spec.name} — "${spec.description}"`);
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Process a task_request through the trust pipeline.
|
|
559
|
+
* @private
|
|
560
|
+
*/
|
|
561
|
+
async handleTrustedTask(msg) {
|
|
562
|
+
const payload = (msg.payload || {});
|
|
563
|
+
const senderId = msg.sender_agent_id;
|
|
564
|
+
// Step 1: Check sender trust
|
|
565
|
+
const senderCheck = isSenderAllowed(senderId, this.trustPolicy);
|
|
566
|
+
if (!senderCheck.allowed) {
|
|
567
|
+
console.log(`[OACP] ⛔ Rejected task from ${senderId.substring(0, 8)}...: ${senderCheck.reason}`);
|
|
568
|
+
await this.send(senderId, 'task_error', {
|
|
569
|
+
error: 'unauthorized',
|
|
570
|
+
message: senderCheck.reason,
|
|
571
|
+
}, { correlationId: msg.correlation_id });
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
// Step 2: Match action
|
|
575
|
+
const actionMatch = matchAction(payload, this.actionSpecs);
|
|
576
|
+
if (!actionMatch.matched) {
|
|
577
|
+
console.log(`[OACP] ⛔ No matching action: ${actionMatch.reason}`);
|
|
578
|
+
await this.send(senderId, 'task_error', {
|
|
579
|
+
error: 'unknown_action',
|
|
580
|
+
message: actionMatch.reason,
|
|
581
|
+
available_actions: this.actionSpecs.map((s) => ({
|
|
582
|
+
name: s.name,
|
|
583
|
+
description: s.description,
|
|
584
|
+
})),
|
|
585
|
+
}, { correlationId: msg.correlation_id });
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
const action = actionMatch.matched;
|
|
589
|
+
// Step 3: Validate payload against inputSchema
|
|
590
|
+
const validation = validatePayload(payload, action.inputSchema);
|
|
591
|
+
if (!validation.valid) {
|
|
592
|
+
console.log(`[OACP] ⛔ Payload validation failed: ${validation.errors.join('; ')}`);
|
|
593
|
+
await this.send(senderId, 'task_error', {
|
|
594
|
+
error: 'invalid_payload',
|
|
595
|
+
message: `Payload does not match schema for action "${action.name}"`,
|
|
596
|
+
validation_errors: validation.errors,
|
|
597
|
+
}, { correlationId: msg.correlation_id });
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
// Step 4: Execute handler
|
|
601
|
+
console.log(`[OACP] ✅ Trust check passed — executing action: ${action.name}`);
|
|
602
|
+
const handler = this.actionHandlers.get(action.name);
|
|
603
|
+
if (!handler) {
|
|
604
|
+
console.error(`[OACP] No handler registered for action: ${action.name}`);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
try {
|
|
608
|
+
const result = await handler(payload, msg);
|
|
609
|
+
// If handler returns a result, auto-send task_result
|
|
610
|
+
if (result && typeof result === 'object') {
|
|
611
|
+
const resultMsgId = await this.send(senderId, 'task_result', result, {
|
|
612
|
+
correlationId: msg.correlation_id,
|
|
613
|
+
});
|
|
614
|
+
// Auto-collect payment if this action has a price
|
|
615
|
+
if (action.price && action.price > 0 && this.agentId) {
|
|
616
|
+
try {
|
|
617
|
+
// Call server-side payment collection — server verifies the task was real
|
|
618
|
+
const response = await fetch(`${this.apiBaseUrl}/collect-payment`, {
|
|
619
|
+
method: 'POST',
|
|
620
|
+
headers: {
|
|
621
|
+
'Content-Type': 'application/json',
|
|
622
|
+
Authorization: `Bearer ${this.registryToken}`,
|
|
623
|
+
},
|
|
624
|
+
body: JSON.stringify({
|
|
625
|
+
provider_agent_id: this.agentId,
|
|
626
|
+
requester_agent_id: senderId,
|
|
627
|
+
amount: action.price,
|
|
628
|
+
message_id: msg.message_id,
|
|
629
|
+
action_name: action.name,
|
|
630
|
+
}),
|
|
631
|
+
});
|
|
632
|
+
if (response.ok) {
|
|
633
|
+
// deno-lint-ignore no-explicit-any
|
|
634
|
+
const data = (await response.json());
|
|
635
|
+
console.log(`[OACP] 💰 Collected ${action.price} tokens from ${senderId.substring(0, 8)}... (tx: ${data.transaction_id})`);
|
|
636
|
+
}
|
|
637
|
+
else {
|
|
638
|
+
const errText = await response.text();
|
|
639
|
+
console.warn(`[OACP] ⚠️ Payment collection failed (${response.status}): ${errText.substring(0, 100)}`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
catch (payErr) {
|
|
643
|
+
console.warn(`[OACP] ⚠️ Payment collection error: ${payErr instanceof Error ? payErr.message : payErr}`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
catch (err) {
|
|
649
|
+
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
|
650
|
+
console.error(`[OACP] Action "${action.name}" failed:`, errorMsg);
|
|
651
|
+
await this.send(senderId, 'task_error', {
|
|
652
|
+
error: 'execution_error',
|
|
653
|
+
message: errorMsg,
|
|
654
|
+
action: action.name,
|
|
655
|
+
}, { correlationId: msg.correlation_id });
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* Dispatch an incoming message to registered handlers.
|
|
660
|
+
* @private
|
|
661
|
+
*/
|
|
662
|
+
dispatchMessage(msg) {
|
|
663
|
+
console.log(`[OACP] Received ${msg.message_type} from ${msg.sender_agent_id?.substring(0, 8)}...`);
|
|
664
|
+
// Dispatch to type-specific handlers
|
|
665
|
+
const typeHandlers = this.messageHandlers.get(msg.message_type) || [];
|
|
666
|
+
for (const handler of typeHandlers) {
|
|
667
|
+
try {
|
|
668
|
+
handler(msg);
|
|
669
|
+
}
|
|
670
|
+
catch (err) {
|
|
671
|
+
console.error(`[OACP] Handler error for ${msg.message_type}:`, err);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
// Dispatch to wildcard handlers
|
|
675
|
+
const wildcardHandlers = this.messageHandlers.get('*') || [];
|
|
676
|
+
for (const handler of wildcardHandlers) {
|
|
677
|
+
try {
|
|
678
|
+
handler(msg);
|
|
679
|
+
}
|
|
680
|
+
catch (err) {
|
|
681
|
+
console.error('[OACP] Wildcard handler error:', err);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
// ============================================================
|
|
686
|
+
// Crypto Utilities (exposed for message signing in M002)
|
|
687
|
+
// ============================================================
|
|
688
|
+
/**
|
|
689
|
+
* Sign a message with this agent's secret key.
|
|
690
|
+
* Returns the hex-encoded Ed25519 signature.
|
|
691
|
+
*/
|
|
692
|
+
sign(message) {
|
|
693
|
+
return signMessage(message, this.keypair.secretKey);
|
|
694
|
+
}
|
|
695
|
+
/**
|
|
696
|
+
* Verify a signature against a message and public key.
|
|
697
|
+
*/
|
|
698
|
+
verify(signature, message, publicKey) {
|
|
699
|
+
return verifySignature(signature, message, publicKey);
|
|
700
|
+
}
|
|
701
|
+
// ============================================================
|
|
702
|
+
// Lifecycle
|
|
703
|
+
// ============================================================
|
|
704
|
+
/**
|
|
705
|
+
* Gracefully shut down the agent.
|
|
706
|
+
* Stops heartbeat and cleans up resources.
|
|
707
|
+
*/
|
|
708
|
+
async shutdown() {
|
|
709
|
+
this.stopHeartbeat();
|
|
710
|
+
this.stopRealtimeSubscription();
|
|
711
|
+
this.messageHandlers.clear();
|
|
712
|
+
console.log(`[OACP] Agent "${this.config.name}" shut down`);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
//# sourceMappingURL=agent.js.map
|