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

Viewing files at commit 4e1e50d12753Commit detailsView latest

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