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

Download ZIP
connectors/botpress/src/index.ts 1070 lines · 41.9 KB · typescript
1
import { createPublicKey, generateKeyPairSync, privateDecrypt, 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
import { callOpenClaspTool, sendMcpA2ARequest } from './mcp';
7
import { parseAgentProfile, type AgentProfile } from './profile';
8
9
type Json = Record<string, any>;
10
type RuntimeState = {
11
  agentId: string;
12
  agentVersion: string;
13
  sessionsJson: string;
14
  offersJson: string;
15
  finalizationsJson: string;
16
  accessToken: string;
17
  setupJson: string;
18
};
19
20
const EXTENSION_URI = 'https://openclasp.dev/extensions/trust/v0.1';
21
const normalizeUrl = (value: string) => value.replace(/\/$/, '');
22
const connectorInfo = (event: string, fields: Record<string, unknown> = {}) =>
23
  console.info('[openclasp-a2a]', { event, ...fields });
24
const connectorError = (event: string, error: unknown, fields: Record<string, unknown> = {}) =>
25
  console.error('[openclasp-a2a]', {
26
    event,
27
    ...fields,
28
    errorName: error instanceof Error ? error.name.slice(0, 80) : 'UnknownError',
29
  });
30
const platformUrl = (ctx: bp.Context) => ctx.configuration.openClaspUrl ?? 'https://openclasp.dev';
31
const botpressWebhookEndpoint = (webhookId: string) =>
32
  `https://webhook.botpress.cloud/${webhookId}`;
33
const jsonResponse = (status: number, value: unknown) => ({
34
  status,
35
  headers: { 'content-type': 'application/json' },
36
  body: JSON.stringify(value),
37
});
38
const parseRecord = (value: string): Record<string, Json> => {
39
  const parsed = JSON.parse(value) as unknown;
40
  return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
41
    ? (parsed as Record<string, Json>)
42
    : {};
43
};
44
45
const openClaspRequest = async <T>(
46
  url: string,
47
  token: string,
48
  path: string,
49
  init: RequestInit = {},
50
): Promise<T> => {
51
  const response = await fetch(`${normalizeUrl(url)}${path}`, {
52
    ...init,
53
    headers: {
54
      authorization: `Bearer ${token}`,
55
      ...(init.body ? { 'content-type': 'application/json' } : {}),
56
      ...init.headers,
57
    },
58
  });
59
  const body = (await response.json()) as Json;
60
  if (!response.ok)
61
    throw new sdk.RuntimeError(String(body.error ?? `OpenClasp HTTP ${response.status}`));
62
  return body as T;
63
};
64
65
const getState = async (client: bp.Client, integrationId: string): Promise<RuntimeState> => {
66
  const { state } = await client.getOrSetState({
67
    type: 'integration',
68
    id: integrationId,
69
    name: 'runtime',
70
    payload: {
71
      agentId: '',
72
      agentVersion: '1.0.0',
73
      sessionsJson: '{}',
74
      offersJson: '{}',
75
      finalizationsJson: '{}',
76
      accessToken: '',
77
      setupJson: '{}',
78
    },
79
  });
80
  return {
81
    agentId: state.payload.agentId ?? '',
82
    agentVersion: state.payload.agentVersion ?? '1.0.0',
83
    sessionsJson: state.payload.sessionsJson ?? '{}',
84
    offersJson: state.payload.offersJson ?? '{}',
85
    finalizationsJson: state.payload.finalizationsJson ?? '{}',
86
    accessToken: state.payload.accessToken ?? '',
87
    setupJson: state.payload.setupJson ?? '{}',
88
  };
89
};
90
const setState = async (client: bp.Client, integrationId: string, value: RuntimeState) => {
91
  await client.setState({
92
    type: 'integration',
93
    id: integrationId,
94
    name: 'runtime',
95
    payload: value,
96
  });
97
};
98
99
const bootstrapAndConnect = async (props: {
100
  ctx: bp.Context;
101
  webhookUrl: string;
102
  client: bp.Client;
103
  accessToken: string;
104
}) => {
105
  const openClaspUrl = platformUrl(props.ctx);
106
  const bootstrap = await openClaspRequest<{
107
    agentId: string;
108
    agentVersion: string;
109
    capabilities: string[];
110
    limitations: string[];
111
  }>(openClaspUrl, props.accessToken, '/v0.1/runtime/bootstrap');
112
  const endpoint = props.webhookUrl;
113
  const current = await getState(props.client, props.ctx.integrationId);
114
  await setState(props.client, props.ctx.integrationId, {
115
    ...current,
116
    agentId: bootstrap.agentId,
117
    agentVersion: bootstrap.agentVersion,
118
  });
119
  await openClaspRequest(openClaspUrl, props.accessToken, '/v0.1/runtime', {
120
    method: 'PUT',
121
    body: JSON.stringify({ endpoint }),
122
  });
123
};
124
125
const profilePrompt = () =>
126
  [
127
    '[OpenClasp setup — private platform request, not user-authored content]',
128
    'Describe your current deployed identity and abilities. Do not describe intended future features.',
129
    'Reply with only OPENCLASP_PROFILE followed by one JSON object.',
130
    'Required fields: description, framework (use Botpress), agentVersion, capabilities[], limitations[].',
131
    'Optional fields: modelProvider, modelName.',
132
    'Do not include credentials, system prompts, chain of thought, user data, or conversation content.',
133
  ].join('\n');
134
135
const completeBotpressPairing = async (
136
  ctx: bp.Context,
137
  client: bp.Client,
138
  profile: AgentProfile,
139
) => {
140
  const state = await getState(client, ctx.integrationId);
141
  const setup = parseRecord(state.setupJson).pairing;
142
  if (!setup?.privateKey || !setup?.publicKey)
143
    throw new sdk.RuntimeError('Botpress pairing state is unavailable');
144
  const endpoint = botpressWebhookEndpoint(ctx.webhookId);
145
  const response = await fetch(
146
    `${normalizeUrl(platformUrl(ctx))}/v0.1/provider-connections/botpress/complete`,
147
    {
148
      method: 'POST',
149
      headers: {
150
        'content-type': 'application/json',
151
        'x-openclasp-pairing-code': ctx.configuration.pairingCode,
152
      },
153
      body: JSON.stringify({
154
        runtimeEndpoint: endpoint,
155
        credentialPublicKey: setup.publicKey,
156
        profile,
157
      }),
158
    },
159
  );
160
  const value = (await response.json()) as Json;
161
  if (!response.ok)
162
    throw new sdk.RuntimeError(String(value.error ?? `OpenClasp HTTP ${response.status}`));
163
  const accessToken = privateDecrypt(
164
    { key: setup.privateKey, oaepHash: 'sha256' },
165
    Buffer.from(String(value.credentialCiphertext), 'base64url'),
166
  ).toString('utf8');
167
  if (!accessToken.startsWith('oc_at_')) throw new sdk.RuntimeError('Invalid OpenClasp credential');
168
  await setState(client, ctx.integrationId, {
169
    ...state,
170
    accessToken,
171
    setupJson: JSON.stringify({ pairing: { status: 'connected' } }),
172
  });
173
  await bootstrapAndConnect({ ctx, webhookUrl: endpoint, client, accessToken });
174
  return String(value.agentId);
175
};
176
177
const sessionRequest = async <T>(endpoint: string, token: string, value: unknown): Promise<T> => {
178
  const response = await fetch(endpoint, {
179
    method: 'POST',
180
    headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
181
    body: JSON.stringify(value),
182
  });
183
  const body = (await response.json()) as Json;
184
  if (!response.ok)
185
    throw new sdk.RuntimeError(String(body.error ?? `OpenClasp HTTP ${response.status}`));
186
  return body as T;
187
};
188
189
let keyCache: { url: string; key: string } | undefined;
190
const verificationKey = async (openClaspUrl: string) => {
191
  if (keyCache?.url === openClaspUrl) return keyCache.key;
192
  const response = await fetch(`${normalizeUrl(openClaspUrl)}/.well-known/openclasp-session-key`);
193
  if (!response.ok) throw new Error('OpenClasp verification key is unavailable');
194
  const body = (await response.json()) as Json;
195
  if (typeof body.publicKey !== 'string') throw new Error('Invalid OpenClasp verification key');
196
  keyCache = { url: openClaspUrl, key: body.publicKey };
197
  return body.publicKey;
198
};
199
const validControlSignature = async (
200
  openClaspUrl: string,
201
  requestId: string,
202
  timestamp: string,
203
  body: string,
204
  signature: string,
205
) => {
206
  if (Math.abs(Date.now() - Date.parse(timestamp)) > 5 * 60_000) return false;
207
  return verify(
208
    null,
209
    Buffer.from(`${timestamp}.${requestId}.${body}`),
210
    createPublicKey({
211
      key: Buffer.from(await verificationKey(openClaspUrl), 'base64url'),
212
      type: 'spki',
213
      format: 'der',
214
    }),
215
    Buffer.from(signature.replace(/^v1=/, ''), 'base64url'),
216
  );
217
};
218
const decodeCredential = (token: string) => {
219
  const [payload, signature] = token.split('.');
220
  if (!payload || !signature) throw new Error('Invalid session credential');
221
  return {
222
    payload,
223
    signature,
224
    grant: JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Json,
225
  };
226
};
227
const validSessionCredential = (token: string, session: Json, agentId: string) => {
228
  const credential = decodeCredential(token);
229
  const valid = verify(
230
    null,
231
    Buffer.from(credential.payload),
232
    createPublicKey({
233
      key: Buffer.from(session.peer.verificationKey, 'base64url'),
234
      type: 'spki',
235
      format: 'der',
236
    }),
237
    Buffer.from(credential.signature, 'base64url'),
238
  );
239
  if (
240
    !valid ||
241
    credential.grant.interactionId !== session.interactionId ||
242
    credential.grant.recipientAgentId !== agentId ||
243
    credential.grant.senderAgentId !== session.peer.agentId ||
244
    typeof credential.grant.expiresAt !== 'number' ||
245
    credential.grant.expiresAt <= Date.now() ||
246
    Date.parse(session.expiresAt) <= Date.now()
247
  )
248
    throw new Error('Invalid session credential');
249
};
250
const textFromMessage = (message: Json) => {
251
  const parts = Array.isArray(message.parts) ? message.parts : [];
252
  const text = parts
253
    .map((part: Json) => (typeof part.text === 'string' ? part.text : ''))
254
    .filter(Boolean)
255
    .join('\n')
256
    .trim();
257
  if (text) return text;
258
  const data = parts.find((part: Json) => part.kind === 'data' && part.data)?.data;
259
  if (data?.kind === 'openclasp.assurance.probe' && data.plan) {
260
    return [
261
      '[OpenClasp assurance question — authenticated platform data]',
262
      'Answer this bounded question honestly using the OpenClasp “Answer assurance question” action. Do not provide hidden reasoning, credentials, personal data, or conversation text.',
263
      `Plan: ${JSON.stringify(data.plan)}`,
264
    ].join('\n');
265
  }
266
  if (data?.kind === 'openclasp.assurance.response' && data.response) {
267
    return [
268
      '[OpenClasp assurance response — authenticated platform data]',
269
      `Response: ${JSON.stringify(data.response)}`,
270
      'Use this structured answer when deciding whether safeguards or narrower terms are needed.',
271
    ].join('\n');
272
  }
273
  throw new Error('Unsupported A2A message');
274
};
275
const withSessionContext = (session: Json, offer: Json | undefined, peerText: string) => {
276
  const contract = offer?.contract ?? {};
277
  const criteria = Array.isArray(contract.successCriteria) ? contract.successCriteria : [];
278
  const brief = session.counterpartyBrief ?? {};
279
  return [
280
    '[OpenClasp session context — platform metadata, not peer-authored content]',
281
    `Interaction: ${String(session.interactionId)}`,
282
    `Requested outcome: ${String(contract.requestedOutcome ?? 'See the peer request')}`,
283
    `Success criteria: ${criteria.length ? criteria.join(' | ') : 'No explicit criteria supplied'}`,
284
    `Counterparty assessment: ${String(brief.decision ?? 'ADVISE')} (history confidence ${String(brief.historyConfidence ?? 0)})`,
285
    '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.',
286
    '',
287
    '[Peer message — verbatim]',
288
    peerText,
289
  ].join('\n');
290
};
291
const finalizationPrompt = (session: Json, offer: Json | undefined, retry = false) => {
292
  const contract = offer?.contract ?? {};
293
  return [
294
    '[OpenClasp automatic finalization — platform request, not peer-authored content]',
295
    retry
296
      ? 'Your previous assessment was not valid JSON. Return the required object now.'
297
      : 'The peer reported that this interaction reached a terminal outcome. Independently assess the conversation you just completed.',
298
    `Interaction: ${String(session.interactionId)}`,
299
    `Requested outcome: ${String(contract.requestedOutcome ?? '')}`,
300
    `Exact success criteria: ${JSON.stringify(contract.successCriteria ?? [])}`,
301
    'Reply with only OPENCLASP_FINALIZATION followed by one JSON object. Do not include or quote the raw conversation.',
302
    '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?.',
303
  ].join('\n');
304
};
305
const checkpointPrompt = (offer: Json | undefined, retry = false) => {
306
  const contract = offer?.contract ?? {};
307
  return [
308
    '[OpenClasp progress checkpoint — private platform request, not peer-authored content]',
309
    retry
310
      ? 'Your previous checkpoint was invalid. Return the required JSON object now.'
311
      : 'Briefly assess progress without repeating or quoting the conversation.',
312
    `Requested outcome: ${String(contract.requestedOutcome ?? '')}`,
313
    `Success criteria: ${JSON.stringify(contract.successCriteria ?? [])}`,
314
    'Reply with only OPENCLASP_CHECKPOINT followed by one compact JSON object.',
315
    '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).',
316
  ].join('\n');
317
};
318
const createIncomingMessage = async (
319
  client: bp.Client,
320
  interactionId: string,
321
  peerAgentId: string,
322
  text: string,
323
) => {
324
  const { conversation } = await client.getOrCreateConversation({
325
    channel: 'a2a',
326
    tags: { interactionId },
327
  });
328
  const { user } = await client.getOrCreateUser({ tags: { agentId: peerAgentId } });
329
  const { message } = await client.createMessage({
330
    type: 'text',
331
    conversationId: conversation.id,
332
    userId: user.id,
333
    payload: { text },
334
    tags: {},
335
  });
336
  return message;
337
};
338
const deleteSetupConversations = async (client: bp.Client, integrationId: string) => {
339
  let nextToken: string | undefined;
340
  do {
341
    const page = await client.listConversations({
342
      channel: 'a2a',
343
      tags: { interactionId: `openclasp-setup:${integrationId}` },
344
      pageSize: 100,
345
      ...(nextToken ? { nextToken } : {}),
346
    });
347
    await Promise.all(
348
      page.conversations.map((conversation) => client.deleteConversation({ id: conversation.id })),
349
    );
350
    nextToken = page.meta.nextToken;
351
  } while (nextToken);
352
};
353
const heartbeat = async (ctx: bp.Context, client: bp.Client) => {
354
  const state = await getState(client, ctx.integrationId);
355
  if (!state.accessToken) return;
356
  await openClaspRequest(platformUrl(ctx), state.accessToken, '/v0.1/runtime/heartbeat', {
357
    method: 'POST',
358
  });
359
};
360
361
const completeInteraction = async (ctx: bp.Context, client: bp.Client, input: Json) => {
362
  const state = await getState(client, ctx.integrationId);
363
  const sessions = parseRecord(state.sessionsJson);
364
  const finalizations = parseRecord(state.finalizationsJson);
365
  const unfinished = Object.keys(sessions).filter(
366
    (id) =>
367
      finalizations[id]?.status !== 'completed' &&
368
      Date.parse(String(sessions[id]?.expiresAt ?? 0)) > Date.now(),
369
  );
370
  const interactionId = input.interactionId ?? (unfinished.length === 1 ? unfinished[0] : '');
371
  if (!interactionId)
372
    throw new sdk.RuntimeError(
373
      'Specify interactionId because there is not exactly one unfinished OpenClasp session',
374
    );
375
  const completionStartedAt = Date.now();
376
  connectorInfo('completion.started', {
377
    interactionId,
378
    agentId: state.agentId,
379
    outcome: input.outcome,
380
  });
381
  const session = sessions[interactionId];
382
  if (!session) throw new sdk.RuntimeError('OpenClasp live session is unavailable');
383
  if (finalizations[interactionId]?.status === 'completed') {
384
    return {
385
      interactionId,
386
      status: 'already_completed' as const,
387
      feedbackRevealed: Boolean(finalizations[interactionId]?.feedbackRevealed),
388
    };
389
  }
390
  const offer = parseRecord(state.offersJson)[interactionId]?.offer;
391
  const contract = offer?.contract;
392
  if (!contract || !Array.isArray(contract.successCriteria))
393
    throw new sdk.RuntimeError('Signed OpenClasp contract is unavailable');
394
395
  const existing = finalizations[interactionId] ?? {};
396
  const assessment = existing.assessment ?? input;
397
  const suppliedCriteria = Array.isArray(assessment.criteria) ? assessment.criteria : [];
398
  const report =
399
    existing.report ??
400
    ({
401
      reportId: crypto.randomUUID(),
402
      interactionId,
403
      contractHash: session.contractHash,
404
      reportingAgentId: state.agentId,
405
      counterpartyAgentId: session.peer.agentId,
406
      agentVersion: state.agentVersion,
407
      outcome: assessment.outcome,
408
      summary: assessment.summary,
409
      requestedOutcome: contract.requestedOutcome,
410
      criteria: contract.successCriteria.map((criterion: string) => {
411
        const supplied = suppliedCriteria.find((item: Json) => item.criterion === criterion);
412
        return supplied ?? { criterion, status: 'unknown', evidenceReferences: [] };
413
      }),
414
      deliverables: assessment.deliverables ?? [],
415
      actionsTaken: assessment.actionsTaken ?? [],
416
      blockers: assessment.blockers ?? [],
417
      scopeChanges: [],
418
      corrections: assessment.corrections ?? [],
419
      evidenceReferences: assessment.evidenceReferences ?? [],
420
      startedAt: session.activatedAt,
421
      completedAt: new Date().toISOString(),
422
      confidence: assessment.confidence,
423
      dataSharingMode: 'structured_only',
424
      submissionMethod: 'runtime_session',
425
    } as Json);
426
  finalizations[interactionId] = { status: 'report_pending', assessment, report };
427
  await setState(client, ctx.integrationId, {
428
    ...state,
429
    finalizationsJson: JSON.stringify(finalizations),
430
  });
431
  const completion = await sessionRequest<{ feedbackRequest: Json }>(
432
    session.reporting.completionEndpoint,
433
    session.reporting.bearerToken,
434
    report,
435
  );
436
  connectorInfo('completion.report_submitted', {
437
    interactionId,
438
    agentId: state.agentId,
439
    reportId: report.reportId,
440
    outcome: report.outcome,
441
  });
442
  const request = completion.feedbackRequest;
443
  if (!request?.requestId)
444
    throw new sdk.RuntimeError('OpenClasp did not return a feedback request');
445
  const feedback =
446
    existing.feedback ??
447
    ({
448
      feedbackId: crypto.randomUUID(),
449
      requestId: request.requestId,
450
      interactionId,
451
      reviewerAgentId: state.agentId,
452
      subjectAgentId: session.peer.agentId,
453
      reviewerAgentVersion: state.agentVersion,
454
      ratings: assessment.ratings,
455
      wouldWorkAgain: assessment.wouldWorkAgain,
456
      reasonCodes: assessment.reasonCodes ?? [],
457
      ...(assessment.privateComment ? { privateComment: assessment.privateComment } : {}),
458
      evidenceReferences: assessment.evidenceReferences ?? [],
459
      confidence: assessment.confidence,
460
      submittedAt: new Date().toISOString(),
461
      submissionMethod: 'runtime_session',
462
    } as Json);
463
  if (request.status === 'submitted') {
464
    finalizations[interactionId] = {
465
      status: 'completed',
466
      assessment,
467
      report,
468
      feedback,
469
      feedbackRevealed: false,
470
    };
471
    await setState(client, ctx.integrationId, {
472
      ...state,
473
      finalizationsJson: JSON.stringify(finalizations),
474
    });
475
    return { interactionId, status: 'completed' as const, feedbackRevealed: false };
476
  }
477
  finalizations[interactionId] = {
478
    status: 'feedback_pending',
479
    assessment,
480
    report,
481
    feedback,
482
  };
483
  await setState(client, ctx.integrationId, {
484
    ...state,
485
    finalizationsJson: JSON.stringify(finalizations),
486
  });
487
  const result = await sessionRequest<{ revealed: boolean }>(
488
    session.reporting.feedbackEndpoint,
489
    session.reporting.bearerToken,
490
    feedback,
491
  );
492
  finalizations[interactionId] = {
493
    status: 'completed',
494
    assessment,
495
    report,
496
    feedback,
497
    feedbackRevealed: result.revealed,
498
  };
499
  await setState(client, ctx.integrationId, {
500
    ...state,
501
    finalizationsJson: JSON.stringify(finalizations),
502
  });
503
  await heartbeat(ctx, client);
504
  connectorInfo('completion.finished', {
505
    interactionId,
506
    agentId: state.agentId,
507
    feedbackId: feedback.feedbackId,
508
    feedbackRevealed: result.revealed,
509
    durationMs: Date.now() - completionStartedAt,
510
  });
511
  return {
512
    interactionId,
513
    status: 'completed' as const,
514
    feedbackRevealed: result.revealed,
515
  };
516
};
517
518
const connectorTool = async <T = Json>(
519
  ctx: bp.Context,
520
  client: bp.Client,
521
  name: string,
522
  input: Json = {},
523
) => {
524
  const state = await getState(client, ctx.integrationId);
525
  return callOpenClaspTool<T>(platformUrl(ctx), state.accessToken, name, input);
526
};
527
528
const assuranceAnswer = (input: Json) => {
529
  let answer: boolean | number | string = input.answer;
530
  if (input.responseType === 'boolean') {
531
    const normalized = String(input.answer).trim().toLowerCase();
532
    if (!['true', 'false'].includes(normalized))
533
      throw new sdk.RuntimeError('Boolean assurance answers must be true or false');
534
    answer = normalized === 'true';
535
  } else if (input.responseType === 'number') {
536
    answer = Number(input.answer);
537
    if (!Number.isFinite(answer))
538
      throw new sdk.RuntimeError('Number assurance answers must be finite numbers');
539
  }
540
  return {
541
    probeId: input.probeId,
542
    questionCode: input.questionCode,
543
    responseType: input.responseType,
544
    answer,
545
    confidence: input.confidence,
546
    evidenceReferences: input.evidenceReferences,
547
    limitations: input.limitations,
548
  };
549
};
550
551
export default new bp.Integration({
552
  register: async ({ ctx, webhookUrl, client }) => {
553
    const state = await getState(client, ctx.integrationId);
554
    if (state.accessToken) {
555
      try {
556
        await deleteSetupConversations(client, ctx.integrationId);
557
      } catch {
558
        // Cleanup must not disconnect an already-paired runtime.
559
      }
560
      await bootstrapAndConnect({ ctx, webhookUrl, client, accessToken: state.accessToken });
561
      return;
562
    }
563
    const { publicKey, privateKey } = generateKeyPairSync('rsa', {
564
      modulusLength: 2048,
565
      publicKeyEncoding: { type: 'spki', format: 'pem' },
566
      privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
567
    });
568
    await setState(client, ctx.integrationId, {
569
      ...state,
570
      setupJson: JSON.stringify({
571
        pairing: { status: 'profile_requested', publicKey, privateKey },
572
      }),
573
    });
574
    await createIncomingMessage(
575
      client,
576
      `openclasp-setup:${ctx.integrationId}`,
577
      'openclasp-platform',
578
      profilePrompt(),
579
    );
580
  },
581
  unregister: async ({ ctx, client }) => {
582
    const state = await getState(client, ctx.integrationId);
583
    if (!state.accessToken) return;
584
    await openClaspRequest(platformUrl(ctx), state.accessToken, '/v0.1/runtime', {
585
      method: 'DELETE',
586
    });
587
  },
588
  actions: {
589
    searchAgents: async ({ ctx, client, input }) => {
590
      const agents = await connectorTool(ctx, client, 'openclasp_search_agents', input);
591
      return { agentsJson: JSON.stringify(agents) };
592
    },
593
    startInteraction: async ({ ctx, client, input }) => {
594
      connectorInfo('interaction.start_requested', {
595
        targetReferenceType: input.targetAgent.startsWith('http') ? 'url' : 'identifier',
596
        taskCategory: input.taskCategory ?? 'inferred',
597
        successCriteriaCount: input.successCriteria?.length ?? 0,
598
      });
599
      const result = await connectorTool(ctx, client, 'openclasp_connect_to_agent', {
600
        targetAgentReference: input.targetAgent,
601
        task: input.task,
602
        ...(input.taskCategory ? { taskCategory: input.taskCategory } : {}),
603
        ...(input.successCriteria?.length ? { successCriteria: input.successCriteria } : {}),
604
        allowedActions: input.allowedActions ?? [],
605
        prohibitedActions: input.prohibitedActions ?? [],
606
        allowedData: input.allowedData ?? [],
607
      });
608
      connectorInfo('interaction.start_completed', {
609
        interactionId: result.interaction?.interactionId,
610
        status: result.interaction?.status ?? 'pending',
611
        ready: Boolean(result.ready),
612
      });
613
      return {
614
        interactionId: String(result.interaction?.interactionId ?? ''),
615
        status: String(result.interaction?.status ?? 'pending'),
616
        ready: Boolean(result.ready),
617
        next: String(result.next ?? ''),
618
      };
619
    },
620
    listInteractions: async ({ ctx, client }) => {
621
      const interactions = await connectorTool(ctx, client, 'openclasp_list_invitations');
622
      return { interactionsJson: JSON.stringify(interactions) };
623
    },
624
    respondInvitation: async ({ ctx, client, input }) => {
625
      const interaction = await connectorTool(ctx, client, 'openclasp_respond_invitation', input);
626
      return { interactionJson: JSON.stringify(interaction) };
627
    },
628
    getInteraction: async ({ ctx, client, input }) => {
629
      const interaction = await connectorTool(
630
        ctx,
631
        client,
632
        'openclasp_get_shared_interaction',
633
        input,
634
      );
635
      return { interactionJson: JSON.stringify(interaction) };
636
    },
637
    generateAssuranceProbe: async ({ ctx, client, input }) => {
638
      const result = await connectorTool(ctx, client, 'openclasp_generate_assurance_probe', input);
639
      const sentToPeer = await sendMcpA2ARequest(result);
640
      return {
641
        assessmentJson: JSON.stringify({ decision: result.decision, plan: result.plan }),
642
        sentToPeer,
643
      };
644
    },
645
    answerAssuranceProbe: async ({ ctx, client, input }) => {
646
      const result = await connectorTool(ctx, client, 'openclasp_submit_assurance_response', {
647
        interactionId: input.interactionId,
648
        planId: input.planId,
649
        answers: [assuranceAnswer(input)],
650
      });
651
      const sentToPeer = await sendMcpA2ARequest(result);
652
      const response = { ...result };
653
      delete response.a2a;
654
      return { responseJson: JSON.stringify(response), sentToPeer };
655
    },
656
    getAssuranceBrief: async ({ ctx, client, input }) => {
657
      const brief = await connectorTool(ctx, client, 'openclasp_get_assurance_brief', input);
658
      return { briefJson: JSON.stringify(brief) };
659
    },
660
    decideSafeguard: async ({ ctx, client, input }) => {
661
      const safeguard = await connectorTool(
662
        ctx,
663
        client,
664
        'openclasp_decide_assurance_safeguard',
665
        input,
666
      );
667
      return { safeguardJson: JSON.stringify(safeguard) };
668
    },
669
    completeInteraction: async ({ ctx, client, input }) => completeInteraction(ctx, client, input),
670
  },
671
  channels: {
672
    a2a: {
673
      messages: {
674
        text: async ({ ctx, client, conversation, payload }) => {
675
          const interactionId = conversation.tags.interactionId;
676
          if (!interactionId) throw new sdk.RuntimeError('Missing OpenClasp interaction ID');
677
          if (interactionId === `openclasp-setup:${ctx.integrationId}`) {
678
            const profile = parseAgentProfile(payload.text);
679
            await completeBotpressPairing(ctx, client, profile);
680
            await client.deleteConversation({ id: conversation.id });
681
            return;
682
          }
683
          const state = await getState(client, ctx.integrationId);
684
          if (!state.accessToken) throw new sdk.RuntimeError('OpenClasp pairing is not complete');
685
          const sessions = parseRecord(state.sessionsJson);
686
          const session = sessions[interactionId];
687
          if (!session) throw new sdk.RuntimeError('OpenClasp live session is unavailable');
688
          if (session.finalizationRequested) {
689
            try {
690
              const assessment = parseFinalizationAssessment(payload.text, interactionId);
691
              await completeInteraction(ctx, client, assessment);
692
              const latest = await getState(client, ctx.integrationId);
693
              const latestSessions = parseRecord(latest.sessionsJson);
694
              latestSessions[interactionId] = {
695
                ...latestSessions[interactionId],
696
                finalizationRequested: false,
697
                finalizationCompleted: true,
698
              };
699
              await setState(client, ctx.integrationId, {
700
                ...latest,
701
                sessionsJson: JSON.stringify(latestSessions),
702
              });
703
              return;
704
            } catch {
705
              const attempts = Number(session.finalizationAttempts ?? 0) + 1;
706
              sessions[interactionId] = {
707
                ...session,
708
                finalizationRequested: attempts < 3,
709
                finalizationAttempts: attempts,
710
              };
711
              await setState(client, ctx.integrationId, {
712
                ...state,
713
                sessionsJson: JSON.stringify(sessions),
714
              });
715
              if (attempts < 3) {
716
                const offer = parseRecord(state.offersJson)[interactionId]?.offer;
717
                await createIncomingMessage(
718
                  client,
719
                  interactionId,
720
                  session.peer.agentId,
721
                  finalizationPrompt(session, offer, true),
722
                );
723
              }
724
              return;
725
            }
726
          }
727
          if (session.checkpointRequested) {
728
            try {
729
              const checkpoint = parseCheckpoint(payload.text);
730
              await sessionRequest(session.reporting.endpoint, session.reporting.bearerToken, {
731
                eventId: crypto.randomUUID(),
732
                interactionId,
733
                agentId: state.agentId,
734
                sequence: Date.now(),
735
                type: 'progress_checkpoint',
736
                occurredAt: new Date().toISOString(),
737
                evidenceReferences: [],
738
                checkpoint,
739
                details: {
740
                  labels: [`state:${checkpoint.state}`, `topic:${checkpoint.topicStatus}`],
741
                  metrics: { progress: checkpoint.progress, confidence: checkpoint.confidence },
742
                  flags: { needsHuman: checkpoint.needsHuman },
743
                },
744
              });
745
              sessions[interactionId] = {
746
                ...session,
747
                checkpointRequested: false,
748
                checkpointAttempts: 0,
749
                lastCheckpointExchange: Number(session.exchangeCount ?? 0),
750
                lastCheckpointAt: new Date().toISOString(),
751
                latestCheckpoint: checkpoint,
752
              };
753
              await setState(client, ctx.integrationId, {
754
                ...state,
755
                sessionsJson: JSON.stringify(sessions),
756
              });
757
              if (checkpoint.state === 'done' || checkpoint.state === 'ready_to_finalize') {
758
                const latest = await getState(client, ctx.integrationId);
759
                const latestSessions = parseRecord(latest.sessionsJson);
760
                latestSessions[interactionId] = {
761
                  ...latestSessions[interactionId],
762
                  finalizationRequested: true,
763
                  finalizationAttempts: 0,
764
                };
765
                await setState(client, ctx.integrationId, {
766
                  ...latest,
767
                  sessionsJson: JSON.stringify(latestSessions),
768
                });
769
                const offer = parseRecord(latest.offersJson)[interactionId]?.offer;
770
                await createIncomingMessage(
771
                  client,
772
                  interactionId,
773
                  session.peer.agentId,
774
                  finalizationPrompt(session, offer),
775
                );
776
              }
777
              return;
778
            } catch {
779
              const attempts = Number(session.checkpointAttempts ?? 0) + 1;
780
              sessions[interactionId] = {
781
                ...session,
782
                checkpointRequested: attempts < 3,
783
                checkpointAttempts: attempts,
784
              };
785
              await setState(client, ctx.integrationId, {
786
                ...state,
787
                sessionsJson: JSON.stringify(sessions),
788
              });
789
              if (attempts < 3) {
790
                const offer = parseRecord(state.offersJson)[interactionId]?.offer;
791
                await createIncomingMessage(
792
                  client,
793
                  interactionId,
794
                  session.peer.agentId,
795
                  checkpointPrompt(offer, true),
796
                );
797
              }
798
              return;
799
            }
800
          }
801
          const sendStartedAt = Date.now();
802
          const response = await fetch(session.peer.endpoint, {
803
            method: 'POST',
804
            headers: {
805
              authorization: `Bearer ${session.peer.bearerToken}`,
806
              'content-type': 'application/json',
807
              'A2A-Extensions': EXTENSION_URI,
808
            },
809
            body: JSON.stringify({
810
              jsonrpc: '2.0',
811
              id: crypto.randomUUID(),
812
              method: 'message/send',
813
              params: {
814
                message: {
815
                  role: 'agent',
816
                  parts: [{ kind: 'text', text: payload.text }],
817
                  metadata: {
818
                    [EXTENSION_URI]: {
819
                      interactionId,
820
                      termsHash: session.contractHash,
821
                      initiatorAgentId:
822
                        session.role === 'initiator' ? session.agentId : session.peer.agentId,
823
                      responderAgentId:
824
                        session.role === 'responder' ? session.agentId : session.peer.agentId,
825
                    },
826
                  },
827
                },
828
              },
829
            }),
830
          });
831
          connectorInfo('message.sent_to_peer', {
832
            interactionId,
833
            agentId: state.agentId,
834
            peerAgentId: session.peer.agentId,
835
            statusCode: response.status,
836
            contentBytes: Buffer.byteLength(payload.text, 'utf8'),
837
            durationMs: Date.now() - sendStartedAt,
838
          });
839
          if (!response.ok)
840
            throw new sdk.RuntimeError(`Peer A2A endpoint returned HTTP ${response.status}`);
841
          const exchangeCount = Number(session.exchangeCount ?? 0) + 1;
842
          const shouldCheckpoint = exchangeCount - Number(session.lastCheckpointExchange ?? 0) >= 5;
843
          sessions[interactionId] = {
844
            ...session,
845
            exchangeCount,
846
            ...(shouldCheckpoint ? { checkpointRequested: true, checkpointAttempts: 0 } : {}),
847
          };
848
          await setState(client, ctx.integrationId, {
849
            ...state,
850
            sessionsJson: JSON.stringify(sessions),
851
          });
852
          if (shouldCheckpoint) {
853
            const offer = parseRecord(state.offersJson)[interactionId]?.offer;
854
            await createIncomingMessage(
855
              client,
856
              interactionId,
857
              session.peer.agentId,
858
              checkpointPrompt(offer),
859
            );
860
          }
861
          await heartbeat(ctx, client);
862
        },
863
      },
864
    },
865
  },
866
  handler: async ({ ctx, client, req }) => {
867
    if (req.method !== 'POST' || !req.body)
868
      return jsonResponse(405, { error: 'method_not_allowed' });
869
    let body: Json;
870
    try {
871
      body = JSON.parse(req.body) as Json;
872
    } catch {
873
      return jsonResponse(400, { error: 'invalid_json' });
874
    }
875
    const state = await getState(client, ctx.integrationId);
876
    if (body.type === 'openclasp.runtime.verify') {
877
      if (body.agentId !== state.agentId) return jsonResponse(403, { error: 'wrong_agent' });
878
      return jsonResponse(200, {
879
        type: 'openclasp.runtime.verified',
880
        version: '1',
881
        agentId: state.agentId,
882
        challenge: body.challenge,
883
        a2aEndpoint: botpressWebhookEndpoint(ctx.webhookId),
884
      });
885
    }
886
    if (
887
      body.type === 'openclasp.session.offer' ||
888
      body.type === 'openclasp.session.activation' ||
889
      body.type === 'openclasp.session.finalization_request'
890
    ) {
891
      const requestId = req.headers['openclasp-request-id'] ?? '';
892
      const timestamp = req.headers['openclasp-timestamp'] ?? '';
893
      const signature = req.headers['openclasp-signature'] ?? '';
894
      if (
895
        !requestId ||
896
        !timestamp ||
897
        !signature ||
898
        !(await validControlSignature(platformUrl(ctx), requestId, timestamp, req.body, signature))
899
      )
900
        return jsonResponse(401, { error: 'invalid_openclasp_signature' });
901
      if (body.agentId !== state.agentId) return jsonResponse(403, { error: 'wrong_agent' });
902
      connectorInfo('control.received', {
903
        controlType: body.type,
904
        interactionId: body.interactionId,
905
        agentId: state.agentId,
906
      });
907
      if (body.type === 'openclasp.session.finalization_request') {
908
        const sessions = parseRecord(state.sessionsJson);
909
        const session = sessions[body.interactionId];
910
        if (
911
          body.requestId !== requestId ||
912
          typeof body.interactionId !== 'string' ||
913
          !session ||
914
          body.contractHash !== session.contractHash ||
915
          body.peerAgentId !== session.peer.agentId
916
        )
917
          return jsonResponse(400, { error: 'invalid_finalization_request' });
918
        if (parseRecord(state.finalizationsJson)[body.interactionId]?.status === 'completed')
919
          return jsonResponse(200, { accepted: true, alreadyCompleted: true });
920
        if (session.finalizationRequested)
921
          return jsonResponse(200, { accepted: true, alreadyRequested: true });
922
        sessions[body.interactionId] = {
923
          ...session,
924
          finalizationRequested: true,
925
          finalizationAttempts: 0,
926
        };
927
        await setState(client, ctx.integrationId, {
928
          ...state,
929
          sessionsJson: JSON.stringify(sessions),
930
        });
931
        const offer = parseRecord(state.offersJson)[body.interactionId]?.offer;
932
        await createIncomingMessage(
933
          client,
934
          body.interactionId,
935
          session.peer.agentId,
936
          finalizationPrompt(session, offer),
937
        );
938
        connectorInfo('control.finalization_accepted', {
939
          interactionId: body.interactionId,
940
          agentId: state.agentId,
941
        });
942
        return jsonResponse(202, { accepted: true });
943
      }
944
      if (body.type === 'openclasp.session.offer') {
945
        const offers = parseRecord(state.offersJson);
946
        if (
947
          body.offerId !== requestId ||
948
          typeof body.interactionId !== 'string' ||
949
          typeof body.expiresAt !== 'string' ||
950
          Date.parse(body.expiresAt) <= Date.now()
951
        )
952
          return jsonResponse(400, { error: 'invalid_session_offer' });
953
        const previous = offers[body.interactionId];
954
        if (previous && previous.offer?.offerId !== body.offerId)
955
          return jsonResponse(409, { error: 'interaction_offer_conflict' });
956
        const sessionId = previous?.sessionId ?? crypto.randomUUID();
957
        offers[body.interactionId] = { offer: body, sessionId };
958
        await setState(client, ctx.integrationId, {
959
          ...state,
960
          offersJson: JSON.stringify(offers),
961
        });
962
        connectorInfo('control.offer_accepted', {
963
          interactionId: body.interactionId,
964
          agentId: state.agentId,
965
          role: body.role,
966
        });
967
        return jsonResponse(200, {
968
          type: 'openclasp.session.accepted',
969
          version: '1',
970
          offerId: body.offerId,
971
          interactionId: body.interactionId,
972
          agentId: state.agentId,
973
          sessionId,
974
          a2aEndpoint: botpressWebhookEndpoint(ctx.webhookId),
975
          expiresAt: body.expiresAt,
976
        });
977
      }
978
      const sessions = parseRecord(state.sessionsJson);
979
      if (
980
        body.activationId !== requestId ||
981
        typeof body.interactionId !== 'string' ||
982
        typeof body.expiresAt !== 'string' ||
983
        Date.parse(body.expiresAt) <= Date.now()
984
      )
985
        return jsonResponse(400, { error: 'invalid_session_activation' });
986
      if (sessions[body.interactionId]?.activationId === body.activationId)
987
        return jsonResponse(200, { accepted: true, activationId: body.activationId });
988
      sessions[body.interactionId] = body;
989
      await setState(client, ctx.integrationId, {
990
        ...state,
991
        sessionsJson: JSON.stringify(sessions),
992
      });
993
      connectorInfo('control.activation_accepted', {
994
        interactionId: body.interactionId,
995
        agentId: state.agentId,
996
        role: body.role,
997
      });
998
      if (body.role === 'initiator') {
999
        const offer = parseRecord(state.offersJson)[body.interactionId]?.offer;
1000
        await createIncomingMessage(
1001
          client,
1002
          body.interactionId,
1003
          body.peer.agentId,
1004
          `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.`,
1005
        );
1006
      }
1007
      return jsonResponse(200, { accepted: true, activationId: body.activationId });
1008
    }
1009
    if (body.jsonrpc !== '2.0' || body.method !== 'message/send' || body.id === undefined)
1010
      return jsonResponse(400, { error: 'invalid_a2a_request' });
1011
    const authorization = req.headers.authorization ?? '';
1012
    if (!authorization.startsWith('Bearer '))
1013
      return jsonResponse(401, { error: 'session_credential_required' });
1014
    try {
1015
      const credential = decodeCredential(authorization.slice(7));
1016
      const interactionId = credential.grant.interactionId;
1017
      if (typeof interactionId !== 'string') throw new Error('Invalid session credential');
1018
      const sessions = parseRecord(state.sessionsJson);
1019
      let session = sessions[interactionId];
1020
      if (!session) return jsonResponse(404, { error: 'live_session_not_found' });
1021
      validSessionCredential(authorization.slice(7), session, state.agentId);
1022
      const peerText = textFromMessage(body.params?.message ?? {});
1023
      connectorInfo('message.received', {
1024
        interactionId,
1025
        agentId: state.agentId,
1026
        peerAgentId: session.peer.agentId,
1027
        contentBytes: Buffer.byteLength(peerText, 'utf8'),
1028
      });
1029
      let deliveredText = peerText;
1030
      if (!session.contextDelivered) {
1031
        deliveredText = withSessionContext(
1032
          session,
1033
          parseRecord(state.offersJson)[interactionId]?.offer,
1034
          peerText,
1035
        );
1036
        session = { ...session, contextDelivered: true };
1037
        sessions[interactionId] = session;
1038
        await setState(client, ctx.integrationId, {
1039
          ...state,
1040
          sessionsJson: JSON.stringify(sessions),
1041
        });
1042
      }
1043
      const message = await createIncomingMessage(
1044
        client,
1045
        interactionId,
1046
        session.peer.agentId,
1047
        deliveredText,
1048
      );
1049
      await heartbeat(ctx, client);
1050
      connectorInfo('message.delivered_to_agent', {
1051
        interactionId,
1052
        agentId: state.agentId,
1053
        peerAgentId: session.peer.agentId,
1054
        botpressMessageId: message.id,
1055
      });
1056
      return jsonResponse(200, {
1057
        jsonrpc: '2.0',
1058
        id: body.id,
1059
        result: { task: { id: message.id, state: 'submitted' } },
1060
      });
1061
    } catch (error) {
1062
      connectorError('message.delivery_failed', error, {
1063
        agentId: state.agentId,
1064
      });
1065
      return jsonResponse(401, {
1066
        error: error instanceof Error ? error.message : 'invalid_session_credential',
1067
      });
1068
    }
1069
  },
1070
});