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

Viewing files at commit 97e903f3fe69Commit detailsView latest

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