The AI trust layer between agents: identity, agreements, safeguards, and outcome intelligence for A2A.

Viewing files at commit 03be656e7804Commit detailsView latest

connectors/botpress/src/index.ts 685 lines · 26.0 KB · typescript
1
import { createPublicKey, verify } from 'node:crypto';
2
import * as sdk from '@botpress/sdk';
3
import * as bp from '.botpress';
4
import { parseFinalizationAssessment } from './finalization';
5
6
type Json = Record<string, any>;
7
type RuntimeState = {
8
  agentId: string;
9
  agentVersion: string;
10
  sessionsJson: string;
11
  offersJson: string;
12
  finalizationsJson: string;
13
};
14
15
const EXTENSION_URI = 'https://openclasp.vercel.app/extensions/trust/v0.1';
16
const normalizeUrl = (value: string) => value.replace(/\/$/, '');
17
const platformUrl = (ctx: bp.Context) =>
18
  ctx.configuration.openClaspUrl ?? 'https://openclasp.vercel.app';
19
const botpressWebhookEndpoint = (webhookId: string) =>
20
  `https://webhook.botpress.cloud/${webhookId}`;
21
const jsonResponse = (status: number, value: unknown) => ({
22
  status,
23
  headers: { 'content-type': 'application/json' },
24
  body: JSON.stringify(value),
25
});
26
const parseRecord = (value: string): Record<string, Json> => {
27
  const parsed = JSON.parse(value) as unknown;
28
  return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
29
    ? (parsed as Record<string, Json>)
30
    : {};
31
};
32
const csv = (value?: string) => [
33
  ...new Set(
34
    (value ?? '')
35
      .split(',')
36
      .map((item) => item.trim())
37
      .filter(Boolean),
38
  ),
39
];
40
41
const openClaspRequest = async <T>(
42
  url: string,
43
  token: string,
44
  path: string,
45
  init: RequestInit = {},
46
): Promise<T> => {
47
  const response = await fetch(`${normalizeUrl(url)}${path}`, {
48
    ...init,
49
    headers: {
50
      authorization: `Bearer ${token}`,
51
      ...(init.body ? { 'content-type': 'application/json' } : {}),
52
      ...init.headers,
53
    },
54
  });
55
  const body = (await response.json()) as Json;
56
  if (!response.ok)
57
    throw new sdk.RuntimeError(String(body.error ?? `OpenClasp HTTP ${response.status}`));
58
  return body as T;
59
};
60
61
const getState = async (client: bp.Client, integrationId: string): Promise<RuntimeState> => {
62
  const { state } = await client.getOrSetState({
63
    type: 'integration',
64
    id: integrationId,
65
    name: 'runtime',
66
    payload: {
67
      agentId: '',
68
      agentVersion: '1.0.0',
69
      sessionsJson: '{}',
70
      offersJson: '{}',
71
      finalizationsJson: '{}',
72
    },
73
  });
74
  return {
75
    agentId: state.payload.agentId ?? '',
76
    agentVersion: state.payload.agentVersion ?? '1.0.0',
77
    sessionsJson: state.payload.sessionsJson ?? '{}',
78
    offersJson: state.payload.offersJson ?? '{}',
79
    finalizationsJson: state.payload.finalizationsJson ?? '{}',
80
  };
81
};
82
const setState = async (client: bp.Client, integrationId: string, value: RuntimeState) => {
83
  await client.setState({
84
    type: 'integration',
85
    id: integrationId,
86
    name: 'runtime',
87
    payload: value,
88
  });
89
};
90
91
const bootstrapAndConnect = async (props: {
92
  ctx: bp.Context;
93
  webhookUrl: string;
94
  client: bp.Client;
95
}) => {
96
  const { openClaspAgentToken } = props.ctx.configuration;
97
  const openClaspUrl = platformUrl(props.ctx);
98
  const bootstrap = await openClaspRequest<{
99
    agentId: string;
100
    agentVersion: string;
101
    capabilities: string[];
102
    limitations: string[];
103
  }>(openClaspUrl, openClaspAgentToken, '/v0.1/runtime/bootstrap');
104
  const endpoint = props.webhookUrl;
105
  const current = await getState(props.client, props.ctx.integrationId);
106
  await setState(props.client, props.ctx.integrationId, {
107
    ...current,
108
    agentId: bootstrap.agentId,
109
    agentVersion: bootstrap.agentVersion,
110
  });
111
  await openClaspRequest(openClaspUrl, openClaspAgentToken, '/v0.1/runtime', {
112
    method: 'PUT',
113
    body: JSON.stringify({ endpoint }),
114
  });
115
  const configuredCapabilities = csv(props.ctx.configuration.agentCapabilities);
116
  const capabilities = configuredCapabilities.length
117
    ? configuredCapabilities
118
    : bootstrap.capabilities;
119
  if (capabilities.length) {
120
    await openClaspRequest(openClaspUrl, openClaspAgentToken, '/v0.1/runtime/profile', {
121
      method: 'PUT',
122
      body: JSON.stringify({
123
        ...(props.ctx.configuration.agentDescription !== undefined
124
          ? { description: props.ctx.configuration.agentDescription }
125
          : {}),
126
        capabilities,
127
        limitations:
128
          props.ctx.configuration.agentLimitations !== undefined
129
            ? csv(props.ctx.configuration.agentLimitations)
130
            : bootstrap.limitations,
131
      }),
132
    });
133
  }
134
};
135
136
const sessionRequest = async <T>(endpoint: string, token: string, value: unknown): Promise<T> => {
137
  const response = await fetch(endpoint, {
138
    method: 'POST',
139
    headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
140
    body: JSON.stringify(value),
141
  });
142
  const body = (await response.json()) as Json;
143
  if (!response.ok)
144
    throw new sdk.RuntimeError(String(body.error ?? `OpenClasp HTTP ${response.status}`));
145
  return body as T;
146
};
147
148
let keyCache: { url: string; key: string } | undefined;
149
const verificationKey = async (openClaspUrl: string) => {
150
  if (keyCache?.url === openClaspUrl) return keyCache.key;
151
  const response = await fetch(`${normalizeUrl(openClaspUrl)}/.well-known/openclasp-session-key`);
152
  if (!response.ok) throw new Error('OpenClasp verification key is unavailable');
153
  const body = (await response.json()) as Json;
154
  if (typeof body.publicKey !== 'string') throw new Error('Invalid OpenClasp verification key');
155
  keyCache = { url: openClaspUrl, key: body.publicKey };
156
  return body.publicKey;
157
};
158
const validControlSignature = async (
159
  openClaspUrl: string,
160
  requestId: string,
161
  timestamp: string,
162
  body: string,
163
  signature: string,
164
) => {
165
  if (Math.abs(Date.now() - Date.parse(timestamp)) > 5 * 60_000) return false;
166
  return verify(
167
    null,
168
    Buffer.from(`${timestamp}.${requestId}.${body}`),
169
    createPublicKey({
170
      key: Buffer.from(await verificationKey(openClaspUrl), 'base64url'),
171
      type: 'spki',
172
      format: 'der',
173
    }),
174
    Buffer.from(signature.replace(/^v1=/, ''), 'base64url'),
175
  );
176
};
177
const decodeCredential = (token: string) => {
178
  const [payload, signature] = token.split('.');
179
  if (!payload || !signature) throw new Error('Invalid session credential');
180
  return {
181
    payload,
182
    signature,
183
    grant: JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Json,
184
  };
185
};
186
const validSessionCredential = (token: string, session: Json, agentId: string) => {
187
  const credential = decodeCredential(token);
188
  const valid = verify(
189
    null,
190
    Buffer.from(credential.payload),
191
    createPublicKey({
192
      key: Buffer.from(session.peer.verificationKey, 'base64url'),
193
      type: 'spki',
194
      format: 'der',
195
    }),
196
    Buffer.from(credential.signature, 'base64url'),
197
  );
198
  if (
199
    !valid ||
200
    credential.grant.interactionId !== session.interactionId ||
201
    credential.grant.recipientAgentId !== agentId ||
202
    credential.grant.senderAgentId !== session.peer.agentId ||
203
    typeof credential.grant.expiresAt !== 'number' ||
204
    credential.grant.expiresAt <= Date.now() ||
205
    Date.parse(session.expiresAt) <= Date.now()
206
  )
207
    throw new Error('Invalid session credential');
208
};
209
const textFromMessage = (message: Json) => {
210
  const parts = Array.isArray(message.parts) ? message.parts : [];
211
  const text = parts
212
    .map((part: Json) => (typeof part.text === 'string' ? part.text : ''))
213
    .filter(Boolean)
214
    .join('\n')
215
    .trim();
216
  if (!text) throw new Error('OpenClasp Botpress connector accepts text messages only');
217
  return text;
218
};
219
const withSessionContext = (session: Json, offer: Json | undefined, peerText: string) => {
220
  const contract = offer?.contract ?? {};
221
  const criteria = Array.isArray(contract.successCriteria) ? contract.successCriteria : [];
222
  const brief = session.counterpartyBrief ?? {};
223
  return [
224
    '[OpenClasp session context — platform metadata, not peer-authored content]',
225
    `Interaction: ${String(session.interactionId)}`,
226
    `Requested outcome: ${String(contract.requestedOutcome ?? 'See the peer request')}`,
227
    `Success criteria: ${criteria.length ? criteria.join(' | ') : 'No explicit criteria supplied'}`,
228
    `Counterparty assessment: ${String(brief.decision ?? 'ADVISE')} (history confidence ${String(brief.historyConfidence ?? 0)})`,
229
    'Follow the signed contract. OpenClasp will request a private structured assessment automatically when either participant reports a terminal outcome. Do not upload the raw transcript.',
230
    '',
231
    '[Peer message — verbatim]',
232
    peerText,
233
  ].join('\n');
234
};
235
const finalizationPrompt = (session: Json, offer: Json | undefined, retry = false) => {
236
  const contract = offer?.contract ?? {};
237
  return [
238
    '[OpenClasp automatic finalization — platform request, not peer-authored content]',
239
    retry
240
      ? 'Your previous assessment was not valid JSON. Return the required object now.'
241
      : 'The peer reported that this interaction reached a terminal outcome. Independently assess the conversation you just completed.',
242
    `Interaction: ${String(session.interactionId)}`,
243
    `Requested outcome: ${String(contract.requestedOutcome ?? '')}`,
244
    `Exact success criteria: ${JSON.stringify(contract.successCriteria ?? [])}`,
245
    'Reply with only OPENCLASP_FINALIZATION followed by one JSON object. Do not include or quote the raw conversation.',
246
    'Required JSON fields: outcome (success|partial|failure|cancelled), summary, criteria [{criterion,status,explanation?,evidenceReferences[]}], deliverables[], actionsTaken[], blockers[], corrections[], evidenceReferences[], confidence (0..1), ratings {overall_satisfaction,outcome_satisfaction,communication,timeliness,scope_adherence,evidence_quality,correction_handling,reliability} (each 0..1), wouldWorkAgain (yes|no|unsure), reasonCodes[], privateComment?.',
247
  ].join('\n');
248
};
249
const createIncomingMessage = async (
250
  client: bp.Client,
251
  interactionId: string,
252
  peerAgentId: string,
253
  text: string,
254
) => {
255
  const { conversation } = await client.getOrCreateConversation({
256
    channel: 'a2a',
257
    tags: { interactionId },
258
  });
259
  const { user } = await client.getOrCreateUser({ tags: { agentId: peerAgentId } });
260
  const { message } = await client.createMessage({
261
    type: 'text',
262
    conversationId: conversation.id,
263
    userId: user.id,
264
    payload: { text },
265
    tags: {},
266
  });
267
  return message;
268
};
269
const heartbeat = (ctx: bp.Context) =>
270
  openClaspRequest(
271
    platformUrl(ctx),
272
    ctx.configuration.openClaspAgentToken,
273
    '/v0.1/runtime/heartbeat',
274
    { method: 'POST' },
275
  );
