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

Viewing files at commit 8a15fe87b720Commit detailsView latest

apps/dashboard/src/components/first-run-guide.tsx 584 lines · 20.7 KB · tsx
1
import { useEffect, useState, type FormEvent } from 'react';
2
import { ArrowRight, Bot, Check, Circle, MessageCircle, ShieldCheck } from 'lucide-react';
3
4
type RecordValue = Record<string, any>;
5
6
type FirstRunData = {
7
  agents: RecordValue[];
8
  publications: RecordValue[];
9
  federatedInteractions: RecordValue[];
10
  hostedThreads: RecordValue[];
11
  completionReports: RecordValue[];
12
  feedbackRequests: RecordValue[];
13
  interactionFeedback: RecordValue[];
14
};
15
16
type FirstRunGuideProps = {
17
  data: FirstRunData;
18
  api: (path: string, init?: RequestInit) => Promise<unknown>;
19
  refreshDashboard: () => Promise<void>;
20
  navigate: (page: 'agents' | 'conversations' | 'history') => void;
21
};
22
23
const splitList = (value: string) =>
24
  value
25
    .split(/[\n,]/)
26
    .map((item) => item.trim())
27
    .filter(Boolean);
28
29
export function FirstRunGuide({ data, api, refreshDashboard, navigate }: FirstRunGuideProps) {
30
  const [working, setWorking] = useState('');
31
  const [error, setError] = useState('');
32
  const [targets, setTargets] = useState<RecordValue[]>([]);
33
  const [targetsLoading, setTargetsLoading] = useState(false);
34
  const [agentForm, setAgentForm] = useState({
35
    agentName: '',
36
    description: '',
37
    capabilities: '',
38
  });
39
  const [agreementForm, setAgreementForm] = useState({
40
    responderAgentId: '',
41
    task: '',
42
    requestedOutcome: '',
43
    successCriterion: '',
44
    deadline: '',
45
  });
46
  const [completionForm, setCompletionForm] = useState({
47
    outcome: 'success' as 'success' | 'partial' | 'failure' | 'cancelled',
48
    summary: '',
49
    evidenceReferences: '',
50
  });
51
  const [feedbackForm, setFeedbackForm] = useState({
52
    rating: 5,
53
    wouldWorkAgain: 'yes' as 'yes' | 'no' | 'unsure',
54
    privateComment: '',
55
  });
56
57
  const firstAgent = data.agents.find((agent) => agent.status !== 'revoked');
58
  const firstAgentId = String(firstAgent?.agentId ?? '');
59
  const firstAgentMode = String(firstAgent?.agentMode ?? '');
60
  const firstCapability = String(firstAgent?.capabilities?.[0] ?? '');
61
  const published = data.publications.some(
62
    (publication) => publication.agentId === firstAgentId && publication.published,
63
  );
64
  const interaction = data.federatedInteractions.find(
65
    (candidate) =>
66
      candidate.contract?.parties?.includes(firstAgentId) &&
67
      !['rejected', 'expired', 'cancelled'].includes(String(candidate.status)),
68
  );
69
  const interactionId = String(interaction?.interactionId ?? '');
70
  const completionReport = data.completionReports.find(
71
    (report) => report.interactionId === interactionId && report.reportingAgentId === firstAgentId,
72
  );
73
  const pendingFeedback = data.feedbackRequests.find(
74
    (request) =>
75
      request.interactionId === interactionId &&
76
      request.reviewerAgentId === firstAgentId &&
77
      request.status === 'pending',
78
  );
79
  const feedbackSubmitted = data.interactionFeedback.some(
80
    (feedback) =>
81
      feedback.interactionId === interactionId && feedback.reviewerAgentId === firstAgentId,
82
  );
83
  const finished = Boolean(interactionId && completionReport && feedbackSubmitted);
84
  const steps = [
85
    { label: 'Create agent', done: Boolean(firstAgent) },
86
    { label: 'Agree safeguards', done: Boolean(interaction) },
87
    { label: 'Record outcome', done: Boolean(completionReport) },
88
    { label: 'Give feedback', done: finished },
89
  ];
90
91
  useEffect(() => {
92
    if (!firstAgentId || !published || interaction) return;
93
    let cancelled = false;
94
    setTargetsLoading(true);
95
    const parameters = new URLSearchParams({ agentId: firstAgentId, limit: '20' });
96
    const taskCategory = firstCapability.trim();
97
    if (taskCategory) parameters.set('taskCategory', taskCategory);
98
    api(`/v0.1/marketplace?${parameters}`)
99
      .then((value) => {
100
        if (cancelled) return;
101
        const available = (value as RecordValue[]).filter(
102
          (candidate) =>
103
            candidate.card?.transports?.length &&
104
            !(
105
              firstAgentMode === 'temporary_chat' && candidate.card?.agentMode === 'temporary_chat'
106
            ),
107
        );
108
        setTargets(available);
109
        setAgreementForm((current) => ({
110
          ...current,
111
          responderAgentId: available.some(
112
            (candidate) => candidate.card.agentId === current.responderAgentId,
113
          )
114
            ? current.responderAgentId
115
            : String(available[0]?.card?.agentId ?? ''),
116
        }));
117
      })
118
      .catch((reason: unknown) => {
119
        if (!cancelled)
120
          setError(reason instanceof Error ? reason.message : 'Could not load counterparties');
121
      })
122
      .finally(() => {
123
        if (!cancelled) setTargetsLoading(false);
124
      });
125
    return () => {
126
      cancelled = true;
127
    };
128
  }, [api, firstAgentId, firstAgentMode, firstCapability, interaction, published]);
129
130
  const createAgent = async (event: FormEvent) => {
131
    event.preventDefault();
132
    setWorking('agent');
133
    setError('');
134
    try {
135
      await api('/v0.1/quickstart/agent', {
136
        method: 'POST',
137
        body: JSON.stringify({
138
          agentName: agentForm.agentName,
139
          projectName: 'My agents',
140
          description: agentForm.description,
141
          capabilities: splitList(agentForm.capabilities),
142
          limitations: [],
143
        }),
144
      });
145
      await refreshDashboard();
146
    } catch (reason) {
147
      setError(reason instanceof Error ? reason.message : 'Could not create agent');
148
    } finally {
149
      setWorking('');
150
    }
151
  };
152
153
  const startAgreement = async (event: FormEvent) => {
154
    event.preventDefault();
155
    setWorking('agreement');
156
    setError('');
157
    try {
158
      await api('/v0.1/federated-interactions/start', {
159
        method: 'POST',
160
        body: JSON.stringify({
161
          initiatorAgentId: firstAgentId,
162
          responderAgentId: agreementForm.responderAgentId,
163
          task: agreementForm.task,
164
          requestedOutcome: agreementForm.requestedOutcome,
165
          successCriterion: agreementForm.successCriterion,
166
          taskCategory: firstCapability || 'general',
167
          ...(agreementForm.deadline
168
            ? { deadline: new Date(agreementForm.deadline).toISOString() }
169
            : {}),
170
        }),
171
      });
172
      await refreshDashboard();
173
    } catch (reason) {
174
      setError(reason instanceof Error ? reason.message : 'Could not start agreement');
175
    } finally {
176
      setWorking('');
177
    }
178
  };
179
180
  const reportOutcome = async (event: FormEvent) => {
181
    event.preventDefault();
182
    setWorking('outcome');
183
    setError('');
184
    try {
185
      await api(`/v0.1/federated-interactions/${encodeURIComponent(interactionId)}/complete`, {
186
        method: 'POST',
187
        body: JSON.stringify({
188
          agentId: firstAgentId,
189
          outcome: completionForm.outcome,
190
          summary: completionForm.summary,
191
          evidenceReferences: splitList(completionForm.evidenceReferences),
192
        }),
193
      });
194
      await refreshDashboard();
195
    } catch (reason) {
196
      setError(reason instanceof Error ? reason.message : 'Could not record outcome');
197
    } finally {
198
      setWorking('');
199
    }
200
  };
201
202
  const submitFeedback = async (event: FormEvent) => {
203
    event.preventDefault();
204
    if (!pendingFeedback) return;
205
    setWorking('feedback');
206
    setError('');
207
    try {
208
      await api(
209
        `/v0.1/feedback-requests/${encodeURIComponent(String(pendingFeedback.requestId))}/respond`,
210
        {
211
          method: 'POST',
212
          body: JSON.stringify({ agentId: firstAgentId, ...feedbackForm }),
213
        },
214
      );
215
      await refreshDashboard();
216
    } catch (reason) {
217
      setError(reason instanceof Error ? reason.message : 'Could not submit feedback');
218
    } finally {
219
      setWorking('');
220
    }
221
  };
222
223
  if (finished)
224
    return (
225
      <section className="firstRunGuide firstRunComplete" aria-label="First interaction complete">
226
        <span className="firstRunIcon complete">
227
          <Check />
228
        </span>
229
        <div>
230
          <strong>First protected interaction recorded</strong>
231
          <p>The agreement, outcome, and private feedback are now part of your verified history.</p>
232
        </div>
233
        <button type="button" className="secondary" onClick={() => navigate('history')}>
234
          View record <ArrowRight />
235
        </button>
236
      </section>
237
    );
238
239
  return (
240
    <section className="firstRunGuide" aria-labelledby="first-run-title">
241
      <header className="firstRunHeader">
242
        <div>
243
          <p className="eyebrow">guided first run</p>
244
          <h2 id="first-run-title">Complete one protected interaction</h2>
245
          <p>Four concrete steps. Every step writes real, structured OpenClasp data.</p>
246
        </div>
247
        <span className="firstRunCount">{steps.filter((step) => step.done).length}/4</span>
248
      </header>
249
      <ol className="firstRunSteps">
250
        {steps.map((step, index) => (
251
          <li
252
            className={step.done ? 'done' : ''}
253
            aria-current={
254
              !step.done && steps.slice(0, index).every((item) => item.done) ? 'step' : undefined
255
            }
256
            key={step.label}
257
          >
258
            <span>{step.done ? <Check /> : index + 1}</span>
259
            {step.label}
260
          </li>
261
        ))}
262
      </ol>
263
264
      {error ? (
265
        <div className="errorBar" role="alert">
266
          {error}
267
        </div>
268
      ) : null}
269
270
      {!firstAgent ? (
271
        <form className="firstRunForm" onSubmit={(event) => void createAgent(event)}>
272
          <div className="firstRunStage">
273
            <span className="firstRunIcon">
274
              <Bot />
275
            </span>
276
            <div>
277
              <strong>Create a hosted agent identity</strong>
278
              <p>No infrastructure needed. You can connect a runtime later.</p>
279
            </div>
280
          </div>
281
          <label>
282
            <span>Agent name</span>
283
            <input
284
              required
285
              maxLength={100}
286
              value={agentForm.agentName}
287
              onChange={(event) =>
288
                setAgentForm((current) => ({ ...current, agentName: event.target.value }))
289
              }
290
              placeholder="Research assistant"
291
            />
292
          </label>
293
          <label>
294
            <span>What does it do?</span>
295
            <input
296
              required
297
              maxLength={500}
298
              value={agentForm.description}
299
              onChange={(event) =>
300
                setAgentForm((current) => ({ ...current, description: event.target.value }))
301
              }
302
              placeholder="Researches markets and returns sourced findings"
303
            />
304
          </label>
305
          <label>
306
            <span>Capabilities</span>
307
            <input
308
              required
309
              value={agentForm.capabilities}
310
              onChange={(event) =>
311
                setAgentForm((current) => ({ ...current, capabilities: event.target.value }))
312
              }
313
              placeholder="market research, source verification"
314
            />
315
          </label>
316
          <button className="primary" type="submit" disabled={working === 'agent'}>
317
            {working === 'agent' ? 'Creating…' : 'Create and publish agent'}
318
          </button>
319
        </form>
320
      ) : !interaction ? (
321
        <form className="firstRunForm" onSubmit={(event) => void startAgreement(event)}>
322
          <div className="firstRunStage">
323
            <span className="firstRunIcon">
324
              <ShieldCheck />
325
            </span>
326
            <div>
327
              <strong>Define the agreement before work starts</strong>
328
              <p>Choose a counterparty, expected result, success test, and deadline.</p>
329
            </div>
330
          </div>
331
          {!published ? (
332
            <div className="firstRunBlocked">
333
              <span>This agent is private. Publish it before starting an agreement.</span>
334
              <button type="button" className="secondary" onClick={() => navigate('agents')}>
335
                Manage agent
336
              </button>
337
            </div>
338
          ) : targetsLoading ? (
339
            <div className="firstRunBlocked">Loading available counterparties…</div>
340
          ) : !targets.length ? (
341
            <div className="firstRunBlocked">
342
              No connected persistent counterparties are available yet. Invite a second account or
343
              connect a persistent runtime.
344
            </div>
345
          ) : (
346
            <>
347
              <label>
348
                <span>Counterparty</span>
349
                <select
350
                  required
351
                  value={agreementForm.responderAgentId}
352
                  onChange={(event) =>
353
                    setAgreementForm((current) => ({
354
                      ...current,
355
                      responderAgentId: event.target.value,
356
                    }))
357
                  }
358
                >
359
                  {targets.map((target) => (
360
                    <option value={target.card.agentId} key={target.card.agentId}>
361
                      {target.card.name} · {Math.round(Number(target.match?.score ?? 0) * 100)}% fit
362
                    </option>
363
                  ))}
364
                </select>
365
              </label>
366
              <label className="fullWidth">
367
                <span>Task</span>
368
                <textarea
369
                  required
370
                  minLength={10}
371
                  maxLength={2000}
372
                  value={agreementForm.task}
373
                  onChange={(event) =>
374
                    setAgreementForm((current) => ({ ...current, task: event.target.value }))
375
                  }
376
                  placeholder="Compare three customer-support platforms for a 20-person team."
377
                />
378
              </label>
379
              <label>
380
                <span>Expected result</span>
381
                <input
382
                  required
383
                  maxLength={1000}
384
                  value={agreementForm.requestedOutcome}
385
                  onChange={(event) =>
386
                    setAgreementForm((current) => ({
387
                      ...current,
388
                      requestedOutcome: event.target.value,
389
                    }))
390
                  }
391
                  placeholder="A ranked shortlist with trade-offs"
392
                />
393
              </label>
394
              <label>
395
                <span>Success means</span>
396
                <input
397
                  required
398
                  maxLength={1000}
399
                  value={agreementForm.successCriterion}
400
                  onChange={(event) =>
401
                    setAgreementForm((current) => ({
402
                      ...current,
403
                      successCriterion: event.target.value,
404
                    }))
405
                  }
406
                  placeholder="Three current options with source links"
407
                />
408
              </label>
409
              <label>
410
                <span>Deadline (optional)</span>
411
                <input
412
                  type="datetime-local"
413
                  value={agreementForm.deadline}
414
                  onChange={(event) =>
415
                    setAgreementForm((current) => ({ ...current, deadline: event.target.value }))
416
                  }
417
                />
418
              </label>
419
              <button className="primary" type="submit" disabled={working === 'agreement'}>
420
                {working === 'agreement' ? 'Sending…' : 'Send protected request'}
421
              </button>
422
            </>
423
          )}
424
        </form>
425
      ) : interaction.status === 'pending' ? (
426
        <div className="firstRunWaiting">
427
          <span className="firstRunIcon">
428
            <Circle />
429
          </span>
430
          <div>
431
            <strong>Waiting for counterparty approval</strong>
432
            <p>{interaction.contract?.purpose}</p>
433
            <small>
434
              The task cannot start until both agents accept the same terms hash. This page updates
435
              automatically.
436
            </small>
437
          </div>
438
        </div>
439
      ) : interaction.status === 'active' && !completionReport ? (
440
        <form className="firstRunForm" onSubmit={(event) => void reportOutcome(event)}>
441
          <div className="firstRunStage">
442
            <span className="firstRunIcon">
443
              <MessageCircle />
444
            </span>
445
            <div>
446
              <strong>Agreement active — do the work, then record the result</strong>
447
              <p>{interaction.contract?.requestedOutcome}</p>
448
            </div>
449
            {data.hostedThreads.some((thread) => thread.interactionId === interactionId) ? (
450
              <button type="button" className="secondary" onClick={() => navigate('conversations')}>
451
                Open conversation
452
              </button>
453
            ) : null}
454
          </div>
455
          <label>
456
            <span>Outcome</span>
457
            <select
458
              value={completionForm.outcome}
459
              onChange={(event) =>
460
                setCompletionForm((current) => ({
461
                  ...current,
462
                  outcome: event.target.value as typeof current.outcome,
463
                }))
464
              }
465
            >
466
              <option value="success">Success</option>
467
              <option value="partial">Partial</option>
468
              <option value="failure">Failure</option>
469
              <option value="cancelled">Cancelled</option>
470
            </select>
471
          </label>
472
          <label className="fullWidth">
473
            <span>What happened?</span>
474
            <textarea
475
              required
476
              minLength={3}
477
              maxLength={2000}
478
              value={completionForm.summary}
479
              onChange={(event) =>
480
                setCompletionForm((current) => ({ ...current, summary: event.target.value }))
481
              }
482
              placeholder="Summarize the delivered result and any material limitations."
483
            />
484
          </label>
485
          <label className="fullWidth">
486
            <span>Evidence URLs (optional, comma-separated)</span>
487
            <input
488
              value={completionForm.evidenceReferences}
489
              onChange={(event) =>
490
                setCompletionForm((current) => ({
491
                  ...current,
492
                  evidenceReferences: event.target.value,
493
                }))
494
              }
495
              placeholder="https://…"
496
            />
497
          </label>
498
          <button className="primary" type="submit" disabled={working === 'outcome'}>
499
            {working === 'outcome' ? 'Recording…' : 'Record outcome'}
500
          </button>
501
        </form>
502
      ) : pendingFeedback ? (
503
        <form className="firstRunForm" onSubmit={(event) => void submitFeedback(event)}>
504
          <div className="firstRunStage">
505
            <span className="firstRunIcon">
506
              <Check />
507
            </span>
508
            <div>
509
              <strong>One last step: private counterparty feedback</strong>
510
              <p>
511
                Structured feedback improves task-specific reliability without storing chat text.
512
              </p>
513
            </div>
514
          </div>
515
          <label>
516
            <span>Overall rating</span>
517
            <select
518
              value={feedbackForm.rating}
519
              onChange={(event) =>
520
                setFeedbackForm((current) => ({
521
                  ...current,
522
                  rating: Number(event.target.value),
523
                }))
524
              }
525
            >
526
              {[5, 4, 3, 2, 1].map((rating) => (
527
                <option value={rating} key={rating}>
528
                  {rating} / 5
529
                </option>
530
              ))}
531
            </select>
532
          </label>
533
          <label>
534
            <span>Work together again?</span>
535
            <select
536
              value={feedbackForm.wouldWorkAgain}
537
              onChange={(event) =>
538
                setFeedbackForm((current) => ({
539
                  ...current,
540
                  wouldWorkAgain: event.target.value as typeof current.wouldWorkAgain,
541
                }))
542
              }
543
            >
544
              <option value="yes">Yes</option>
545
              <option value="unsure">Unsure</option>
546
              <option value="no">No</option>
547
            </select>
548
          </label>
549
          <label className="fullWidth">
550
            <span>Private note (optional)</span>
551
            <textarea
552
              maxLength={1000}
553
              value={feedbackForm.privateComment}
554
              onChange={(event) =>
555
                setFeedbackForm((current) => ({
556
                  ...current,
557
                  privateComment: event.target.value,
558
                }))
559
              }
560
            />
561
          </label>
562
          <button className="primary" type="submit" disabled={working === 'feedback'}>
563
            {working === 'feedback' ? 'Submitting…' : 'Submit private feedback'}
564
          </button>
565
        </form>
566
      ) : (
567
        <div className="firstRunWaiting">
568
          <span className="firstRunIcon complete">
569
            <Check />
570
          </span>
571
          <div>
572
            <strong>Your outcome is recorded</strong>
573
            <p>
574
              Waiting for the counterparty report before OpenClasp finalizes the shared receipt.
575
            </p>
576
          </div>
577
          <button type="button" className="secondary" onClick={() => navigate('history')}>
578
            View progress
579
          </button>
580
        </div>
581
      )}
582
    </section>
583
  );
584
}