@agent-native/core 0.176.5 → 0.176.6-nightly-20260903200904

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/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 8901
34
+ - template files: 8902
@@ -587,6 +587,7 @@
587
587
  .notion-editor .notion-task-list li label {
588
588
  display: flex;
589
589
  align-items: center;
590
+ align-self: start;
590
591
  min-height: 1.7em;
591
592
  margin-top: 0;
592
593
  }
@@ -0,0 +1,93 @@
1
+ interface ActionQuery {
2
+ queryKey: readonly unknown[];
3
+ }
4
+
5
+ interface ActionEvent {
6
+ source?: string;
7
+ key?: string;
8
+ }
9
+
10
+ const COMMENT_MUTATIONS = new Set([
11
+ "add-comment",
12
+ "delete-comment",
13
+ "sync-notion-comments",
14
+ "update-comment",
15
+ ]);
16
+
17
+ const DOCUMENT_MUTATIONS = new Set([
18
+ "create-and-link-notion-page",
19
+ "delete-document",
20
+ "delete-document-property",
21
+ "duplicate-document-property",
22
+ "edit-document",
23
+ "execute-builder-source-batch",
24
+ "execute-builder-source-execution",
25
+ "import-content-source",
26
+ "migrate-content-database-rows",
27
+ "move-document",
28
+ "mutate-content-database-block",
29
+ "process-builder-body-hydration",
30
+ "pull-builder-doc",
31
+ "pull-document",
32
+ "pull-notion-page",
33
+ "push-builder-doc",
34
+ "push-notion-page",
35
+ "reorder-document-property",
36
+ "resolve-local-folder-conflict",
37
+ "resolve-notion-sync-conflict",
38
+ "restore-document",
39
+ "restore-document-version",
40
+ "set-document-discoverability",
41
+ "set-document-property",
42
+ "set-image-alt-text",
43
+ "sync-local-folder-source",
44
+ "sync-manifest-local-folder-source",
45
+ "transcribe-media",
46
+ "update-document",
47
+ ]);
48
+
49
+ const CONTENT_MUTATIONS = new Set([
50
+ ...COMMENT_MUTATIONS,
51
+ ...DOCUMENT_MUTATIONS,
52
+ ]);
53
+
54
+ function queryTargetsDocument(query: ActionQuery, documentId: string): boolean {
55
+ if (query.queryKey[0] !== "action") return false;
56
+ if (
57
+ query.queryKey[1] !== "get-document" &&
58
+ query.queryKey[1] !== "list-comments"
59
+ ) {
60
+ return false;
61
+ }
62
+ const args = query.queryKey[2];
63
+ return (
64
+ !!args &&
65
+ typeof args === "object" &&
66
+ (("id" in args && args.id === documentId) ||
67
+ ("documentId" in args && args.documentId === documentId))
68
+ );
69
+ }
70
+
71
+ export function contentDocumentIdFromPathname(
72
+ pathname: string,
73
+ ): string | undefined {
74
+ const match = /^\/page\/([^/]+)\/?$/.exec(pathname);
75
+ return match?.[1] ? decodeURIComponent(match[1]) : undefined;
76
+ }
77
+
78
+ export function contentActionInvalidatePredicate(
79
+ pathname: string,
80
+ ): (query: ActionQuery, events: readonly ActionEvent[]) => boolean {
81
+ const documentId = contentDocumentIdFromPathname(pathname);
82
+ return (query, events) => {
83
+ if (documentId === undefined || !queryTargetsDocument(query, documentId)) {
84
+ return false;
85
+ }
86
+ return events.some(
87
+ (event) =>
88
+ event.source === "action" &&
89
+ typeof event.key === "string" &&
90
+ CONTENT_MUTATIONS.has(event.key),
91
+ );
92
+ };
93
+ }
@@ -1,20 +1,21 @@
1
- import { useDbSync as useCoreDbSync } from "@agent-native/core/client/hooks";
1
+ import {
2
+ getBrowserTabId,
3
+ useDbSync as useCoreDbSync,
4
+ } from "@agent-native/core/client/hooks";
2
5
  import { useQueryClient } from "@tanstack/react-query";