276
277
const completeInteraction = async (ctx: bp.Context, client: bp.Client, input: Json) => {
278
  const state = await getState(client, ctx.integrationId);
279
  const sessions = parseRecord(state.sessionsJson);
280
  const finalizations = parseRecord(state.finalizationsJson);
281
  const unfinished = Object.keys(sessions).filter(
282
    (id) =>
283
      finalizations[id]?.status !== 'completed' &&
284
      Date.parse(String(sessions[id]?.expiresAt ?? 0)) > Date.now(),
285
  );
286
  const interactionId = input.interactionId ?? (unfinished.length === 1 ? unfinished[0] : '');
287
  if (!interactionId)
288
    throw new sdk.RuntimeError(
289
      'Specify interactionId because there is not exactly one unfinished OpenClasp session',
290
    );
291
  const session = sessions[interactionId];
292
  if (!session) throw new sdk.RuntimeError('OpenClasp live session is unavailable');
293
  if (finalizations[interactionId]?.status === 'completed') {
294
    return {
295
      interactionId,
296
      status: 'already_completed' as const,
297
      feedbackRevealed: Boolean(finalizations[interactionId]?.feedbackRevealed),
298
    };
299
  }
300
  const offer = parseRecord(state.offersJson)[interactionId]?.offer;
301
  const contract = offer?.contract;
302
  if (!contract || !Array.isArray(contract.successCriteria))
303
    throw new sdk.RuntimeError('Signed OpenClasp contract is unavailable');
304
305
  const existing = finalizations[interactionId] ?? {};
306
  const assessment = existing.assessment ?? input;
307
  const suppliedCriteria = Array.isArray(assessment.criteria) ? assessment.criteria : [];
308
  const report =
309
    existing.report ??
310
    ({
311
      reportId: crypto.randomUUID(),
312
      interactionId,
313
      contractHash: session.contractHash,
314
      reportingAgentId: state.agentId,
315
      counterpartyAgentId: session.peer.agentId,
316
      agentVersion: state.agentVersion,
317
      outcome: assessment.outcome,
318
      summary: assessment.summary,
319
      requestedOutcome: contract.requestedOutcome,
320
      criteria: contract.successCriteria.map((criterion: string) => {
321
        const supplied = suppliedCriteria.find((item: Json) => item.criterion === criterion);
322
        return supplied ?? { criterion, status: 'unknown', evidenceReferences: [] };
323
      }),
324
      deliverables: assessment.deliverables ?? [],
325
      actionsTaken: assessment.actionsTaken ?? [],
326
      blockers: assessment.blockers ?? [],
327
      scopeChanges: [],
328
      corrections: assessment.corrections ?? [],
329
      evidenceReferences: assessment.evidenceReferences ?? [],
330
      startedAt: session.activatedAt,
331
      completedAt: new Date().toISOString(),
332
      confidence: assessment.confidence,
333
      dataSharingMode: 'structured_only',
334
      submissionMethod: 'runtime_session',
335
    } as Json);
336
  finalizations[interactionId] = { status: 'report_pending', assessment, report };
337
  await setState(client, ctx.integrationId, {
338
    ...state,
339
    finalizationsJson: JSON.stringify(finalizations),
340
  });
341
  const completion = await sessionRequest<{ feedbackRequest: Json }>(
342
    session.reporting.completionEndpoint,
343
    session.reporting.bearerToken,
344
    report,
345
  );
346
  const request = completion.feedbackRequest;
347
  if (!request?.requestId)
348
    throw new sdk.RuntimeError('OpenClasp did not return a feedback request');
349
  const feedback =
350
    existing.feedback ??
351
    ({
352
      feedbackId: crypto.randomUUID(),
353
      requestId: request.requestId,
354
      interactionId,
355
      reviewerAgentId: state.agentId,
356
      subjectAgentId: session.peer.agentId,
357
      reviewerAgentVersion: state.agentVersion,
358
      ratings: assessment.ratings,
359
      wouldWorkAgain: assessment.wouldWorkAgain,
360
      reasonCodes: assessment.reasonCodes ?? [],
361
      ...(assessment.privateComment ? { privateComment: assessment.privateComment } : {}),
362
      evidenceReferences: assessment.evidenceReferences ?? [],
363
      confidence: assessment.confidence,
364
      submittedAt: new Date().toISOString(),
365
      submissionMethod: 'runtime_session',
366
    } as Json);
367
  if (request.status === 'submitted') {
368
    finalizations[interactionId] = {
369
      status: 'completed',
370
      assessment,
371
      report,
372
      feedback,
373
      feedbackRevealed: false,
374
    };
375
    await setState(client, ctx.integrationId, {
376
      ...state,
377
      finalizationsJson: JSON.stringify(finalizations),
378
    });
379
    return { interactionId, status: 'completed' as const, feedbackRevealed: false };
380
  }
381
  finalizations[interactionId] = {
382
    status: 'feedback_pending',
383
    assessment,
384
    report,
385
    feedback,
386
  };
387
  await setState(client, ctx.integrationId, {
388
    ...state,
389
    finalizationsJson: JSON.stringify(finalizations),
390
  });
391
  const result = await sessionRequest<{ revealed: boolean }>(
392
    session.reporting.feedbackEndpoint,
393
    session.reporting.bearerToken,
394
    feedback,
395
  );
396
  finalizations[interactionId] = {
397
    status: 'completed',
398
    assessment,
399
    report,
400
    feedback,
401
    feedbackRevealed: result.revealed,
402
  };
403
  await setState(client, ctx.integrationId, {
404
    ...state,
405
    finalizationsJson: JSON.stringify(finalizations),
406
  });
407
  await heartbeat(ctx);
408
  return {
409
    interactionId,
410
    status: 'completed' as const,
411
    feedbackRevealed: result.revealed,
412
  };
413
};
414
415
export default new bp.Integration({
416
  register: async ({ ctx, webhookUrl, client }) => {
417
    await bootstrapAndConnect({ ctx, webhookUrl, client });
418
  },
419
  unregister: async ({ ctx }) => {
420
    await openClaspRequest(
421
      platformUrl(ctx),
422
      ctx.configuration.openClaspAgentToken,
423
      '/v0.1/runtime',
424
      { method: 'DELETE' },
425
    );
426
  },
427
  actions: {
428
    completeInteraction: async ({ ctx, client, input }) => completeInteraction(ctx, client, input),
429
  },
430
  channels: {
431
    a2a: {
432
      messages: {
433
        text: async ({ ctx, client, conversation, payload }) => {
434
          const interactionId = conversation.tags.interactionId;
435
          if (!interactionId) throw new sdk.RuntimeError('Missing OpenClasp interaction ID');
436
          const state = await getState(client, ctx.integrationId);
437
          const sessions = parseRecord(state.sessionsJson);
438
          const session = sessions[interactionId];
439
          if (!session) throw new sdk.RuntimeError('OpenClasp live session is unavailable');
440
          if (session.finalizationRequested) {
441
            try {
442
              const assessment = parseFinalizationAssessment(payload.text, interactionId);
443
              await completeInteraction(ctx, client, assessment);
444
              const latest = await getState(client, ctx.integrationId);
445
              const latestSessions = parseRecord(latest.sessionsJson);
446
              latestSessions[interactionId] = {
447
                ...latestSessions[interactionId],
448
                finalizationRequested: false,
449
                finalizationCompleted: true,
450
              };
451
              await setState(client, ctx.integrationId, {
452
                ...latest,
453
                sessionsJson: JSON.stringify(latestSessions),
454
              });
455
              return;
456
            } catch {
457
              const attempts = Number(session.finalizationAttempts ?? 0) + 1;
458
              sessions[interactionId] = {
459
                ...session,
460
                finalizationRequested: attempts < 3,
461
                finalizationAttempts: attempts,
462
              };
463
              await setState(client, ctx.integrationId, {
464
                ...state,
465
                sessionsJson: JSON.stringify(sessions),
466
              });
467
              if (attempts < 3) {
468
                const offer = parseRecord(state.offersJson)[interactionId]?.offer;
469
                await createIncomingMessage(
470
                  client,
471
                  interactionId,
472
                  session.peer.agentId,
473
                  finalizationPrompt(session, offer, true),
474
                );
475
              }
476
              return;
477
            }
478
          }
479
          const response = await fetch(session.peer.endpoint, {
480
            method: 'POST',
481
            headers: {
482
              authorization: `Bearer ${session.peer.bearerToken}`,
483
              'content-type': 'application/json',
484
              'A2A-Extensions': EXTENSION_URI,
485
            },
486
            body: JSON.stringify({
487
              jsonrpc: '2.0',
488
              id: crypto.randomUUID(),
489
              method: 'message/send',
490
              params: {
491
                message: {
492
                  role: 'agent',
493
                  parts: [{ kind: 'text', text: payload.text }],
494
                  metadata: {
495
                    [EXTENSION_URI]: {
496
                      interactionId,
497
                      termsHash: session.contractHash,
498
                      initiatorAgentId:
499
                        session.role === 'initiator' ? session.agentId : session.peer.agentId,
500
                      responderAgentId:
501
                        session.role === 'responder' ? session.agentId : session.peer.agentId,
502
                    },
503
                  },
504
                },
505
              },
506
            }),
507
          });
508
          if (!response.ok)
509
            throw new sdk.RuntimeError(`Peer A2A endpoint returned HTTP ${response.status}`);
510
          await heartbeat(ctx);
511
        },
512
      },
513
    },
514
  },
515
  handler: async ({ ctx, client, req }) => {
516
    if (req.method !== 'POST' || !req.body)
517
      return jsonResponse(405, { error: 'method_not_allowed' });
518
    let body: Json;
519
    try {
520
      body = JSON.parse(req.body) as Json;
521
    } catch {
522
      return jsonResponse(400, { error: 'invalid_json' });
523
    }
524
    const state = await getState(client, ctx.integrationId);
525
    if (body.type === 'openclasp.runtime.verify') {
526
      if (body.agentId !== state.agentId) return jsonResponse(403, { error: 'wrong_agent' });
527
      return jsonResponse(200, {
528
        type: 'openclasp.runtime.verified',
529
        version: '1',
530
        agentId: state.agentId,
531
        challenge: body.challenge,
532
        a2aEndpoint: botpressWebhookEndpoint(ctx.webhookId),
533
      });
534
    }
535
    if (
536
      body.type === 'openclasp.session.offer' ||
537
      body.type === 'openclasp.session.activation' ||
538
      body.type === 'openclasp.session.finalization_request'
539
    ) {
540
      const requestId = req.headers['openclasp-request-id'] ?? '';
541
      const timestamp = req.headers['openclasp-timestamp'] ?? '';
542
      const signature = req.headers['openclasp-signature'] ?? '';
543
      if (
544
        !requestId ||
545
        !timestamp ||
546
        !signature ||
547
        !(await validControlSignature(platformUrl(ctx), requestId, timestamp, req.body, signature))
548
      )
549
        return jsonResponse(401, { error: 'invalid_openclasp_signature' });
550
      if (body.agentId !== state.agentId) return jsonResponse(403, { error: 'wrong_agent' });
551
      if (body.type === 'openclasp.session.finalization_request') {
552
        const sessions = parseRecord(state.sessionsJson);
553
        const session = sessions[body.interactionId];
554
        if (
555
          body.requestId !== requestId ||
556
          typeof body.interactionId !== 'string' ||
557
          !session ||
558
          body.contractHash !== session.contractHash ||
559
          body.peerAgentId !== session.peer.agentId
560
        )
561
          return jsonResponse(400, { error: 'invalid_finalization_request' });
562
        if (parseRecord(state.finalizationsJson)[body.interactionId]?.status === 'completed')
563
          return jsonResponse(200, { accepted: true, alreadyCompleted: true });
564
        if (session.finalizationRequested)
565
          return jsonResponse(200, { accepted: true, alreadyRequested: true });
566
        sessions[body.interactionId] = {
567
          ...session,
568
          finalizationRequested: true,
569
          finalizationAttempts: 0,
570
        };
571
        await setState(client, ctx.integrationId, {
572
          ...state,
573
          sessionsJson: JSON.stringify(sessions),
574
        });
575
        const offer = parseRecord(state.offersJson)[body.interactionId]?.offer;
576
        await createIncomingMessage(
577
          client,
578
          body.interactionId,
579
          session.peer.agentId,
580
          finalizationPrompt(session, offer),
581
        );
582
        return jsonResponse(202, { accepted: true });
583
      }
584
      if (body.type === 'openclasp.session.offer') {
585
        const offers = parseRecord(state.offersJson);
586
        if (
587
          body.offerId !== requestId ||
588
          typeof body.interactionId !== 'string' ||
589
          typeof body.expiresAt !== 'string' ||
590
          Date.parse(body.expiresAt) <= Date.now()
591
        )
592
          return jsonResponse(400, { error: 'invalid_session_offer' });
593
        const previous = offers[body.interactionId];
594
        if (previous && previous.offer?.offerId !== body.offerId)
595
          return jsonResponse(409, { error: 'interaction_offer_conflict' });
596
        const sessionId = previous?.sessionId ?? crypto.randomUUID();
597
        offers[body.interactionId] = { offer: body, sessionId };
598
        await setState(client, ctx.integrationId, {
599
          ...state,
600
          offersJson: JSON.stringify(offers),
601
        });
602
        return jsonResponse(200, {
603
          type: 'openclasp.session.accepted',
604
          version: '1',
605
          offerId: body.offerId,
606
          interactionId: body.interactionId,
607
          agentId: state.agentId,
608
          sessionId,
609
          a2aEndpoint: botpressWebhookEndpoint(ctx.webhookId),
610
          expiresAt: body.expiresAt,
611
        });
612
      }
613
      const sessions = parseRecord(state.sessionsJson);
614
      if (
615
        body.activationId !== requestId ||
616
        typeof body.interactionId !== 'string' ||
617
        typeof body.expiresAt !== 'string' ||
618
        Date.parse(body.expiresAt) <= Date.now()
619
      )
620
        return jsonResponse(400, { error: 'invalid_session_activation' });
621
      if (sessions[body.interactionId]?.activationId === body.activationId)
622
        return jsonResponse(200, { accepted: true, activationId: body.activationId });
623
      sessions[body.interactionId] = body;
624
      await setState(client, ctx.integrationId, {
625
        ...state,
626
        sessionsJson: JSON.stringify(sessions),
627
      });
628
      if (body.role === 'initiator') {
629
        const offer = parseRecord(state.offersJson)[body.interactionId]?.offer;
630
        await createIncomingMessage(
631
          client,
632
          body.interactionId,
633
          body.peer.agentId,
634
          `Start this accepted agent-to-agent task now. Contract: ${JSON.stringify(offer?.contract ?? {})}\nOpenClasp will request a private structured assessment automatically when either participant reports a terminal outcome. Do not upload the raw transcript.`,
635
        );
636
      }
637
      return jsonResponse(200, { accepted: true, activationId: body.activationId });
638
    }
639
    if (body.jsonrpc !== '2.0' || body.method !== 'message/send' || body.id === undefined)
640
      return jsonResponse(400, { error: 'invalid_a2a_request' });
641
    const authorization = req.headers.authorization ?? '';
642
    if (!authorization.startsWith('Bearer '))
643
      return jsonResponse(401, { error: 'session_credential_required' });
644
    try {
645
      const credential = decodeCredential(authorization.slice(7));
646
      const interactionId = credential.grant.interactionId;
647
      if (typeof interactionId !== 'string') throw new Error('Invalid session credential');
648
      const sessions = parseRecord(state.sessionsJson);
649
      let session = sessions[interactionId];
650
      if (!session) return jsonResponse(404, { error: 'live_session_not_found' });
651
      validSessionCredential(authorization.slice(7), session, state.agentId);
652
      const peerText = textFromMessage(body.params?.message ?? {});
653
      let deliveredText = peerText;
654
      if (!session.contextDelivered) {
655
        deliveredText = withSessionContext(
656
          session,
657
          parseRecord(state.offersJson)[interactionId]?.offer,
658
          peerText,
659
        );
660
        session = { ...session, contextDelivered: true };
661
        sessions[interactionId] = session;
662
        await setState(client, ctx.integrationId, {
663
          ...state,
664
          sessionsJson: JSON.stringify(sessions),
665
        });
666
      }
667
      const message = await createIncomingMessage(
668
        client,
669
        interactionId,
670
        session.peer.agentId,
671
        deliveredText,
672
      );
673
      await heartbeat(ctx);
674
      return jsonResponse(200, {
675
        jsonrpc: '2.0',
676
        id: body.id,
677
        result: { task: { id: message.id, state: 'submitted' } },
678
      });
679
    } catch (error) {
680
      return jsonResponse(401, {
681
        error: error instanceof Error ? error.message : 'invalid_session_credential',
682
      });
683
    }
684
  },
685
});