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

Viewing files at commit 81afd7789b27Commit detailsView latest

packages/persistence/src/hosted.ts 3123 lines · 120.8 KB · typescript
1
import { neon, type NeonQueryFunction } from '@neondatabase/serverless';
2
import { createHash, randomBytes } from 'node:crypto';
3
import {
4
  buildCounterpartyBrief,
5
  buildInteractionConclusion,
6
  deriveBehaviouralObservations,
7
  evaluateLearningEligibility,
8
  updateContextualBehaviouralProfile,
9
} from '../../core/src/index.js';
10
import {
11
  AgentIdentitySchema,
12
  BehaviouralProfileDeltaSchema,
13
  DEFAULT_EXTENSION_URI,
14
  CounterpartyBriefSchema,
15
  FederatedInteractionSchema,
16
  FeedbackDimensionSchema,
17
  FeedbackRequestSchema,
18
  LiveSessionAcceptanceSchema,
19
  LiveSessionActivationSchema,
20
  LiveSessionEventSchema,
21
  LiveSessionOfferSchema,
22
  HostedMessageSchema,
23
  HostedThreadSchema,
24
  InteractionCompletionReportSchema,
25
  InteractionConclusionSchema,
26
  InteractionFeedbackSchema,
27
  LearningEligibilityDecisionSchema,
28
  PublicAgentCardSchema,
29
  ReceiptSchema,
30
  canonicalHash,
31
  verifyObject,
32
  type FederatedInteraction,
33
  type AgentPresence,
34
  type BehaviouralProfileDelta,
35
  type CounterpartyBrief,
36
  type LiveSessionActivation,
37
  type LiveSessionEvent,
38
  type LiveSessionInsight,
39
  type HostedMessage,
40
  type HostedThread,
41
  type InteractionCompletionReport,
42
  type InteractionFeedback,
43
  type InteractionConclusion,
44
  type PublicAgentCard,
45
} from '../../protocol/src/index.js';
46
import type { AgentProfile } from './onboarding.js';
47
import type { AgentInstallation } from './onboarding.js';
48
import {
49
  agentAccessTokenClientId,
50
  agentAccessTokenId,
51
  createAgentAccessToken,
52
  matchesAgentAccessToken,
53
  type AgentAccessTokenMetadata,
54
} from './access-token.js';
55
import {
56
  decryptGatewayPayload,
57
  encryptGatewayPayload,
58
  attestSessionRecord,
59
  getSessionKeyId,
60
  getSessionVerificationKey,
61
  issueSessionGrant,
62
  signSessionControl,
63
  verifySessionGrant,
64
} from './relay.js';
65
import { postRuntimeJson, resolvePublicRuntimeEndpoint } from './runtime.js';
66
67
export type HostedRecordKind =
68
  | 'agent'
69
  | 'delegation'
70
  | 'contract'
71
  | 'interaction'
72
  | 'event'
73
  | 'receipt'
74
  | 'feedback'
75
  | 'counterparty_brief'
76
  | 'completion_report'
77
  | 'feedback_request'
78
  | 'interaction_feedback'
79
  | 'interaction_conclusion'
80
  | 'learning_eligibility'
81
  | 'profile_delta'
82
  | 'conflict'
83
  | 'profile'
84
  | 'consent'
85
  | 'revocation'
86
  | 'project'
87
  | 'agent_profile'
88
  | 'installation'
89
  | 'setup_request'
90
  | 'publication'
91
  | 'presence';