3
6
 
7
+ import { contentActionInvalidatePredicate } from "./content-action-refresh";
8
+
4
9
  export function useDbSync() {
5
10
  const queryClient = useQueryClient();
11
+ const browserTabId = getBrowserTabId();
6
12
 
7
13
  useCoreDbSync({
8
14
  queryClient,
9
- // refresh-notion-sync-status is a POST behind an ["action"]-keyed query
10
- // (useDocumentSyncStatus). Without suppression its own action-change event
11
- // invalidates all action queries, which refetches the POST, which emits
12
- // the next event — a self-sustaining refetch storm on every poll tick.
13
- suppressActionInvalidationFor: [
14
- "process-builder-body-hydration",
15
- "refresh-content-database-source",
16
- "refresh-notion-sync-status",
17
- ],
15
+ ignoreSource: browserTabId,
16
+ actionInvalidatePredicate: contentActionInvalidatePredicate(
17
+ typeof window === "undefined" ? "" : window.location.pathname,
18
+ ),
18
19
  queryKeys: [
19
20
  "action",
20
21
  "document-sync",
@@ -1,4 +1,5 @@
1
- import { useState, useEffect } from "react";
1
+ import { useEffect, useLayoutEffect, useState } from "react";
2
+ const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
2
3
  /**
3
4
  * Renders children only on the client (after hydration).
4
5
  *
@@ -9,7 +10,9 @@ import { useState, useEffect } from "react";
9
10
  */
10
11
  export function ClientOnly({ children, fallback, }) {
11
12
  const [mounted, setMounted] = useState(false);
12
- useEffect(() => setMounted(true), []);
13
+ // The static shell and client tree must hand off before paint or the loader
14
+ // flashes again while the authenticated app mounts.
15
+ useBrowserLayoutEffect(() => setMounted(true), []);
13
16
  if (!mounted)
14
17
  return fallback ?? null;
15
18
  return children;
@@ -57,6 +57,7 @@ import { useEffect, useRef } from "react";
57
57
  import { useInRouterContext } from "react-router";
58
58
  import { isHumanReadableDocumentTitle, normalizeDocumentTitle, } from "../shared/document-title.js";
59
59
  import { getSsrBetaRedirectScriptBody } from "../shared/ssr-beta-redirect.js";
60
+ import { agentNativePath } from "./api-path.js";
60
61
  import { ClientOnly } from "./ClientOnly.js";
61
62
  import { DefaultSpinner } from "./DefaultSpinner.js";
62
63
  import { EnvironmentBadge } from "./EnvironmentBadge.js";
@@ -70,7 +71,9 @@ import { EMBEDDED_THEME_CHANGE_EVENT, applyEmbeddedThemeUpdate, parseEmbeddedThe
70
71
  import { createAgentNativeServerActionWebMcpRegistration } from "./webmcp.js";
71
72
  const DEFAULT_TOASTER = (_jsx(Toaster, { richColors: true, position: "bottom-left", offset: { bottom: 44, left: 32 }, mobileOffset: { bottom: 44, left: 16 } }));
72
73
  function EarlyBetaRedirectScript() {
73
- return (_jsx("script", { "data-agent-native-beta-redirect": "1", dangerouslySetInnerHTML: { __html: getSsrBetaRedirectScriptBody() } }));
74
+ return (_jsx("script", { "data-agent-native-beta-redirect": "1", dangerouslySetInnerHTML: {
75
+ __html: getSsrBetaRedirectScriptBody(agentNativePath("/_agent-native/auth/session")),
76
+ } }));
74
77
  }
75
78
  function RoutedAppEnhancements() {
76
79
  const isInRouter = useInRouterContext();
@@ -101,7 +101,8 @@ export declare function subscribeSyncEvents(options: SubscribeSyncEventsOptions)
101
101
  * value. Use a per-tab ID so the UI ignores its own writes while still
102
102
  * picking up changes from other tabs, agents, and scripts.
103
103
  * @param options.actionInvalidatePredicate - Optional filter for the broad
104
- * compatibility invalidate triggered by `action` events. Use this to keep
104
+ * compatibility invalidate triggered by sync events. The current event batch
105
+ * is provided so apps can preserve action-level targeting. Use this to keep
105
106
  * expensive active queries on explicit-refresh semantics while still letting
106
107
  * normal source-versioned queries react through `useChangeVersion`.
107
108
  * @param options.suppressActionInvalidationFor - Action names whose sync events
@@ -120,7 +121,7 @@ export declare function useDbSync(options?: {
120
121
  fallbackInterval?: number;
121
122
  pauseWhenHidden?: boolean;
122
123
  ignoreSource?: string;
123
- actionInvalidatePredicate?: (query: Query) => boolean;
124
+ actionInvalidatePredicate?: (query: Query, events: readonly SyncEvent[]) => boolean;
124
125
  suppressActionInvalidationFor?: string[];
125
126
  }): void;
126
127
  /** @deprecated Use useDbSync instead */
@@ -1174,7 +1174,8 @@ export function subscribeSyncEvents(options) {
1174
1174
  * value. Use a per-tab ID so the UI ignores its own writes while still
1175
1175
  * picking up changes from other tabs, agents, and scripts.
1176
1176
  * @param options.actionInvalidatePredicate - Optional filter for the broad
1177
- * compatibility invalidate triggered by `action` events. Use this to keep
1177
+ * compatibility invalidate triggered by sync events. The current event batch
1178
+ * is provided so apps can preserve action-level targeting. Use this to keep
1178
1179
  * expensive active queries on explicit-refresh semantics while still letting
1179
1180
  * normal source-versioned queries react through `useChangeVersion`.
1180
1181
  * @param options.suppressActionInvalidationFor - Action names whose sync events
@@ -1351,7 +1352,10 @@ export function useDbSync(options = {}) {
1351
1352
  // makes one agent write fan out across unrelated provider reads,
1352
1353
  // dashboards, and background status checks. Older apps that still
1353
1354
  // need broad compatibility can opt in with a predicate.
1354
- const predicate = actionInvalidatePredicateRef.current;
1355
+ const appPredicate = actionInvalidatePredicateRef.current;
1356
+ const predicate = appPredicate
1357
+ ? (query) => appPredicate(query, invalidating)
1358
+ : undefined;
1355
1359
  invalidateWithoutCancel(predicate ? { predicate } : { queryKey: ["action"] });
1356
1360
  }
1357
1361
  // Framework-level invalidate: a small, fixed list of query-key
@@ -1394,7 +1398,10 @@ export function useDbSync(options = {}) {
1394
1398
  // ["action"] query regardless of what the app opted out of — and
1395
1399
  // an app cannot work around it, because both the prefix and this
1396
1400
  // call are framework-owned.
1397
- const predicate = actionInvalidatePredicateRef.current;
1401
+ const appPredicate = actionInvalidatePredicateRef.current;
1402
+ const predicate = appPredicate
1403
+ ? (query) => appPredicate(query, invalidating)
1404
+ : undefined;
1398
1405
  invalidateWithoutCancel(predicate ? { predicate } : { queryKey: ["action"] });
1399
1406
  }
1400
1407
  if (!hasActionEvent || hasFrameworkPrefixEvent) {
@@ -62,11 +62,11 @@ export declare const postAwareness: import("h3").EventHandlerWithFetch<import("h
62
62
  error: string;
63
63
  states?: undefined;
64
64
  } | {
65
- error?: undefined;
66
65
  states: {
67
66
  clientId: number;
68
67
  state: string;
69
68
  }[];
69
+ error?: undefined;
70
70
  }>>;
71
71
  /**
72
72
  * GET /_agent-native/collab/:docId/users
@@ -77,9 +77,9 @@ export declare const getActiveUsers: import("h3").EventHandlerWithFetch<import("
77
77
  error: string;
78
78
  users?: undefined;
79
79
  } | {
80
- error?: undefined;
81
80
  users: {
82
81
  clientId: number;
83
82
  lastSeen: number;
84
83
  }[];
84
+ error?: undefined;
85
85
  }>>;
@@ -16,18 +16,18 @@ export declare function createNotificationsHandler(): import("h3").EventHandlerW
16
16
  error?: undefined;
17
17
  ok?: undefined;
18
18
  } | {
19
- count?: undefined;
20
19
  updated: number;
21
20
  error?: undefined;
22
21
  ok?: undefined;
23
- } | {
24
22
  count?: undefined;
23
+ } | {
25
24
  updated?: undefined;
26
25
  error: string;
27
26
  ok?: undefined;
28
- } | {
29
27
  count?: undefined;
28
+ } | {
30
29
  updated?: undefined;
31
- ok: boolean;
32
30
  error?: undefined;
31
+ ok: boolean;
32
+ count?: undefined;
33
33
  }>>;
@@ -41,27 +41,27 @@ export declare function createObservabilityHandler(): import("h3").EventHandlerW
41
41
  thumbsUpRate: number;
42
42
  avgEvalScore: number;
43
43
  } | {
44
+ error?: undefined;
45
+ ok?: undefined;
44
46
  summary: import("./types.js").TraceSummary;
45
47
  spans: import("./types.js").TraceSpan[];
46
48
  id?: undefined;
49
+ } | {
47
50
  error?: undefined;
48
51
  ok?: undefined;
49
- } | {
50
52
  summary?: undefined;
51
53
  spans?: undefined;
52
54
  id: string;
53
- error?: undefined;
54
- ok?: undefined;
55
55
  } | {
56
+ ok?: undefined;
56
57
  summary?: undefined;
57
58
  spans?: undefined;
58
59
  id?: undefined;
59
60
  error: any;
60
- ok?: undefined;
61
61
  } | {
62
+ error?: undefined;
62
63
  summary?: undefined;
63
64
  spans?: undefined;
64
65
  id?: undefined;
65
- error?: undefined;
66
66
  ok: boolean;
67
67
  }>>;
@@ -1,7 +1,7 @@
1
1
  import { captureError } from "../server/capture-error.js";
2
2
  import { getRequestContext } from "../server/request-context.js";
3
3
  import { MAX_AI_CONTENT_BYTES, MAX_AI_SPANS_PER_RUN, boundAiContent, emitAiSpanEvent, emitAiTraceEvent, resolveAiError, toAiErrorDetail, toPostHogMessages, } from "./posthog-ai.js";
4
- import { endAgentSpan, startAgentSpan } from "./tracing.js";
4
+ import { endAgentSpan, startAgentSpan, withAgentSpanContext, } from "./tracing.js";
5
5
  import { trackingIdentityProperties } from "./tracking-identity.js";
6
6
  function spanId() {
7
7
  return `span-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
@@ -384,10 +384,9 @@ export async function instrumentAgentLoop(opts) {
384
384
  // don't have to thread it down by hand.
385
385
  const browserSessionId = opts.browserSessionId ?? getRequestContext()?.browserSessionId;
386
386
  // Optional OpenTelemetry root span for this run. No-ops unless a host has
387
- // installed `@opentelemetry/api` and registered a provider. The promise is
388
- // resolved before the loop runs so child tool/model spans can parent under
389
- // it conceptually (we keep them flat in the same tracer, which is enough
390
- // for the dashboards an embedding app would build).
387
+ // installed `@opentelemetry/api` and registered a provider. The root is
388
+ // installed as the active context while the loop runs so child tool/model
389
+ // spans have a real parent relationship in the exported trace.
391
390
  const otelRunSpanPromise = startAgentSpan("agent.run", {
392
391
  "agent.run_id": runId,
393
392
  "agent.thread_id": threadId ?? undefined,
@@ -401,6 +400,7 @@ export async function instrumentAgentLoop(opts) {
401
400
  ? opts.experimentAssignments[0].variantId
402
401
  : undefined,
403
402
  });
403
+ let otelRunSpan = null;
404
404
  const spans = [];
405
405
  let toolInvocationCounter = 0;
406
406
  // Keyed by counter to handle concurrent calls to the same tool name
@@ -433,6 +433,82 @@ export async function instrumentAgentLoop(opts) {
433
433
  /** The call currently streaming, or the last one that streamed — text and
434
434
  * usage arriving between calls belong to the call that just finished. */
435
435
  const currentRoundTrip = () => modelRoundTrips[modelRoundTrips.length - 1];
436
+ let calculateCost;
437
+ const calculateUsageCost = (callUsage) => {
438
+ if (!calculateCost || !callUsage)
439
+ return undefined;
440
+ try {
441
+ return calculateCost(callUsage.inputTokens, callUsage.outputTokens, callUsage.model, callUsage.cacheReadTokens, callUsage.cacheWriteTokens);
442
+ }
443
+ catch {
444
+ // coercion-ok: cost estimation is enrichment and cannot fail tracing.
445
+ return undefined;
446
+ }
447
+ };
448
+ const pendingOtelModelSpans = new Map();
449
+ const openOtelModelSpans = new Set();
450
+ const modelSpansAwaitingFinalError = new Set();
451
+ const modelSpanAttributes = (index) => {
452
+ const trip = modelRoundTrips[index];
453
+ const callUsage = trip?.usage;
454
+ return {
455
+ "llm.model": callUsage?.model ?? loopOpts.model,
456
+ "llm.call_index": index,
457
+ "llm.stop_reason": trip?.stopReason,
458
+ "llm.input_tokens": callUsage?.inputTokens,
459
+ "llm.output_tokens": callUsage?.outputTokens,
460
+ "llm.cache_read_tokens": callUsage?.cacheReadTokens,
461
+ "llm.cache_write_tokens": callUsage?.cacheWriteTokens,
462
+ "llm.cost_cents_x100": calculateUsageCost(callUsage),
463
+ };
464
+ };
465
+ const startOtelModelSpan = (index) => {
466
+ const entry = {
467
+ spanPromise: Promise.resolve(null),
468
+ span: null,
469
+ endResult: undefined,
470
+ ended: false,
471
+ };
472
+ entry.spanPromise = startAgentSpan("llm.call", {
473
+ "llm.model": loopOpts.model,
474
+ "llm.call_index": index,
475
+ }, otelRunSpan);
476
+ pendingOtelModelSpans.set(index, entry);
477
+ void entry.spanPromise.then((span) => {
478
+ if (!span || entry.ended)
479
+ return;
480
+ if (entry.endResult) {
481
+ entry.ended = true;
482
+ endAgentSpan(span, entry.endResult);
483
+ }
484
+ else {
485
+ entry.span = span;
486
+ openOtelModelSpans.add(span);
487
+ }
488
+ });
489
+ };
490
+ const finishOtelModelSpan = (index, result) => {
491
+ const entry = pendingOtelModelSpans.get(index);
492
+ if (!entry || entry.ended)
493
+ return;
494
+ entry.endResult = result;
495
+ if (!entry.span)
496
+ return;
497
+ entry.ended = true;
498
+ openOtelModelSpans.delete(entry.span);
499
+ endAgentSpan(entry.span, result);
500
+ };
501
+ const finishAwaitingOtelModelSpans = (finalErrorMessage = null) => {
502
+ for (const tripIndex of modelSpansAwaitingFinalError) {
503
+ finishOtelModelSpan(tripIndex, {
504
+ status: "error",
505
+ errorMessage: finalErrorMessage ?? "Model stream ended before completion.",
506
+ attributes: modelSpanAttributes(tripIndex),
507
+ endTime: modelRoundTrips[tripIndex]?.end,
508
+ });
509
+ }
510
+ modelSpansAwaitingFinalError.clear();
511
+ };
436
512
  const modelStreamIntervals = [];
437
513
  let modelStreamOpenedAt = null;
438
514
  /** Tool span id → the round-trip that requested it. */
@@ -507,6 +583,7 @@ export async function instrumentAgentLoop(opts) {
507
583
  // counted as a successful delegated generation. A later clear/done means
508
584
  // the wrapper recovered and finished cleanly, so reset in that case.
509
585
  if (event.type === "clear" || event.type === "done") {
586
+ finishAwaitingOtelModelSpans();
510
587
  runStatus = "success";
511
588
  errorMessage = null;
512
589
  cutOffReason = null;
@@ -539,8 +616,13 @@ export async function instrumentAgentLoop(opts) {
539
616
  // The emitter brackets these itself, so a repeated start or an
540
617
  // unmatched end is a no-op here rather than a fabricated interval.
541
618
  if (event.status === "start") {
619
+ // A reasonless closure is emitted before the agent loop decides
620
+ // whether to retry. If another attempt starts, the old attempt is
621
+ // definitely final and must not span the retry backoff.
622
+ finishAwaitingOtelModelSpans();
542
623
  if (modelStreamOpenedAt === null) {
543
624
  modelStreamOpenedAt = Date.now();
625
+ const tripIndex = modelRoundTrips.length;
544
626
  modelRoundTrips.push({
545
627
  spanId: spanId(),
546
628
  start: modelStreamOpenedAt,
@@ -561,17 +643,25 @@ export async function instrumentAgentLoop(opts) {
561
643
  : {}),
562
644
  assistantText: [],
563
645
  });
646
+ startOtelModelSpan(tripIndex);
564
647
  }
565
648
  }
566
649
  else if (modelStreamOpenedAt !== null) {
567
650
  const end = Date.now();
568
651
  modelStreamIntervals.push({ start: modelStreamOpenedAt, end });
652
+ const tripIndex = modelRoundTrips.length - 1;
569
653
  const trip = currentRoundTrip();
570
654
  if (trip) {
571
655
  trip.end = end;
572
656
  if (event.reason)
573
657
  trip.stopReason = event.reason;
574
658
  }
659
+ if (event.reason === undefined || event.reason === "error") {
660
+ // The engine emits this from a `finally`, before the outer catch
661
+ // has classified a provider error. Defer ending the span so that
662
+ // the real error message wins over a generic stream-ended value.
663
+ modelSpansAwaitingFinalError.add(tripIndex);
664
+ }
575
665
  modelStreamOpenedAt = null;
576
666
  }
577
667
  }
@@ -604,7 +694,7 @@ export async function instrumentAgentLoop(opts) {
604
694
  toolCallIdToCounter.set(event.id, counter);
605
695
  void startAgentSpan("tool.call", {
606
696
  "tool.name": event.tool,
607
- }).then((span) => {
697
+ }, otelRunSpan).then((span) => {
608
698
  if (!span)
609
699
  return;
610
700
  // If `tool_done` already ran for this call, end the span now with the
@@ -769,7 +859,8 @@ export async function instrumentAgentLoop(opts) {
769
859
  ? [...loopOpts.messages]
770
860
  : loopOpts.messages;
771
861
  try {
772
- usage = await runAgentLoop({
862
+ otelRunSpan = await otelRunSpanPromise;
863
+ usage = await withAgentSpanContext(otelRunSpan, () => runAgentLoop({
773
864
  ...loopOpts,
774
865
  runId,
775
866
  send: instrumentedSend,
@@ -782,7 +873,7 @@ export async function instrumentAgentLoop(opts) {
782
873
  trip.usage = callUsage;
783
874
  loopOpts.onUsage?.(callUsage);
784
875
  },
785
- });
876
+ }));
786
877
  }
787
878
  catch (err) {
788
879
  const classification = opts.classifyError?.(err) ?? null;
@@ -814,6 +905,9 @@ export async function instrumentAgentLoop(opts) {
814
905
  // model was still running when the run stopped, so the interval closes at
815
906
  // the run's end rather than being dropped.
816
907
  const failedInsideModelCall = modelStreamOpenedAt !== null;
908
+ const interruptedModelRoundTrip = modelStreamOpenedAt !== null && modelRoundTrips.length > 0
909
+ ? modelRoundTrips.length - 1
910
+ : null;
817
911
  if (modelStreamOpenedAt !== null) {
818
912
  modelStreamIntervals.push({ start: modelStreamOpenedAt, end: runEnd });
819
913
  const trip = currentRoundTrip();
@@ -890,7 +984,6 @@ export async function instrumentAgentLoop(opts) {
890
984
  let costCentsX100 = 0;
891
985
  // Held for the per-generation costs below, which price each round-trip
892
986
  // from its own tokens rather than splitting the run total.
893
- let calculateCost;
894
987
  try {
895
988
  ({ calculateCost } = await import("../usage/store.js"));
896
989
  if (usage) {
@@ -937,8 +1030,8 @@ export async function instrumentAgentLoop(opts) {
937
1030
  llmCallCount =
938
1031
  usage?.llmCalls ??
939
1032
  // Compatibility for custom loop implementations that predate the
940
- // attempt counter: a measured run still counts as one call.
941
- 1;
1033
+ // attempt counter: observed brackets still count every attempt.
1034
+ (modelRoundTrips.length > 0 ? modelRoundTrips.length : 1);
942
1035
  const runUsage = usage ?? {
943
1036
  inputTokens: 0,
944
1037
  outputTokens: 0,
@@ -1308,13 +1401,34 @@ export async function instrumentAgentLoop(opts) {
1308
1401
  createdAt: runStart,
1309
1402
  };
1310
1403
  writeTraceData(spans, summary, runId, config).catch(() => { });
1311
- // OpenTelemetry export (no-op unless a provider is registered). Emit a
1312
- // self-contained `llm.call` span carrying model + token usage, end any
1313
- // tool spans still open (loop threw mid-tool), and end the run span. Awaited
1314
- // so the spans are emitted before the function returns; cheap when no-op.
1404
+ // OpenTelemetry export (no-op unless a provider is registered). Bracketed
1405
+ // model calls have already emitted live spans; engines without brackets
1406
+ // get one aggregate generation. End any tool/model spans still open and
1407
+ // then end the run span. Awaited so spans are emitted before return.
1315
1408
  try {
1316
- if (usage) {
1317
- endAgentSpan(await startAgentSpan("llm.call", {}), {
1409
+ if (interruptedModelRoundTrip !== null) {
1410
+ finishOtelModelSpan(interruptedModelRoundTrip, {
1411
+ status: "error",
1412
+ errorMessage: errorMessage ?? "Model stream interrupted before completion.",
1413
+ attributes: modelSpanAttributes(interruptedModelRoundTrip),
1414
+ endTime: runEnd,
1415
+ });
1416
+ }
1417
+ for (const [tripIndex, trip] of modelRoundTrips.entries()) {
1418
+ if (trip.stopReason && trip.stopReason !== "error") {
1419
+ finishOtelModelSpan(tripIndex, {
1420
+ status: "success",
1421
+ errorMessage: null,
1422
+ attributes: modelSpanAttributes(tripIndex),
1423
+ endTime: trip.end,
1424
+ });
1425
+ }
1426
+ }
1427
+ finishAwaitingOtelModelSpans(errorMessage);
1428
+ await Promise.all([...pendingOtelModelSpans.values()].map((entry) => entry.spanPromise));
1429
+ if (usage && modelRoundTrips.length === 0) {
1430
+ const aggregateLlmSpan = await withAgentSpanContext(otelRunSpan, () => startAgentSpan("llm.call", {}, otelRunSpan));
1431
+ endAgentSpan(aggregateLlmSpan, {
1318
1432
  status: runStatus,
1319
1433
  errorMessage,
1320
1434
  attributes: {
@@ -1327,6 +1441,13 @@ export async function instrumentAgentLoop(opts) {
1327
1441
  },
1328
1442
  });
1329
1443
  }
1444
+ for (const modelSpan of openOtelModelSpans) {
1445
+ endAgentSpan(modelSpan, {
1446
+ status: "error",
1447
+ errorMessage: "Agent run ended before model_stream completed.",
1448
+ });
1449
+ }
1450
+ openOtelModelSpans.clear();
1330
1451
  for (const toolSpan of openOtelToolSpans) {
1331
1452
  endAgentSpan(toolSpan, {
1332
1453
  status: "error",
@@ -1334,10 +1455,11 @@ export async function instrumentAgentLoop(opts) {
1334
1455
  });
1335
1456
  }
1336
1457
  openOtelToolSpans.clear();
1337
- endAgentSpan(await otelRunSpanPromise, {
1458
+ endAgentSpan(otelRunSpan, {
1338
1459
  status: runStatus,
1339
1460
  errorMessage,
1340
1461
  attributes: {
1462
+ "agent.llm_calls": llmCallCount,
1341
1463
  "agent.tool_calls": toolCallCount,
1342
1464
  "agent.successful_tools": successfulTools,
1343
1465
  "agent.failed_tools": failedTools,
@@ -36,7 +36,7 @@ export interface AgentSpan {
36
36
  name?: string;
37
37
  message: string;
38
38
  }): void;
39
- end(): void;
39
+ end(endTime?: unknown): void;
40
40
  }
41
41
  /** OTel `SpanStatusCode` values, inlined so we don't need the api types here. */
42
42
  export declare const SPAN_STATUS_OK = 1;
@@ -44,14 +44,30 @@ export declare const SPAN_STATUS_ERROR = 2;
44
44
  interface AgentTracer {
45
45
  startSpan(name: string, options?: {
46
46
  attributes?: Record<string, string | number | boolean>;
47
- }): AgentSpan;
47
+ }, context?: unknown): AgentSpan;
48
+ }
49
+ interface AgentTraceRuntime {
50
+ tracer: AgentTracer;
51
+ context?: {
52
+ active(): unknown;
53
+ with<T>(context: unknown, callback: () => T): T;
54
+ };
55
+ trace?: {
56
+ setSpan(context: unknown, span: AgentSpan): unknown;
57
+ };
48
58
  }
49
59
  /**
50
60
  * Start a span. When OTel isn't installed (or no provider is registered) this
51
61
  * returns `null` and the caller simply skips span bookkeeping — there is no
52
62
  * runtime cost beyond the cached null check.
53
63
  */
54
- export declare function startAgentSpan(name: string, attributes?: Record<string, string | number | boolean | null | undefined>): Promise<AgentSpan | null>;
64
+ export declare function startAgentSpan(name: string, attributes?: Record<string, string | number | boolean | null | undefined>, parentSpan?: AgentSpan | null): Promise<AgentSpan | null>;
65
+ /**
66
+ * Run a callback with the supplied span installed as the active OTel span so
67
+ * spans created by the callback become descendants of it. The bridge is
68
+ * deliberately best-effort: telemetry setup must never change agent behavior.
69
+ */
70
+ export declare function withAgentSpanContext<T>(span: AgentSpan | null, callback: () => T): T;
55
71
  /**
56
72
  * Finish a span, setting OK/ERROR status and recording the error message when
57
73
  * present. Safe to call with `null` (no-op) and never throws.
@@ -60,6 +76,7 @@ export declare function endAgentSpan(span: AgentSpan | null, result?: {
60
76
  status?: "success" | "error";
61
77
  errorMessage?: string | null;
62
78
  attributes?: Record<string, string | number | boolean | null | undefined>;
79
+ endTime?: number;
63
80
  }): void;
64
81
  /** For tests — reset the cached tracer so a fresh provider can be detected. */
65
82
  export declare function __resetAgentTracerCache(): void;
@@ -69,4 +86,6 @@ export declare function __resetAgentTracerCache(): void;
69
86
  * to simulate "no tracer available".
70
87
  */
71
88
  export declare function __setAgentTracerForTests(tracer: AgentTracer | null): void;
89
+ /** For tests — inject a runtime with an active-context implementation. */
90
+ export declare function __setAgentTraceRuntimeForTests(runtime: AgentTraceRuntime | null): void;
72
91
  export {};