@threadplane/ag-ui 0.0.55 → 0.0.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/fesm2022/threadplane-ag-ui.mjs +462 -200
- package/fesm2022/threadplane-ag-ui.mjs.map +1 -1
- package/package.json +1 -1
- package/types/threadplane-ag-ui.d.ts +27 -5
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { signal, computed, InjectionToken, inject } from '@angular/core';
|
|
2
2
|
import { Subject, Observable } from 'rxjs';
|
|
3
|
-
import { toAgentError, isAbortError } from '@threadplane/chat';
|
|
3
|
+
import { completeDelivery, staticDelivery, streamingDelivery, toAgentError, selectPendingClientToolCalls, isAbortError } from '@threadplane/chat';
|
|
4
4
|
import { HttpAgent, AbstractAgent, EventType } from '@ag-ui/client';
|
|
5
5
|
|
|
6
6
|
// SPDX-License-Identifier: MIT
|
|
@@ -263,12 +263,19 @@ function normalizeCitation(entry, fallbackIndex) {
|
|
|
263
263
|
}
|
|
264
264
|
return undefined;
|
|
265
265
|
};
|
|
266
|
+
const publishedAt = e['publishedAt'];
|
|
267
|
+
const normalizedPublishedAt = typeof publishedAt === 'string' || typeof publishedAt === 'number' || publishedAt instanceof Date
|
|
268
|
+
? publishedAt
|
|
269
|
+
: undefined;
|
|
266
270
|
return {
|
|
267
271
|
id: str('id') ?? str('refId') ?? `c${fallbackIndex}`,
|
|
268
272
|
index: typeof e['index'] === 'number' ? e['index'] : fallbackIndex,
|
|
269
273
|
title: firstStr('title', 'name'),
|
|
270
274
|
url: firstStr('url', 'href', 'source'),
|
|
271
275
|
snippet: firstStr('snippet', 'content', 'excerpt'),
|
|
276
|
+
sourceType: str('sourceType'),
|
|
277
|
+
iconUrl: str('iconUrl'),
|
|
278
|
+
publishedAt: normalizedPublishedAt,
|
|
272
279
|
extra: typeof e['extra'] === 'object' && e['extra'] !== null
|
|
273
280
|
? e['extra']
|
|
274
281
|
: undefined,
|
|
@@ -280,6 +287,16 @@ function normalizeCitation(entry, fallbackIndex) {
|
|
|
280
287
|
// Discriminator strings (e.g. 'RUN_STARTED') match EventType enum members
|
|
281
288
|
// verbatim; the switch cases below use the string literals directly so this
|
|
282
289
|
// file has no runtime dependency on the EventType enum import.
|
|
290
|
+
/** Finalize one run without touching messages owned by another generation. */
|
|
291
|
+
function finalizeDeliveryRun(store, run, outcome) {
|
|
292
|
+
if (run.outcome !== undefined)
|
|
293
|
+
return false;
|
|
294
|
+
run.outcome = outcome;
|
|
295
|
+
store.messages.update(messages => messages.map(message => message.delivery.generation === run.generation
|
|
296
|
+
? { ...message, delivery: completeDelivery(run.generation, outcome) }
|
|
297
|
+
: message));
|
|
298
|
+
return true;
|
|
299
|
+
}
|
|
283
300
|
/**
|
|
284
301
|
* Per-message reasoning timing. Populated by REASONING_MESSAGE_START /
|
|
285
302
|
* REASONING_MESSAGE_END handlers. The map lives on the module — same
|
|
@@ -306,6 +323,9 @@ function resolveReasoningDurationMs(messageId) {
|
|
|
306
323
|
function reduceEvent(event, store) {
|
|
307
324
|
switch (event.type) {
|
|
308
325
|
case 'RUN_STARTED': {
|
|
326
|
+
const run = store.deliveryRun;
|
|
327
|
+
if (!run || run.outcome !== undefined || !bindRunId(event, run))
|
|
328
|
+
return;
|
|
309
329
|
store.status.set('running');
|
|
310
330
|
store.isLoading.set(true);
|
|
311
331
|
store.error.set(undefined);
|
|
@@ -315,11 +335,17 @@ function reduceEvent(event, store) {
|
|
|
315
335
|
return;
|
|
316
336
|
}
|
|
317
337
|
case 'RUN_FINISHED': {
|
|
338
|
+
const run = currentRunForEvent(event, store);
|
|
339
|
+
if (!run || !finalizeDeliveryRun(store, run, 'success'))
|
|
340
|
+
return;
|
|
318
341
|
store.status.set('idle');
|
|
319
342
|
store.isLoading.set(false);
|
|
320
343
|
return;
|
|
321
344
|
}
|
|
322
345
|
case 'RUN_ERROR': {
|
|
346
|
+
const run = currentRunForEvent(event, store);
|
|
347
|
+
if (!run || !finalizeDeliveryRun(store, run, 'error'))
|
|
348
|
+
return;
|
|
323
349
|
store.status.set('error');
|
|
324
350
|
store.isLoading.set(false);
|
|
325
351
|
const runErrorMsg = event.message;
|
|
@@ -328,28 +354,37 @@ function reduceEvent(event, store) {
|
|
|
328
354
|
}
|
|
329
355
|
case 'TEXT_MESSAGE_START': {
|
|
330
356
|
const id = messageIdFrom(event);
|
|
357
|
+
const delivery = ownAssistantMessage(store, id);
|
|
358
|
+
if (!delivery)
|
|
359
|
+
return;
|
|
331
360
|
store.messages.update((prev) => prev.some((m) => m.id === id)
|
|
332
|
-
? prev.map((m) => m.id === id ? { ...m, content: m.content ?? '' } : m)
|
|
333
|
-
: [...prev, { id, role: 'assistant', content: '' }]);
|
|
361
|
+
? prev.map((m) => m.id === id ? { ...m, content: m.content ?? '', delivery } : m)
|
|
362
|
+
: [...prev, { id, role: 'assistant', content: '', delivery }]);
|
|
334
363
|
return;
|
|
335
364
|
}
|
|
336
365
|
case 'REASONING_MESSAGE_START': {
|
|
337
366
|
const id = messageIdFrom(event);
|
|
367
|
+
const delivery = ownAssistantMessage(store, id);
|
|
368
|
+
if (!delivery)
|
|
369
|
+
return;
|
|
338
370
|
reasoningTimingMap.set(id, { startedAt: Date.now() });
|
|
339
371
|
// Initialize an assistant slot with empty reasoning if it doesn't already exist.
|
|
340
372
|
store.messages.update((prev) => prev.some((m) => m.id === id)
|
|
341
373
|
? prev.map((m) => m.id === id
|
|
342
|
-
? { ...m, reasoning: m.reasoning ?? '' }
|
|
374
|
+
? { ...m, reasoning: m.reasoning ?? '', delivery }
|
|
343
375
|
: m)
|
|
344
|
-
: [...prev, { id, role: 'assistant', content: '', reasoning: '' }]);
|
|
376
|
+
: [...prev, { id, role: 'assistant', content: '', reasoning: '', delivery }]);
|
|
345
377
|
return;
|
|
346
378
|
}
|
|
347
379
|
case 'REASONING_MESSAGE_CONTENT':
|
|
348
380
|
case 'REASONING_MESSAGE_CHUNK': {
|
|
349
381
|
const id = messageIdFrom(event);
|
|
382
|
+
const delivery = ownAssistantMessage(store, id);
|
|
383
|
+
if (!delivery)
|
|
384
|
+
return;
|
|
350
385
|
const delta = event.delta ?? '';
|
|
351
386
|
store.messages.update((prev) => prev.map((m) => m.id === id
|
|
352
|
-
? { ...m, reasoning: (m.reasoning ?? '') + delta }
|
|
387
|
+
? { ...m, reasoning: (m.reasoning ?? '') + delta, delivery }
|
|
353
388
|
: m));
|
|
354
389
|
return;
|
|
355
390
|
}
|
|
@@ -368,8 +403,11 @@ function reduceEvent(event, store) {
|
|
|
368
403
|
}
|
|
369
404
|
case 'TEXT_MESSAGE_CONTENT': {
|
|
370
405
|
const id = messageIdFrom(event);
|
|
406
|
+
const delivery = ownAssistantMessage(store, id);
|
|
407
|
+
if (!delivery)
|
|
408
|
+
return;
|
|
371
409
|
const delta = event.delta ?? '';
|
|
372
|
-
store.messages.update((prev) => prev.map((m) => m.id === id ? { ...m, content: m.content + delta } : m));
|
|
410
|
+
store.messages.update((prev) => prev.map((m) => m.id === id ? { ...m, content: m.content + delta, delivery } : m));
|
|
373
411
|
return;
|
|
374
412
|
}
|
|
375
413
|
case 'TEXT_MESSAGE_END': {
|
|
@@ -390,14 +428,17 @@ function reduceEvent(event, store) {
|
|
|
390
428
|
// tool-call-only turn emits no TEXT_MESSAGE_START), create a slot.
|
|
391
429
|
const parentId = e.parentMessageId;
|
|
392
430
|
if (parentId) {
|
|
431
|
+
const delivery = ownAssistantMessage(store, parentId);
|
|
432
|
+
if (!delivery)
|
|
433
|
+
return;
|
|
393
434
|
store.messages.update((prev) => {
|
|
394
435
|
const existing = prev.find((m) => m.id === parentId);
|
|
395
436
|
if (existing) {
|
|
396
437
|
return prev.map((m) => m.id === parentId
|
|
397
|
-
? { ...m, toolCallIds: [...(m.toolCallIds ?? []), e.toolCallId] }
|
|
438
|
+
? { ...m, toolCallIds: [...(m.toolCallIds ?? []), e.toolCallId], delivery }
|
|
398
439
|
: m);
|
|
399
440
|
}
|
|
400
|
-
return [...prev, { id: parentId, role: 'assistant', content: '', toolCallIds: [e.toolCallId] }];
|
|
441
|
+
return [...prev, { id: parentId, role: 'assistant', content: '', toolCallIds: [e.toolCallId], delivery }];
|
|
401
442
|
});
|
|
402
443
|
}
|
|
403
444
|
return;
|
|
@@ -456,6 +497,13 @@ function reduceEvent(event, store) {
|
|
|
456
497
|
case 'MESSAGES_SNAPSHOT': {
|
|
457
498
|
const e = event;
|
|
458
499
|
const raw = e.messages ?? [];
|
|
500
|
+
const run = store.deliveryRun?.outcome === undefined ? store.deliveryRun : null;
|
|
501
|
+
const canonicalAssistantId = resolveCanonicalAssistantId(raw, run);
|
|
502
|
+
const activeCanonicalAssistantId = canonicalAssistantId
|
|
503
|
+
&& ownAssistantMessage(store, canonicalAssistantId)
|
|
504
|
+
? canonicalAssistantId
|
|
505
|
+
: undefined;
|
|
506
|
+
const previousById = new Map(store.messages().map(message => [message.id, message]));
|
|
459
507
|
// AG-UI AssistantMessage carries `toolCalls` (ToolCall objects) on the
|
|
460
508
|
// snapshot wire. Bridge them to `toolCallIds` so that the chat lib's
|
|
461
509
|
// per-message tool-call resolution (resolveMessageToolCalls) can scope
|
|
@@ -463,23 +511,71 @@ function reduceEvent(event, store) {
|
|
|
463
511
|
// so the data is visible to <chat-tool-views>.
|
|
464
512
|
const snapshotToolCalls = [];
|
|
465
513
|
const messages = raw.map((m) => {
|
|
514
|
+
const previous = previousById.get(m.id);
|
|
515
|
+
const completedMessage = m.id !== activeCanonicalAssistantId
|
|
516
|
+
&& previous?.delivery.phase === 'complete'
|
|
517
|
+
&& (!run
|
|
518
|
+
|| run.ownedMessageIds.has(m.id)
|
|
519
|
+
|| run.snapshotReplacementIds.has(m.id))
|
|
520
|
+
? previous
|
|
521
|
+
: undefined;
|
|
522
|
+
let delivery = previous?.delivery ?? staticDelivery(m.id);
|
|
523
|
+
let snapshotMessage;
|
|
466
524
|
if (m.role !== 'assistant' || !m.toolCalls || m.toolCalls.length === 0) {
|
|
467
|
-
|
|
525
|
+
snapshotMessage = m;
|
|
526
|
+
}
|
|
527
|
+
else {
|
|
528
|
+
const ids = [];
|
|
529
|
+
for (const tc of m.toolCalls) {
|
|
530
|
+
ids.push(tc.id);
|
|
531
|
+
snapshotToolCalls.push({
|
|
532
|
+
id: tc.id,
|
|
533
|
+
name: tc.function.name,
|
|
534
|
+
args: safeParseArgs(tc.function.arguments),
|
|
535
|
+
status: 'complete',
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
const { toolCalls: _dropped, ...rest } = m;
|
|
539
|
+
snapshotMessage = { ...rest, toolCallIds: ids };
|
|
540
|
+
}
|
|
541
|
+
if (completedMessage) {
|
|
542
|
+
const snapshotChanged = completedMessage.content !== snapshotMessage.content
|
|
543
|
+
|| !sameStringArray(completedMessage.toolCallIds, snapshotMessage.toolCallIds);
|
|
544
|
+
if (!snapshotChanged)
|
|
545
|
+
return completedMessage;
|
|
546
|
+
run?.snapshotReplacementIds.add(m.id);
|
|
547
|
+
return {
|
|
548
|
+
...completedMessage,
|
|
549
|
+
...snapshotMessage,
|
|
550
|
+
delivery: completeDelivery(store.allocateDeliveryGeneration(`snapshot:${m.id}`), 'success'),
|
|
551
|
+
};
|
|
468
552
|
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
status: 'complete',
|
|
477
|
-
});
|
|
553
|
+
if (run && (run.ownedMessageIds.has(m.id) || m.id === canonicalAssistantId)) {
|
|
554
|
+
delivery = delivery.generation === run.generation && delivery.phase === 'complete'
|
|
555
|
+
? delivery
|
|
556
|
+
: streamingDelivery(run.generation);
|
|
557
|
+
run.ownedMessageIds.add(m.id);
|
|
558
|
+
if (m.id === activeCanonicalAssistantId)
|
|
559
|
+
run.currentAssistantMessageId = m.id;
|
|
478
560
|
}
|
|
479
|
-
|
|
480
|
-
return { ...rest, toolCallIds: ids };
|
|
561
|
+
return { ...snapshotMessage, delivery };
|
|
481
562
|
});
|
|
482
|
-
|
|
563
|
+
const currentAssistant = run?.currentAssistantMessageId
|
|
564
|
+
? previousById.get(run.currentAssistantMessageId)
|
|
565
|
+
: undefined;
|
|
566
|
+
if (run
|
|
567
|
+
&& currentAssistant?.delivery.generation === run.generation
|
|
568
|
+
&& currentAssistant.delivery.phase === 'streaming'
|
|
569
|
+
&& !messages.some(message => message.id === currentAssistant.id)) {
|
|
570
|
+
messages.push(currentAssistant);
|
|
571
|
+
}
|
|
572
|
+
// Re-apply per-message citations from the already-received STATE. A
|
|
573
|
+
// MESSAGES_SNAPSHOT replaces the streamed messages wholesale — and the
|
|
574
|
+
// final snapshot message id (str(AIMessage.id), e.g. "resp-…") differs
|
|
575
|
+
// from the streaming chunk id the earlier STATE_SNAPSHOT bridged against,
|
|
576
|
+
// so without re-bridging here the citations (keyed by the final id) would
|
|
577
|
+
// be dropped on the message swap.
|
|
578
|
+
store.messages.set(bridgeCitationsState({ state: store.state() }, messages));
|
|
483
579
|
if (snapshotToolCalls.length > 0) {
|
|
484
580
|
store.toolCalls.update((prev) => {
|
|
485
581
|
// Merge: keep existing entries (they may carry richer state from
|
|
@@ -498,7 +594,14 @@ function reduceEvent(event, store) {
|
|
|
498
594
|
// (e.g. ChatApprovalCardComponent) receive a plain object, not a string.
|
|
499
595
|
const parsedValue = typeof e.value === 'string' ? safeParseJson(e.value) : e.value;
|
|
500
596
|
if (e.name === 'on_interrupt') {
|
|
597
|
+
const run = currentRunForEvent(event, store);
|
|
598
|
+
if (store.deliveryRun && !run)
|
|
599
|
+
return;
|
|
501
600
|
store.interrupt.set({ id: randomId$1(), value: parsedValue, resumable: true });
|
|
601
|
+
if (run && finalizeDeliveryRun(store, run, 'paused')) {
|
|
602
|
+
store.status.set('idle');
|
|
603
|
+
store.isLoading.set(false);
|
|
604
|
+
}
|
|
502
605
|
return;
|
|
503
606
|
}
|
|
504
607
|
// Surface every other custom event on the customEvents signal so the
|
|
@@ -517,13 +620,17 @@ function reduceEvent(event, store) {
|
|
|
517
620
|
const e = event;
|
|
518
621
|
const map = new Map(store.activities());
|
|
519
622
|
const existing = map.get(e.messageId);
|
|
520
|
-
if (existing && existing.activityType === e.activityType
|
|
521
|
-
|
|
623
|
+
if (existing && existing.activityType === e.activityType) {
|
|
624
|
+
if (e.replace)
|
|
625
|
+
existing.content.set(e.content ?? {});
|
|
626
|
+
else
|
|
627
|
+
existing.content.update((c) => ({ ...c, ...e.content }));
|
|
522
628
|
}
|
|
523
629
|
else {
|
|
524
630
|
map.set(e.messageId, {
|
|
525
631
|
messageId: e.messageId,
|
|
526
632
|
activityType: e.activityType,
|
|
633
|
+
generation: store.allocateDeliveryGeneration(`activity:${e.messageId}`),
|
|
527
634
|
content: signal(e.content ?? {}),
|
|
528
635
|
});
|
|
529
636
|
}
|
|
@@ -559,6 +666,68 @@ function reduceEvent(event, store) {
|
|
|
559
666
|
function randomId$1() {
|
|
560
667
|
return Math.random().toString(36).slice(2);
|
|
561
668
|
}
|
|
669
|
+
function eventRunId(event) {
|
|
670
|
+
const runId = event.runId;
|
|
671
|
+
return typeof runId === 'string' ? runId : undefined;
|
|
672
|
+
}
|
|
673
|
+
function bindRunId(event, run) {
|
|
674
|
+
const runId = eventRunId(event);
|
|
675
|
+
if (!runId)
|
|
676
|
+
return true;
|
|
677
|
+
if (run.protocolRunId && run.protocolRunId !== runId)
|
|
678
|
+
return false;
|
|
679
|
+
run.protocolRunId = runId;
|
|
680
|
+
return true;
|
|
681
|
+
}
|
|
682
|
+
function currentRunForEvent(event, store) {
|
|
683
|
+
const run = store.deliveryRun;
|
|
684
|
+
if (!run || !bindRunId(event, run))
|
|
685
|
+
return null;
|
|
686
|
+
return run;
|
|
687
|
+
}
|
|
688
|
+
function ownAssistantMessage(store, id) {
|
|
689
|
+
const run = store.deliveryRun;
|
|
690
|
+
if (!run || run.outcome !== undefined)
|
|
691
|
+
return undefined;
|
|
692
|
+
const currentId = run.currentAssistantMessageId;
|
|
693
|
+
if (currentId && currentId !== id) {
|
|
694
|
+
if (run.ownedMessageIds.has(id))
|
|
695
|
+
return undefined;
|
|
696
|
+
store.messages.update(messages => messages.map(message => message.id === currentId
|
|
697
|
+
&& message.delivery.generation === run.generation
|
|
698
|
+
&& message.delivery.phase === 'streaming'
|
|
699
|
+
? { ...message, delivery: completeDelivery(run.generation, 'success') }
|
|
700
|
+
: message));
|
|
701
|
+
}
|
|
702
|
+
run.ownedMessageIds.add(id);
|
|
703
|
+
run.currentAssistantMessageId = id;
|
|
704
|
+
return streamingDelivery(run.generation);
|
|
705
|
+
}
|
|
706
|
+
function sameStringArray(left, right) {
|
|
707
|
+
if (left === right)
|
|
708
|
+
return true;
|
|
709
|
+
if (!left || !right || left.length !== right.length)
|
|
710
|
+
return false;
|
|
711
|
+
return left.every((value, index) => value === right[index]);
|
|
712
|
+
}
|
|
713
|
+
function resolveCanonicalAssistantId(messages, run) {
|
|
714
|
+
if (!run)
|
|
715
|
+
return undefined;
|
|
716
|
+
if (run.currentAssistantMessageId
|
|
717
|
+
&& messages.some(message => message.id === run.currentAssistantMessageId)) {
|
|
718
|
+
return run.currentAssistantMessageId;
|
|
719
|
+
}
|
|
720
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
721
|
+
const message = messages[index];
|
|
722
|
+
if (message.role === 'assistant' && !run.baselineMessageIds.has(message.id)) {
|
|
723
|
+
return message.id;
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
return run.eligibleBaselineAssistantId
|
|
727
|
+
&& messages.some(message => message.role === 'assistant' && message.id === run.eligibleBaselineAssistantId)
|
|
728
|
+
? run.eligibleBaselineAssistantId
|
|
729
|
+
: undefined;
|
|
730
|
+
}
|
|
562
731
|
function messageIdFrom(event) {
|
|
563
732
|
return event.messageId ?? 'unknown';
|
|
564
733
|
}
|
|
@@ -618,21 +787,59 @@ function safeStringify(v) {
|
|
|
618
787
|
* have no backend result, and haven't been resolved client-side yet — but ONLY
|
|
619
788
|
* when the run is not in progress (isLoading===false). The backend ends the run
|
|
620
789
|
* without emitting TOOL_CALL_RESULT for client tools, so result stays undefined.
|
|
621
|
-
* -
|
|
790
|
+
* - settle(id, result): marks the call as resolved, writes the outcome onto
|
|
622
791
|
* the local ToolCall in the store (so the transcript freezes: the mounted
|
|
623
792
|
* ask component re-renders with its emitted value as props and can branch to
|
|
624
|
-
* a frozen state), adds a ToolMessage via source.addMessage
|
|
625
|
-
*
|
|
793
|
+
* a frozen state), and adds a ToolMessage via source.addMessage without
|
|
794
|
+
* starting a run.
|
|
795
|
+
* - flush(): no-op. settle() already made the result durable by adding it to
|
|
796
|
+
* the source's message list, so there is nothing left to write.
|
|
797
|
+
* - resolve(id, result): settles the result, then requests a continuation
|
|
798
|
+
* through the adapter-owned run gateway. Any ToolMessages previously
|
|
799
|
+
* settled into the source are flushed by that single run.
|
|
626
800
|
*
|
|
627
801
|
* Call catalogAsAgUiTools() to get the current catalog as AG-UI Tool[] for
|
|
628
|
-
*
|
|
802
|
+
* attaching to each adapter-owned run.
|
|
629
803
|
*/
|
|
630
|
-
function createClientToolsCapability(source, store) {
|
|
804
|
+
function createClientToolsCapability(source, store, continueRun) {
|
|
631
805
|
const catalog = signal([], ...(ngDevMode ? [{ debugName: "catalog" }] : []));
|
|
632
806
|
const resolvedIds = signal(new Set(), ...(ngDevMode ? [{ debugName: "resolvedIds" }] : []));
|
|
633
807
|
function catalogAsAgUiTools() {
|
|
634
808
|
return catalog().map(toAgUiTool);
|
|
635
809
|
}
|
|
810
|
+
function settleResult(id, result) {
|
|
811
|
+
// Mark as resolved first so pending() drops it immediately.
|
|
812
|
+
resolvedIds.update((s) => new Set(s).add(id));
|
|
813
|
+
// Write the outcome onto the LOCAL ToolCall in the store. The client tool
|
|
814
|
+
// DID produce a result client-side, so this is semantically correct — and
|
|
815
|
+
// it freezes the transcript card: toToolViewSpec spreads `{...args,
|
|
816
|
+
// ...result, status}` into the mounted ask component, so the component
|
|
817
|
+
// re-renders with its own emitted value as props and can branch to a
|
|
818
|
+
// resolved/frozen state. The backend ToolMessage never reaches this local
|
|
819
|
+
// ToolCall, so without this write the card stays interactive forever.
|
|
820
|
+
const ok = result.ok;
|
|
821
|
+
const value = result.value;
|
|
822
|
+
const error = result.error;
|
|
823
|
+
store.toolCalls.update((calls) => calls.map((tc) => tc.id === id
|
|
824
|
+
? {
|
|
825
|
+
...tc,
|
|
826
|
+
result: ok ? value : { error },
|
|
827
|
+
...(ok ? {} : { error, status: 'error' }),
|
|
828
|
+
}
|
|
829
|
+
: tc));
|
|
830
|
+
// Cast rather than rely on discriminant narrowing: consumer apps that
|
|
831
|
+
// compile this source with `strictNullChecks: false` don't narrow the
|
|
832
|
+
// ClientToolResult union in a ternary.
|
|
833
|
+
const content = ok
|
|
834
|
+
? safeStringify(value)
|
|
835
|
+
: `Error: ${error}`;
|
|
836
|
+
source.addMessage({
|
|
837
|
+
id: `tool-${id}`,
|
|
838
|
+
role: 'tool',
|
|
839
|
+
toolCallId: id,
|
|
840
|
+
content,
|
|
841
|
+
});
|
|
842
|
+
}
|
|
636
843
|
const clientTools = {
|
|
637
844
|
setCatalog(specs) {
|
|
638
845
|
catalog.set([...specs]);
|
|
@@ -640,45 +847,25 @@ function createClientToolsCapability(source, store) {
|
|
|
640
847
|
pending: computed(() => {
|
|
641
848
|
// Client tools are only actionable after the run ends (backend signals it
|
|
642
849
|
// by ending the run WITHOUT emitting TOOL_CALL_RESULT for client tools).
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
850
|
+
return selectPendingClientToolCalls({
|
|
851
|
+
isLoading: store.isLoading(),
|
|
852
|
+
toolCalls: store.toolCalls(),
|
|
853
|
+
catalogNames: new Set(catalog().map((s) => s.name)),
|
|
854
|
+
resolvedIds: resolvedIds(),
|
|
855
|
+
});
|
|
648
856
|
}),
|
|
857
|
+
settle(id, result) {
|
|
858
|
+
settleResult(id, result);
|
|
859
|
+
},
|
|
860
|
+
// AG-UI's settle() already calls source.addMessage(), which places the
|
|
861
|
+
// ToolMessage in the outgoing message list. Nothing further is needed to
|
|
862
|
+
// make it durable — the next run carries it.
|
|
863
|
+
flush() {
|
|
864
|
+
/* no-op: settle() is already durable */
|
|
865
|
+
},
|
|
649
866
|
resolve(id, result) {
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
// Write the outcome onto the LOCAL ToolCall in the store. The client tool
|
|
653
|
-
// DID produce a result client-side, so this is semantically correct — and
|
|
654
|
-
// it freezes the transcript card: toToolViewSpec spreads `{...args,
|
|
655
|
-
// ...result, status}` into the mounted ask component, so the component
|
|
656
|
-
// re-renders with its own emitted value as props and can branch to a
|
|
657
|
-
// resolved/frozen state. The backend ToolMessage never reaches this local
|
|
658
|
-
// ToolCall, so without this write the card stays interactive forever.
|
|
659
|
-
const ok = result.ok;
|
|
660
|
-
const value = result.value;
|
|
661
|
-
const error = result.error;
|
|
662
|
-
store.toolCalls.update((calls) => calls.map((tc) => tc.id === id
|
|
663
|
-
? {
|
|
664
|
-
...tc,
|
|
665
|
-
result: ok ? value : { error },
|
|
666
|
-
...(ok ? {} : { error, status: 'error' }),
|
|
667
|
-
}
|
|
668
|
-
: tc));
|
|
669
|
-
// Cast rather than rely on discriminant narrowing: consumer apps that
|
|
670
|
-
// compile this source with `strictNullChecks: false` don't narrow the
|
|
671
|
-
// ClientToolResult union in a ternary.
|
|
672
|
-
const content = ok
|
|
673
|
-
? safeStringify(value)
|
|
674
|
-
: `Error: ${error}`;
|
|
675
|
-
source.addMessage({
|
|
676
|
-
id: `tool-${id}`,
|
|
677
|
-
role: 'tool',
|
|
678
|
-
toolCallId: id,
|
|
679
|
-
content,
|
|
680
|
-
});
|
|
681
|
-
void source.runAgent({ tools: catalogAsAgUiTools() });
|
|
867
|
+
settleResult(id, result);
|
|
868
|
+
void continueRun();
|
|
682
869
|
},
|
|
683
870
|
catalogAsAgUiTools,
|
|
684
871
|
};
|
|
@@ -721,8 +908,18 @@ function agentRuntimeTelemetryErrorClass(error) {
|
|
|
721
908
|
* of toAgent() should treat the returned object's lifecycle as tied to the
|
|
722
909
|
* agent instance they constructed. The subscriber registered via
|
|
723
910
|
* source.subscribe() will fire for the lifetime of source.
|
|
911
|
+
*
|
|
912
|
+
* @example
|
|
913
|
+
* ```ts
|
|
914
|
+
* import { HttpAgent } from '@ag-ui/client';
|
|
915
|
+
* import { toAgent } from '@threadplane/ag-ui';
|
|
916
|
+
*
|
|
917
|
+
* const agent = toAgent(new HttpAgent({ url: '/api/agent' }));
|
|
918
|
+
* ```
|
|
724
919
|
*/
|
|
725
920
|
function toAgent(source, options = {}) {
|
|
921
|
+
let generationSequence = 0;
|
|
922
|
+
const allocateDeliveryGeneration = (scope) => `${scope}-${++generationSequence}-${Math.random().toString(36).slice(2, 10)}`;
|
|
726
923
|
const store = {
|
|
727
924
|
messages: signal([]),
|
|
728
925
|
status: signal('idle'),
|
|
@@ -734,55 +931,39 @@ function toAgent(source, options = {}) {
|
|
|
734
931
|
events$: new Subject(),
|
|
735
932
|
customEvents: signal([]),
|
|
736
933
|
activities: signal(new Map()),
|
|
934
|
+
deliveryRun: null,
|
|
935
|
+
allocateDeliveryGeneration,
|
|
737
936
|
};
|
|
738
937
|
const telemetryProperties = { transport: 'ag-ui', surface: 'to_agent' };
|
|
739
938
|
let activeRun = null;
|
|
740
|
-
|
|
741
|
-
// abort (graceful cancel) from a genuine stream failure.
|
|
742
|
-
let abortRequested = false;
|
|
743
|
-
// Set to true the first time settleIfAborted() handles an abort error for the
|
|
744
|
-
// current run. The AG-UI client can surface the same abort via both the event
|
|
745
|
-
// stream (RUN_ERROR event) AND onRunFailed — abortSettled lets the second
|
|
746
|
-
// delivery see through as a no-op rather than re-writing store state or
|
|
747
|
-
// triggering a real error path. Both flags are reset together at the top of
|
|
748
|
-
// submit() so the next run starts clean.
|
|
749
|
-
let abortSettled = false;
|
|
939
|
+
const runsByProtocolId = new Map();
|
|
750
940
|
// Tracks the last AgentSubmitInput so retry() can re-run it without
|
|
751
941
|
// duplicating the user message. Set at the top of submit()'s message path.
|
|
752
942
|
let lastInput;
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
if (run) {
|
|
775
|
-
finishRunTelemetry(run);
|
|
776
|
-
// Mark errored so any subsequent finishRunTelemetry/failRunTelemetry
|
|
777
|
-
// call on the same run object (e.g. from submit's try block resolving
|
|
778
|
-
// after the abort) is a no-op — telemetry fires at most once per run.
|
|
779
|
-
run.errored = true;
|
|
943
|
+
function resolveCallbackRun(protocolRunId) {
|
|
944
|
+
if (!protocolRunId)
|
|
945
|
+
return activeRun;
|
|
946
|
+
const known = runsByProtocolId.get(protocolRunId);
|
|
947
|
+
if (known)
|
|
948
|
+
return known;
|
|
949
|
+
if (!activeRun || activeRun.protocolRunId)
|
|
950
|
+
return null;
|
|
951
|
+
activeRun.protocolRunId = protocolRunId;
|
|
952
|
+
runsByProtocolId.set(protocolRunId, activeRun);
|
|
953
|
+
while (runsByProtocolId.size > 16) {
|
|
954
|
+
const oldestId = runsByProtocolId.keys().next().value;
|
|
955
|
+
if (!oldestId)
|
|
956
|
+
break;
|
|
957
|
+
if (runsByProtocolId.get(oldestId) === activeRun) {
|
|
958
|
+
const current = runsByProtocolId.get(oldestId);
|
|
959
|
+
runsByProtocolId.delete(oldestId);
|
|
960
|
+
runsByProtocolId.set(oldestId, current);
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
runsByProtocolId.delete(oldestId);
|
|
780
964
|
}
|
|
781
|
-
return
|
|
965
|
+
return activeRun;
|
|
782
966
|
}
|
|
783
|
-
// Build the client-tools capability. catalogAsAgUiTools() is used below to
|
|
784
|
-
// thread the catalog into every runAgent() call.
|
|
785
|
-
const clientToolsCap = createClientToolsCapability(source, store);
|
|
786
967
|
/** Forward a neutral-contract state patch onto the AG-UI run input.
|
|
787
968
|
* Mirrors the canonical demo's `input.state` mechanism: the patch is
|
|
788
969
|
* merged into the source agent's client state (carried on
|
|
@@ -795,9 +976,27 @@ function toAgent(source, options = {}) {
|
|
|
795
976
|
store.state.update((prev) => ({ ...prev, ...patch }));
|
|
796
977
|
};
|
|
797
978
|
captureAgentRuntimeTelemetry(options.telemetry, 'tplane:runtime_instance_created', telemetryProperties);
|
|
798
|
-
function
|
|
799
|
-
|
|
979
|
+
function beginRun(requestType, allowBaselineTail = false) {
|
|
980
|
+
if (activeRun && activeRun.outcome === undefined) {
|
|
981
|
+
const supersededRun = activeRun;
|
|
982
|
+
finalizeDeliveryRun(store, supersededRun, 'interrupted');
|
|
983
|
+
const interruption = new Error('Run superseded by a newer request');
|
|
984
|
+
interruption.name = 'InterruptedError';
|
|
985
|
+
failRunTelemetry(interruption, supersededRun);
|
|
986
|
+
}
|
|
987
|
+
const run = {
|
|
988
|
+
generation: allocateDeliveryGeneration('run'),
|
|
989
|
+
baselineMessageIds: new Set(store.messages().map(message => message.id)),
|
|
990
|
+
ownedMessageIds: new Set(),
|
|
991
|
+
snapshotReplacementIds: new Set(),
|
|
992
|
+
eligibleBaselineAssistantId: allowBaselineTail
|
|
993
|
+
? getTailAssistantMessageId(store.messages())
|
|
994
|
+
: undefined,
|
|
995
|
+
startedAt: Date.now(),
|
|
996
|
+
telemetrySettled: false,
|
|
997
|
+
};
|
|
800
998
|
activeRun = run;
|
|
999
|
+
store.deliveryRun = run;
|
|
801
1000
|
captureAgentRuntimeTelemetry(options.telemetry, 'tplane:runtime_request_created', {
|
|
802
1001
|
...telemetryProperties,
|
|
803
1002
|
requestType,
|
|
@@ -806,76 +1005,115 @@ function toAgent(source, options = {}) {
|
|
|
806
1005
|
return run;
|
|
807
1006
|
}
|
|
808
1007
|
function finishRunTelemetry(run) {
|
|
809
|
-
if (run.
|
|
1008
|
+
if (run.telemetrySettled)
|
|
810
1009
|
return;
|
|
1010
|
+
run.telemetrySettled = true;
|
|
811
1011
|
captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_ended', {
|
|
812
1012
|
...telemetryProperties,
|
|
813
1013
|
durationMs: Date.now() - run.startedAt,
|
|
814
1014
|
});
|
|
815
|
-
if (activeRun === run)
|
|
816
|
-
activeRun = null;
|
|
817
1015
|
}
|
|
818
1016
|
function failRunTelemetry(error, run = activeRun) {
|
|
819
|
-
if (!run || run.
|
|
1017
|
+
if (!run || run.telemetrySettled)
|
|
820
1018
|
return;
|
|
821
|
-
run.
|
|
1019
|
+
run.telemetrySettled = true;
|
|
822
1020
|
captureAgentRuntimeTelemetry(options.telemetry, 'tplane:stream_errored', {
|
|
823
1021
|
...telemetryProperties,
|
|
824
1022
|
durationMs: Date.now() - run.startedAt,
|
|
825
1023
|
errorClass: agentRuntimeTelemetryErrorClass(error),
|
|
826
1024
|
});
|
|
827
|
-
if (activeRun === run)
|
|
828
|
-
activeRun = null;
|
|
829
1025
|
}
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
1026
|
+
function failRun(run, error) {
|
|
1027
|
+
if (run.outcome !== undefined)
|
|
1028
|
+
return;
|
|
1029
|
+
finalizeDeliveryRun(store, run, 'error');
|
|
1030
|
+
if (activeRun === run) {
|
|
1031
|
+
store.status.set('error');
|
|
1032
|
+
store.isLoading.set(false);
|
|
1033
|
+
store.error.set(toAgentError(error));
|
|
1034
|
+
}
|
|
1035
|
+
failRunTelemetry(error, run);
|
|
1036
|
+
}
|
|
1037
|
+
function settleTransportClose(run) {
|
|
1038
|
+
if (run.outcome === undefined) {
|
|
1039
|
+
finalizeDeliveryRun(store, run, run.ownedMessageIds.size > 0 ? 'interrupted' : 'success');
|
|
1040
|
+
if (activeRun === run) {
|
|
1041
|
+
store.status.set('idle');
|
|
1042
|
+
store.isLoading.set(false);
|
|
1043
|
+
store.error.set(undefined);
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
finishRunTelemetry(run);
|
|
1047
|
+
}
|
|
1048
|
+
async function executeRun(requestType, parameters, allowBaselineTail = false) {
|
|
1049
|
+
const run = beginRun(requestType, allowBaselineTail);
|
|
837
1050
|
const tools = clientToolsCap.catalogAsAgUiTools();
|
|
1051
|
+
const runParameters = parameters === undefined && tools.length === 0
|
|
1052
|
+
? undefined
|
|
1053
|
+
: { ...parameters, ...(tools.length > 0 ? { tools } : {}) };
|
|
838
1054
|
try {
|
|
839
|
-
await source.runAgent(
|
|
840
|
-
|
|
1055
|
+
await source.runAgent(runParameters);
|
|
1056
|
+
settleTransportClose(run);
|
|
841
1057
|
}
|
|
842
1058
|
catch (err) {
|
|
843
|
-
if (
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
store.error.set(toAgentError(err));
|
|
847
|
-
failRunTelemetry(err, run);
|
|
848
|
-
}
|
|
1059
|
+
if (run.outcome === 'aborted' && isAbortError(err))
|
|
1060
|
+
return;
|
|
1061
|
+
failRun(run, err);
|
|
849
1062
|
}
|
|
850
1063
|
}
|
|
1064
|
+
const clientToolsCap = createClientToolsCapability(source, store, () => executeRun('client-tool-continuation', undefined, true));
|
|
851
1065
|
// Tap all events from the source agent via the AgentSubscriber API.
|
|
852
1066
|
// This subscription lives for the lifetime of `source`.
|
|
853
1067
|
source.subscribe({
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1068
|
+
onRunInitialized({ input }) {
|
|
1069
|
+
resolveCallbackRun(input.runId);
|
|
1070
|
+
},
|
|
1071
|
+
onEvent({ event, input }) {
|
|
1072
|
+
const callbackRunId = input?.runId ?? event.runId;
|
|
1073
|
+
const run = resolveCallbackRun(callbackRunId);
|
|
1074
|
+
if (!run) {
|
|
1075
|
+
if (!callbackRunId)
|
|
1076
|
+
reduceEvent(event, store);
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
if (run !== activeRun) {
|
|
1080
|
+
if (event.type === 'RUN_FINISHED')
|
|
1081
|
+
finalizeDeliveryRun(store, run, 'success');
|
|
1082
|
+
else if (event.type === 'RUN_ERROR')
|
|
1083
|
+
finalizeDeliveryRun(store, run, 'error');
|
|
1084
|
+
return;
|
|
862
1085
|
}
|
|
1086
|
+
if (event.type === 'RUN_ERROR' && run.outcome === 'aborted')
|
|
1087
|
+
return;
|
|
863
1088
|
reduceEvent(event, store);
|
|
1089
|
+
if (run && event.type === 'RUN_FINISHED' && run.outcome === 'success') {
|
|
1090
|
+
finishRunTelemetry(run);
|
|
1091
|
+
}
|
|
1092
|
+
else if (run && event.type === 'RUN_ERROR' && run.outcome === 'error') {
|
|
1093
|
+
failRunTelemetry(event.message ?? event, run);
|
|
1094
|
+
}
|
|
864
1095
|
},
|
|
865
|
-
onRunFailed({ error }) {
|
|
866
|
-
|
|
1096
|
+
onRunFailed({ error, input }) {
|
|
1097
|
+
const run = resolveCallbackRun(input?.runId);
|
|
1098
|
+
if (run) {
|
|
1099
|
+
if (run.outcome === 'aborted' && isAbortError(error))
|
|
1100
|
+
return;
|
|
1101
|
+
failRun(run, error);
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
if (input?.runId)
|
|
867
1105
|
return;
|
|
868
1106
|
store.status.set('error');
|
|
869
1107
|
store.isLoading.set(false);
|
|
870
1108
|
store.error.set(toAgentError(error));
|
|
871
|
-
failRunTelemetry(error);
|
|
872
1109
|
},
|
|
873
1110
|
});
|
|
874
1111
|
// Stable Subagent wrappers per messageId so chat-subagents (tracks by
|
|
875
1112
|
// toolCallId) doesn't churn as activity content streams.
|
|
876
1113
|
const subagentWrappers = new Map();
|
|
877
1114
|
function subagentFor(id, entry) {
|
|
878
|
-
|
|
1115
|
+
const cached = subagentWrappers.get(id);
|
|
1116
|
+
let w = cached?.generation === entry.generation ? cached.wrapper : undefined;
|
|
879
1117
|
if (!w) {
|
|
880
1118
|
w = {
|
|
881
1119
|
toolCallId: entry.content()['toolCallId'] ?? id,
|
|
@@ -883,17 +1121,27 @@ function toAgent(source, options = {}) {
|
|
|
883
1121
|
status: computed(() => entry.content()['status'] ?? 'running'),
|
|
884
1122
|
messages: computed(() => {
|
|
885
1123
|
const c = entry.content();
|
|
1124
|
+
const status = c['status'] ?? 'running';
|
|
1125
|
+
const assistantDelivery = status === 'error'
|
|
1126
|
+
? completeDelivery(entry.generation, 'error')
|
|
1127
|
+
: status === 'complete'
|
|
1128
|
+
? completeDelivery(entry.generation, 'success')
|
|
1129
|
+
: streamingDelivery(entry.generation);
|
|
886
1130
|
const raw = c['messages'];
|
|
887
1131
|
if (Array.isArray(raw)) {
|
|
888
|
-
return raw.map((m, i) =>
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1132
|
+
return raw.map((m, i) => {
|
|
1133
|
+
const messageId = m['id'] ?? `${id}-${i}`;
|
|
1134
|
+
return {
|
|
1135
|
+
id: messageId,
|
|
1136
|
+
role: 'assistant',
|
|
1137
|
+
content: typeof m['content'] === 'string' ? m['content'] : m['content'] ?? '',
|
|
1138
|
+
delivery: m['role'] === 'assistant' ? assistantDelivery : staticDelivery(messageId),
|
|
1139
|
+
...(Array.isArray(m['toolCallIds']) ? { toolCallIds: m['toolCallIds'] } : {}),
|
|
1140
|
+
...(typeof m['reasoning'] === 'string' ? { reasoning: m['reasoning'] } : {}),
|
|
1141
|
+
};
|
|
1142
|
+
});
|
|
895
1143
|
}
|
|
896
|
-
return [{ id, role: 'assistant', content: String(c['text'] ?? '') }];
|
|
1144
|
+
return [{ id, role: 'assistant', content: String(c['text'] ?? ''), delivery: assistantDelivery }];
|
|
897
1145
|
}),
|
|
898
1146
|
toolCalls: computed(() => {
|
|
899
1147
|
const raw = entry.content()['toolCalls'];
|
|
@@ -901,7 +1149,7 @@ function toAgent(source, options = {}) {
|
|
|
901
1149
|
}),
|
|
902
1150
|
state: computed(() => entry.content()['state'] ?? {}),
|
|
903
1151
|
};
|
|
904
|
-
subagentWrappers.set(id, w);
|
|
1152
|
+
subagentWrappers.set(id, { generation: entry.generation, wrapper: w });
|
|
905
1153
|
}
|
|
906
1154
|
return w;
|
|
907
1155
|
}
|
|
@@ -932,33 +1180,13 @@ function toAgent(source, options = {}) {
|
|
|
932
1180
|
}),
|
|
933
1181
|
clientTools: clientToolsCap,
|
|
934
1182
|
submit: async (input, _opts) => {
|
|
935
|
-
// Reset both abort flags so a new submit starts clean and genuine
|
|
936
|
-
// failures after a previous stop are never swallowed.
|
|
937
|
-
abortRequested = false;
|
|
938
|
-
abortSettled = false;
|
|
939
1183
|
if (input.resume !== undefined) {
|
|
940
1184
|
// Resume path: clear the pending interrupt and replay the run with the
|
|
941
1185
|
// resume payload forwarded to the LangGraph backend via AG-UI's
|
|
942
1186
|
// forwardedProps.command.resume mechanism.
|
|
943
1187
|
applyStatePatch(input.state);
|
|
944
1188
|
store.interrupt.set(undefined);
|
|
945
|
-
|
|
946
|
-
const tools = clientToolsCap.catalogAsAgUiTools();
|
|
947
|
-
try {
|
|
948
|
-
await source.runAgent({
|
|
949
|
-
forwardedProps: { command: { resume: input.resume } },
|
|
950
|
-
...(tools.length > 0 ? { tools } : {}),
|
|
951
|
-
});
|
|
952
|
-
finishRunTelemetry(run);
|
|
953
|
-
}
|
|
954
|
-
catch (err) {
|
|
955
|
-
if (!settleIfAborted(err)) {
|
|
956
|
-
store.status.set('error');
|
|
957
|
-
store.isLoading.set(false);
|
|
958
|
-
store.error.set(toAgentError(err));
|
|
959
|
-
failRunTelemetry(err, run);
|
|
960
|
-
}
|
|
961
|
-
}
|
|
1189
|
+
await executeRun('resume', { forwardedProps: { command: { resume: input.resume } } }, true);
|
|
962
1190
|
return;
|
|
963
1191
|
}
|
|
964
1192
|
applyStatePatch(input.state);
|
|
@@ -973,7 +1201,7 @@ function toAgent(source, options = {}) {
|
|
|
973
1201
|
// Record the input so retry() can re-run it without re-appending the
|
|
974
1202
|
// user message (the message is already in the list by this point).
|
|
975
1203
|
lastInput = input;
|
|
976
|
-
await
|
|
1204
|
+
await executeRun('submit');
|
|
977
1205
|
},
|
|
978
1206
|
retry: async () => {
|
|
979
1207
|
if (store.isLoading())
|
|
@@ -984,22 +1212,23 @@ function toAgent(source, options = {}) {
|
|
|
984
1212
|
// Re-run the same message list against the source without appending a
|
|
985
1213
|
// duplicate user message — the message is already in store.messages and
|
|
986
1214
|
// source's internal list from the original submit().
|
|
987
|
-
await
|
|
1215
|
+
await executeRun('retry', undefined, true);
|
|
988
1216
|
},
|
|
989
1217
|
stop: async () => {
|
|
990
|
-
|
|
1218
|
+
const run = activeRun;
|
|
1219
|
+
if (run && run.outcome === undefined) {
|
|
1220
|
+
finalizeDeliveryRun(store, run, 'aborted');
|
|
1221
|
+
store.status.set('idle');
|
|
1222
|
+
store.isLoading.set(false);
|
|
1223
|
+
store.error.set(undefined);
|
|
1224
|
+
finishRunTelemetry(run);
|
|
1225
|
+
}
|
|
991
1226
|
source.abortRun();
|
|
992
1227
|
},
|
|
993
1228
|
regenerate: async (assistantMessageIndex) => {
|
|
994
1229
|
if (store.isLoading()) {
|
|
995
1230
|
throw new Error('Cannot regenerate while agent is loading another response');
|
|
996
1231
|
}
|
|
997
|
-
// Reset abort flags so a regenerate starts clean, exactly like submit().
|
|
998
|
-
// Without this, flags left over from a prior stop() would cause the
|
|
999
|
-
// duplicate-delivery guard in settleIfAborted() to silently swallow the
|
|
1000
|
-
// abort error without settling, wedging the store in streaming/running.
|
|
1001
|
-
abortRequested = false;
|
|
1002
|
-
abortSettled = false;
|
|
1003
1232
|
const msgs = store.messages();
|
|
1004
1233
|
const target = msgs[assistantMessageIndex];
|
|
1005
1234
|
if (!target || target.role !== 'assistant') {
|
|
@@ -1025,20 +1254,7 @@ function toAgent(source, options = {}) {
|
|
|
1025
1254
|
// agent's internal message list without appending — the trailing user
|
|
1026
1255
|
// message in `trimmed` becomes the active prompt for the next run.
|
|
1027
1256
|
source.setMessages(trimmed);
|
|
1028
|
-
|
|
1029
|
-
const regenTools = clientToolsCap.catalogAsAgUiTools();
|
|
1030
|
-
try {
|
|
1031
|
-
await source.runAgent(regenTools.length > 0 ? { tools: regenTools } : undefined);
|
|
1032
|
-
finishRunTelemetry(run);
|
|
1033
|
-
}
|
|
1034
|
-
catch (err) {
|
|
1035
|
-
if (!settleIfAborted(err)) {
|
|
1036
|
-
store.status.set('error');
|
|
1037
|
-
store.isLoading.set(false);
|
|
1038
|
-
store.error.set(toAgentError(err));
|
|
1039
|
-
failRunTelemetry(err, run);
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1257
|
+
await executeRun('regenerate');
|
|
1042
1258
|
},
|
|
1043
1259
|
};
|
|
1044
1260
|
}
|
|
@@ -1048,11 +1264,16 @@ function buildUserMessage(input) {
|
|
|
1048
1264
|
const content = typeof input.message === 'string'
|
|
1049
1265
|
? input.message
|
|
1050
1266
|
: input.message.map((b) => b.type === 'text' ? b.text : JSON.stringify(b)).join('');
|
|
1051
|
-
|
|
1267
|
+
const id = randomId();
|
|
1268
|
+
return { id, role: 'user', content, delivery: staticDelivery(id) };
|
|
1052
1269
|
}
|
|
1053
1270
|
function randomId() {
|
|
1054
1271
|
return Math.random().toString(36).slice(2);
|
|
1055
1272
|
}
|
|
1273
|
+
function getTailAssistantMessageId(messages) {
|
|
1274
|
+
const tail = messages[messages.length - 1];
|
|
1275
|
+
return tail?.role === 'assistant' ? tail.id : undefined;
|
|
1276
|
+
}
|
|
1056
1277
|
|
|
1057
1278
|
// SPDX-License-Identifier: MIT
|
|
1058
1279
|
/**
|
|
@@ -1111,6 +1332,8 @@ class FakeAgent extends AbstractAgent {
|
|
|
1111
1332
|
reasoningTokens;
|
|
1112
1333
|
/** Milliseconds between successive token emissions. */
|
|
1113
1334
|
delayMs;
|
|
1335
|
+
/** Optional deterministic event branches for tests that need exact streams. */
|
|
1336
|
+
script;
|
|
1114
1337
|
constructor(opts = {}) {
|
|
1115
1338
|
super();
|
|
1116
1339
|
this.tokens = opts.tokens ?? [
|
|
@@ -1119,11 +1342,14 @@ class FakeAgent extends AbstractAgent {
|
|
|
1119
1342
|
];
|
|
1120
1343
|
this.reasoningTokens = opts.reasoningTokens ?? [];
|
|
1121
1344
|
this.delayMs = opts.delayMs ?? 60;
|
|
1345
|
+
this.script = opts.script ?? [];
|
|
1122
1346
|
}
|
|
1123
1347
|
run(input) {
|
|
1348
|
+
const scripted = this.scriptedSequence(input);
|
|
1349
|
+
if (scripted)
|
|
1350
|
+
return this.emitSequence(scripted, 30);
|
|
1124
1351
|
const tokens = this.tokens;
|
|
1125
1352
|
const reasoningTokens = this.reasoningTokens;
|
|
1126
|
-
const delayMs = this.delayMs;
|
|
1127
1353
|
const messageId = `fake-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
1128
1354
|
const sequence = [
|
|
1129
1355
|
{ type: EventType.RUN_STARTED, threadId: input.threadId, runId: input.runId },
|
|
@@ -1141,6 +1367,22 @@ class FakeAgent extends AbstractAgent {
|
|
|
1141
1367
|
}
|
|
1142
1368
|
sequence.push({ type: EventType.TEXT_MESSAGE_END, messageId });
|
|
1143
1369
|
sequence.push({ type: EventType.RUN_FINISHED, threadId: input.threadId, runId: input.runId });
|
|
1370
|
+
return this.emitSequence(sequence, 30);
|
|
1371
|
+
}
|
|
1372
|
+
scriptedSequence(input) {
|
|
1373
|
+
if (this.script.length === 0)
|
|
1374
|
+
return undefined;
|
|
1375
|
+
const branch = this.script.find((candidate) => matchesBranch(candidate.when, input));
|
|
1376
|
+
if (!branch)
|
|
1377
|
+
return undefined;
|
|
1378
|
+
return [
|
|
1379
|
+
{ type: EventType.RUN_STARTED, threadId: input.threadId, runId: input.runId },
|
|
1380
|
+
...branch.events,
|
|
1381
|
+
{ type: EventType.RUN_FINISHED, threadId: input.threadId, runId: input.runId },
|
|
1382
|
+
];
|
|
1383
|
+
}
|
|
1384
|
+
emitSequence(sequence, initialDelayMs) {
|
|
1385
|
+
const delayMs = this.delayMs;
|
|
1144
1386
|
return new Observable((observer) => {
|
|
1145
1387
|
let cancelled = false;
|
|
1146
1388
|
let timer;
|
|
@@ -1157,7 +1399,7 @@ class FakeAgent extends AbstractAgent {
|
|
|
1157
1399
|
// Steady cadence except a tiny initial delay before RUN_STARTED.
|
|
1158
1400
|
timer = setTimeout(emitNext, delayMs);
|
|
1159
1401
|
};
|
|
1160
|
-
timer = setTimeout(emitNext,
|
|
1402
|
+
timer = setTimeout(emitNext, initialDelayMs);
|
|
1161
1403
|
return () => {
|
|
1162
1404
|
cancelled = true;
|
|
1163
1405
|
if (timer !== undefined)
|
|
@@ -1166,6 +1408,26 @@ class FakeAgent extends AbstractAgent {
|
|
|
1166
1408
|
});
|
|
1167
1409
|
}
|
|
1168
1410
|
}
|
|
1411
|
+
function matchesBranch(when, input) {
|
|
1412
|
+
if (when === 'initial')
|
|
1413
|
+
return !hasToolMessages(input);
|
|
1414
|
+
return hasToolMessageFor(input, when.toolMessageFor);
|
|
1415
|
+
}
|
|
1416
|
+
function hasToolMessages(input) {
|
|
1417
|
+
return (input.messages ?? []).some((message) => isToolMessage(message));
|
|
1418
|
+
}
|
|
1419
|
+
function hasToolMessageFor(input, toolCallId) {
|
|
1420
|
+
return (input.messages ?? []).some((message) => {
|
|
1421
|
+
if (!isToolMessage(message))
|
|
1422
|
+
return false;
|
|
1423
|
+
const record = message;
|
|
1424
|
+
return record['toolCallId'] === toolCallId || record['tool_call_id'] === toolCallId;
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
function isToolMessage(message) {
|
|
1428
|
+
const record = message;
|
|
1429
|
+
return record['role'] === 'tool' || record['type'] === 'tool';
|
|
1430
|
+
}
|
|
1169
1431
|
|
|
1170
1432
|
/**
|
|
1171
1433
|
* Registers an in-process FakeAgent under AGENT.
|
|
@@ -1176,7 +1438,7 @@ class FakeAgent extends AbstractAgent {
|
|
|
1176
1438
|
* @example
|
|
1177
1439
|
* ```ts
|
|
1178
1440
|
* TestBed.configureTestingModule({
|
|
1179
|
-
* providers: [provideFakeAgent({
|
|
1441
|
+
* providers: [provideFakeAgent({ tokens: ['Hello from the fake agent'] })],
|
|
1180
1442
|
* });
|
|
1181
1443
|
* ```
|
|
1182
1444
|
*/
|