92
93
export type { PublicAgentCard, FederatedInteraction } from '../../protocol/src/index.js';
94
95
type ContextualProfile = {
96
  recordId: string;
97
  agentId: string;
98
  agentVersion: string;
99
  taskCategory: string;
100
  sampleSize: number;
101
  effectiveSampleSize?: number;
102
  updatedAt: string;
103
  completion: number;
104
  acceptance: number;
105
  specification: number;
106
  deadline: number;
107
  communication: number;
108
  evidence: number;
109
  scope: number;
110
  disputes: number;
111
};
112
113
const FEEDBACK_DIMENSIONS = FeedbackDimensionSchema.options;
114
115
function reviewerCredibility(feedback: InteractionFeedback): number {
116
  const provenance =
117
    feedback.submissionMethod === 'agent_signature'
118
      ? 0.9
119
      : feedback.submissionMethod === 'runtime_session'
120
        ? 0.8
121
        : feedback.submissionMethod === 'agent_access_token'
122
          ? 0.75
123
          : 0.65;
124
  return Math.min(1, provenance + (feedback.evidenceReferences.length ? 0.1 : 0));
125
}
126
127
function deterministicUuid(value: string): string {
128
  const bytes = createHash('sha256').update(value).digest().subarray(0, 16);
129
  bytes[6] = (bytes[6]! & 0x0f) | 0x50;
130
  bytes[8] = (bytes[8]! & 0x3f) | 0x80;
131
  const hex = bytes.toString('hex');
132
  return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
133
}
134
135
function normalizePublicAgentCard(value: unknown): PublicAgentCard {
136
  const current = PublicAgentCardSchema.safeParse(value);
137
  if (current.success) return current.data;
138
  const legacy = value as Record<string, unknown>;
139
  const agentId = String(legacy.agentId ?? '');
140
  const baseUrl = (process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(
141
    /\/$/,
142
    '',
143
  );
144
  return PublicAgentCardSchema.parse({
145
    protocolVersion: '0.1',
146
    agentId,
147
    name: legacy.name,
148
    description: '',
149
    framework: legacy.framework,
150
    agentVersion: '1.0.0',
151
    capabilities: legacy.capabilities,
152
    limitations: legacy.limitations,
153
    assurance: legacy.assurance,
154
    transports:
155
      typeof legacy.a2aEndpoint === 'string'
156
        ? [
157
            {
158
              protocol: 'A2A/1.0',
159
              protocolBinding: 'JSONRPC',
160
              endpoint: legacy.a2aEndpoint,
161
              managedBy: 'agent',
162
            },
163
          ]
164
        : [],
165
    cardUrl: `${baseUrl}/agents/${encodeURIComponent(agentId)}/card.json`,
166
    a2aAgentCardUrl: `${baseUrl}/agents/${encodeURIComponent(agentId)}/a2a-agent-card.json`,
167
    extensionUri: DEFAULT_EXTENSION_URI,
168
    publishedAt: legacy.publishedAt,
169
    updatedAt: legacy.updatedAt,
170
  });
171
}
172
173
export function buildPublicAgentCard(
174
  agent: AgentProfile,
175
  baseUrl: string,
176
  previous?: PublicAgentCard,
177
): PublicAgentCard {
178
  const now = new Date().toISOString();
179
  const root = baseUrl.replace(/\/$/, '');
180
  return PublicAgentCardSchema.parse({
181
    protocolVersion: '0.1',
182
    agentId: agent.agentId,
183
    name: agent.name,
184
    description: agent.description ?? '',
185
    framework: agent.framework,
186
    agentVersion: agent.agentVersion ?? '1.0.0',
187
    capabilities: agent.capabilities,
188
    limitations: agent.limitations,
189
    assurance: 'oauth_authenticated',
190
    agentMode: agent.agentMode ?? (agent.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat'),
191
    transports:
192
      (agent.agentMode ?? (agent.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat')) ===
193
      'temporary_chat'
194
        ? [
195
            {
196
              protocol: 'A2A/1.0',
197
              protocolBinding: 'JSONRPC',
198
              endpoint: `${root}/a2a/temporary/${encodeURIComponent(agent.agentId)}`,
199
              managedBy: 'openclasp',
200
            },
201
          ]
202
        : agent.a2aEndpoint
203
          ? [
204
              {
205
                protocol: 'A2A/1.0',
206
                protocolBinding: 'JSONRPC',
207
                endpoint: agent.a2aEndpoint,
208
                managedBy: 'agent',
209
              },
210
            ]
211
          : [],
212
    cardUrl: `${root}/agents/${encodeURIComponent(agent.agentId)}/card.json`,
213
    a2aAgentCardUrl: `${root}/agents/${encodeURIComponent(agent.agentId)}/a2a-agent-card.json`,
214
    extensionUri: DEFAULT_EXTENSION_URI,
215
    publishedAt: previous?.publishedAt ?? now,
216
    updatedAt: now,
217
  });
218
}
219
220
const normalized = (value: string) =>
221
  value
222
    .trim()
223
    .toLowerCase()
224
    .replace(/[^a-z0-9]+/g, '_');
225
226
export const AGENT_ONLINE_WINDOW_MS = 2 * 60_000;
227
228
export function resolveAgentPresence(lastSeenAt?: string, now = new Date()): AgentPresence {
229
  const online = Boolean(
230
    lastSeenAt && now.getTime() - Date.parse(lastSeenAt) <= AGENT_ONLINE_WINDOW_MS,
231
  );
232
  return {
233
    status: online ? 'online' : 'offline',
234
    ...(lastSeenAt ? { lastSeenAt } : {}),
235
    checkedAt: now.toISOString(),
236
  };
237
}
238
239
export function canAutoAcceptInteraction(
240
  agent: AgentProfile,
241
  interaction: FederatedInteraction,
242
): boolean {
243
  if (agent.autoAcceptPolicy !== 'safe_matching' || agent.status !== 'active') return false;
244
  const categories = new Set(
245
    [...(agent.autoAcceptTaskCategories ?? []), ...(agent.capabilities ?? [])].map(normalized),
246
  );
247
  const taskCategory = normalized(interaction.contract.taskCategory);
248
  if (!taskCategory || !categories.has(taskCategory)) return false;
249
  if (interaction.contract.allowedData.length > 0) return false;
250
  if (
251
    /\b(password|secret|api[_ -]?key|access[_ -]?token|credential|bank|payment|medical|health|ssn|personal[_ -]?data|customer[_ -]?record)\b/i.test(
252
      `${interaction.contract.purpose} ${interaction.contract.requestedOutcome}`,
253
    )
254
  )
255
    return false;
256
  if (interaction.contract.humanApprovalRequirements.length > 0) return false;
257
  if (interaction.contract.retentionDays > 30) return false;
258
  if (interaction.contract.allowedActions.length > 0) {
259
    const capabilities = new Set((agent.capabilities ?? []).map(normalized));
260
    if (
261
      !interaction.contract.allowedActions.every((action) => capabilities.has(normalized(action)))
262
    )
263
      return false;
264
  }
265
  return true;
266
}
267
268
export type AccountSettings = {
269
  displayName: string;
270
  contributionEnabled: boolean;
271
  retentionDays: number;
272
  evidenceSharing: 'never' | 'ask' | 'contract_only';
273
  rawConversationsStored: false;
274
};
275
276
const defaults: AccountSettings = {
277
  displayName: '',
278
  contributionEnabled: false,
279
  retentionDays: 30,
280
  evidenceSharing: 'ask',
281
  rawConversationsStored: false,
282
};
283
284
export class HostedRepository {
285
  private readonly sql: NeonQueryFunction<false, false>;
286
  private initialized?: Promise<void>;
287
288
  constructor(databaseUrl: string) {
289
    this.sql = neon(databaseUrl);
290
  }
291
292
  private gatewaySecret() {
293
    const value = process.env.OPENCLASP_RELAY_ENCRYPTION_KEY;
294
    if (!value || value.length < 32) throw new Error('Gateway encryption is not configured');
295
    return value;
296
  }
297
298
  ensureSchema(): Promise<void> {
299
    return (this.initialized ??= (async () => {
300
      await this.sql`
301
        CREATE TABLE IF NOT EXISTS openclasp_records (
302
          operator_id TEXT NOT NULL,
303
          kind TEXT NOT NULL,
304
          record_id TEXT NOT NULL,
305
          payload JSONB NOT NULL,
306
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
307
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
308
          PRIMARY KEY (operator_id, kind, record_id)
309
        )
310
      `;
311
      await this.sql`
312
        CREATE INDEX IF NOT EXISTS openclasp_records_operator_created
313
        ON openclasp_records(operator_id, created_at DESC)
314
      `;
315
      await this.sql`
316
        CREATE TABLE IF NOT EXISTS openclasp_account_settings (
317
          operator_id TEXT PRIMARY KEY,
318
          display_name TEXT NOT NULL DEFAULT '',
319
          contribution_enabled BOOLEAN NOT NULL DEFAULT FALSE,
320
          retention_days INTEGER NOT NULL DEFAULT 30 CHECK (retention_days BETWEEN 0 AND 3650),
321
          evidence_sharing TEXT NOT NULL DEFAULT 'ask'
322
            CHECK (evidence_sharing IN ('never', 'ask', 'contract_only')),
323
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
324
        )
325
      `;
326
      await this.sql`
327
        CREATE TABLE IF NOT EXISTS openclasp_public_agents (
328
          agent_id TEXT PRIMARY KEY,
329
          operator_id TEXT NOT NULL,
330
          card JSONB NOT NULL,
331
          published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
332
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
333
        )
334
      `;
335
      await this.sql`
336
        CREATE INDEX IF NOT EXISTS openclasp_public_agents_updated
337
        ON openclasp_public_agents(updated_at DESC)
338
      `;
339
      await this.sql`
340
        CREATE TABLE IF NOT EXISTS openclasp_federated_interactions (
341
          interaction_id TEXT PRIMARY KEY,
342
          initiator_operator_id TEXT NOT NULL,
343
          responder_operator_id TEXT NOT NULL,
344
          initiator_agent_id TEXT NOT NULL,
345
          responder_agent_id TEXT NOT NULL,
346
          status TEXT NOT NULL,
347
          payload JSONB NOT NULL,
348
          expires_at TIMESTAMPTZ NOT NULL,
349
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
350
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
351
        )
352
      `;
353
      await this.sql`
354
        CREATE INDEX IF NOT EXISTS openclasp_federated_interactions_participants
355
        ON openclasp_federated_interactions(initiator_operator_id, responder_operator_id, updated_at DESC)
356
      `;
357
      await this.sql`
358
        CREATE TABLE IF NOT EXISTS openclasp_agent_runtimes (
359
          agent_id TEXT PRIMARY KEY,
360
          operator_id TEXT NOT NULL,
361
          endpoint TEXT NOT NULL,
362
          secret_ciphertext TEXT NOT NULL,
363
          secret_iv TEXT NOT NULL,
364
          secret_auth_tag TEXT NOT NULL,
365
          status TEXT NOT NULL CHECK (status IN ('verified', 'disabled')),
366
          verified_at TIMESTAMPTZ NOT NULL,
367
          last_delivery_at TIMESTAMPTZ,
368
          last_error TEXT,
369
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
370
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
371
        )
372
      `;
373
      await this.sql`
374
        CREATE TABLE IF NOT EXISTS openclasp_agent_access_tokens (
375
          token_id TEXT PRIMARY KEY,
376
          operator_id TEXT NOT NULL,
377
          agent_id TEXT NOT NULL,
378
          name TEXT NOT NULL,
379
          token_hash TEXT NOT NULL,
380
          scopes JSONB NOT NULL,
381
          expires_at TIMESTAMPTZ NOT NULL,
382
          last_used_at TIMESTAMPTZ,
383
          revoked_at TIMESTAMPTZ,
384
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
385
        )
386
      `;
387
      await this.sql`
388
        CREATE INDEX IF NOT EXISTS openclasp_agent_access_tokens_owner_agent
389
        ON openclasp_agent_access_tokens(operator_id, agent_id, created_at DESC)
390
      `;
391
      await this.sql`
392
        ALTER TABLE openclasp_agent_runtimes
393
        ADD COLUMN IF NOT EXISTS a2a_endpoint TEXT,
394
        ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ
395
      `;
396
      await this.sql`
397
        CREATE TABLE IF NOT EXISTS openclasp_live_sessions (
398
          interaction_id TEXT PRIMARY KEY,
399
          initiator_agent_id TEXT NOT NULL,
400
          responder_agent_id TEXT NOT NULL,
401
          initiator_session_id TEXT NOT NULL,
402
          responder_session_id TEXT NOT NULL,
403
          initiator_endpoint TEXT NOT NULL,
404
          responder_endpoint TEXT NOT NULL,
405
          status TEXT NOT NULL CHECK (status IN ('preparing', 'active', 'completed', 'failed')),
406
          expires_at TIMESTAMPTZ NOT NULL,
407
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
408
          activated_at TIMESTAMPTZ,
409
          completed_at TIMESTAMPTZ,
410
          last_error TEXT
411
        )
412
      `;
413
      await this.sql`
414
        CREATE TABLE IF NOT EXISTS openclasp_live_session_events (
415
          interaction_id TEXT NOT NULL,
416
          event_id TEXT NOT NULL,
417
          agent_id TEXT NOT NULL,
418
          sequence INTEGER NOT NULL,
419
          event JSONB NOT NULL,
420
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
421
          PRIMARY KEY (interaction_id, event_id),
422
          UNIQUE (interaction_id, agent_id, sequence)
423
        )
424
      `;
425
      await this.sql`
426
        CREATE TABLE IF NOT EXISTS openclasp_hosted_threads (
427
          thread_id TEXT PRIMARY KEY,
428
          interaction_id TEXT NOT NULL UNIQUE,
429
          participant_a_agent_id TEXT NOT NULL,
430
          participant_b_agent_id TEXT NOT NULL,
431
          status TEXT NOT NULL CHECK (status IN ('open', 'closed')) DEFAULT 'open',
432
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
433
          updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
434
          expires_at TIMESTAMPTZ NOT NULL
435
        )
436
      `;
437
      await this.sql`
438
        CREATE INDEX IF NOT EXISTS openclasp_hosted_threads_participants
439
        ON openclasp_hosted_threads(participant_a_agent_id, participant_b_agent_id, updated_at DESC)
440
      `;
441
      await this.sql`
442
        CREATE TABLE IF NOT EXISTS openclasp_hosted_messages (
443
          message_id TEXT PRIMARY KEY,
444
          thread_id TEXT NOT NULL REFERENCES openclasp_hosted_threads(thread_id) ON DELETE CASCADE,
445
          interaction_id TEXT NOT NULL,
446
          sender_agent_id TEXT NOT NULL,
447
          recipient_agent_id TEXT NOT NULL,
448
          request_key TEXT NOT NULL,
449
          content_ciphertext TEXT NOT NULL,
450
          content_iv TEXT NOT NULL,
451
          content_auth_tag TEXT NOT NULL,
452
          content_hash TEXT NOT NULL,
453
          delivery TEXT NOT NULL CHECK (delivery IN ('accepted', 'delivered', 'read')),
454
          created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
455
          read_at TIMESTAMPTZ
456
        )
457
      `;
458
      await this.sql`
459
        CREATE UNIQUE INDEX IF NOT EXISTS openclasp_hosted_messages_dedup
460
        ON openclasp_hosted_messages(thread_id, sender_agent_id, request_key)
461
      `;
462
      await this.sql`
463
        CREATE INDEX IF NOT EXISTS openclasp_hosted_messages_thread_created
464
        ON openclasp_hosted_messages(thread_id, created_at ASC)
465
      `;
466
    })());
467
  }
468
469
  getRuntimeVerificationKey() {
470
    return {
471
      algorithm: 'Ed25519' as const,
472
      keyId: getSessionKeyId(this.gatewaySecret()),
473
      publicKey: getSessionVerificationKey(this.gatewaySecret()),
474
    };
475
  }
476
477
  async upsert(
478
    operatorId: string,
479
    kind: HostedRecordKind,
480
    recordId: string,
481
    payload: unknown,
482
  ): Promise<void> {
483
    await this.ensureSchema();
484
    const encoded = JSON.stringify(payload);
485
    await this.sql`
486
      INSERT INTO openclasp_records(operator_id, kind, record_id, payload)
487
      VALUES (${operatorId}, ${kind}, ${recordId}, ${encoded}::jsonb)
488
      ON CONFLICT (operator_id, kind, record_id)
489
      DO UPDATE SET payload = EXCLUDED.payload, updated_at = NOW()
490
    `;
491
  }
492
493
  async list(
494
    operatorId: string,
495
  ): Promise<{ kind: HostedRecordKind; recordId: string; payload: any }[]> {
496
    await this.ensureSchema();
497
    const rows = await this.sql`
498
      SELECT kind, record_id, payload
499
      FROM openclasp_records
500
      WHERE operator_id = ${operatorId}
501
      ORDER BY created_at ASC
502
    `;
503
    return rows.map((row) => ({
504
      kind: row.kind as HostedRecordKind,
505
      recordId: String(row.record_id),
506
      payload: row.payload,
507
    }));
508
  }
509
510
  async dashboard(operatorId: string) {
511
    await this.ensureSchema();
512
    const outstanding = await this.sql`
513
      SELECT DISTINCT report.payload->>'interactionId' AS interaction_id
514
      FROM openclasp_records report
515
      INNER JOIN openclasp_federated_interactions interaction
516
        ON interaction.interaction_id::text = report.payload->>'interactionId'
517
      WHERE report.kind = 'completion_report'
518
        AND (
519
          interaction.initiator_operator_id = ${operatorId}
520
          OR interaction.responder_operator_id = ${operatorId}
521
        )
522
        AND NOT EXISTS (
523
          SELECT 1 FROM openclasp_records conclusion
524
          WHERE conclusion.operator_id = ${operatorId}
525
            AND conclusion.kind = 'interaction_conclusion'
526
            AND conclusion.payload->>'interactionId' = report.payload->>'interactionId'
527
        )
528
      LIMIT 20
529
    `;
530
    for (const row of outstanding) {
531
      const interactionId = String(row.interaction_id ?? '');
532
      if (interactionId)
533
        await this.finalizeInteractionConclusion(interactionId).catch(() => undefined);
534
    }
535
    const rows = await this.list(operatorId);
536
    const [federatedInteractions, runtimes, accessTokens, liveSessionRows, liveEventRows] =
537
      await Promise.all([
538
        this.listFederatedInteractions(operatorId),
539
        this.listAgentRuntimes(operatorId),
540
        this.listAgentAccessTokens(operatorId),
541
        this.sql`
542
        SELECT session.interaction_id, session.initiator_agent_id, session.responder_agent_id,
543
          session.status, session.created_at, session.activated_at, session.completed_at,
544
          session.expires_at, session.last_error
545
        FROM openclasp_live_sessions session
546
        INNER JOIN openclasp_federated_interactions interaction
547
          ON interaction.interaction_id = session.interaction_id
548
        WHERE interaction.initiator_operator_id = ${operatorId}
549
           OR interaction.responder_operator_id = ${operatorId}
550
        ORDER BY session.created_at DESC
551
      `,
552
        this.sql`
553
        SELECT events.event
554
        FROM openclasp_live_session_events events
555
        INNER JOIN openclasp_federated_interactions interaction
556
          ON interaction.interaction_id = events.interaction_id
557
        WHERE interaction.initiator_operator_id = ${operatorId}
558
           OR interaction.responder_operator_id = ${operatorId}
559
        ORDER BY events.created_at ASC
560
      `,
561
      ]);
562
    const ofKind = (kind: HostedRecordKind) =>
563
      rows.filter((row) => row.kind === kind).map((row) => row.payload);
564
    const agentProfiles = ofKind('agent_profile') as AgentProfile[];
565
    const hostedThreads = (
566
      await Promise.all(
567
        agentProfiles
568
          .filter(
569
            (agent) =>
570
              (agent.agentMode ?? (agent.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat')) ===
571
              'temporary_chat',
572
          )
573
          .map((agent) => this.listHostedThreads(operatorId, agent.agentId)),
574
      )
575
    ).flat();
576
    const presence = new Map(
577
      rows
578
        .filter((row) => row.kind === 'presence')
579
        .map((row) => [row.recordId, String(row.payload.lastSeenAt)]),
580
    );
581
    return {
582
      agents: [...ofKind('agent_profile'), ...ofKind('agent')].map((agent) => ({
583
        ...agent,
584
        presence: resolveAgentPresence(presence.get(String(agent.agentId))),
585
      })),
586
      projects: ofKind('project'),
587
      installations: ofKind('installation'),
588
      setupRequests: ofKind('setup_request'),
589
      publications: ofKind('publication'),
590
      interactions: ofKind('interaction'),
591
      events: [...ofKind('event'), ...liveEventRows.map((row) => row.event)],
592
      conflicts: ofKind('conflict'),
593
      receipts: ofKind('receipt'),
594
      profiles: ofKind('profile'),
595
      counterpartyBriefs: ofKind('counterparty_brief'),
596
      completionReports: ofKind('completion_report'),
597
      feedbackRequests: ofKind('feedback_request'),
598
      interactionFeedback: ofKind('interaction_feedback'),
599
      interactionConclusions: ofKind('interaction_conclusion'),
600
      learningEligibility: ofKind('learning_eligibility'),
601
      profileDeltas: ofKind('profile_delta'),
602
      federatedInteractions,
603
      liveSessions: liveSessionRows.map((row) => ({
604
        interactionId: String(row.interaction_id),
605
        initiatorAgentId: String(row.initiator_agent_id),
606
        responderAgentId: String(row.responder_agent_id),
607
        status: String(row.status),
608
        createdAt: new Date(String(row.created_at)).toISOString(),
609
        ...(row.activated_at
610
          ? { activatedAt: new Date(String(row.activated_at)).toISOString() }
611
          : {}),
612
        ...(row.completed_at
613
          ? { completedAt: new Date(String(row.completed_at)).toISOString() }
614
          : {}),
615
        expiresAt: new Date(String(row.expires_at)).toISOString(),
616
        ...(row.last_error ? { lastError: String(row.last_error) } : {}),
617
      })),
618
      hostedThreads,
619
      runtimes,
620
      accessTokens,
621
    };
622
  }
623
624
  async issueAgentAccessToken(
625
    operatorId: string,
626
    agentId: string,
627
    input: { name: string; expiresInDays: number },
628
  ): Promise<AgentAccessTokenMetadata & { token: string }> {
629
    await this.ensureSchema();
630
    const agents = await this.sql`
631
      SELECT payload FROM openclasp_records
632
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
633
      LIMIT 1
634
    `;
635
    const agent = agents[0]?.payload as AgentProfile | undefined;
636
    if (!agent) throw new Error('Owned agent not found');
637
    if (agent.status !== 'active') throw new Error('Revoked agents cannot receive access tokens');
638
    const name = input.name.trim();
639
    if (!name) throw new Error('Token name is required');
640
    const { tokenId, token, tokenHash } = createAgentAccessToken();
641
    const createdAt = new Date();
642
    const expiresAt = new Date(createdAt.getTime() + input.expiresInDays * 86_400_000);
643
    // The credential is bound to one agent. It may use MCP outbound and let that
644
    // same agent register its own inbound runtime; it cannot manage other agents.
645
    const scopes = ['mcp:access', 'runtime:connect'];
646
    await this.sql`
647
      INSERT INTO openclasp_agent_access_tokens(
648
        token_id, operator_id, agent_id, name, token_hash, scopes, expires_at, created_at
649
      ) VALUES (
650
        ${tokenId}, ${operatorId}, ${agentId}, ${name}, ${tokenHash},
651
        ${JSON.stringify(scopes)}::jsonb, ${expiresAt.toISOString()}, ${createdAt.toISOString()}
652
      )
653
    `;
654
    const clientId = agentAccessTokenClientId(tokenId);
655
    await this.upsert(operatorId, 'installation', clientId, {
656
      installationId: `install_${tokenId}`,
657
      clientId,
658
      agentId,
659
      projectId: agent.projectId,
660
      connectedAt: createdAt.toISOString(),
661
      updatedAt: createdAt.toISOString(),
662
    } satisfies AgentInstallation);
663
    return {
664
      tokenId,
665
      token,
666
      agentId,
667
      name,
668
      scopes,
669
      createdAt: createdAt.toISOString(),
670
      expiresAt: expiresAt.toISOString(),
671
    };
672
  }
673
674
  async listAgentAccessTokens(
675
    operatorId: string,
676
    agentId?: string,
677
  ): Promise<AgentAccessTokenMetadata[]> {
678
    await this.ensureSchema();
679
    const rows = agentId
680
      ? await this.sql`
681
          SELECT token_id, agent_id, name, scopes, expires_at, last_used_at, revoked_at, created_at
682
          FROM openclasp_agent_access_tokens
683
          WHERE operator_id = ${operatorId} AND agent_id = ${agentId}
684
          ORDER BY created_at DESC
685
        `
686
      : await this.sql`
687
          SELECT token_id, agent_id, name, scopes, expires_at, last_used_at, revoked_at, created_at
688
          FROM openclasp_agent_access_tokens
689
          WHERE operator_id = ${operatorId}
690
          ORDER BY created_at DESC
691
        `;
692
    return rows.map((row) => ({
693
      tokenId: String(row.token_id),
694
      agentId: String(row.agent_id),
695
      name: String(row.name),
696
      scopes: Array.isArray(row.scopes)
697
        ? row.scopes.filter((scope): scope is string => typeof scope === 'string')
698
        : [],
699
      createdAt: new Date(String(row.created_at)).toISOString(),
700
      expiresAt: new Date(String(row.expires_at)).toISOString(),
701
      ...(row.last_used_at ? { lastUsedAt: new Date(String(row.last_used_at)).toISOString() } : {}),
702
      ...(row.revoked_at ? { revokedAt: new Date(String(row.revoked_at)).toISOString() } : {}),
703
    }));
704
  }
705
706
  async revokeAgentAccessToken(operatorId: string, agentId: string, tokenId: string) {
707
    await this.ensureSchema();
708
    const rows = await this.sql`
709
      UPDATE openclasp_agent_access_tokens
710
      SET revoked_at = COALESCE(revoked_at, NOW())
711
      WHERE token_id = ${tokenId} AND operator_id = ${operatorId} AND agent_id = ${agentId}
712
      RETURNING revoked_at
713
    `;
714
    if (!rows.length) throw new Error('Agent access token not found');
715
    const clientId = agentAccessTokenClientId(tokenId);
716
    await this.sql`
717
      DELETE FROM openclasp_records
718
      WHERE operator_id = ${operatorId} AND kind = 'installation' AND record_id = ${clientId}
719
    `;
720
    return {
721
      tokenId,
722
      agentId,
723
      revokedAt: new Date(String(rows[0]?.revoked_at)).toISOString(),
724
    };
725
  }
726
727
  async verifyAgentAccessToken(token: string) {
728
    await this.ensureSchema();
729
    const tokenId = agentAccessTokenId(token);
730
    if (!tokenId) throw new Error('Invalid agent access token');
731
    const rows = await this.sql`
732
      SELECT tokens.operator_id, tokens.agent_id, tokens.token_hash, tokens.scopes,
733
        tokens.expires_at, profile.payload AS profile
734
      FROM openclasp_agent_access_tokens tokens
735
      INNER JOIN openclasp_records profile
736
        ON profile.operator_id = tokens.operator_id
737
        AND profile.kind = 'agent_profile'
738
        AND profile.record_id = tokens.agent_id
739
      WHERE tokens.token_id = ${tokenId} AND tokens.revoked_at IS NULL
740
      LIMIT 1
741
    `;
742
    const row = rows[0];
743
    if (!row || !matchesAgentAccessToken(token, String(row.token_hash)))
744
      throw new Error('Invalid agent access token');
745
    if (Date.parse(String(row.expires_at)) <= Date.now())
746
      throw new Error('Agent access token has expired');
747
    const profile = row.profile as Partial<AgentProfile>;
748
    if (profile.status !== 'active') throw new Error('Agent access token is disabled');
749
    await this.sql`
750
      UPDATE openclasp_agent_access_tokens
751
      SET last_used_at = NOW()
752
      WHERE token_id = ${tokenId}
753
        AND (last_used_at IS NULL OR last_used_at < NOW() - INTERVAL '5 minutes')
754
    `;
755
    return {
756
      tokenId,
757
      operatorId: String(row.operator_id),
758
      agentId: String(row.agent_id),
759
      clientId: agentAccessTokenClientId(tokenId),
760
      scopes: Array.isArray(row.scopes)
761
        ? row.scopes.filter((scope): scope is string => typeof scope === 'string')
762
        : [],
763
      expiresAt: new Date(String(row.expires_at)).toISOString(),
764
    };
765
  }
766
767
  async publishAgent(operatorId: string, card: PublicAgentCard): Promise<PublicAgentCard> {
768
    await this.ensureSchema();
769
    card = PublicAgentCardSchema.parse(card);
770
    const runtimes = await this.sql`
771
      SELECT a2a_endpoint, endpoint FROM openclasp_agent_runtimes
772
      WHERE operator_id = ${operatorId} AND agent_id = ${card.agentId} AND status = 'verified'
773
    `;
774
    if (runtimes[0]) {
775
      card = PublicAgentCardSchema.parse({
776
        ...card,
777
        agentMode: 'persistent_runtime',
778
        transports: [
779
          {
780
            protocol: 'A2A/1.0',
781
            protocolBinding: 'JSONRPC',
782
            endpoint: String(runtimes[0].a2a_endpoint ?? runtimes[0].endpoint),
783
            managedBy: 'agent',
784
          },
785
        ],
786
      });
787
    }
788
    const encoded = JSON.stringify(card);
789
    const rows = await this.sql`
790
      INSERT INTO openclasp_public_agents(agent_id, operator_id, card, published_at, updated_at)
791
      VALUES (${card.agentId}, ${operatorId}, ${encoded}::jsonb, NOW(), NOW())
792
      ON CONFLICT (agent_id) DO UPDATE SET
793
        card = EXCLUDED.card,
794
        updated_at = NOW()
795
      WHERE openclasp_public_agents.operator_id = ${operatorId}
796
      RETURNING card
797
    `;
798
    const published = rows[0]?.card as PublicAgentCard | undefined;
799
    if (!published) throw new Error('Agent ID is already owned by another operator');
800
    return published;
801
  }
802
803
  async unpublishAgent(operatorId: string, agentId: string): Promise<boolean> {
804
    await this.ensureSchema();
805
    const rows = await this.sql`
806
      DELETE FROM openclasp_public_agents
807
      WHERE agent_id = ${agentId} AND operator_id = ${operatorId}
808
      RETURNING agent_id
809
    `;
810
    return rows.length > 0;
811
  }
812
813
  async getPublishedAgent(agentId: string): Promise<PublicAgentCard | undefined> {
814
    await this.ensureSchema();
815
    const rows = await this.sql`
816
      SELECT agents.card, runtime.a2a_endpoint, runtime.endpoint, profile.payload AS profile
817
      FROM openclasp_public_agents agents
818
      LEFT JOIN openclasp_agent_runtimes runtime
819
        ON runtime.agent_id = agents.agent_id AND runtime.status = 'verified'
820
      LEFT JOIN openclasp_records profile
821
        ON profile.operator_id = agents.operator_id AND profile.kind = 'agent_profile'
822
        AND profile.record_id = agents.agent_id
823
      WHERE agents.agent_id = ${agentId}
824
    `;
825
    if (!rows[0]?.card) return undefined;
826
    let card = normalizePublicAgentCard(rows[0].card);
827
    const profile = rows[0].profile as Partial<AgentProfile> | undefined;
828
    const mode =
829
      profile?.agentMode ??
830
      (rows[0].a2a_endpoint || rows[0].endpoint ? 'persistent_runtime' : 'temporary_chat');
831
    if (mode === 'temporary_chat') {
832
      const root = (process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(
833
        /\/$/,
834
        '',
835
      );
836
      card = PublicAgentCardSchema.parse({
837
        ...card,
838
        agentMode: 'temporary_chat',
839
        transports: [
840
          {
841
            protocol: 'A2A/1.0',
842
            protocolBinding: 'JSONRPC',
843
            endpoint: `${root}/a2a/temporary/${encodeURIComponent(agentId)}`,
844
            managedBy: 'openclasp',
845
          },
846
        ],
847
      });
848
    } else if (rows[0].a2a_endpoint || rows[0].endpoint)
849
      card = PublicAgentCardSchema.parse({
850
        ...card,
851
        agentMode: 'persistent_runtime',
852
        transports: [
853
          {
854
            protocol: 'A2A/1.0',
855
            protocolBinding: 'JSONRPC',
856
            endpoint: String(rows[0].a2a_endpoint ?? rows[0].endpoint),
857
            managedBy: 'agent',
858
          },
859
        ],
860
      });
861
    return { ...card, presence: await this.getAgentPresence(agentId) };
862
  }
863
864
  async searchPublishedAgents(input: {
865
    query?: string | undefined;
866
    capability?: string | undefined;
867
    limit?: number | undefined;
868
  }): Promise<PublicAgentCard[]> {
869
    await this.ensureSchema();
870
    const rows = await this.sql`
871
      SELECT agents.card, runtime.a2a_endpoint, runtime.endpoint, profile.payload AS profile
872
      FROM openclasp_public_agents agents
873
      LEFT JOIN openclasp_agent_runtimes runtime
874
        ON runtime.agent_id = agents.agent_id AND runtime.status = 'verified'
875
      LEFT JOIN openclasp_records profile
876
        ON profile.operator_id = agents.operator_id AND profile.kind = 'agent_profile'
877
        AND profile.record_id = agents.agent_id
878
      ORDER BY agents.updated_at DESC LIMIT 100
879
    `;
880
    const query = input.query?.trim().toLowerCase();
881
    const capability = input.capability?.trim().toLowerCase();
882
    const presenceRows = await this.sql`
883
      SELECT records.record_id, records.payload
884
      FROM openclasp_records records
885
      INNER JOIN openclasp_public_agents agents ON agents.agent_id = records.record_id
886
      WHERE records.kind = 'presence'
887
    `;
888
    const presence = new Map(
889
      presenceRows.map((row) => [String(row.record_id), String(row.payload.lastSeenAt)]),
890
    );
891
    return rows
892
      .map((row) => {
893
        let card = normalizePublicAgentCard(row.card);
894
        const profile = row.profile as Partial<AgentProfile> | undefined;
895
        const mode =
896
          profile?.agentMode ??
897
          (row.a2a_endpoint || row.endpoint ? 'persistent_runtime' : 'temporary_chat');
898
        if (mode === 'temporary_chat') {
899
          const root = (process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(
900
            /\/$/,
901
            '',
902
          );
903
          card = PublicAgentCardSchema.parse({
904
            ...card,
905
            agentMode: 'temporary_chat',
906
            transports: [
907
              {
908
                protocol: 'A2A/1.0',
909
                protocolBinding: 'JSONRPC',
910
                endpoint: `${root}/a2a/temporary/${encodeURIComponent(card.agentId)}`,
911
                managedBy: 'openclasp',
912
              },
913
            ],
914
          });
915
        } else if (row.a2a_endpoint || row.endpoint)
916
          card = PublicAgentCardSchema.parse({
917
            ...card,
918
            agentMode: 'persistent_runtime',
919
            transports: [
920
              {
921
                protocol: 'A2A/1.0',
922
                protocolBinding: 'JSONRPC',
923
                endpoint: String(row.a2a_endpoint ?? row.endpoint),
924
                managedBy: 'agent',
925
              },
926
            ],
927
          });
928
        return { ...card, presence: resolveAgentPresence(presence.get(card.agentId)) };
929
      })
930
      .filter(
931
        (card) =>
932
          (!query ||
933
            card.name.toLowerCase().includes(query) ||
934
            card.framework.toLowerCase().includes(query)) &&
935
          (!capability ||
936
            card.capabilities.some((value) => value.toLowerCase().includes(capability))),
937
      )
938
      .slice(0, Math.min(Math.max(input.limit ?? 20, 1), 50));
939
  }
940
941
  async touchAgentPresence(operatorId: string, agentId: string): Promise<AgentPresence> {
942
    await this.ensureSchema();
943
    const owned = await this.sql`
944
      SELECT 1 FROM openclasp_records
945
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
946
    `;
947
    if (!owned.length) throw new Error('Agent is not owned by this account');
948
    const lastSeenAt = new Date().toISOString();
949
    await this.upsert(operatorId, 'presence', agentId, { lastSeenAt });
950
    await this.sql`
951
      UPDATE openclasp_agent_runtimes SET last_seen_at = ${lastSeenAt}, updated_at = NOW()
952
      WHERE operator_id = ${operatorId} AND agent_id = ${agentId} AND status = 'verified'
953
    `;
954
    return resolveAgentPresence(lastSeenAt);
955
  }
956
957
  async getAgentPresence(agentId: string): Promise<AgentPresence> {
958
    await this.ensureSchema();
959
    const rows = await this.sql`
960
      SELECT records.payload
961
      FROM openclasp_public_agents agents
962
      LEFT JOIN openclasp_records records
963
        ON records.operator_id = agents.operator_id
964
        AND records.kind = 'presence'
965
        AND records.record_id = agents.agent_id
966
      WHERE agents.agent_id = ${agentId}
967
      LIMIT 1
968
    `;
969
    if (!rows.length) throw new Error('Published agent not found');
970
    const lastSeenAt = rows[0]?.payload?.lastSeenAt;
971
    return resolveAgentPresence(typeof lastSeenAt === 'string' ? lastSeenAt : undefined);
972
  }
973
974
  async listAgentRuntimes(operatorId: string) {
975
    await this.ensureSchema();
976
    const rows = await this.sql`
977
      SELECT agent_id, endpoint, a2a_endpoint, status, verified_at, last_seen_at, last_error, updated_at
978
      FROM openclasp_agent_runtimes
979
      WHERE operator_id = ${operatorId}
980
      ORDER BY updated_at DESC
981
    `;
982
    return rows.map((row) => ({
983
      agentId: String(row.agent_id),
984
      endpoint: String(row.endpoint),
985
      a2aEndpoint: String(row.a2a_endpoint ?? row.endpoint),
986
      status: row.status as 'verified' | 'disabled',
987
      verifiedAt: new Date(String(row.verified_at)).toISOString(),
988
      ...(row.last_seen_at ? { lastSeenAt: new Date(String(row.last_seen_at)).toISOString() } : {}),
989
      ...(row.last_error ? { lastError: String(row.last_error) } : {}),
990
      updatedAt: new Date(String(row.updated_at)).toISOString(),
991
    }));
992
  }
993
994
  async registerAgentRuntime(operatorId: string, agentId: string, endpoint: string) {
995
    await this.ensureSchema();
996
    const owned = await this.sql`
997
      SELECT 1 FROM openclasp_records
998
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
999
    `;
1000
    if (!owned.length) throw new Error('Agent is not owned by this account');
1001
    const challenge = crypto.randomUUID();
1002
    const verification = await postRuntimeJson(endpoint, {
1003
      type: 'openclasp.runtime.verify',
1004
      version: '1',
1005
      agentId,
1006
      challenge,
1007
    });
1008
    if (
1009
      verification.status < 200 ||
1010
      verification.status >= 300 ||
1011
      !verification.body ||
1012
      typeof verification.body !== 'object' ||
1013
      (verification.body as { type?: unknown }).type !== 'openclasp.runtime.verified' ||
1014
      (verification.body as { version?: unknown }).version !== '1' ||
1015
      (verification.body as { agentId?: unknown }).agentId !== agentId ||
1016
      (verification.body as { challenge?: unknown }).challenge !== challenge
1017
    )
1018
      throw new Error('Runtime endpoint did not return the verification challenge');
1019
    const a2aEndpointValue = (verification.body as { a2aEndpoint?: unknown }).a2aEndpoint;
1020
    const a2aEndpoint =
1021
      typeof a2aEndpointValue === 'string' && a2aEndpointValue ? a2aEndpointValue : endpoint;
1022
    await resolvePublicRuntimeEndpoint(a2aEndpoint);
1023
    const runtimeSecret = randomBytes(32).toString('base64url');
1024
    const encrypted = encryptGatewayPayload(this.gatewaySecret(), runtimeSecret);
1025
    const verifiedAt = new Date().toISOString();
1026
    const saved = await this.sql`
1027
      INSERT INTO openclasp_agent_runtimes(
1028
        agent_id, operator_id, endpoint, a2a_endpoint, secret_ciphertext, secret_iv, secret_auth_tag,
1029
        status, verified_at, last_seen_at, updated_at
1030
      ) VALUES (
1031
        ${agentId}, ${operatorId}, ${endpoint}, ${a2aEndpoint}, ${encrypted.ciphertext},
1032
        ${encrypted.iv}, ${encrypted.authTag}, 'verified', ${verifiedAt}, ${verifiedAt}, NOW()
1033
      )
1034
      ON CONFLICT (agent_id) DO UPDATE SET
1035
        operator_id = EXCLUDED.operator_id,
1036
        endpoint = EXCLUDED.endpoint,
1037
        a2a_endpoint = EXCLUDED.a2a_endpoint,
1038
        secret_ciphertext = EXCLUDED.secret_ciphertext,
1039
        secret_iv = EXCLUDED.secret_iv,
1040
        secret_auth_tag = EXCLUDED.secret_auth_tag,
1041
        status = 'verified',
1042
        verified_at = EXCLUDED.verified_at,
1043
        last_seen_at = EXCLUDED.last_seen_at,
1044
        last_error = NULL,
1045
        updated_at = NOW()
1046
      WHERE openclasp_agent_runtimes.operator_id = ${operatorId}
1047
      RETURNING agent_id
1048
    `;
1049
    if (!saved.length) throw new Error('Agent runtime is owned by another account');
1050
    await this.sql`
1051
      UPDATE openclasp_public_agents
1052
      SET card = jsonb_set(
1053
          jsonb_set(
1054
            card,
1055
            '{transports}',
1056
            ${JSON.stringify([
1057
              {
1058
                protocol: 'A2A/1.0',
1059
                protocolBinding: 'JSONRPC',
1060
                endpoint: a2aEndpoint,
1061
                managedBy: 'agent',
1062
              },
1063
            ])}::jsonb
1064
          ),
1065
          '{agentMode}', '"persistent_runtime"'::jsonb
1066
        ), updated_at = NOW()
1067
      WHERE agent_id = ${agentId} AND operator_id = ${operatorId}
1068
    `;
1069
    await this.sql`
1070
      UPDATE openclasp_records
1071
      SET payload = jsonb_set(
1072
          jsonb_set(payload, '{a2aEndpoint}', to_jsonb(${a2aEndpoint}::text)),
1073
          '{agentMode}', '"persistent_runtime"'::jsonb
1074
        ),
1075
        updated_at = NOW()
1076
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
1077
    `;
1078
    await this.touchAgentPresence(operatorId, agentId);
1079
    return {
1080
      agentId,
1081
      endpoint,
1082
      a2aEndpoint,
1083
      status: 'verified' as const,
1084
      verifiedAt,
1085
      verificationKey: getSessionVerificationKey(this.gatewaySecret()),
1086
    };
1087
  }
1088
1089
  async disableAgentRuntime(operatorId: string, agentId: string) {
1090
    await this.ensureSchema();
1091
    const rows = await this.sql`
1092
      UPDATE openclasp_agent_runtimes
1093
      SET status = 'disabled', updated_at = NOW()
1094
      WHERE operator_id = ${operatorId} AND agent_id = ${agentId}
1095
      RETURNING agent_id
1096
    `;
1097
    if (!rows.length) throw new Error('Agent runtime not found');
1098
    return { agentId, status: 'disabled' as const };
1099
  }
1100
1101
  async deleteAgent(operatorId: string, agentId: string) {
1102
    await this.ensureSchema();
1103
    const owned = await this.sql`
1104
      SELECT 1 FROM openclasp_records
1105
      WHERE operator_id = ${operatorId}
1106
        AND kind = 'agent_profile'
1107
        AND record_id = ${agentId}
1108
      LIMIT 1
1109
    `;
1110
    if (!owned.length) throw new Error('Owned agent not found');
1111
    const openInteractions = await this.sql`
1112
      SELECT interaction_id FROM openclasp_federated_interactions
1113
      WHERE (initiator_agent_id = ${agentId} OR responder_agent_id = ${agentId})
1114
        AND status IN ('pending', 'active')
1115
      LIMIT 1
1116
    `;
1117
    if (openInteractions.length) {
1118
      const error = new Error('Finish or reject the agent’s open interactions before deleting it');
1119
      Object.assign(error, { statusCode: 409 });
1120
      throw error;
1121
    }
1122
    await this.sql`
1123
      DELETE FROM openclasp_hosted_threads
1124
      WHERE participant_a_agent_id = ${agentId} OR participant_b_agent_id = ${agentId}
1125
    `;
1126
    await this.sql`
1127
      DELETE FROM openclasp_public_agents
1128
      WHERE operator_id = ${operatorId} AND agent_id = ${agentId}
1129
    `;
1130
    await this.sql`
1131
      DELETE FROM openclasp_agent_runtimes
1132
      WHERE operator_id = ${operatorId} AND agent_id = ${agentId}
1133
    `;
1134
    await this.sql`
1135
      DELETE FROM openclasp_agent_access_tokens
1136
      WHERE operator_id = ${operatorId} AND agent_id = ${agentId}
1137
    `;
1138
    await this.sql`
1139
      DELETE FROM openclasp_records
1140
      WHERE operator_id = ${operatorId}
1141
        AND (
1142
          (record_id = ${agentId} AND kind IN ('agent', 'agent_profile', 'publication', 'presence'))
1143
          OR (kind = 'installation' AND payload->>'agentId' = ${agentId})
1144
          OR (kind = 'setup_request' AND payload->>'existingAgentId' = ${agentId})
1145
        )
1146
    `;
1147
    return {
1148
      agentId,
1149
      deleted: true as const,
1150
      historyRetained: true as const,
1151
    };
1152
  }
1153
1154
  private async sessionParticipant(agentId: string) {
1155
    const rows = await this.sql`
1156
      SELECT agents.operator_id, agents.card, profile.payload AS profile,
1157
        runtime.endpoint, runtime.a2a_endpoint
1158
      FROM openclasp_public_agents agents
1159
      LEFT JOIN openclasp_records profile
1160
        ON profile.operator_id = agents.operator_id
1161
        AND profile.kind = 'agent_profile'
1162
        AND profile.record_id = agents.agent_id
1163
      LEFT JOIN openclasp_agent_runtimes runtime
1164
        ON runtime.agent_id = agents.agent_id AND runtime.status = 'verified'
1165
      WHERE agents.agent_id = ${agentId}
1166
      LIMIT 1
1167
    `;
1168
    const row = rows[0];
1169
    if (!row) throw new Error(`Published agent ${agentId} was not found`);
1170
    const card = normalizePublicAgentCard(row.card);
1171
    const profile = row.profile as Partial<AgentProfile> | undefined;
1172
    const mode =
1173
      profile?.agentMode ??
1174
      (row.endpoint || row.a2a_endpoint ? 'persistent_runtime' : 'temporary_chat');
1175
    if (mode === 'persistent_runtime' && !row.endpoint)
1176
      throw new Error(`Agent ${agentId} does not have a verified live runtime`);
1177
    const baseUrl = (process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(
1178
      /\/$/,
1179
      '',
1180
    );
1181
    return {
1182
      agentId,
1183
      operatorId: String(row.operator_id),
1184
      mode: mode as 'persistent_runtime' | 'temporary_chat',
1185
      ...(row.endpoint ? { callbackEndpoint: String(row.endpoint) } : {}),
1186
      a2aEndpoint:
1187
        mode === 'temporary_chat'
1188
          ? `${baseUrl}/a2a/temporary/${encodeURIComponent(agentId)}`
1189
          : String(row.a2a_endpoint ?? row.endpoint),
1190
      card: PublicAgentCardSchema.parse({ ...card, agentMode: mode }),
1191
    };
1192
  }
1193
1194
  private async signedRuntimeRequest(
1195
    runtime: { callbackEndpoint: string },
1196
    requestId: string,
1197
    value: unknown,
1198
  ) {
1199
    const body = JSON.stringify(value);
1200
    const timestamp = new Date().toISOString();
1201
    return postRuntimeJson(runtime.callbackEndpoint, value, {
1202
      'openclasp-request-id': requestId,
1203
      'openclasp-timestamp': timestamp,
1204
      'openclasp-signature': `v1=${signSessionControl(this.gatewaySecret(), requestId, timestamp, body)}`,
1205
    });
1206
  }
1207
1208
  private sessionGrant(
1209
    interaction: FederatedInteraction,
1210
    senderAgentId: string,
1211
    recipientAgentId: string,
1212
  ) {
1213
    return issueSessionGrant(this.gatewaySecret(), {
1214
      interactionId: interaction.interactionId,
1215
      senderAgentId,
1216
      recipientAgentId,
1217
      expiresAt: Math.min(Date.parse(interaction.expiresAt), Date.now() + 60 * 60_000),
1218
    });
1219
  }
1220
1221
  private async privateCounterpartyInsights(
1222
    viewerOperatorId: string,
1223
    counterparty: { agentId: string; card: PublicAgentCard },
1224
    taskCategory: string,
1225
  ): Promise<{
1226
    insights: LiveSessionInsight[];
1227
    relevantSampleSize: number;
1228
    historyConfidence: number;
1229
  }> {
1230
    const rows = await this.sql`
1231
      SELECT record_id, payload
1232
      FROM openclasp_records
1233
      WHERE operator_id = ${viewerOperatorId}
1234
        AND kind = 'profile'
1235
        AND payload->>'agentId' = ${counterparty.agentId}
1236
        AND payload->>'taskCategory' = ${taskCategory}
1237
      ORDER BY (payload->>'sampleSize')::integer DESC, updated_at DESC
1238
    `;
1239
    const profiles: ContextualProfile[] = rows
1240
      .map<Record<string, unknown>>((row) => ({
1241
        recordId: String(row.record_id),
1242
        ...(row.payload as Record<string, unknown>),
1243
      }))
1244
      .filter(
1245
        (profile) =>
1246
          typeof profile.agentVersion === 'string' &&
1247
          typeof profile.sampleSize === 'number' &&
1248
          typeof profile.updatedAt === 'string' &&
1249
          typeof profile.completion === 'number' &&
1250
          typeof profile.acceptance === 'number' &&
1251
          typeof profile.specification === 'number' &&
1252
          typeof profile.deadline === 'number' &&
1253
          typeof profile.communication === 'number' &&
1254
          typeof profile.evidence === 'number' &&
1255
          typeof profile.scope === 'number' &&
1256
          typeof profile.disputes === 'number',
1257
      ) as ContextualProfile[];
1258
    const exact = profiles.find(
1259
      (profile) => profile.agentVersion === counterparty.card.agentVersion,
1260
    );
1261
    const current = exact ?? profiles[0];
1262
    const insights: LiveSessionInsight[] = [];
1263
    let relevantSampleSize = 0;
1264
    let historyConfidence = 0;
1265
    if (!current) {
1266
      insights.push({
1267
        code: 'limited_verified_history',
1268
        severity: 'caution',
1269
        message: `No eligible ${taskCategory} history is available for this agent version. Ask for task-relevant evidence.`,
1270
        evidenceReferences: [],
1271
        requirementReferences: [],
1272
      });
1273
    } else {
1274
      const ageDays = Math.max(
1275
        0,
1276
        (Date.now() - Date.parse(String(current.updatedAt))) / 86_400_000,
1277
      );
1278
      const freshness = Math.exp(-ageDays / 180);
1279
      const sampleSize = Number(current.sampleSize);
1280
      const effectiveSampleSize = Number(current.effectiveSampleSize ?? sampleSize);
1281
      const versionChanged = current.agentVersion !== counterparty.card.agentVersion;
1282
      const confidence = Math.min(
1283
        0.95,
1284
        (effectiveSampleSize / (effectiveSampleSize + 5)) * freshness * (versionChanged ? 0.35 : 1),
1285
      );
1286
      relevantSampleSize = sampleSize;
1287
      historyConfidence = confidence;
1288
      const reference = `openclasp:profile:${current.recordId}`;
1289
      insights.push({
1290
        code: versionChanged ? 'version_history_only' : 'contextual_history',
1291
        severity: versionChanged || confidence < 0.25 ? 'caution' : 'info',
1292
        message: versionChanged
1293
          ? `Only prior-version ${taskCategory} history is available (${sampleSize} eligible interaction${sampleSize === 1 ? '' : 's'}); confidence is reduced for version ${counterparty.card.agentVersion}.`
1294
          : `Based on ${sampleSize} eligible ${taskCategory} interaction${sampleSize === 1 ? '' : 's'}; confidence ${(confidence * 100).toFixed(0)}%.`,
1295
        evidenceReferences: [reference],
1296
        requirementReferences: [],
1297
      });
1298
      const weakDimensions = [
1299
        ['completion', Number(current.completion)],
1300
        ['output acceptance', Number(current.acceptance)],
1301
        ['specification adherence', Number(current.specification)],
1302
        ['deadline reliability', Number(current.deadline)],
1303
        ['communication quality', Number(current.communication)],
1304
        ['evidence quality', Number(current.evidence)],
1305
        ['scope adherence', Number(current.scope)],
1306
      ].filter(([, score]) => Number(score) < 0.7);
1307
      if (weakDimensions.length)
1308
        insights.push({
1309
          code: 'historical_weaknesses',
1310
          severity: weakDimensions.some(([, score]) => Number(score) < 0.5) ? 'high' : 'caution',
1311
          message: `Observed weaker areas: ${weakDimensions
1312
            .map(([name, score]) => `${String(name)} ${(Number(score) * 100).toFixed(0)}%`)
1313
            .join(', ')}.`,
1314
          evidenceReferences: [reference],
1315
          requirementReferences: [],
1316
        });
1317
      if (Number(current.disputes) > 0.25)
1318
        insights.push({
1319
          code: 'elevated_dispute_history',
1320
          severity: Number(current.disputes) >= 0.5 ? 'high' : 'caution',
1321
          message: `Eligible feedback shows a ${(Number(current.disputes) * 100).toFixed(0)}% dispute rate in this task category.`,
1322
          evidenceReferences: [reference],
1323
          requirementReferences: [],
1324
        });
1325
    }
1326
    if (counterparty.card.limitations.length)
1327
      insights.push({
1328
        code: 'declared_limitations',
1329
        severity: 'info',
1330
        message: `Counterparty declares: ${counterparty.card.limitations.join('; ')}`,
1331
        evidenceReferences: [counterparty.card.cardUrl],
1332
        requirementReferences: [],
1333
      });
1334
    return { insights, relevantSampleSize, historyConfidence };
1335
  }
1336
1337
  async brokerLiveSession(interaction: FederatedInteraction) {
1338
    await this.ensureSchema();
1339
    const existing = await this.sql`
1340
      SELECT status FROM openclasp_live_sessions
1341
      WHERE interaction_id = ${interaction.interactionId}
1342
    `;
1343
    if (existing[0]?.status === 'active') return;
1344
    const [initiator, responder] = await Promise.all([
1345
      this.sessionParticipant(interaction.initiatorAgentId),
1346
      this.sessionParticipant(interaction.responderAgentId),
1347
    ]);
1348
    if (initiator.mode === 'temporary_chat' && responder.mode === 'temporary_chat')
1349
      throw new Error('Temporary-to-temporary hosted conversations are not supported in this MVP');
1350
    const [initiatorContext, responderContext] = await Promise.all([
1351
      this.privateCounterpartyInsights(
1352
        initiator.operatorId,
1353
        responder,
1354
        interaction.contract.taskCategory,
1355
      ),
1356
      this.privateCounterpartyInsights(
1357
        responder.operatorId,
1358
        initiator,
1359
        interaction.contract.taskCategory,
1360
      ),
1361
    ]);
1362
    const issuedAt = new Date().toISOString();
1363
    const initiatorBrief = buildCounterpartyBrief({
1364
      interactionId: interaction.interactionId,
1365
      contractHash: interaction.termsHash,
1366
      contract: interaction.contract,
1367
      recipientAgentId: initiator.agentId,
1368
      subject: responder.card,
1369
      historyInsights: initiatorContext.insights,
1370
      relevantSampleSize: initiatorContext.relevantSampleSize,
1371
      historyConfidence: initiatorContext.historyConfidence,
1372
      generatedAt: issuedAt,
1373
      expiresAt: interaction.expiresAt,
1374
    });
1375
    const responderBrief = buildCounterpartyBrief({
1376
      interactionId: interaction.interactionId,
1377
      contractHash: interaction.termsHash,
1378
      contract: interaction.contract,
1379
      recipientAgentId: responder.agentId,
1380
      subject: initiator.card,
1381
      historyInsights: responderContext.insights,
1382
      relevantSampleSize: responderContext.relevantSampleSize,
1383
      historyConfidence: responderContext.historyConfidence,
1384
      generatedAt: issuedAt,
1385
      expiresAt: interaction.expiresAt,
1386
    });
1387
    await Promise.all([
1388
      this.upsert(
1389
        initiator.operatorId,
1390
        'counterparty_brief',
1391
        initiatorBrief.briefId,
1392
        initiatorBrief,
1393
      ),
1394
      this.upsert(
1395
        responder.operatorId,
1396
        'counterparty_brief',
1397
        responderBrief.briefId,
1398
        responderBrief,
1399
      ),
1400
    ]);
1401
    const makeOffer = (
1402
      runtime: typeof initiator,
1403
      counterparty: typeof responder,
1404
      role: 'initiator' | 'responder',
1405
      privateInsights: LiveSessionInsight[],
1406
      counterpartyBrief: CounterpartyBrief,
1407
    ) =>
1408
      LiveSessionOfferSchema.parse({
1409
        type: 'openclasp.session.offer',
1410
        version: '1',
1411
        offerId: crypto.randomUUID(),
1412
        interactionId: interaction.interactionId,
1413
        agentId: runtime.agentId,
1414
        role,
1415
        counterparty: {
1416
          agentId: counterparty.agentId,
1417
          name: counterparty.card.name,
1418
          agentVersion: counterparty.card.agentVersion,
1419
          capabilities: counterparty.card.capabilities,
1420
        },
1421
        contract: interaction.contract,
1422
        contractHash: interaction.termsHash,
1423
        privateInsights,
1424
        counterpartyBrief,
1425
        issuedAt,
1426
        expiresAt: interaction.expiresAt,
1427
      });
1428
    const initiatorOffer = makeOffer(
1429
      initiator,
1430
      responder,
1431
      'initiator',
1432
      initiatorBrief.insights,
1433
      initiatorBrief,
1434
    );
1435
    const responderOffer = makeOffer(
1436
      responder,
1437
      initiator,
1438
      'responder',
1439
      responderBrief.insights,
1440
      responderBrief,
1441
    );
1442
    const prepare = async (
1443
      participant: typeof initiator,
1444
      offer: typeof initiatorOffer,
1445
      label: 'Initiator' | 'Responder',
1446
    ) => {
1447
      if (participant.mode === 'temporary_chat')
1448
        return LiveSessionAcceptanceSchema.parse({
1449
          type: 'openclasp.session.accepted',
1450
          version: '1',
1451
          offerId: offer.offerId,
1452
          interactionId: interaction.interactionId,
1453
          agentId: participant.agentId,
1454
          sessionId: crypto.randomUUID(),
1455
          a2aEndpoint: participant.a2aEndpoint,
1456
          expiresAt: interaction.expiresAt,
1457
        });
1458
      if (!participant.callbackEndpoint) throw new Error(`${label} runtime is not configured`);
1459
      const response = await this.signedRuntimeRequest(
1460
        { callbackEndpoint: participant.callbackEndpoint },
1461
        offer.offerId,
1462
        offer,
1463
      );
1464
      if (response.status < 200 || response.status >= 300)
1465
        throw new Error(`${label} runtime is not live (HTTP ${response.status})`);
1466
      return LiveSessionAcceptanceSchema.parse(response.body);
1467
    };
1468
    const [initiatorAcceptance, responderAcceptance] = await Promise.all([
1469
      prepare(initiator, initiatorOffer, 'Initiator'),
1470
      prepare(responder, responderOffer, 'Responder'),
1471
    ]);
1472
    if (
1473
      initiatorAcceptance.offerId !== initiatorOffer.offerId ||
1474
      initiatorAcceptance.agentId !== initiator.agentId ||
1475
      responderAcceptance.offerId !== responderOffer.offerId ||
1476
      responderAcceptance.agentId !== responder.agentId
1477
    )
1478
      throw new Error('Runtime returned a mismatched live-session acceptance');
1479
    await Promise.all([
1480
      ...(initiator.mode === 'persistent_runtime'
1481
        ? [resolvePublicRuntimeEndpoint(initiatorAcceptance.a2aEndpoint)]
1482
        : []),
1483
      ...(responder.mode === 'persistent_runtime'
1484
        ? [resolvePublicRuntimeEndpoint(responderAcceptance.a2aEndpoint)]
1485
        : []),
1486
    ]);
1487
    await this.sql`
1488
      INSERT INTO openclasp_live_sessions(
1489
        interaction_id, initiator_agent_id, responder_agent_id,
1490
        initiator_session_id, responder_session_id,
1491
        initiator_endpoint, responder_endpoint, status, expires_at
1492
      ) VALUES (
1493
        ${interaction.interactionId}, ${interaction.initiatorAgentId}, ${interaction.responderAgentId},
1494
        ${initiatorAcceptance.sessionId}, ${responderAcceptance.sessionId},
1495
        ${initiatorAcceptance.a2aEndpoint}, ${responderAcceptance.a2aEndpoint},
1496
        'preparing', ${interaction.expiresAt}
1497
      )
1498
      ON CONFLICT (interaction_id) DO UPDATE SET
1499
        initiator_session_id = EXCLUDED.initiator_session_id,
1500
        responder_session_id = EXCLUDED.responder_session_id,
1501
        initiator_endpoint = EXCLUDED.initiator_endpoint,
1502
        responder_endpoint = EXCLUDED.responder_endpoint,
1503
        status = 'preparing', expires_at = EXCLUDED.expires_at, last_error = NULL
1504
    `;
1505
    const baseUrl = (process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(
1506
      /\/$/,
1507
      '',
1508
    );
1509
    const activatedAt = new Date().toISOString();
1510
    const verificationKey = getSessionVerificationKey(this.gatewaySecret());
1511
    const makeActivation = (
1512
      agentId: string,
1513
      sessionId: string,
1514
      role: 'initiator' | 'responder',
1515
      peerAgentId: string,
1516
      peerSessionId: string,
1517
      peerEndpoint: string,
1518
      privateInsights: LiveSessionInsight[],
1519
      counterpartyBrief: CounterpartyBrief,
1520
    ): LiveSessionActivation => {
1521
      const bearerToken = this.sessionGrant(interaction, agentId, peerAgentId);
1522
      return LiveSessionActivationSchema.parse({
1523
        type: 'openclasp.session.activation',
1524
        version: '1',
1525
        activationId: crypto.randomUUID(),
1526
        interactionId: interaction.interactionId,
1527
        agentId,
1528
        sessionId,
1529
        role,
1530
        peer: {
1531
          agentId: peerAgentId,
1532
          sessionId: peerSessionId,
1533
          endpoint: peerEndpoint,
1534
          bearerToken,
1535
          verificationKey,
1536
        },
1537
        reporting: {
1538
          endpoint: `${baseUrl}/sessions/${encodeURIComponent(interaction.interactionId)}/events`,
1539
          completionEndpoint: `${baseUrl}/sessions/${encodeURIComponent(interaction.interactionId)}/completion-reports`,
1540
          feedbackEndpoint: `${baseUrl}/sessions/${encodeURIComponent(interaction.interactionId)}/feedback`,
1541
          bearerToken,
1542
        },
1543
        privateInsights,
1544
        counterpartyBrief,
1545
        contractHash: interaction.termsHash,
1546
        activatedAt,
1547
        expiresAt: interaction.expiresAt,
1548
      });
1549
    };
1550
    const initiatorActivation = makeActivation(
1551
      initiator.agentId,
1552
      initiatorAcceptance.sessionId,
1553
      'initiator',
1554
      responder.agentId,
1555
      responderAcceptance.sessionId,
1556
      responderAcceptance.a2aEndpoint,
1557
      initiatorBrief.insights,
1558
      initiatorBrief,
1559
    );
1560
    const responderActivation = makeActivation(
1561
      responder.agentId,
1562
      responderAcceptance.sessionId,
1563
      'responder',
1564
      initiator.agentId,
1565
      initiatorAcceptance.sessionId,
1566
      initiatorAcceptance.a2aEndpoint,
1567
      responderBrief.insights,
1568
      responderBrief,
1569
    );
1570
    try {
1571
      const activate = async (
1572
        participant: typeof initiator,
1573
        activation: LiveSessionActivation,
1574
        label: 'Initiator' | 'Responder',
1575
      ) => {
1576
        if (participant.mode === 'temporary_chat') return;
1577
        if (!participant.callbackEndpoint) throw new Error(`${label} runtime is not configured`);
1578
        const response = await this.signedRuntimeRequest(
1579
          { callbackEndpoint: participant.callbackEndpoint },
1580
          activation.activationId,
1581
          activation,
1582
        );
1583
        if (response.status < 200 || response.status >= 300)
1584
          throw new Error(`${label} activation failed with HTTP ${response.status}`);
1585
      };
1586
      await activate(responder, responderActivation, 'Responder');
1587
      await activate(initiator, initiatorActivation, 'Initiator');
1588
      await this.sql`
1589
        UPDATE openclasp_live_sessions
1590
        SET status = 'active', activated_at = NOW(), last_error = NULL
1591
        WHERE interaction_id = ${interaction.interactionId}
1592
      `;
1593
      await this.sql`
1594
        UPDATE openclasp_agent_runtimes
1595
        SET last_seen_at = NOW(), last_error = NULL, updated_at = NOW()
1596
        WHERE agent_id IN (${initiator.agentId}, ${responder.agentId})
1597
      `;
1598
      await Promise.all([
1599
        this.touchAgentPresence(initiator.operatorId, initiator.agentId),
1600
        this.touchAgentPresence(responder.operatorId, responder.agentId),
1601
      ]);
1602
    } catch (error) {
1603
      const reason =
1604
        error instanceof Error ? error.message.slice(0, 500) : 'Session activation failed';
1605
      await this.sql`
1606
        UPDATE openclasp_live_sessions SET status = 'failed', last_error = ${reason}
1607
        WHERE interaction_id = ${interaction.interactionId}
1608
      `;
1609
      throw error;
1610
    }
1611
  }
1612
1613
  async getLiveSession(operatorId: string, interactionId: string, agentId: string) {
1614
    await this.ensureSchema();
1615
    const rows = await this.sql`
1616
      SELECT session.*, interaction.payload,
1617
        initiator.operator_id AS initiator_operator_id,
1618
        responder.operator_id AS responder_operator_id
1619
      FROM openclasp_live_sessions session
1620
      INNER JOIN openclasp_federated_interactions interaction
1621
        ON interaction.interaction_id = session.interaction_id
1622
      INNER JOIN openclasp_public_agents initiator
1623
        ON initiator.agent_id = session.initiator_agent_id
1624
      INNER JOIN openclasp_public_agents responder
1625
        ON responder.agent_id = session.responder_agent_id
1626
      WHERE session.interaction_id = ${interactionId} AND session.status = 'active'
1627
      LIMIT 1
1628
    `;
1629
    const row = rows[0];
1630
    if (!row) throw new Error('Active live session not found');
1631
    const isInitiator = row.initiator_agent_id === agentId;
1632
    const isResponder = row.responder_agent_id === agentId;
1633
    if (!isInitiator && !isResponder) throw new Error('Agent is not a session participant');
1634
    const owner = isInitiator ? row.initiator_operator_id : row.responder_operator_id;
1635
    if (owner !== operatorId) throw new Error('Agent is not owned by this account');
1636
    const interaction = FederatedInteractionSchema.parse(row.payload);
1637
    const peerAgentId = String(isInitiator ? row.responder_agent_id : row.initiator_agent_id);
1638
    const peer = await this.sessionParticipant(peerAgentId);
1639
    const privateContext = await this.privateCounterpartyInsights(
1640
      String(owner),
1641
      peer,
1642
      interaction.contract.taskCategory,
1643
    );
1644
    const briefRows = await this.sql`
1645
      SELECT payload FROM openclasp_records
1646
      WHERE operator_id = ${String(owner)}
1647
        AND kind = 'counterparty_brief'
1648
        AND payload->>'interactionId' = ${interactionId}
1649
        AND payload->>'recipientAgentId' = ${agentId}
1650
      ORDER BY updated_at DESC
1651
      LIMIT 1
1652
    `;
1653
    const storedBrief = CounterpartyBriefSchema.safeParse(briefRows[0]?.payload);
1654
    const counterpartyBrief = storedBrief.success
1655
      ? storedBrief.data
1656
      : buildCounterpartyBrief({
1657
          interactionId,
1658
          contractHash: interaction.termsHash,
1659
          contract: interaction.contract,
1660
          recipientAgentId: agentId,
1661
          subject: peer.card,
1662
          historyInsights: privateContext.insights,
1663
          relevantSampleSize: privateContext.relevantSampleSize,
1664
          historyConfidence: privateContext.historyConfidence,
1665
          expiresAt: new Date(String(row.expires_at)).toISOString(),
1666
        });
1667
    const bearerToken = this.sessionGrant(interaction, agentId, peerAgentId);
1668
    return {
1669
      type: 'openclasp.session.activation' as const,
1670
      version: '1' as const,
1671
      activationId: crypto.randomUUID(),
1672
      interactionId,
1673
      agentId,
1674
      sessionId: String(isInitiator ? row.initiator_session_id : row.responder_session_id),
1675
      role: isInitiator ? ('initiator' as const) : ('responder' as const),
1676
      peer: {
1677
        agentId: peerAgentId,
1678
        sessionId: String(isInitiator ? row.responder_session_id : row.initiator_session_id),
1679
        endpoint: String(isInitiator ? row.responder_endpoint : row.initiator_endpoint),
1680
        bearerToken,
1681
        verificationKey: getSessionVerificationKey(this.gatewaySecret()),
1682
      },
1683
      reporting: {
1684
        endpoint: `${(process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(/\/$/, '')}/sessions/${encodeURIComponent(interactionId)}/events`,
1685
        completionEndpoint: `${(process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(/\/$/, '')}/sessions/${encodeURIComponent(interactionId)}/completion-reports`,
1686
        feedbackEndpoint: `${(process.env.OPENCLASP_PUBLIC_URL ?? 'https://openclasp.vercel.app').replace(/\/$/, '')}/sessions/${encodeURIComponent(interactionId)}/feedback`,
1687
        bearerToken,
1688
      },
1689
      privateInsights: privateContext.insights,
1690
      counterpartyBrief,
1691
      contractHash: interaction.termsHash,
1692
      activatedAt: row.activated_at
1693
        ? new Date(String(row.activated_at)).toISOString()
1694
        : new Date().toISOString(),
1695
      expiresAt: new Date(String(row.expires_at)).toISOString(),
1696
    };
1697
  }
1698
1699
  private async interactionParticipant(operatorId: string, interactionId: string, agentId: string) {
1700
    await this.ensureSchema();
1701
    const rows = await this.sql`
1702
      SELECT payload, initiator_operator_id, responder_operator_id,
1703
        initiator_agent_id, responder_agent_id
1704
      FROM openclasp_federated_interactions
1705
      WHERE interaction_id = ${interactionId}
1706
        AND (
1707
          (initiator_operator_id = ${operatorId} AND initiator_agent_id = ${agentId})
1708
          OR (responder_operator_id = ${operatorId} AND responder_agent_id = ${agentId})
1709
        )
1710
      LIMIT 1
1711
    `;
1712
    const row = rows[0];
1713
    if (!row) throw new Error('Interaction participant not found for this account');
1714
    const interaction = FederatedInteractionSchema.parse(row.payload);
1715
    const isInitiator = interaction.initiatorAgentId === agentId;
1716
    return {
1717
      interaction,
1718
      counterpartyAgentId: isInitiator
1719
        ? interaction.responderAgentId
1720
        : interaction.initiatorAgentId,
1721
      participantOperatorId: String(
1722
        isInitiator ? row.initiator_operator_id : row.responder_operator_id,
1723
      ),
1724
      counterpartyOperatorId: String(
1725
        isInitiator ? row.responder_operator_id : row.initiator_operator_id,
1726
      ),
1727
    };
1728
  }
1729
1730
  async getCounterpartyBrief(operatorId: string, interactionId: string, agentId: string) {
1731
    await this.interactionParticipant(operatorId, interactionId, agentId);
1732
    const rows = await this.sql`
1733
      SELECT payload FROM openclasp_records
1734
      WHERE operator_id = ${operatorId}
1735
        AND kind = 'counterparty_brief'
1736
        AND payload->>'interactionId' = ${interactionId}
1737
        AND payload->>'recipientAgentId' = ${agentId}
1738
      ORDER BY updated_at DESC
1739
      LIMIT 1
1740
    `;
1741
    const parsed = CounterpartyBriefSchema.safeParse(rows[0]?.payload);
1742
    if (!parsed.success) throw new Error('Counterparty brief is not available');
1743
    return parsed.data;
1744
  }
1745
1746
  private async ensureFeedbackRequest(
1747
    operatorId: string,
1748
    interactionId: string,
1749
    reviewerAgentId: string,
1750
    subjectAgentId: string,
1751
  ) {
1752
    const existing = await this.sql`
1753
      SELECT payload FROM openclasp_records
1754
      WHERE operator_id = ${operatorId}
1755
        AND kind = 'feedback_request'
1756
        AND payload->>'interactionId' = ${interactionId}
1757
        AND payload->>'reviewerAgentId' = ${reviewerAgentId}
1758
      ORDER BY updated_at DESC
1759
      LIMIT 1
1760
    `;
1761
    const parsed = FeedbackRequestSchema.safeParse(existing[0]?.payload);
1762
    if (parsed.success) return parsed.data;
1763
    const requestedAt = new Date();
1764
    const base = FeedbackRequestSchema.parse({
1765
      requestId: deterministicUuid(`feedback-request:${interactionId}:${reviewerAgentId}`),
1766
      interactionId,
1767
      reviewerAgentId,
1768
      subjectAgentId,
1769
      status: 'pending',
1770
      requestedDimensions: FEEDBACK_DIMENSIONS,
1771
      requestedAt: requestedAt.toISOString(),
1772
      dueAt: new Date(requestedAt.getTime() + 24 * 60 * 60_000).toISOString(),
1773
    });
1774
    const request = FeedbackRequestSchema.parse({
1775
      ...base,
1776
      platformAttestation: attestSessionRecord(this.gatewaySecret(), base),
1777
    });
1778
    await this.upsert(operatorId, 'feedback_request', request.requestId, request);
1779
    return request;
1780
  }
1781
1782
  private async requestRuntimeFinalization(
1783
    interactionId: string,
1784
    reportingAgentId: string,
1785
    counterpartyAgentId: string,
1786
    contractHash: string,
1787
  ) {
1788
    const rows = await this.sql`
1789
      SELECT endpoint FROM openclasp_agent_runtimes
1790
      WHERE agent_id = ${counterpartyAgentId} AND status = 'verified'
1791
      LIMIT 1
1792
    `;
1793
    if (!rows[0]?.endpoint) return false;
1794
    const requestId = crypto.randomUUID();
1795
    const value = {
1796
      type: 'openclasp.session.finalization_request',
1797
      version: '1',
1798
      requestId,
1799
      interactionId,
1800
      agentId: counterpartyAgentId,
1801
      peerAgentId: reportingAgentId,
1802
      contractHash,
1803
      requestedAt: new Date().toISOString(),
1804
    };
1805
    const response = await this.signedRuntimeRequest(
1806
      { callbackEndpoint: String(rows[0].endpoint) },
1807
      requestId,
1808
      value,
1809
    );
1810
    return response.status >= 200 && response.status < 300;
1811
  }
1812
1813
  async listFeedbackRequests(operatorId: string, agentId: string) {
1814
    const rows = await this.sql`
1815
      SELECT payload FROM openclasp_records
1816
      WHERE operator_id = ${operatorId}
1817
        AND kind = 'feedback_request'
1818
        AND payload->>'reviewerAgentId' = ${agentId}
1819
      ORDER BY updated_at DESC
1820
    `;
1821
    return rows.map((row) => FeedbackRequestSchema.parse(row.payload));
1822
  }
1823
1824
  async submitCompletionReport(
1825
    operatorId: string,
1826
    agentId: string,
1827
    value: InteractionCompletionReport,
1828
    submissionMethod: 'oauth_installation' | 'agent_access_token' | 'runtime_session',
1829
  ) {
1830
    const participant = await this.interactionParticipant(operatorId, value.interactionId, agentId);
1831
    const report = InteractionCompletionReportSchema.parse(value);
1832
    if (report.platformAttestation)
1833
      throw new Error('Platform attestation is assigned by OpenClasp');
1834
    if (report.reportingAgentId !== agentId)
1835
      throw new Error('Completion report identity does not match the authenticated agent');
1836
    if (report.counterpartyAgentId !== participant.counterpartyAgentId)
1837
      throw new Error('Completion report counterparty does not match the interaction');
1838
    if (report.contractHash !== participant.interaction.termsHash)
1839
      throw new Error('Completion report contract hash does not match the interaction');
1840
    if (report.requestedOutcome !== participant.interaction.contract.requestedOutcome)
1841
      throw new Error('Completion report requested outcome does not match the contract');
1842
    if (
1843
      report.criteria.some(
1844
        (criterion) =>
1845
          !participant.interaction.contract.successCriteria.includes(criterion.criterion),
1846
      )
1847
    )
1848
      throw new Error('Completion report contains a criterion outside the contract');
1849
    if (!['active', 'completed'].includes(participant.interaction.status))
1850
      throw new Error('Only active or completed interactions accept completion reports');
1851
    const profiles = await this.sql`
1852
      SELECT payload FROM openclasp_records
1853
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
1854
      LIMIT 1
1855
    `;
1856
    const profile = profiles[0]?.payload as Partial<AgentProfile> | undefined;
1857
    if (!profile || profile.agentVersion !== report.agentVersion)
1858
      throw new Error('Completion report agent version does not match the registered agent');
1859
    let verifiedSubmissionMethod: InteractionCompletionReport['submissionMethod'] =
1860
      submissionMethod;
1861
    if (report.signature) {
1862
      if (report.submissionMethod !== 'agent_signature')
1863
        throw new Error('Signed completion reports must declare agent_signature submission');
1864
      const identities = await this.sql`
1865
        SELECT payload FROM openclasp_records
1866
        WHERE operator_id = ${operatorId} AND kind = 'agent' AND record_id = ${agentId}
1867
        LIMIT 1
1868
      `;
1869
      const identity = AgentIdentitySchema.safeParse(identities[0]?.payload);
1870
      if (
1871
        !identity.success ||
1872
        !verifyObject(report as unknown as Record<string, unknown>, identity.data.publicKey)
1873
      )
1874
        throw new Error('Completion report agent signature is invalid or unverifiable');
1875
      verifiedSubmissionMethod = 'agent_signature';
1876
    }
1877
    const unsigned = InteractionCompletionReportSchema.parse({
1878
      ...report,
1879
      submissionMethod: verifiedSubmissionMethod,
1880
    });
1881
    const stored = InteractionCompletionReportSchema.parse({
1882
      ...unsigned,
1883
      platformAttestation: attestSessionRecord(this.gatewaySecret(), unsigned),
1884
    });
1885
    const existing = await this.sql`
1886
      SELECT payload FROM openclasp_records
1887
      WHERE kind = 'completion_report' AND record_id = ${stored.reportId}
1888
        AND payload->>'interactionId' = ${stored.interactionId}
1889
      LIMIT 1
1890
    `;
1891
    if (existing[0] && canonicalHash(existing[0].payload) !== canonicalHash(stored))
1892
      throw new Error('Conflicting completion report ID');
1893
    await Promise.all([
1894
      this.upsert(participant.participantOperatorId, 'completion_report', stored.reportId, stored),
1895
      this.upsert(participant.counterpartyOperatorId, 'completion_report', stored.reportId, stored),
1896
    ]);
1897
    const [feedbackRequest] = await Promise.all([
1898
      this.ensureFeedbackRequest(
1899
        participant.participantOperatorId,
1900
        stored.interactionId,
1901
        stored.reportingAgentId,
1902
        stored.counterpartyAgentId,
1903
      ),
1904
      this.ensureFeedbackRequest(
1905
        participant.counterpartyOperatorId,
1906
        stored.interactionId,
1907
        stored.counterpartyAgentId,
1908
        stored.reportingAgentId,
1909
      ),
1910
    ]);
1911
    const completed = await this.sql`
1912
      SELECT COUNT(DISTINCT payload->>'reportingAgentId') AS count
1913
      FROM openclasp_records
1914
      WHERE kind = 'completion_report'
1915
        AND payload->>'interactionId' = ${stored.interactionId}
1916
    `;
1917
    let peerReportStatus: InteractionConclusion['peerReportStatus'] = 'received';
1918
    if (Number(completed[0]?.count ?? 0) >= 2) {
1919
      const completedAt = new Date().toISOString();
1920
      await Promise.all([
1921
        this.sql`
1922
          UPDATE openclasp_live_sessions SET status = 'completed', completed_at = NOW()
1923
          WHERE interaction_id = ${stored.interactionId} AND status = 'active'
1924
        `,
1925
        this.sql`
1926
          UPDATE openclasp_federated_interactions
1927
          SET status = 'completed',
1928
            payload = jsonb_set(
1929
              jsonb_set(payload, '{status}', '"completed"'::jsonb),
1930
              '{updatedAt}', to_jsonb(${completedAt}::text)
1931
            ),
1932
            updated_at = NOW()
1933
          WHERE interaction_id = ${stored.interactionId} AND status = 'active'
1934
        `,
1935
      ]);
1936
    } else {
1937
      const requested = await this.requestRuntimeFinalization(
1938
        stored.interactionId,
1939
        stored.reportingAgentId,
1940
        stored.counterpartyAgentId,
1941
        stored.contractHash,
1942
      ).catch(() => false);
1943
      peerReportStatus = requested ? 'awaiting' : 'unreachable';
1944
    }
1945
    const conclusion = await this.finalizeInteractionConclusion(stored.interactionId, {
1946
      peerReportStatus,
1947
    });
1948
    return {
1949
      report: stored,
1950
      feedbackRequest,
1951
      peerReportRequested: peerReportStatus === 'awaiting',
1952
      ...(conclusion.released ? { conclusion: conclusion.conclusion } : {}),
1953
    };
1954
  }
1955
1956
  async recordSessionCompletionReport(token: string, value: InteractionCompletionReport) {
1957
    const report = InteractionCompletionReportSchema.parse(value);
1958
    const grant = verifySessionGrant(this.gatewaySecret(), token);
1959
    if (
1960
      grant.interactionId !== report.interactionId ||
1961
      grant.senderAgentId !== report.reportingAgentId ||
1962
      grant.recipientAgentId !== report.counterpartyAgentId
1963
    )
1964
      throw new Error('Session credential does not match the completion report');
1965
    const owners = await this.sql`
1966
      SELECT initiator_operator_id, responder_operator_id,
1967
        initiator_agent_id, responder_agent_id
1968
      FROM openclasp_federated_interactions
1969
      WHERE interaction_id = ${report.interactionId}
1970
      LIMIT 1
1971
    `;
1972
    const row = owners[0];
1973
    if (!row) throw new Error('Interaction not found');
1974
    const operatorId = String(
1975
      row.initiator_agent_id === report.reportingAgentId
1976
        ? row.initiator_operator_id
1977
        : row.responder_operator_id,
1978
    );
1979
    return this.submitCompletionReport(
1980
      operatorId,
1981
      report.reportingAgentId,
1982
      report,
1983
      'runtime_session',
1984
    );
1985
  }
1986
1987
  private async applyInteractionLearning(input: {
1988
    interaction: FederatedInteraction;
1989
    initiatorOperatorId: string;
1990
    responderOperatorId: string;
1991
    reports: InteractionCompletionReport[];
1992
    feedback: InteractionFeedback[];
1993
    conclusion: InteractionConclusion;
1994
  }) {
1995
    const { interaction, reports, feedback, conclusion } = input;
1996
    const [initiatorSettings, responderSettings, cards] = await Promise.all([
1997
      this.getSettings(input.initiatorOperatorId),
1998
      this.getSettings(input.responderOperatorId),
1999
      this.sql`
2000
        SELECT agent_id, card FROM openclasp_public_agents
2001
        WHERE agent_id IN (${interaction.initiatorAgentId}, ${interaction.responderAgentId})
2002
      `,
2003
    ]);
2004
    const contributionMode =
2005
      initiatorSettings.contributionEnabled && responderSettings.contributionEnabled
2006
        ? ('network_aggregate' as const)
2007
        : ('local_only' as const);
2008
    const eligibilityBase = evaluateLearningEligibility({
2009
      interactionId: interaction.interactionId,
2010
      reports,
2011
      feedback,
2012
      consensus: conclusion.consensus,
2013
      contributionMode,
2014
      reviewerCredibility: Object.fromEntries(
2015
        feedback.map((item) => [item.reviewerAgentId, reviewerCredibility(item)]),
2016
      ),
2017
      decisionId: deterministicUuid(`learning-eligibility:${interaction.interactionId}`),
2018
      decidedAt: conclusion.generatedAt,
2019
    });
2020
    const eligibility = LearningEligibilityDecisionSchema.parse({
2021
      ...eligibilityBase,
2022
      platformAttestation: attestSessionRecord(this.gatewaySecret(), eligibilityBase),
2023
    });
2024
    await Promise.all([
2025
      this.upsert(
2026
        input.initiatorOperatorId,
2027
        'learning_eligibility',
2028
        eligibility.decisionId,
2029
        eligibility,
2030
      ),
2031
      this.upsert(
2032
        input.responderOperatorId,
2033
        'learning_eligibility',
2034
        eligibility.decisionId,
2035
        eligibility,
2036
      ),
2037
    ]);
2038
    if (!eligibility.eligible || eligibility.sampleWeight <= 0)
2039
      return { eligibility, profileDeltas: [] as BehaviouralProfileDelta[] };
2040
2041
    const appliedAt = new Date().toISOString();
2042
    const versions = new Map<string, string>();
2043
    for (const report of reports) versions.set(report.reportingAgentId, report.agentVersion);
2044
    for (const row of cards) {
2045
      const card = PublicAgentCardSchema.safeParse(row.card);
2046
      if (card.success && !versions.has(String(row.agent_id)))
2047
        versions.set(String(row.agent_id), card.data.agentVersion);
2048
    }
2049
    const participants = [
2050
      {
2051
        viewerOperatorId: input.initiatorOperatorId,
2052
        reviewerAgentId: interaction.initiatorAgentId,
2053
        subjectAgentId: interaction.responderAgentId,
2054
      },
2055
      {
2056
        viewerOperatorId: input.responderOperatorId,
2057
        reviewerAgentId: interaction.responderAgentId,
2058
        subjectAgentId: interaction.initiatorAgentId,
2059
      },
2060
    ];
2061
    const profileDeltas = await Promise.all(
2062
      participants.map(async (participant) => {
2063
        const agentVersion = versions.get(participant.subjectAgentId);
2064
        if (!agentVersion) return undefined;
2065
        const deltaId = deterministicUuid(
2066
          `profile-delta:${interaction.interactionId}:${participant.viewerOperatorId}:${participant.subjectAgentId}`,
2067
        );
2068
        const existingDelta = await this.sql`
2069
          SELECT payload FROM openclasp_records
2070
          WHERE operator_id = ${participant.viewerOperatorId}
2071
            AND kind = 'profile_delta' AND record_id = ${deltaId}
2072
          LIMIT 1
2073
        `;
2074
        const parsedDelta = BehaviouralProfileDeltaSchema.safeParse(existingDelta[0]?.payload);
2075
        if (parsedDelta.success) return parsedDelta.data;
2076
        const profileId = deterministicUuid(
2077
          `contextual-profile:${participant.subjectAgentId}:${agentVersion}:${interaction.contract.taskCategory}`,
2078
        );
2079
        const currentRows = await this.sql`
2080
          SELECT payload FROM openclasp_records
2081
          WHERE operator_id = ${participant.viewerOperatorId}
2082
            AND kind = 'profile' AND record_id = ${profileId}
2083
          LIMIT 1
2084
        `;
2085
        const current = currentRows[0]?.payload as Partial<ContextualProfile> | undefined;
2086
        const reviewerFeedback = feedback.find(
2087
          (item) =>
2088
            item.reviewerAgentId === participant.reviewerAgentId &&
2089
            item.subjectAgentId === participant.subjectAgentId,
2090
        );
2091
        const observations = deriveBehaviouralObservations({
2092
          subjectAgentId: participant.subjectAgentId,
2093
          reviewerAgentId: participant.reviewerAgentId,
2094
          reports,
2095
          ...(reviewerFeedback ? { reviewerFeedback } : {}),
2096
          conclusion,
2097
        });
2098
        if (!Object.keys(observations).length) return undefined;
2099
        const applied = updateContextualBehaviouralProfile({
2100
          ...(current ? { current } : {}),
2101
          observations,
2102
          sampleWeight: eligibility.sampleWeight,
2103
          appliedAt,
2104
        });
2105
        const profile: ContextualProfile = {
2106
          recordId: profileId,
2107
          agentId: participant.subjectAgentId,
2108
          agentVersion,
2109
          taskCategory: interaction.contract.taskCategory,
2110
          ...applied.profile,
2111
        };
2112
        const deltaBase = BehaviouralProfileDeltaSchema.parse({
2113
          deltaId,
2114
          interactionId: interaction.interactionId,
2115
          agentId: participant.subjectAgentId,
2116
          agentVersion,
2117
          taskCategory: interaction.contract.taskCategory,
2118
          sampleWeight: eligibility.sampleWeight,
2119
          dimensionDeltas: applied.dimensionDeltas,
2120
          explanation:
2121
            'Applied platform-attested structured outcome and eligible reviewer signals; private comments and raw conversation content were excluded.',
2122
          appliedAt,
2123
        });
2124
        const delta = BehaviouralProfileDeltaSchema.parse({
2125
          ...deltaBase,
2126
          platformAttestation: attestSessionRecord(this.gatewaySecret(), deltaBase),
2127
        });
2128
        await Promise.all([
2129
          this.upsert(participant.viewerOperatorId, 'profile', profileId, profile),
2130
          this.upsert(participant.viewerOperatorId, 'profile_delta', deltaId, delta),
2131
        ]);
2132
        return delta;
2133
      }),
2134
    );
2135
    return {
2136
      eligibility,
2137
      profileDeltas: profileDeltas.filter(
2138
        (delta): delta is BehaviouralProfileDelta => delta !== undefined,
2139
      ),
2140
    };
2141
  }
2142
2143
  private async finalizeInteractionConclusion(
2144
    interactionId: string,
2145
    options: { peerReportStatus?: InteractionConclusion['peerReportStatus'] } = {},
2146
  ) {
2147
    const [interactionRows, reportRows, feedbackRows, requestRows, existingRows] =
2148
      await Promise.all([
2149
        this.sql`
2150
        SELECT payload, initiator_operator_id, responder_operator_id
2151
        FROM openclasp_federated_interactions
2152
        WHERE interaction_id = ${interactionId}
2153
        LIMIT 1
2154
      `,
2155
        this.sql`
2156
        SELECT record_id, payload FROM openclasp_records
2157
        WHERE kind = 'completion_report' AND payload->>'interactionId' = ${interactionId}
2158
      `,
2159
        this.sql`
2160
        SELECT record_id, payload FROM openclasp_records
2161
        WHERE kind = 'interaction_feedback' AND payload->>'interactionId' = ${interactionId}
2162
      `,
2163
        this.sql`
2164
        SELECT record_id, payload FROM openclasp_records
2165
        WHERE kind = 'feedback_request' AND payload->>'interactionId' = ${interactionId}
2166
      `,
2167
        this.sql`
2168
        SELECT payload FROM openclasp_records
2169
        WHERE kind = 'interaction_conclusion' AND payload->>'interactionId' = ${interactionId}
2170
        LIMIT 1
2171
      `,
2172
      ]);
2173
    const interactionRow = interactionRows[0];
2174
    if (!interactionRow) throw new Error('Interaction not found');
2175
    const interaction = FederatedInteractionSchema.parse(interactionRow.payload);
2176
    const reports = [
2177
      ...new Map(
2178
        reportRows
2179
          .map((row) => InteractionCompletionReportSchema.parse(row.payload))
2180
          .map((report) => [report.reportId, report]),
2181
      ).values(),
2182
    ];
2183
    const feedback = [
2184
      ...new Map(
2185
        feedbackRows
2186
          .map((row) => InteractionFeedbackSchema.parse(row.payload))
2187
          .map((item) => [item.feedbackId, item]),
2188
      ).values(),
2189
    ];
2190
    const requests = [
2191
      ...new Map(
2192
        requestRows
2193
          .map((row) => FeedbackRequestSchema.parse(row.payload))
2194
          .map((request) => [request.requestId, request]),
2195
      ).values(),
2196
    ];
2197
    if (!reports.length) return { released: false as const, feedbackRevealed: false as const };
2198
    const existing = InteractionConclusionSchema.safeParse(existingRows[0]?.payload);
2199
    const reportingAgentIds = new Set(reports.map((report) => report.reportingAgentId));
2200
    const missingReportAgentIds = [
2201
      interaction.initiatorAgentId,
2202
      interaction.responderAgentId,
2203
    ].filter((agentId) => !reportingAgentIds.has(agentId));
2204
    const feedbackWindowClosed =
2205
      requests.length >= 2 && requests.every((request) => request.status !== 'pending');
2206
    const lifecycle =
2207
      missingReportAgentIds.length === 0 || feedbackWindowClosed
2208
        ? ('final' as const)
2209
        : ('provisional' as const);
2210
    const visibleFeedback = feedbackWindowClosed ? feedback : [];
2211
    const peerReportStatus: InteractionConclusion['peerReportStatus'] =
2212
      missingReportAgentIds.length === 0
2213
        ? 'received'
2214
        : feedbackWindowClosed
2215
          ? 'timed_out'
2216
          : (options.peerReportStatus ?? existing.data?.peerReportStatus ?? 'awaiting');
2217
    const pendingFeedbackAgentIds = requests
2218
      .filter((request) => request.status === 'pending')
2219
      .map((request) => request.reviewerAgentId);
2220
    if (requests.length < 2) return { released: false as const };
2221
    const base = buildInteractionConclusion({
2222
      interaction,
2223
      reports,
2224
      feedback: visibleFeedback,
2225
      conclusionId: deterministicUuid(`interaction-conclusion:${interactionId}`),
2226
      lifecycle,
2227
      pendingFeedbackAgentIds,
2228
      peerReportStatus,
2229
    });
2230
    const conclusion = InteractionConclusionSchema.parse({
2231
      ...base,
2232
      platformAttestation: attestSessionRecord(this.gatewaySecret(), base),
2233
    });
2234
    const startedAt = reports
2235
      .map((report) => report.startedAt)
2236
      .filter((value): value is string => typeof value === 'string')
2237
      .sort()[0];
2238
    const receiptBase = {
2239
      receiptId: deterministicUuid(`interaction-receipt:${interactionId}`),
2240
      interactionId,
2241
      participants: [interaction.initiatorAgentId, interaction.responderAgentId],
2242
      agentVersions: Object.fromEntries(
2243
        reports.map((report) => [report.reportingAgentId, report.agentVersion]),
2244
      ),
2245
      contractHash: interaction.termsHash,
2246
      startedAt: startedAt ?? interaction.createdAt,
2247
      completedAt:
2248
        reports
2249
          .map((report) => report.completedAt)
2250
          .sort()
2251
          .at(-1) ?? conclusion.generatedAt,
2252
      outcome: conclusion.outcome,
2253
      commitmentsFulfilled: conclusion.criteria
2254
        .filter((criterion) => criterion.status === 'met')
2255
        .map((criterion) => criterion.criterion),
2256
      commitmentsMissed: conclusion.criteria
2257
        .filter((criterion) => criterion.status !== 'met')
2258
        .map((criterion) => criterion.criterion),
2259
      evidenceHashes: conclusion.evidenceReferences.map((reference) => canonicalHash(reference)),
2260
      policyWarnings: reports.flatMap((report) => report.blockers),
2261
      policyViolations: [],
2262
      disputeStatus: conclusion.consensus === 'conflicting' ? ('open' as const) : ('none' as const),
2263
      delegationChainHash: canonicalHash(interaction.contract.delegationRules),
2264
      unilateral: reports.length < 2,
2265
      provisional: lifecycle === 'provisional',
2266
      confidence: conclusion.confidence,
2267
      signatures: {},
2268
      completionReportIds: conclusion.reportIds,
2269
      conclusionId: conclusion.conclusionId,
2270
    };
2271
    const receipt = ReceiptSchema.parse({
2272
      ...receiptBase,
2273
      platformAttestation: attestSessionRecord(this.gatewaySecret(), receiptBase),
2274
    });
2275
    await Promise.all([
2276
      this.upsert(
2277
        String(interactionRow.initiator_operator_id),
2278
        'interaction_conclusion',
2279
        conclusion.conclusionId,
2280
        conclusion,
2281
      ),
2282
      this.upsert(
2283
        String(interactionRow.responder_operator_id),
2284
        'interaction_conclusion',
2285
        conclusion.conclusionId,
2286
        conclusion,
2287
      ),
2288
      this.upsert(
2289
        String(interactionRow.initiator_operator_id),
2290
        'receipt',
2291
        receipt.receiptId,
2292
        receipt,
2293
      ),
2294
      this.upsert(
2295
        String(interactionRow.responder_operator_id),
2296
        'receipt',
2297
        receipt.receiptId,
2298
        receipt,
2299
      ),
2300
    ]);
2301
    const learning =
2302
      lifecycle === 'final' && feedbackWindowClosed
2303
        ? await this.applyInteractionLearning({
2304
            interaction,
2305
            initiatorOperatorId: String(interactionRow.initiator_operator_id),
2306
            responderOperatorId: String(interactionRow.responder_operator_id),
2307
            reports,
2308
            feedback: visibleFeedback,
2309
            conclusion,
2310
          })
2311
        : undefined;
2312
    return {
2313
      released: true as const,
2314
      feedbackRevealed: feedbackWindowClosed,
2315
      conclusion,
2316
      receipt,
2317
      ...(learning ? { learning } : {}),
2318
    };
2319
  }
2320
2321
  async submitInteractionFeedback(
2322
    operatorId: string,
2323
    agentId: string,
2324
    value: InteractionFeedback,
2325
    submissionMethod: 'oauth_installation' | 'agent_access_token' | 'runtime_session',
2326
  ) {
2327
    const feedback = InteractionFeedbackSchema.parse(value);
2328
    if (feedback.platformAttestation)
2329
      throw new Error('Platform attestation is assigned by OpenClasp');
2330
    if (feedback.reviewerAgentId !== agentId)
2331
      throw new Error('Feedback reviewer does not match the authenticated agent');
2332
    const participant = await this.interactionParticipant(
2333
      operatorId,
2334
      feedback.interactionId,
2335
      agentId,
2336
    );
2337
    if (feedback.subjectAgentId !== participant.counterpartyAgentId)
2338
      throw new Error('Feedback subject does not match the interaction counterparty');
2339
    const requestRows = await this.sql`
2340
      SELECT payload FROM openclasp_records
2341
      WHERE operator_id = ${operatorId}
2342
        AND kind = 'feedback_request'
2343
        AND record_id = ${feedback.requestId}
2344
      LIMIT 1
2345
    `;
2346
    const request = FeedbackRequestSchema.parse(requestRows[0]?.payload);
2347
    if (
2348
      request.interactionId !== feedback.interactionId ||
2349
      request.reviewerAgentId !== feedback.reviewerAgentId ||
2350
      request.subjectAgentId !== feedback.subjectAgentId
2351
    )
2352
      throw new Error('Feedback does not match its request');
2353
    if (request.status !== 'pending') throw new Error('Feedback request is no longer pending');
2354
    if (Date.parse(request.dueAt) <= Date.now()) throw new Error('Feedback request has expired');
2355
    if (
2356
      request.requestedDimensions.some(
2357
        (dimension) => typeof feedback.ratings[dimension] !== 'number',
2358
      )
2359
    )
2360
      throw new Error('Feedback must rate every requested dimension');
2361
    const profiles = await this.sql`
2362
      SELECT payload FROM openclasp_records
2363
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
2364
      LIMIT 1
2365
    `;
2366
    const profile = profiles[0]?.payload as Partial<AgentProfile> | undefined;
2367
    if (!profile || profile.agentVersion !== feedback.reviewerAgentVersion)
2368
      throw new Error('Feedback reviewer version does not match the registered agent');
2369
    let verifiedSubmissionMethod: InteractionFeedback['submissionMethod'] = submissionMethod;
2370
    if (feedback.signature) {
2371
      if (feedback.submissionMethod !== 'agent_signature')
2372
        throw new Error('Signed feedback must declare agent_signature submission');
2373
      const identities = await this.sql`
2374
        SELECT payload FROM openclasp_records
2375
        WHERE operator_id = ${operatorId} AND kind = 'agent' AND record_id = ${agentId}
2376
        LIMIT 1
2377
      `;
2378
      const identity = AgentIdentitySchema.safeParse(identities[0]?.payload);
2379
      if (
2380
        !identity.success ||
2381
        !verifyObject(feedback as unknown as Record<string, unknown>, identity.data.publicKey)
2382
      )
2383
        throw new Error('Feedback agent signature is invalid or unverifiable');
2384
      verifiedSubmissionMethod = 'agent_signature';
2385
    }
2386
    const unattested = InteractionFeedbackSchema.parse({
2387
      ...feedback,
2388
      submissionMethod: verifiedSubmissionMethod,
2389
    });
2390
    const stored = InteractionFeedbackSchema.parse({
2391
      ...unattested,
2392
      platformAttestation: attestSessionRecord(this.gatewaySecret(), unattested),
2393
    });
2394
    const existing = await this.sql`
2395
      SELECT payload FROM openclasp_records
2396
      WHERE operator_id = ${operatorId}
2397
        AND kind = 'interaction_feedback'
2398
        AND record_id = ${stored.feedbackId}
2399
      LIMIT 1
2400
    `;
2401
    if (existing[0] && canonicalHash(existing[0].payload) !== canonicalHash(stored))
2402
      throw new Error('Conflicting feedback ID');
2403
    await this.upsert(operatorId, 'interaction_feedback', stored.feedbackId, stored);
2404
    const requestBase = { ...request, status: 'submitted' as const };
2405
    delete requestBase.platformAttestation;
2406
    const updatedRequest = FeedbackRequestSchema.parse({
2407
      ...requestBase,
2408
      platformAttestation: attestSessionRecord(this.gatewaySecret(), requestBase),
2409
    });
2410
    await this.upsert(operatorId, 'feedback_request', request.requestId, updatedRequest);
2411
    const release = await this.finalizeInteractionConclusion(feedback.interactionId);
2412
    return {
2413
      feedbackId: stored.feedbackId,
2414
      status: 'submitted' as const,
2415
      revealed: release.released && release.feedbackRevealed,
2416
      ...(release.released ? { conclusion: release.conclusion } : {}),
2417
    };
2418
  }
2419
2420
  async processDueFeedback(now = new Date()) {
2421
    await this.ensureSchema();
2422
    const expiredInteractions = await this.sql`
2423
      UPDATE openclasp_federated_interactions
2424
      SET status = 'expired',
2425
        payload = jsonb_set(
2426
          jsonb_set(payload, '{status}', '"expired"'::jsonb),
2427
          '{updatedAt}', to_jsonb(${now.toISOString()}::text)
2428
        ),
2429
        updated_at = NOW()
2430
      WHERE status IN ('pending', 'active') AND expires_at <= ${now.toISOString()}
2431
      RETURNING interaction_id
2432
    `;
2433
    if (expiredInteractions.length) {
2434
      await this.sql`
2435
        UPDATE openclasp_live_sessions
2436
        SET status = 'failed', completed_at = COALESCE(completed_at, NOW()),
2437
          last_error = COALESCE(last_error, 'Interaction expired before bilateral completion')
2438
        WHERE status IN ('preparing', 'active')
2439
          AND interaction_id IN (
2440
            SELECT interaction_id FROM openclasp_federated_interactions
2441
            WHERE status = 'expired' AND expires_at <= ${now.toISOString()}
2442
          )
2443
      `;
2444
    }
2445
    const [rows, backfillRows] = await Promise.all([
2446
      this.sql`
2447
        SELECT operator_id, record_id, payload FROM openclasp_records
2448
        WHERE kind = 'feedback_request'
2449
          AND payload->>'status' = 'pending'
2450
          AND (payload->>'dueAt')::timestamptz <= ${now.toISOString()}
2451
        LIMIT 500
2452
      `,
2453
      this.sql`
2454
        SELECT source.interaction_id
2455
        FROM (
2456
          SELECT conclusion.payload->>'interactionId' AS interaction_id
2457
          FROM openclasp_records conclusion
2458
          WHERE conclusion.kind = 'interaction_conclusion'
2459
            AND NOT EXISTS (
2460
              SELECT 1 FROM openclasp_records eligibility
2461
              WHERE eligibility.operator_id = conclusion.operator_id
2462
                AND eligibility.kind = 'learning_eligibility'
2463
                AND eligibility.payload->>'interactionId' = conclusion.payload->>'interactionId'
2464
            )
2465
          UNION
2466
          SELECT report.payload->>'interactionId' AS interaction_id
2467
          FROM openclasp_records report
2468
          WHERE report.kind = 'completion_report'
2469
            AND NOT EXISTS (
2470
              SELECT 1 FROM openclasp_records conclusion
2471
              WHERE conclusion.kind = 'interaction_conclusion'
2472
                AND conclusion.payload->>'interactionId' = report.payload->>'interactionId'
2473
            )
2474
        ) source
2475
        WHERE source.interaction_id IS NOT NULL
2476
        GROUP BY source.interaction_id
2477
        ORDER BY source.interaction_id ASC
2478
        LIMIT 200
2479
      `,
2480
    ]);
2481
    const interactions = new Set<string>();
2482
    for (const row of rows) {
2483
      const request = FeedbackRequestSchema.parse(row.payload);
2484
      const base = { ...request, status: 'expired' as const };
2485
      delete base.platformAttestation;
2486
      const expired = FeedbackRequestSchema.parse({
2487
        ...base,
2488
        platformAttestation: attestSessionRecord(this.gatewaySecret(), base),
2489
      });
2490
      await this.upsert(
2491
        String(row.operator_id),
2492
        'feedback_request',
2493
        String(row.record_id),
2494
        expired,
2495
      );
2496
      interactions.add(request.interactionId);
2497
    }
2498
    for (const row of backfillRows) interactions.add(String(row.interaction_id));
2499
    let released = 0;
2500
    for (const interactionId of interactions) {
2501
      const result = await this.finalizeInteractionConclusion(interactionId);
2502
      if (result.released) released += 1;
2503
    }
2504
    return {
2505
      expired: rows.length,
2506
      interactionsExpired: expiredInteractions.length,
2507
      released,
2508
      backfilled: backfillRows.length,
2509
    };
2510
  }
2511
2512
  async recordSessionFeedback(token: string, value: InteractionFeedback) {
2513
    const feedback = InteractionFeedbackSchema.parse(value);
2514
    const grant = verifySessionGrant(this.gatewaySecret(), token);
2515
    if (
2516
      grant.interactionId !== feedback.interactionId ||
2517
      grant.senderAgentId !== feedback.reviewerAgentId ||
2518
      grant.recipientAgentId !== feedback.subjectAgentId
2519
    )
2520
      throw new Error('Session credential does not match the feedback');
2521
    const owners = await this.sql`
2522
      SELECT initiator_operator_id, responder_operator_id,
2523
        initiator_agent_id, responder_agent_id
2524
      FROM openclasp_federated_interactions
2525
      WHERE interaction_id = ${feedback.interactionId}
2526
      LIMIT 1
2527
    `;
2528
    const row = owners[0];
2529
    if (!row) throw new Error('Interaction not found');
2530
    const operatorId = String(
2531
      row.initiator_agent_id === feedback.reviewerAgentId
2532
        ? row.initiator_operator_id
2533
        : row.responder_operator_id,
2534
    );
2535
    return this.submitInteractionFeedback(
2536
      operatorId,
2537
      feedback.reviewerAgentId,
2538
      feedback,
2539
      'runtime_session',
2540
    );
2541
  }
2542
2543
  private async ownedTemporaryAgent(operatorId: string, agentId: string) {
2544
    const rows = await this.sql`
2545
      SELECT payload FROM openclasp_records
2546
      WHERE operator_id = ${operatorId} AND kind = 'agent_profile' AND record_id = ${agentId}
2547
      LIMIT 1
2548
    `;
2549
    const profile = rows[0]?.payload as Partial<AgentProfile> | undefined;
2550
    if (!profile) throw new Error('Agent is not owned by this account');
2551
    const mode =
2552
      profile.agentMode ?? (profile.a2aEndpoint ? 'persistent_runtime' : 'temporary_chat');
2553
    if (mode !== 'temporary_chat')
2554
      throw new Error('Hosted conversations are available only to temporary chat agents');
2555
    return profile;
2556
  }
2557
2558
  private hostedMessage(row: Record<string, any>): HostedMessage {
2559
    const decrypted = decryptGatewayPayload(this.gatewaySecret(), {
2560
      ciphertext: String(row.content_ciphertext),
2561
      iv: String(row.content_iv),
2562
      authTag: String(row.content_auth_tag),
2563
    }) as { content?: unknown };
2564
    return HostedMessageSchema.parse({
2565
      messageId: String(row.message_id),
2566
      threadId: String(row.thread_id),
2567
      interactionId: String(row.interaction_id),
2568
      senderAgentId: String(row.sender_agent_id),
2569
      recipientAgentId: String(row.recipient_agent_id),
2570
      contentType: 'text/plain',
2571
      content: decrypted.content,
2572
      contentHash: String(row.content_hash),
2573
      delivery: row.delivery,
2574
      createdAt: new Date(String(row.created_at)).toISOString(),
2575
      ...(row.read_at ? { readAt: new Date(String(row.read_at)).toISOString() } : {}),
2576
    });
2577
  }
2578
2579
  private async storeHostedMessage(input: {
2580
    interactionId: string;
2581
    senderAgentId: string;
2582
    recipientAgentId: string;
2583
    requestKey: string;
2584
    content: string;
2585
    delivery: 'accepted' | 'delivered';
2586
  }) {
2587
    const content = input.content.trim();
2588
    if (!content || content.length > 20_000)
2589
      throw new Error('Message must contain 1-20000 characters');
2590
    const sessions = await this.sql`
2591
      SELECT session.status, interaction.status AS interaction_status,
2592
        session.initiator_agent_id, session.responder_agent_id
2593
      FROM openclasp_live_sessions session
2594
      INNER JOIN openclasp_federated_interactions interaction
2595
        ON interaction.interaction_id = session.interaction_id
2596
      WHERE session.interaction_id = ${input.interactionId}
2597
      LIMIT 1
2598
    `;
2599
    const session = sessions[0];
2600
    if (!session || session.status !== 'active' || session.interaction_status !== 'active')
2601
      throw new Error('Active interaction not found');
2602
    const participants = new Set([
2603
      String(session.initiator_agent_id),
2604
      String(session.responder_agent_id),
2605
    ]);
2606
    if (!participants.has(input.senderAgentId) || !participants.has(input.recipientAgentId))
2607
      throw new Error('Message participants do not match the active interaction');
2608
    const now = new Date();
2609
    const threadId = input.interactionId;
2610
    const expiresAt = new Date(now.getTime() + 30 * 86_400_000).toISOString();
2611
    await this.sql`
2612
      INSERT INTO openclasp_hosted_threads(
2613
        thread_id, interaction_id, participant_a_agent_id, participant_b_agent_id,
2614
        status, expires_at
2615
      ) VALUES (
2616
        ${threadId}, ${input.interactionId}, ${String(session.initiator_agent_id)},
2617
        ${String(session.responder_agent_id)}, 'open', ${expiresAt}
2618
      )
2619
      ON CONFLICT (thread_id) DO UPDATE SET updated_at = NOW()
2620
      WHERE openclasp_hosted_threads.status = 'open'
2621
    `;
2622
    const thread = await this.sql`
2623
      SELECT status, expires_at FROM openclasp_hosted_threads WHERE thread_id = ${threadId}
2624
    `;
2625
    if (thread[0]?.status !== 'open') throw new Error('Hosted thread is closed');
2626
    if (Date.parse(String(thread[0].expires_at)) <= Date.now())
2627
      throw new Error('Hosted thread has expired');
2628
    const encrypted = encryptGatewayPayload(this.gatewaySecret(), { content });
2629
    const messageId = crypto.randomUUID();
2630
    const contentHash = canonicalHash(content);
2631
    const inserted = await this.sql`
2632
      INSERT INTO openclasp_hosted_messages(
2633
        message_id, thread_id, interaction_id, sender_agent_id, recipient_agent_id,
2634
        request_key, content_ciphertext, content_iv, content_auth_tag, content_hash, delivery
2635
      ) VALUES (
2636
        ${messageId}, ${threadId}, ${input.interactionId}, ${input.senderAgentId},
2637
        ${input.recipientAgentId}, ${input.requestKey}, ${encrypted.ciphertext}, ${encrypted.iv},
2638
        ${encrypted.authTag}, ${contentHash}, ${input.delivery}
2639
      )
2640
      ON CONFLICT (thread_id, sender_agent_id, request_key) DO NOTHING
2641
      RETURNING *
2642
    `;
2643
    if (!inserted[0]) {
2644
      const existing = await this.sql`
2645
        SELECT * FROM openclasp_hosted_messages
2646
        WHERE thread_id = ${threadId} AND sender_agent_id = ${input.senderAgentId}
2647
          AND request_key = ${input.requestKey}
2648
        LIMIT 1
2649
      `;
2650
      return { message: this.hostedMessage(existing[0]!), deduplicated: true };
2651
    }
2652
    const sequenceRows = await this.sql`
2653
      SELECT COALESCE(MAX(sequence), -1) + 1 AS sequence
2654
      FROM openclasp_live_session_events
2655
      WHERE interaction_id = ${input.interactionId} AND agent_id = ${input.senderAgentId}
2656
    `;
2657
    const event = LiveSessionEventSchema.parse({
2658
      eventId: crypto.randomUUID(),
2659
      interactionId: input.interactionId,
2660
      agentId: input.senderAgentId,
2661
      sequence: Number(sequenceRows[0]?.sequence ?? 0),
2662
      type: 'message_sent',
2663
      occurredAt: now.toISOString(),
2664
      messageHash: contentHash,
2665
      evidenceReferences: [],
2666
      details: { labels: ['hosted_temporary'], metrics: {}, flags: { rawStoredEncrypted: true } },
2667
    });
2668
    const eventWithAttestation = JSON.stringify({
2669
      ...event,
2670
      attestation: attestSessionRecord(this.gatewaySecret(), event),
2671
    });
2672
    await this.sql`
2673
      INSERT INTO openclasp_live_session_events(interaction_id, event_id, agent_id, sequence, event)
2674
      VALUES (${event.interactionId}, ${event.eventId}, ${event.agentId}, ${event.sequence}, ${eventWithAttestation}::jsonb)
2675
      ON CONFLICT DO NOTHING
2676
    `;
2677
    return { message: this.hostedMessage(inserted[0]), deduplicated: false };
2678
  }
2679
2680
  async receiveTemporaryMessage(
2681
    token: string,
2682
    recipientAgentId: string,
2683
    requestKey: string,
2684
    content: string,
2685
  ) {
2686
    await this.ensureSchema();
2687
    const grant = verifySessionGrant(this.gatewaySecret(), token);
2688
    if (grant.recipientAgentId !== recipientAgentId)
2689
      throw new Error('Session credential is not valid for this temporary agent');
2690
    const recipient = await this.sessionParticipant(recipientAgentId);
2691
    if (recipient.mode !== 'temporary_chat')
2692
      throw new Error('Target is not a temporary chat agent');
2693
    const stored = await this.storeHostedMessage({
2694
      interactionId: grant.interactionId,
2695
      senderAgentId: grant.senderAgentId,
2696
      recipientAgentId,
2697
      requestKey,
2698
      content,
2699
      delivery: 'delivered',
2700
    });
2701
    return stored;
2702
  }
2703
2704
  async sendTemporaryMessage(
2705
    operatorId: string,
2706
    senderAgentId: string,
2707
    interactionId: string,
2708
    content: string,
2709
  ) {
2710
    await this.ensureSchema();
2711
    await this.ownedTemporaryAgent(operatorId, senderAgentId);
2712
    const session = await this.getLiveSession(operatorId, interactionId, senderAgentId);
2713
    const peer = await this.sessionParticipant(session.peer.agentId);
2714
    if (peer.mode !== 'persistent_runtime')
2715
      throw new Error('Temporary-to-temporary hosted conversations are not supported');
2716
    const requestKey = crypto.randomUUID();
2717
    const stored = await this.storeHostedMessage({
2718
      interactionId,
2719
      senderAgentId,
2720
      recipientAgentId: session.peer.agentId,
2721
      requestKey,
2722
      content,
2723
      delivery: 'accepted',
2724
    });
2725
    const response = await postRuntimeJson(
2726
      session.peer.endpoint,
2727
      {
2728
        jsonrpc: '2.0',
2729
        id: requestKey,
2730
        method: 'message/send',
2731
        params: {
2732
          message: {
2733
            role: 'user',
2734
            parts: [{ kind: 'text', text: content.trim() }],
2735
            metadata: {
2736
              [DEFAULT_EXTENSION_URI]: {
2737
                interactionId,
2738
                termsHash: session.contractHash,
2739
                initiatorAgentId:
2740
                  session.role === 'initiator' ? senderAgentId : session.peer.agentId,
2741
                responderAgentId:
2742
                  session.role === 'responder' ? senderAgentId : session.peer.agentId,
2743
              },
2744
            },
2745
          },
2746
        },
2747
      },
2748
      {
2749
        authorization: `Bearer ${session.peer.bearerToken}`,
2750
        'A2A-Extensions': DEFAULT_EXTENSION_URI,
2751
      },
2752
    );
2753
    if (response.status < 200 || response.status >= 300)
2754
      throw new Error(`Peer A2A endpoint returned HTTP ${response.status}`);
2755
    await this.sql`
2756
      UPDATE openclasp_hosted_messages SET delivery = 'delivered'
2757
      WHERE message_id = ${stored.message.messageId}
2758
    `;
2759
    return {
2760
      ...stored,
2761
      message: { ...stored.message, delivery: 'delivered' as const },
2762
      peerResponse: response.body,
2763
    };
2764
  }
2765
2766
  async listHostedThreads(operatorId: string, agentId: string): Promise<HostedThread[]> {
2767
    await this.ensureSchema();
2768
    await this.ownedTemporaryAgent(operatorId, agentId);
2769
    await this.sql`DELETE FROM openclasp_hosted_threads WHERE expires_at <= NOW()`;
2770
    const rows = await this.sql`
2771
      SELECT thread.*,
2772
        (SELECT COUNT(*) FROM openclasp_hosted_messages message
2773
          WHERE message.thread_id = thread.thread_id
2774
            AND message.recipient_agent_id = ${agentId} AND message.read_at IS NULL) AS unread_count
2775
      FROM openclasp_hosted_threads thread
2776
      WHERE ${agentId} IN (thread.participant_a_agent_id, thread.participant_b_agent_id)
2777
      ORDER BY thread.updated_at DESC
2778
    `;
2779
    return rows.map((row) =>
2780
      HostedThreadSchema.parse({
2781
        threadId: String(row.thread_id),
2782
        interactionId: String(row.interaction_id),
2783
        participantAgentIds: [
2784
          String(row.participant_a_agent_id),
2785
          String(row.participant_b_agent_id),
2786
        ],
2787
        status: row.status,
2788
        privacyMode: 'openclasp_hosted_temporary',
2789
        unreadCount: Number(row.unread_count ?? 0),
2790
        createdAt: new Date(String(row.created_at)).toISOString(),
2791
        updatedAt: new Date(String(row.updated_at)).toISOString(),
2792
        expiresAt: new Date(String(row.expires_at)).toISOString(),
2793
      }),
2794
    );
2795
  }
2796
2797
  async getHostedThread(operatorId: string, agentId: string, threadId: string) {
2798
    const threads = await this.listHostedThreads(operatorId, agentId);
2799
    const thread = threads.find((value) => value.threadId === threadId);
2800
    if (!thread) throw new Error('Hosted thread not found');
2801
    const rows = await this.sql`
2802
      SELECT * FROM openclasp_hosted_messages
2803
      WHERE thread_id = ${threadId} ORDER BY created_at ASC
2804
    `;
2805
    const peerAgentId = thread.participantAgentIds.find((value) => value !== agentId);
2806
    if (!peerAgentId) throw new Error('Hosted thread has invalid participants');
2807
    const peer = await this.sessionParticipant(peerAgentId);
2808
    const interactions = await this.sql`
2809
      SELECT payload FROM openclasp_federated_interactions
2810
      WHERE interaction_id = ${thread.interactionId} LIMIT 1
2811
    `;
2812
    const interaction = FederatedInteractionSchema.parse(interactions[0]?.payload);
2813
    const insights = await this.privateCounterpartyInsights(
2814
      operatorId,
2815
      peer,
2816
      interaction.contract.taskCategory,
2817
    );
2818
    return { thread, messages: rows.map((row) => this.hostedMessage(row)), insights };
2819
  }
2820
2821
  async markHostedThreadRead(operatorId: string, agentId: string, threadId: string) {
2822
    await this.getHostedThread(operatorId, agentId, threadId);
2823
    const rows = await this.sql`
2824
      UPDATE openclasp_hosted_messages
2825
      SET delivery = 'read', read_at = COALESCE(read_at, NOW())
2826
      WHERE thread_id = ${threadId} AND recipient_agent_id = ${agentId} AND read_at IS NULL
2827
      RETURNING message_id
2828
    `;
2829
    return { threadId, markedRead: rows.length };
2830
  }
2831
2832
  async closeHostedThread(operatorId: string, agentId: string, threadId: string) {
2833
    await this.getHostedThread(operatorId, agentId, threadId);
2834
    await this.sql`
2835
      UPDATE openclasp_hosted_threads SET status = 'closed', updated_at = NOW()
2836
      WHERE thread_id = ${threadId}
2837
    `;
2838
    return { threadId, status: 'closed' as const };
2839
  }
2840
2841
  async recordLiveSessionEvent(token: string, value: LiveSessionEvent) {
2842
    await this.ensureSchema();
2843
    const event = LiveSessionEventSchema.parse(value);
2844
    const grant = verifySessionGrant(this.gatewaySecret(), token);
2845
    if (grant.interactionId !== event.interactionId || grant.senderAgentId !== event.agentId)
2846
      throw new Error('Session credential does not match the event');
2847
    const sessions = await this.sql`
2848
      SELECT 1 FROM openclasp_live_sessions
2849
      WHERE interaction_id = ${event.interactionId}
2850
        AND status = 'active'
2851
        AND ${event.agentId} IN (initiator_agent_id, responder_agent_id)
2852
    `;
2853
    if (!sessions.length) throw new Error('Active live session not found');
2854
    const attestation = attestSessionRecord(this.gatewaySecret(), event);
2855
    const encoded = JSON.stringify({ ...event, attestation });
2856
    const rows = await this.sql`
2857
      INSERT INTO openclasp_live_session_events(
2858
        interaction_id, event_id, agent_id, sequence, event
2859
      ) SELECT ${event.interactionId}, ${event.eventId}, ${event.agentId}, ${event.sequence}, ${encoded}::jsonb
2860
      WHERE (
2861
        SELECT COUNT(*) FROM openclasp_live_session_events
2862
        WHERE interaction_id = ${event.interactionId}
2863
      ) < 1000
2864
      ON CONFLICT DO NOTHING
2865
      RETURNING event_id
2866
    `;
2867
    if (event.type === 'session_completed' || event.type === 'session_failed') {
2868
      const terminal = await this.sql`
2869
        SELECT COUNT(DISTINCT agent_id) AS count
2870
        FROM openclasp_live_session_events
2871
        WHERE interaction_id = ${event.interactionId}
2872
          AND event->>'type' IN ('session_completed', 'session_failed')
2873
      `;
2874
      if (Number(terminal[0]?.count ?? 0) >= 2) {
2875
        await this.sql`
2876
          UPDATE openclasp_live_sessions SET status = 'completed', completed_at = NOW()
2877
          WHERE interaction_id = ${event.interactionId} AND status = 'active'
2878
        `;
2879
        const completedAt = new Date().toISOString();
2880
        await this.sql`
2881
          UPDATE openclasp_federated_interactions
2882
          SET status = 'completed',
2883
            payload = jsonb_set(
2884
              jsonb_set(payload, '{status}', '"completed"'::jsonb),
2885
              '{updatedAt}', to_jsonb(${completedAt}::text)
2886
            ),
2887
            updated_at = NOW()
2888
          WHERE interaction_id = ${event.interactionId} AND status = 'active'
2889
        `;
2890
      }
2891
    }
2892
    return {
2893
      recorded: rows.length > 0,
2894
      deduplicated: rows.length === 0,
2895
      eventId: event.eventId,
2896
      attestation,
2897
    };
2898
  }
2899
2900
  async createFederatedInteraction(
2901
    operatorId: string,
2902
    value: FederatedInteraction,
2903
  ): Promise<FederatedInteraction> {
2904
    await this.ensureSchema();
2905
    const interaction = FederatedInteractionSchema.parse(value);
2906
    if (interaction.status !== 'pending') throw new Error('New interactions must be pending');
2907
    if (interaction.contract.interactionId !== interaction.interactionId)
2908
      throw new Error('Contract interaction ID does not match');
2909
    if (canonicalHash(interaction.contract) !== interaction.termsHash)
2910
      throw new Error('Contract hash does not match the immutable terms');
2911
    if (
2912
      interaction.contract.parties.length !== 2 ||
2913
      interaction.contract.parties[0] !== interaction.initiatorAgentId ||
2914
      interaction.contract.parties[1] !== interaction.responderAgentId
2915
    )
2916
      throw new Error('Contract parties do not match the interaction participants');
2917
    const acceptanceEntries = Object.entries(interaction.acceptances);
2918
    if (
2919
      acceptanceEntries.length !== 1 ||
2920
      acceptanceEntries[0]?.[0] !== interaction.initiatorAgentId ||
2921
      acceptanceEntries[0]?.[1].agentId !== interaction.initiatorAgentId ||
2922
      acceptanceEntries[0]?.[1].termsHash !== interaction.termsHash ||
2923
      !['oauth_installation', 'oauth_account'].includes(acceptanceEntries[0]?.[1].method ?? '')
2924
    )
2925
      throw new Error('A pending interaction requires only the initiator acceptance');
2926
    if (Date.parse(interaction.expiresAt) <= Date.now())
2927
      throw new Error('Interaction expiry must be in the future');
2928
    const owners = await this.sql`
2929
      SELECT agent_id, operator_id, card FROM openclasp_public_agents
2930
      WHERE agent_id IN (${interaction.initiatorAgentId}, ${interaction.responderAgentId})
2931
    `;
2932
    const initiator = owners.find((row) => row.agent_id === interaction.initiatorAgentId);
2933
    const responder = owners.find((row) => row.agent_id === interaction.responderAgentId);
2934
    if (!initiator || initiator.operator_id !== operatorId)
2935
      throw new Error('The initiating agent must be published and owned by this account');
2936
    if (!responder) throw new Error('Responder is not a published OpenClasp agent');
2937
    if (responder.operator_id === operatorId)
2938
      throw new Error('Federated interactions require agents from different accounts');
2939
    const responderCard = normalizePublicAgentCard(responder.card);
2940
    if (
2941
      !responderCard.transports.some(
2942
        (transport) =>
2943
          transport.endpoint === interaction.responderTransport.endpoint &&
2944
          transport.protocolBinding === interaction.responderTransport.protocolBinding,
2945
      )
2946
    )
2947
      throw new Error('Responder transport does not match its published Agent Card');
2948
    const encoded = JSON.stringify(interaction);
2949
    const rows = await this.sql`
2950
      INSERT INTO openclasp_federated_interactions(
2951
        interaction_id, initiator_operator_id, responder_operator_id,
2952
        initiator_agent_id, responder_agent_id, status, payload, expires_at
2953
      ) VALUES (
2954
        ${interaction.interactionId}, ${operatorId}, ${responder.operator_id},
2955
        ${interaction.initiatorAgentId}, ${interaction.responderAgentId},
2956
        ${interaction.status}, ${encoded}::jsonb, ${interaction.expiresAt}
2957
      )
2958
      RETURNING payload
2959
    `;
2960
    const stored = FederatedInteractionSchema.parse(rows[0]?.payload);
2961
    const profiles = await this.sql`
2962
      SELECT payload FROM openclasp_records
2963
      WHERE operator_id = ${responder.operator_id}
2964
        AND kind = 'agent_profile'
2965
        AND record_id = ${interaction.responderAgentId}
2966
      LIMIT 1
2967
    `;
2968
    const responderProfile = profiles[0]?.payload as AgentProfile | undefined;
2969
    if (responderProfile && canAutoAcceptInteraction(responderProfile, stored))
2970
      return this.respondToFederatedInteraction(
2971
        String(responder.operator_id),
2972
        stored.interactionId,
2973
        stored.responderAgentId,
2974
        'accept',
2975
        'policy_auto_accept',
2976
      );
2977
    return stored;
2978
  }
2979
2980
  async listFederatedInteractions(operatorId: string): Promise<FederatedInteraction[]> {
2981
    await this.ensureSchema();
2982
    await this.sql`
2983
      UPDATE openclasp_federated_interactions
2984
      SET status = 'expired', payload = jsonb_set(payload, '{status}', '"expired"'::jsonb), updated_at = NOW()
2985
      WHERE status = 'pending' AND expires_at <= NOW()
2986
    `;
2987
    const rows = await this.sql`
2988
      SELECT payload FROM openclasp_federated_interactions
2989
      WHERE initiator_operator_id = ${operatorId} OR responder_operator_id = ${operatorId}
2990
      ORDER BY updated_at DESC
2991
    `;
2992
    return rows.map((row) => FederatedInteractionSchema.parse(row.payload));
2993
  }
2994
2995
  async getFederatedInteraction(
2996
    operatorId: string,
2997
    interactionId: string,
2998
  ): Promise<FederatedInteraction | undefined> {
2999
    const values = await this.listFederatedInteractions(operatorId);
3000
    return values.find((value) => value.interactionId === interactionId);
3001
  }
3002
3003
  async respondToFederatedInteraction(
3004
    operatorId: string,
3005
    interactionId: string,
3006
    agentId: string,
3007
    decision: 'accept' | 'reject',
3008
    method: 'oauth_installation' | 'oauth_account' | 'policy_auto_accept' = 'oauth_account',
3009
  ): Promise<FederatedInteraction> {
3010
    await this.ensureSchema();
3011
    const rows = await this.sql`
3012
      SELECT payload, responder_operator_id, responder_agent_id
3013
      FROM openclasp_federated_interactions WHERE interaction_id = ${interactionId}
3014
    `;
3015
    const row = rows[0];
3016
    if (!row) throw new Error('Interaction invitation not found');
3017
    if (row.responder_operator_id !== operatorId || row.responder_agent_id !== agentId)
3018
      throw new Error('Only the invited agent may respond');
3019
    const current = FederatedInteractionSchema.parse(row.payload);
3020
    if (current.status !== 'pending') throw new Error('Invitation is no longer pending');
3021
    const now = new Date().toISOString();
3022
    if (Date.parse(current.expiresAt) <= Date.now()) {
3023
      const expired = { ...current, status: 'expired' as const, updatedAt: now };
3024
      await this.updateFederatedInteraction(interactionId, current.status, expired);
3025
      throw new Error('Invitation has expired');
3026
    }
3027
    const next: FederatedInteraction =
3028
      decision === 'reject'
3029
        ? { ...current, status: 'rejected', updatedAt: now }
3030
        : {
3031
            ...current,
3032
            status: 'active',
3033
            updatedAt: now,
3034
            acceptances: {
3035
              ...current.acceptances,
3036
              [agentId]: {
3037
                agentId,
3038
                method,
3039
                termsHash: current.termsHash,
3040
                acceptedAt: now,
3041
              },
3042
            },
3043
          };
3044
    if (decision === 'accept') {
3045
      try {
3046
        await this.brokerLiveSession(next);
3047
      } catch (error) {
3048
        const reason = error instanceof Error ? error.message.slice(0, 500) : 'Live session failed';
3049
        await this.sql`
3050
          UPDATE openclasp_agent_runtimes SET last_error = ${reason}, updated_at = NOW()
3051
          WHERE agent_id IN (${current.initiatorAgentId}, ${current.responderAgentId})
3052
        `;
3053
        throw error;
3054
      }
3055
    }
3056
    const updated = await this.updateFederatedInteraction(interactionId, current.status, next);
3057
    if (!updated) throw new Error('Invitation was already handled');
3058
    return next;
3059
  }
3060
3061
  private async updateFederatedInteraction(
3062
    interactionId: string,
3063
    expectedStatus: string,
3064
    value: FederatedInteraction,
3065
  ): Promise<boolean> {
3066
    const encoded = JSON.stringify(FederatedInteractionSchema.parse(value));
3067
    const rows = await this.sql`
3068
      UPDATE openclasp_federated_interactions
3069
      SET status = ${value.status}, payload = ${encoded}::jsonb, updated_at = NOW()
3070
      WHERE interaction_id = ${interactionId} AND status = ${expectedStatus}
3071
      RETURNING interaction_id
3072
    `;
3073
    return rows.length > 0;
3074
  }
3075
3076
  async getSettings(operatorId: string): Promise<AccountSettings> {
3077
    await this.ensureSchema();
3078
    const rows = await this.sql`
3079
      SELECT display_name, contribution_enabled, retention_days, evidence_sharing
3080
      FROM openclasp_account_settings
3081
      WHERE operator_id = ${operatorId}
3082
    `;
3083
    const row = rows[0] as
3084
      | {
3085
          display_name: string;
3086
          contribution_enabled: boolean;
3087
          retention_days: number;
3088
          evidence_sharing: AccountSettings['evidenceSharing'];
3089
        }
3090
      | undefined;
3091
    return row
3092
      ? {
3093
          displayName: row.display_name,
3094
          contributionEnabled: row.contribution_enabled,
3095
          retentionDays: row.retention_days,
3096
          evidenceSharing: row.evidence_sharing,
3097
          rawConversationsStored: false,
3098
        }
3099
      : defaults;
3100
  }
3101
3102
  async saveSettings(
3103
    operatorId: string,
3104
    settings: Omit<AccountSettings, 'rawConversationsStored'>,
3105
  ): Promise<AccountSettings> {
3106
    await this.ensureSchema();
3107
    await this.sql`
3108
      INSERT INTO openclasp_account_settings(
3109
        operator_id, display_name, contribution_enabled, retention_days, evidence_sharing
3110
      ) VALUES (
3111
        ${operatorId}, ${settings.displayName}, ${settings.contributionEnabled},
3112
        ${settings.retentionDays}, ${settings.evidenceSharing}
3113
      )
3114
      ON CONFLICT (operator_id) DO UPDATE SET
3115
        display_name = EXCLUDED.display_name,
3116
        contribution_enabled = EXCLUDED.contribution_enabled,
3117
        retention_days = EXCLUDED.retention_days,
3118
        evidence_sharing = EXCLUDED.evidence_sharing,
3119
        updated_at = NOW()
3120
    `;
3121
    return { ...settings, rawConversationsStored: false };
3122
  }
3123
}