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

Viewing files at commit 81afd7789b27Commit detailsView latest

tests/core.test.ts 467 lines · 16.1 KB · typescript
1
import { describe, expect, it } from 'vitest';
2
import {
3
  buildCounterpartyBrief,
4
  buildInteractionConclusion,
5
  createIdentity,
6
  deriveBehaviouralObservations,
7
  evaluateLearningEligibility,
8
  FixtureFactCheckProvider,
9
  TrustEngine,
10
  updateContextualBehaviouralProfile,
11
} from '@openclasp/core';
12
import {
13
  canonicalHash,
14
  InteractionCompletionReportSchema,
15
  InteractionFeedbackSchema,
16
  PublicAgentCardSchema,
17
  signNamed,
18
  type InteractionContract,
19
  type InteractionConclusion,
20
  type InteractionCompletionReport,
21
  type InteractionFeedback,
22
  type Receipt,
23
} from '@openclasp/protocol';
24
import { createSignedEvent } from '@openclasp/sdk';
25
26
describe('general assurance behavior', () => {
27
  it('requires every contract party to sign before the agreement is active', () => {
28
    const engine = new TrustEngine();
29
    const first = createIdentity({
30
      agentId: 'agent:first',
31
      operatorRef: 'operator:a',
32
      capabilities: ['coordinate'],
33
    });
34
    const second = createIdentity({
35
      agentId: 'agent:second',
36
      operatorRef: 'operator:b',
37
      capabilities: ['coordinate'],
38
    });
39
    engine.registerAgent(first.identity);
40
    engine.registerAgent(second.identity);
41
    const unsigned: InteractionContract = {
42
      protocolVersion: '0.1',
43
      interactionId: crypto.randomUUID(),
44
      purpose: 'Coordinate a task',
45
      parties: [first.identity.agentId, second.identity.agentId],
46
      taskCategory: 'coordination',
47
      requestedOutcome: 'Agreed plan',
48
      successCriteria: ['Plan accepted'],
49
      allowedActions: ['coordinate'],
50
      prohibitedActions: [],
51
      allowedData: ['public'],
52
      prohibitedData: ['private'],
53
      evidenceRequirements: [],
54
      delegationRules: [],
55
      humanApprovalRequirements: [],
56
      factCheckingPolicy: 'important_claims',
57
      mediationPolicy: 'mutual_consent',
58
      retentionDays: 30,
59
      completionConditions: ['plan_delivered'],
60
      cancellationConditions: ['either_party'],
61
      signatures: {},
62
    };
63
    const oneSignature = signNamed(
64
      unsigned as unknown as Record<string, unknown>,
65
      first.identity.agentId,
66
      first.keyPair,
67
    ) as unknown as InteractionContract;
68
    expect(() => engine.saveContract(oneSignature)).toThrow('every party');
69
    const complete = signNamed(
70
      oneSignature as unknown as Record<string, unknown>,
71
      second.identity.agentId,
72
      second.keyPair,
73
    ) as unknown as InteractionContract;
74
    expect(engine.saveContract(complete).interactionId).toBe(unsigned.interactionId);
75
  });
76
77
  it('does not fact-check opinions as objective truth', async () => {
78
    const provider = new FixtureFactCheckProvider();
79
    expect((await provider.check('I think green is the best color')).status).toBe(
80
      'not_fact_checkable',
81
    );
82
  });
83
84
  it('verifies a receipt without recording it', () => {
85
    const engine = new TrustEngine();
86
    const agent = createIdentity({
87
      agentId: 'agent:receipt',
88
      operatorRef: 'operator:a',
89
      capabilities: ['work'],
90
    });
91
    engine.registerAgent(agent.identity);
92
    const base: Receipt = {
93
      receiptId: crypto.randomUUID(),
94
      interactionId: crypto.randomUUID(),
95
      participants: [agent.identity.agentId],
96
      agentVersions: { [agent.identity.agentId]: '1.0.0' },
97
      contractHash: 'contract',
98
      startedAt: new Date(Date.now() - 1000).toISOString(),
99
      completedAt: new Date().toISOString(),
100
      outcome: 'success',
101
      commitmentsFulfilled: ['done'],
102
      commitmentsMissed: [],
103
      evidenceHashes: [],
104
      policyWarnings: [],
105
      policyViolations: [],
106
      disputeStatus: 'none',
107
      delegationChainHash: 'none',
108
      unilateral: false,
109
      signatures: {},
110
    };
111
    const signed = signNamed(
112
      base as unknown as Record<string, unknown>,
113
      agent.identity.agentId,
114
      agent.keyPair,
115
    ) as unknown as Receipt;
116
    expect(engine.verifyReceipt(signed).receiptId).toBe(base.receiptId);
117
    expect(engine.receipts.size).toBe(0);
118
  });
119
120
  it('requires opt-in and strips payloads from network contributions', () => {
121
    const engine = new TrustEngine();
122
    const agent = createIdentity({
123
      agentId: 'agent:a',
124
      operatorRef: 'operator:a',
125
      capabilities: ['chat'],
126
    });
127
    engine.registerAgent(agent.identity);
128
    const event = createSignedEvent(
129
      {
130
        protocolVersion: '0.1',
131
        eventId: crypto.randomUUID(),
132
        interactionId: crypto.randomUUID(),
133
        eventType: 'claim',
134
        agentId: agent.identity.agentId,
135
        agentVersion: '1.0.0',
136
        timestamp: new Date().toISOString(),
137
        visibility: 'network_aggregate',
138
        provenance: 'observed',
139
        payload: { rawMessage: 'private conversation', claim: 'secret' },
140
      },
141
      agent.keyPair,
142
    );
143
    expect(engine.networkContribution(event)).toBeNull();
144
    engine.setContributionConsent(agent.identity.agentId, true);
145
    const contribution = engine.networkContribution(event);
146
    expect(contribution).not.toHaveProperty('payload');
147
    expect(JSON.stringify(contribution)).not.toContain('private conversation');
148
  });
149
150
  it('requires mutual mediation consent', () => {
151
    const engine = new TrustEngine();
152
    const conflict = engine.createConflict({
153
      interactionId: crypto.randomUUID(),
154
      issue: 'ambiguous requirement',
155
      participants: ['a', 'b'],
156
      positions: {},
157
      evidence: [],
158
      contractClauses: [],
159
      missingInformation: [],
160
      possibleResolutions: ['clarify'],
161
    });
162
    engine.permitMediation(conflict.conflictId, 'a');
163
    expect(() => engine.resolveConflict(conflict.conflictId, 'clarified')).toThrow('Mutual');
164
    engine.permitMediation(conflict.conflictId, 'b');
165
    expect(engine.resolveConflict(conflict.conflictId, 'clarified').status).toBe('resolved');
166
  });
167
168
  it('builds a private brief against the actual contract requirements', () => {
169
    const interactionId = crypto.randomUUID();
170
    const contract: InteractionContract = {
171
      protocolVersion: '0.1',
172
      interactionId,
173
      purpose: 'Find an engineering role',
174
      parties: ['agent:candidate', 'agent:recruiter'],
175
      taskCategory: 'recruiting',
176
      requestedOutcome: 'Available backend software engineering positions',
177
      successCriteria: ['At least one matching backend role'],
178
      allowedActions: ['recruiting'],
179
      prohibitedActions: [],
180
      allowedData: ['public'],
181
      prohibitedData: ['private'],
182
      evidenceRequirements: ['Job description link'],
183
      delegationRules: [],
184
      humanApprovalRequirements: [],
185
      factCheckingPolicy: 'important_claims',
186
      mediationPolicy: 'mutual_consent',
187
      retentionDays: 30,
188
      completionConditions: ['positions returned'],
189
      cancellationConditions: ['either party'],
190
      signatures: {},
191
    };
192
    const subject = PublicAgentCardSchema.parse({
193
      protocolVersion: '0.1',
194
      agentId: 'agent:recruiter',
195
      name: 'Recruiter',
196
      description: 'Recruiting assistant for marketing roles',
197
      framework: 'Botpress',
198
      agentVersion: '1.0.0',
199
      capabilities: ['recruiting'],
200
      limitations: ['no backend recruitment'],
201
      assurance: 'oauth_authenticated',
202
      transports: [],
203
      cardUrl: 'https://openclasp.example/agents/recruiter/card.json',
204
      a2aAgentCardUrl: 'https://openclasp.example/agents/recruiter/a2a-agent-card.json',
205
      extensionUri: 'https://openclasp.example/extensions/trust/v0.1',
206
      publishedAt: '2026-08-29T00:00:00.000Z',
207
      updatedAt: '2026-08-29T00:00:00.000Z',
208
    });
209
    const brief = buildCounterpartyBrief({
210
      interactionId,
211
      contractHash: canonicalHash(contract),
212
      contract,
213
      recipientAgentId: 'agent:candidate',
214
      subject,
215
      historyInsights: [
216
        {
217
          code: 'limited_verified_history',
218
          severity: 'caution',
219
          message: 'No eligible history exists.',
220
          evidenceReferences: [],
221
          requirementReferences: [],
222
        },
223
      ],
224
      relevantSampleSize: 0,
225
      historyConfidence: 0,
226
      generatedAt: '2026-08-29T00:00:00.000Z',
227
      expiresAt: '2026-08-29T01:00:00.000Z',
228
    });
229
    expect(brief.decision).toBe('CHALLENGE');
230
    expect(brief.requirements).toEqual(
231
      expect.arrayContaining([
232
        expect.objectContaining({ requirementId: 'task-category', status: 'match' }),
233
        expect.objectContaining({ requirementId: 'requested-outcome', status: 'mismatch' }),
234
      ]),
235
    );
236
    expect(brief.insights[0]?.requirementReferences.length).toBeGreaterThan(0);
237
  });
238
239
  it('releases an aggregate conclusion without private feedback comments', () => {
240
    const interactionId = crypto.randomUUID();
241
    const contract: InteractionContract = {
242
      protocolVersion: '0.1',
243
      interactionId,
244
      purpose: 'Coordinate a task',
245
      parties: ['agent:a', 'agent:b'],
246
      taskCategory: 'coordination',
247
      requestedOutcome: 'Deliver a plan',
248
      successCriteria: ['Plan delivered'],
249
      allowedActions: [],
250
      prohibitedActions: [],
251
      allowedData: [],
252
      prohibitedData: [],
253
      evidenceRequirements: [],
254
      delegationRules: [],
255
      humanApprovalRequirements: [],
256
      factCheckingPolicy: 'important_claims',
257
      mediationPolicy: 'mutual_consent',
258
      retentionDays: 30,
259
      completionConditions: ['Plan delivered'],
260
      cancellationConditions: [],
261
      signatures: {},
262
    };
263
    const report = (agentId: string, counterpartyAgentId: string, outcome: 'success' | 'partial') =>
264
      InteractionCompletionReportSchema.parse({
265
        reportId: crypto.randomUUID(),
266
        interactionId,
267
        contractHash: canonicalHash(contract),
268
        reportingAgentId: agentId,
269
        counterpartyAgentId,
270
        agentVersion: '1.0.0',
271
        outcome,
272
        summary: 'Structured summary',
273
        requestedOutcome: contract.requestedOutcome,
274
        criteria: [
275
          {
276
            criterion: 'Plan delivered',
277
            status: outcome === 'success' ? 'met' : 'partially_met',
278
          },
279
        ],
280
        completedAt: '2026-08-29T00:10:00.000Z',
281
        confidence: 0.9,
282
      });
283
    const feedback = (reviewerAgentId: string, subjectAgentId: string, rating: number) =>
284
      InteractionFeedbackSchema.parse({
285
        feedbackId: crypto.randomUUID(),
286
        requestId: crypto.randomUUID(),
287
        interactionId,
288
        reviewerAgentId,
289
        subjectAgentId,
290
        reviewerAgentVersion: '1.0.0',
291
        ratings: { reliability: rating },
292
        wouldWorkAgain: 'yes',
293
        privateComment: `private note from ${reviewerAgentId}`,
294
        confidence: 0.9,
295
        submittedAt: '2026-08-29T00:11:00.000Z',
296
      });
297
    const conclusion = buildInteractionConclusion({
298
      interaction: { interactionId, termsHash: canonicalHash(contract), contract },
299
      reports: [report('agent:a', 'agent:b', 'success'), report('agent:b', 'agent:a', 'partial')],
300
      feedback: [feedback('agent:a', 'agent:b', 0.8), feedback('agent:b', 'agent:a', 0.6)],
301
      conclusionId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
302
      generatedAt: '2026-08-29T00:12:00.000Z',
303
    });
304
    expect(conclusion.consensus).toBe('bilateral_partial_agreement');
305
    expect(conclusion.averageRatings.reliability).toBeCloseTo(0.7);
306
    expect(JSON.stringify(conclusion)).not.toContain('private note');
307
308
    const provisional = buildInteractionConclusion({
309
      interaction: { interactionId, termsHash: canonicalHash(contract), contract },
310
      reports: [report('agent:a', 'agent:b', 'partial')],
311
      feedback: [],
312
      pendingFeedbackAgentIds: ['agent:a', 'agent:b'],
313
      peerReportStatus: 'unreachable',
314
    });
315
    expect(provisional).toMatchObject({
316
      lifecycle: 'provisional',
317
      consensus: 'unilateral',
318
      missingReportAgentIds: ['agent:b'],
319
      pendingFeedbackAgentIds: ['agent:a', 'agent:b'],
320
      peerReportStatus: 'unreachable',
321
    });
322
    expect(provisional.confidence).toBeLessThanOrEqual(0.55);
323
  });
324
});
325
326
describe('behavioural learning', () => {
327
  const attestation = {
328
    algorithm: 'Ed25519' as const,
329
    keyId: 'openclasp:test',
330
    value: 'signed-test-record',
331
    digest: 'a'.repeat(43),
332
  };
333
  const interactionId = 'dddddddd-dddd-4ddd-8ddd-dddddddddddd';
334
  const report = (
335
    reportingAgentId: string,
336
    counterpartyAgentId: string,
337
    outcome: 'success' | 'partial' = 'success',
338
  ): InteractionCompletionReport =>
339
    InteractionCompletionReportSchema.parse({
340
      reportId: crypto.randomUUID(),
341
      interactionId,
342
      contractHash: 'contract-hash',
343
      reportingAgentId,
344
      counterpartyAgentId,
345
      agentVersion: '1.0.0',
346
      outcome,
347
      summary: 'Structured outcome only',
348
      requestedOutcome: 'Complete the task',
349
      criteria: [
350
        {
351
          criterion: 'Task completed',
352
          status: outcome === 'success' ? 'met' : 'partially_met',
353
        },
354
      ],
355
      confidence: 0.9,
356
      completedAt: '2026-08-29T00:10:00.000Z',
357
      platformAttestation: attestation,
358
    });
359
  const feedback = (
360
    reviewerAgentId: string,
361
    subjectAgentId: string,
362
    rating: number,
363
    evidenceReferences: string[] = [],
364
  ): InteractionFeedback =>
365
    InteractionFeedbackSchema.parse({
366
      feedbackId: crypto.randomUUID(),
367
      requestId: crypto.randomUUID(),
368
      interactionId,
369
      reviewerAgentId,
370
      subjectAgentId,
371
      reviewerAgentVersion: '1.0.0',
372
      ratings: {
373
        outcome_satisfaction: rating,
374
        communication: rating,
375
        timeliness: rating,
376
        evidence_quality: rating,
377
        scope_adherence: rating,
378
      },
379
      wouldWorkAgain: rating >= 0.5 ? 'yes' : 'no',
380
      evidenceReferences,
381
      confidence: 1,
382
      submittedAt: '2026-08-29T00:11:00.000Z',
383
      submissionMethod: 'runtime_session',
384
      platformAttestation: attestation,
385
    });
386
  const conclusion = (consensus: InteractionConclusion['consensus']): InteractionConclusion => ({
387
    conclusionId: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',
388
    interactionId,
389
    contractHash: 'contract-hash',
390
    outcome: 'partial',
391
    consensus,
392
    summary: 'Structured conclusion',
393
    criteria: [],
394
    reportIds: [],
395
    feedbackIds: [],
396
    averageRatings: {},
397
    evidenceReferences: [],
398
    generatedAt: '2026-08-29T00:12:00.000Z',
399
  });
400
401
  it('keeps unilateral evidence local at low weight and penalizes manipulation signals', () => {
402
    const reports = [report('agent:a', 'agent:b'), report('agent:b', 'agent:a')];
403
    const normal = evaluateLearningEligibility({
404
      interactionId,
405
      reports,
406
      feedback: [feedback('agent:a', 'agent:b', 0.8)],
407
      consensus: 'bilateral_agreement',
408
      contributionMode: 'local_only',
409
      reviewerCredibility: { 'agent:a': 1 },
410
    });
411
    const extreme = evaluateLearningEligibility({
412
      interactionId,
413
      reports,
414
      feedback: [feedback('agent:a', 'agent:b', 1)],
415
      consensus: 'bilateral_agreement',
416
      contributionMode: 'local_only',
417
      reviewerCredibility: { 'agent:a': 1 },
418
    });
419
    const unsupported = evaluateLearningEligibility({
420
      interactionId,
421
      reports: [report('agent:a', 'agent:b')],
422
      feedback: [],
423
      consensus: 'unilateral',
424
      contributionMode: 'network_aggregate',
425
    });
426
    expect(normal.eligible).toBe(true);
427
    expect(extreme.sampleWeight).toBeLessThan(normal.sampleWeight);
428
    expect(unsupported).toMatchObject({
429
      eligible: true,
430
      sampleWeight: 0.1,
431
      contributionMode: 'local_only',
432
      structuredDataOnly: true,
433
    });
434
  });
435
436
  it('derives bounded observations and applies weighted history decay', () => {
437
    const observations = deriveBehaviouralObservations({
438
      subjectAgentId: 'agent:b',
439
      reports: [report('agent:b', 'agent:a', 'partial')],
440
      reviewerFeedback: feedback('agent:a', 'agent:b', 0.8, ['evidence:artifact:1']),
441
      conclusion: conclusion('conflicting'),
442
    });
443
    expect(observations).toMatchObject({
444
      completion: 0.5,
445
      specification: 0.5,
446
      acceptance: 0.8,
447
      communication: 0.8,
448
      disputes: 1,
449
    });
450
    const updated = updateContextualBehaviouralProfile({
451
      current: {
452
        completion: 1,
453
        communication: 1,
454
        sampleSize: 10,
455
        effectiveSampleSize: 10,
456
        updatedAt: '2026-03-02T00:12:00.000Z',
457
      },
458
      observations,
459
      sampleWeight: 0.8,
460
      appliedAt: '2026-08-29T00:12:00.000Z',
461
    });
462
    expect(updated.profile.sampleSize).toBe(11);
463
    expect(updated.profile.effectiveSampleSize).toBeLessThan(5);
464
    expect(updated.profile.completion).toBeLessThan(1);
465
    expect(updated.dimensionDeltas.completion).toBeLessThan(0);
466
  });
467
});