a2acalling 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,463 @@
1
+ /**
2
+ * Federation API Routes
3
+ *
4
+ * Mount at: /api/federation
5
+ *
6
+ * Security notes:
7
+ * - Rate limiting is in-memory (resets on restart) - for production, use Redis
8
+ * - Body size should be limited by Express middleware (e.g., express.json({ limit: '100kb' }))
9
+ */
10
+
11
+ const { TokenStore } = require('../lib/tokens');
12
+ const crypto = require('crypto');
13
+
14
+ // Lazy-load conversation store (optional dependency)
15
+ let ConversationStore = null;
16
+ let conversationStore = null;
17
+ function getConversationStore() {
18
+ if (!ConversationStore) {
19
+ try {
20
+ ConversationStore = require('../lib/conversations').ConversationStore;
21
+ conversationStore = new ConversationStore();
22
+ if (!conversationStore.isAvailable()) {
23
+ conversationStore = null;
24
+ }
25
+ } catch (err) {
26
+ // Conversation storage not available
27
+ return null;
28
+ }
29
+ }
30
+ return conversationStore;
31
+ }
32
+
33
+ // Lazy-load call monitor
34
+ let CallMonitor = null;
35
+ let callMonitor = null;
36
+ function getCallMonitor(options = {}) {
37
+ if (!CallMonitor) {
38
+ try {
39
+ CallMonitor = require('../lib/call-monitor').CallMonitor;
40
+ } catch (err) {
41
+ return null;
42
+ }
43
+ }
44
+ if (!callMonitor && options.convStore) {
45
+ callMonitor = new CallMonitor(options);
46
+ callMonitor.start();
47
+ }
48
+ return callMonitor;
49
+ }
50
+
51
+ // Rate limiting state (in-memory - resets on restart)
52
+ // For production: use Redis or persistent store
53
+ const rateLimits = new Map();
54
+
55
+ // Constants
56
+ const MAX_MESSAGE_LENGTH = 10000; // 10KB max message
57
+ const MAX_TIMEOUT_SECONDS = 300; // 5 min max timeout
58
+ const MIN_TIMEOUT_SECONDS = 5; // 5 sec min timeout
59
+
60
+ function checkRateLimit(tokenId, limits = { minute: 10, hour: 100, day: 1000 }) {
61
+ const now = Date.now();
62
+ const minute = Math.floor(now / 60000);
63
+ const hour = Math.floor(now / 3600000);
64
+ const day = Math.floor(now / 86400000);
65
+
66
+ let state = rateLimits.get(tokenId);
67
+ if (!state) {
68
+ state = {
69
+ minute: { count: 0, bucket: minute },
70
+ hour: { count: 0, bucket: hour },
71
+ day: { count: 0, bucket: day }
72
+ };
73
+ rateLimits.set(tokenId, state);
74
+ }
75
+
76
+ // Reset buckets if needed
77
+ if (state.minute.bucket !== minute) state.minute = { count: 0, bucket: minute };
78
+ if (state.hour.bucket !== hour) state.hour = { count: 0, bucket: hour };
79
+ if (state.day.bucket !== day) state.day = { count: 0, bucket: day };
80
+
81
+ // Check limits
82
+ if (state.minute.count >= limits.minute) {
83
+ return { limited: true, error: 'rate_limited', message: 'Too many requests per minute', retryAfter: 60 };
84
+ }
85
+ if (state.hour.count >= limits.hour) {
86
+ return { limited: true, error: 'rate_limited', message: 'Too many requests per hour', retryAfter: 3600 };
87
+ }
88
+ if (state.day.count >= limits.day) {
89
+ return { limited: true, error: 'rate_limited', message: 'Too many requests per day', retryAfter: 86400 };
90
+ }
91
+
92
+ // Increment
93
+ state.minute.count++;
94
+ state.hour.count++;
95
+ state.day.count++;
96
+
97
+ return { limited: false };
98
+ }
99
+
100
+ /**
101
+ * Create federation routes
102
+ *
103
+ * @param {object} options
104
+ * @param {TokenStore} options.tokenStore - Token store instance
105
+ * @param {function} options.handleMessage - Async function to handle incoming messages
106
+ * @param {function} options.notifyOwner - Async function to notify owner of calls
107
+ * @param {object} options.rateLimits - Custom rate limits { minute, hour, day }
108
+ * @param {function} options.summarizer - Async function to summarize conversations
109
+ * @param {object} options.ownerContext - Owner context for summaries
110
+ * @param {number} options.idleTimeoutMs - Idle timeout for auto-conclude (default: 60000)
111
+ * @param {number} options.maxDurationMs - Max call duration (default: 300000)
112
+ */
113
+ function createRoutes(options = {}) {
114
+ const express = require('express');
115
+ const router = express.Router();
116
+
117
+ const tokenStore = options.tokenStore || new TokenStore();
118
+ const handleMessage = options.handleMessage || defaultMessageHandler;
119
+ const notifyOwner = options.notifyOwner || (() => Promise.resolve());
120
+ const limits = options.rateLimits || { minute: 10, hour: 100, day: 1000 };
121
+
122
+ // Initialize conversation store and call monitor
123
+ const convStore = getConversationStore();
124
+ const monitor = getCallMonitor({
125
+ convStore,
126
+ summarizer: options.summarizer,
127
+ notifyOwner,
128
+ ownerContext: options.ownerContext || {},
129
+ idleTimeoutMs: options.idleTimeoutMs || 60000,
130
+ maxDurationMs: options.maxDurationMs || 300000
131
+ });
132
+
133
+ /**
134
+ * GET /status
135
+ * Check if federation is enabled
136
+ */
137
+ router.get('/status', (req, res) => {
138
+ res.json({
139
+ federation: true,
140
+ version: require('../../package.json').version,
141
+ capabilities: ['invoke', 'multi-turn'],
142
+ rate_limits: limits
143
+ });
144
+ });
145
+
146
+ /**
147
+ * GET /ping
148
+ * Simple health check
149
+ */
150
+ router.get('/ping', (req, res) => {
151
+ res.json({ pong: true, timestamp: new Date().toISOString() });
152
+ });
153
+
154
+ /**
155
+ * POST /invoke
156
+ * Call the agent
157
+ */
158
+ router.post('/invoke', async (req, res) => {
159
+ // Extract token
160
+ const authHeader = req.headers.authorization;
161
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
162
+ return res.status(401).json({
163
+ success: false,
164
+ error: 'missing_token',
165
+ message: 'Authorization header required'
166
+ });
167
+ }
168
+
169
+ const token = authHeader.slice(7);
170
+
171
+ // Validate token
172
+ const validation = tokenStore.validate(token);
173
+ if (!validation.valid) {
174
+ // Use generic error to prevent token enumeration
175
+ // All invalid token states return same response
176
+ return res.status(401).json({
177
+ success: false,
178
+ error: 'unauthorized',
179
+ message: 'Invalid or expired token'
180
+ });
181
+ }
182
+
183
+ // Check rate limit
184
+ const rateCheck = checkRateLimit(validation.id, limits);
185
+ if (rateCheck.limited) {
186
+ res.set('Retry-After', rateCheck.retryAfter);
187
+ return res.status(429).json({
188
+ success: false,
189
+ error: rateCheck.error,
190
+ message: rateCheck.message
191
+ });
192
+ }
193
+
194
+ // Extract and validate request
195
+ const { message, conversation_id, caller, context, timeout_seconds = 60 } = req.body;
196
+
197
+ if (!message) {
198
+ return res.status(400).json({
199
+ success: false,
200
+ error: 'missing_message',
201
+ message: 'Message is required'
202
+ });
203
+ }
204
+
205
+ // Validate message length
206
+ if (typeof message !== 'string' || message.length > MAX_MESSAGE_LENGTH) {
207
+ return res.status(400).json({
208
+ success: false,
209
+ error: 'invalid_message',
210
+ message: `Message must be a string under ${MAX_MESSAGE_LENGTH} characters`
211
+ });
212
+ }
213
+
214
+ // Validate and bound timeout
215
+ const boundedTimeout = Math.max(MIN_TIMEOUT_SECONDS, Math.min(MAX_TIMEOUT_SECONDS, Number(timeout_seconds) || 60));
216
+
217
+ // Sanitize caller data (only allow expected fields)
218
+ const sanitizedCaller = caller ? {
219
+ name: String(caller.name || '').slice(0, 100),
220
+ instance: String(caller.instance || '').slice(0, 200),
221
+ context: String(caller.context || '').slice(0, 500)
222
+ } : {};
223
+
224
+ // Build federation context with secure conversation ID
225
+ const isNewConversation = !conversation_id;
226
+ const federationContext = {
227
+ mode: 'federation',
228
+ token_id: validation.id,
229
+ token_name: validation.name,
230
+ tier: validation.tier,
231
+ allowed_topics: validation.allowed_topics,
232
+ disclosure: validation.disclosure,
233
+ caller: sanitizedCaller,
234
+ conversation_id: conversation_id || `conv_${Date.now()}_${crypto.randomBytes(6).toString('hex')}`
235
+ };
236
+
237
+ // Track conversation if store available
238
+ if (convStore) {
239
+ try {
240
+ convStore.startConversation({
241
+ id: federationContext.conversation_id,
242
+ contactId: validation.id,
243
+ contactName: sanitizedCaller.name || validation.name,
244
+ tokenId: validation.id,
245
+ direction: 'inbound'
246
+ });
247
+
248
+ // Track activity for auto-conclude
249
+ if (monitor) {
250
+ monitor.trackActivity(federationContext.conversation_id, sanitizedCaller);
251
+ }
252
+
253
+ // Store incoming message
254
+ convStore.addMessage(federationContext.conversation_id, {
255
+ direction: 'inbound',
256
+ role: 'user',
257
+ content: message
258
+ });
259
+ } catch (err) {
260
+ console.error('[a2a] Conversation tracking error:', err.message);
261
+ }
262
+ }
263
+
264
+ try {
265
+ // Handle the message
266
+ const response = await handleMessage(message, federationContext, { timeout: boundedTimeout * 1000 });
267
+
268
+ // Store outgoing response
269
+ if (convStore) {
270
+ try {
271
+ convStore.addMessage(federationContext.conversation_id, {
272
+ direction: 'outbound',
273
+ role: 'assistant',
274
+ content: response.text
275
+ });
276
+ } catch (err) {
277
+ console.error('[a2a] Message storage error:', err.message);
278
+ }
279
+ }
280
+
281
+ // Notify owner if configured
282
+ if (validation.notify !== 'none') {
283
+ notifyOwner({
284
+ level: validation.notify,
285
+ token: validation,
286
+ caller,
287
+ context,
288
+ message,
289
+ response: response.text,
290
+ conversation_id: federationContext.conversation_id
291
+ }).catch(err => {
292
+ console.error('[a2a] Failed to notify owner:', err.message);
293
+ });
294
+ }
295
+
296
+ res.json({
297
+ success: true,
298
+ conversation_id: federationContext.conversation_id,
299
+ response: response.text,
300
+ can_continue: response.canContinue !== false,
301
+ tokens_remaining: validation.calls_remaining
302
+ });
303
+
304
+ } catch (err) {
305
+ console.error('[a2a] Message handling error:', err);
306
+ res.status(500).json({
307
+ success: false,
308
+ error: 'internal_error',
309
+ message: 'Failed to process message'
310
+ });
311
+ }
312
+ });
313
+
314
+ /**
315
+ * POST /end
316
+ * End a conversation and trigger summary generation
317
+ */
318
+ router.post('/end', async (req, res) => {
319
+ // Extract token
320
+ const authHeader = req.headers.authorization;
321
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
322
+ return res.status(401).json({
323
+ success: false,
324
+ error: 'unauthorized',
325
+ message: 'Authorization header required'
326
+ });
327
+ }
328
+
329
+ const token = authHeader.slice(7);
330
+ const validation = tokenStore.validate(token);
331
+ if (!validation.valid) {
332
+ return res.status(401).json({
333
+ success: false,
334
+ error: 'unauthorized',
335
+ message: 'Invalid or expired token'
336
+ });
337
+ }
338
+
339
+ const { conversation_id } = req.body;
340
+ if (!conversation_id) {
341
+ return res.status(400).json({
342
+ success: false,
343
+ error: 'missing_conversation_id',
344
+ message: 'conversation_id is required'
345
+ });
346
+ }
347
+
348
+ const convStore = getConversationStore();
349
+ if (!convStore) {
350
+ return res.json({ success: true, message: 'Conversation storage not enabled' });
351
+ }
352
+
353
+ try {
354
+ // Conclude with summarizer if available
355
+ const summarizer = options.summarizer || null;
356
+ const ownerContext = options.ownerContext || {};
357
+
358
+ const result = await convStore.concludeConversation(conversation_id, {
359
+ summarizer,
360
+ ownerContext
361
+ });
362
+
363
+ // Notify owner of conversation conclusion
364
+ if (validation.notify !== 'none' && result.success) {
365
+ const conv = convStore.getConversationContext(conversation_id);
366
+ notifyOwner({
367
+ level: validation.notify,
368
+ type: 'conversation_concluded',
369
+ token: validation,
370
+ conversation: conv
371
+ }).catch(err => {
372
+ console.error('[a2a] Failed to notify owner:', err.message);
373
+ });
374
+ }
375
+
376
+ res.json({
377
+ success: true,
378
+ conversation_id,
379
+ status: 'concluded',
380
+ summary: result.summary
381
+ });
382
+ } catch (err) {
383
+ console.error('[a2a] End conversation error:', err);
384
+ res.status(500).json({
385
+ success: false,
386
+ error: 'internal_error',
387
+ message: 'Failed to end conversation'
388
+ });
389
+ }
390
+ });
391
+
392
+ /**
393
+ * GET /conversations
394
+ * List conversations (requires auth)
395
+ * This is for the agent owner, not federated callers
396
+ */
397
+ router.get('/conversations', (req, res) => {
398
+ // This endpoint should be protected by local auth, not federation tokens
399
+ // For now, require an admin token or local access
400
+ const adminToken = req.headers['x-admin-token'];
401
+ if (adminToken !== process.env.A2A_ADMIN_TOKEN && req.ip !== '127.0.0.1') {
402
+ return res.status(401).json({ error: 'unauthorized' });
403
+ }
404
+
405
+ const convStore = getConversationStore();
406
+ if (!convStore) {
407
+ return res.json({ conversations: [], message: 'Conversation storage not enabled' });
408
+ }
409
+
410
+ const { contact_id, status, limit = 20 } = req.query;
411
+
412
+ const conversations = convStore.listConversations({
413
+ contactId: contact_id,
414
+ status,
415
+ limit: parseInt(limit),
416
+ includeMessages: false
417
+ });
418
+
419
+ res.json({ conversations });
420
+ });
421
+
422
+ /**
423
+ * GET /conversations/:id
424
+ * Get conversation details with context
425
+ */
426
+ router.get('/conversations/:id', (req, res) => {
427
+ const adminToken = req.headers['x-admin-token'];
428
+ if (adminToken !== process.env.A2A_ADMIN_TOKEN && req.ip !== '127.0.0.1') {
429
+ return res.status(401).json({ error: 'unauthorized' });
430
+ }
431
+
432
+ const convStore = getConversationStore();
433
+ if (!convStore) {
434
+ return res.status(404).json({ error: 'conversation_storage_disabled' });
435
+ }
436
+
437
+ const { recent_messages = 10 } = req.query;
438
+ const context = convStore.getConversationContext(
439
+ req.params.id,
440
+ parseInt(recent_messages)
441
+ );
442
+
443
+ if (!context) {
444
+ return res.status(404).json({ error: 'conversation_not_found' });
445
+ }
446
+
447
+ res.json(context);
448
+ });
449
+
450
+ return router;
451
+ }
452
+
453
+ /**
454
+ * Default message handler (placeholder)
455
+ */
456
+ async function defaultMessageHandler(message, context, options) {
457
+ return {
458
+ text: `[A2A Federation Active] Received message from ${context.caller?.name || 'unknown'}. Agent integration pending.`,
459
+ canContinue: true
460
+ };
461
+ }
462
+
463
+ module.exports = { createRoutes, checkRateLimit };
package/src/server.js ADDED
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * A2A Federation Server
4
+ *
5
+ * Standalone server for testing or running alongside OpenClaw.
6
+ *
7
+ * Usage:
8
+ * node src/server.js [--port 3001]
9
+ * PORT=3001 node src/server.js
10
+ */
11
+
12
+ const express = require('express');
13
+ const { createRoutes } = require('./routes/federation');
14
+ const { TokenStore } = require('./lib/tokens');
15
+
16
+ const port = process.env.PORT || parseInt(process.argv[2]) || 3001;
17
+
18
+ const app = express();
19
+ app.use(express.json());
20
+
21
+ // Initialize token store
22
+ const tokenStore = new TokenStore();
23
+
24
+ // Mount federation routes
25
+ app.use('/api/federation', createRoutes({
26
+ tokenStore,
27
+
28
+ // Default message handler - in production, this connects to the agent
29
+ async handleMessage(message, context, options) {
30
+ console.log(`[a2a] Received message from ${context.caller?.name || 'unknown'}: ${message}`);
31
+ return {
32
+ text: `[A2A Federation Active] Received: "${message}". Full agent integration pending.`,
33
+ canContinue: true
34
+ };
35
+ },
36
+
37
+ // Default owner notification - in production, this sends to chat
38
+ async notifyOwner({ level, token, caller, message, response }) {
39
+ console.log(`[a2a] Notification (${level}): ${caller?.name || 'unknown'} called via token "${token.name}"`);
40
+ console.log(`[a2a] Message: ${message}`);
41
+ console.log(`[a2a] Response: ${response}`);
42
+ }
43
+ }));
44
+
45
+ // Health check at root
46
+ app.get('/', (req, res) => {
47
+ res.json({ service: 'a2a-federation', status: 'ok' });
48
+ });
49
+
50
+ app.listen(port, () => {
51
+ console.log(`[a2a] Federation server listening on port ${port}`);
52
+ console.log(`[a2a] Endpoints:`);
53
+ console.log(`[a2a] GET /api/federation/status`);
54
+ console.log(`[a2a] GET /api/federation/ping`);
55
+ console.log(`[a2a] POST /api/federation/invoke`);
56
+ });