@gajae-code/agent-core 0.1.1

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.
Files changed (55) hide show
  1. package/CHANGELOG.md +482 -0
  2. package/README.md +473 -0
  3. package/dist/types/agent-loop.d.ts +55 -0
  4. package/dist/types/agent.d.ts +334 -0
  5. package/dist/types/append-only-context.d.ts +113 -0
  6. package/dist/types/compaction/branch-summarization.d.ts +94 -0
  7. package/dist/types/compaction/compaction.d.ts +166 -0
  8. package/dist/types/compaction/entries.d.ts +103 -0
  9. package/dist/types/compaction/errors.d.ts +26 -0
  10. package/dist/types/compaction/index.d.ts +11 -0
  11. package/dist/types/compaction/messages.d.ts +61 -0
  12. package/dist/types/compaction/openai.d.ts +58 -0
  13. package/dist/types/compaction/pruning.d.ts +18 -0
  14. package/dist/types/compaction/utils.d.ts +32 -0
  15. package/dist/types/compaction.d.ts +1 -0
  16. package/dist/types/harmony-leak.d.ts +99 -0
  17. package/dist/types/index.d.ts +10 -0
  18. package/dist/types/proxy.d.ts +84 -0
  19. package/dist/types/run-collector.d.ts +196 -0
  20. package/dist/types/telemetry.d.ts +588 -0
  21. package/dist/types/thinking.d.ts +17 -0
  22. package/dist/types/types.d.ts +407 -0
  23. package/package.json +75 -0
  24. package/src/agent-loop.ts +1279 -0
  25. package/src/agent.ts +1399 -0
  26. package/src/append-only-context.ts +297 -0
  27. package/src/compaction/branch-summarization.ts +339 -0
  28. package/src/compaction/compaction.ts +1065 -0
  29. package/src/compaction/entries.ts +133 -0
  30. package/src/compaction/errors.ts +31 -0
  31. package/src/compaction/index.ts +12 -0
  32. package/src/compaction/messages.ts +212 -0
  33. package/src/compaction/openai.ts +552 -0
  34. package/src/compaction/prompts/auto-handoff-threshold-focus.md +1 -0
  35. package/src/compaction/prompts/branch-summary-context.md +5 -0
  36. package/src/compaction/prompts/branch-summary-preamble.md +2 -0
  37. package/src/compaction/prompts/branch-summary.md +30 -0
  38. package/src/compaction/prompts/compaction-short-summary.md +9 -0
  39. package/src/compaction/prompts/compaction-summary-context.md +5 -0
  40. package/src/compaction/prompts/compaction-summary.md +38 -0
  41. package/src/compaction/prompts/compaction-turn-prefix.md +17 -0
  42. package/src/compaction/prompts/compaction-update-summary.md +45 -0
  43. package/src/compaction/prompts/file-operations.md +10 -0
  44. package/src/compaction/prompts/handoff-document.md +49 -0
  45. package/src/compaction/prompts/summarization-system.md +3 -0
  46. package/src/compaction/pruning.ts +92 -0
  47. package/src/compaction/utils.ts +185 -0
  48. package/src/compaction.ts +1 -0
  49. package/src/harmony-leak.ts +427 -0
  50. package/src/index.ts +19 -0
  51. package/src/proxy.ts +326 -0
  52. package/src/run-collector.ts +631 -0
  53. package/src/telemetry.ts +2018 -0
  54. package/src/thinking.ts +19 -0
  55. package/src/types.ts +467 -0
@@ -0,0 +1,2018 @@
1
+ /**
2
+ * OpenTelemetry instrumentation for the agent loop.
3
+ *
4
+ * Implements the OpenTelemetry GenAI semantic conventions
5
+ * (https://opentelemetry.io/docs/specs/semconv/gen-ai/) plus `pi.gen_ai.*`
6
+ * extension attributes for run summaries, dashboard summaries, and cost hints
7
+ * that are useful to downstream observability UIs.
8
+ *
9
+ * Span hierarchy emitted by the loop:
10
+ *
11
+ * invoke_agent {agent.name} (one per runLoop, gen_ai.operation.name=invoke_agent)
12
+ * ├── chat {model} (one per LLM call, gen_ai.operation.name=chat)
13
+ * ├── execute_tool {tool.name} (one per tool call, gen_ai.operation.name=execute_tool)
14
+ * └── ...
15
+ *
16
+ * The `handoff` operation is emitted via the public {@link recordHandoff}
17
+ * helper for hosts that route work between named agents.
18
+ *
19
+ * Activation is opt-in: callers pass an {@link AgentTelemetryConfig} on
20
+ * `AgentLoopConfig.telemetry`. When unset, every helper short-circuits and
21
+ * the loop performs zero tracer lookups. When set but no OTEL SDK is
22
+ * registered, `@opentelemetry/api` returns a no-op tracer and all calls are
23
+ * cheap pass-throughs.
24
+ */
25
+
26
+ import {
27
+ type Api,
28
+ type AssistantMessage,
29
+ type Context,
30
+ completeSimple,
31
+ type Message,
32
+ type Model,
33
+ type ServiceTier,
34
+ type SimpleStreamOptions,
35
+ type StopReason,
36
+ shouldSendServiceTier,
37
+ type ToolChoice,
38
+ type Usage,
39
+ } from "@gajae-code/ai";
40
+ import {
41
+ type Attributes,
42
+ type AttributeValue,
43
+ context,
44
+ type Span,
45
+ SpanKind,
46
+ SpanStatusCode,
47
+ type Tracer,
48
+ trace,
49
+ } from "@opentelemetry/api";
50
+ import { AgentRunCollector, type AgentRunCoverage, type AgentRunSummary, type ToolStatus } from "./run-collector";
51
+ import type { AgentTool } from "./types";
52
+
53
+ /** Default tracer name. Override via {@link AgentTelemetryConfig.tracerName}. */
54
+ export const DEFAULT_TRACER_NAME = "@gajae-code/agent-core";
55
+
56
+ /** Env var matching the OTEL semconv content-capture toggle. */
57
+ const CONTENT_CAPTURE_ENV = "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT";
58
+
59
+ const MAX_TELEMETRY_ARRAY_ITEMS = 64;
60
+ const MAX_TELEMETRY_MESSAGE_COUNT = 16;
61
+ const MAX_TELEMETRY_OBJECT_DEPTH = 3;
62
+ const MAX_TELEMETRY_OBJECT_KEYS = 12;
63
+ const MAX_TELEMETRY_TEXT_CHARS = 240;
64
+
65
+ /**
66
+ * GenAI semantic-convention attribute keys grouped by operation. Hoisted so
67
+ * call sites stay typo-proof and easy to grep.
68
+ */
69
+ export const enum GenAIAttr {
70
+ // Common identifiers
71
+ ProviderName = "gen_ai.provider.name",
72
+ OperationName = "gen_ai.operation.name",
73
+ ConversationId = "gen_ai.conversation.id",
74
+ OutputType = "gen_ai.output.type",
75
+ // Agent identity
76
+ AgentId = "gen_ai.agent.id",
77
+ AgentName = "gen_ai.agent.name",
78
+ AgentDescription = "gen_ai.agent.description",
79
+ // Request shape
80
+ RequestModel = "gen_ai.request.model",
81
+ RequestMaxTokens = "gen_ai.request.max_tokens",
82
+ RequestTemperature = "gen_ai.request.temperature",
83
+ RequestTopP = "gen_ai.request.top_p",
84
+ RequestTopK = "gen_ai.request.top_k",
85
+ RequestFrequencyPenalty = "gen_ai.request.frequency_penalty",
86
+ RequestPresencePenalty = "gen_ai.request.presence_penalty",
87
+ RequestStopSequences = "gen_ai.request.stop_sequences",
88
+ RequestSeed = "gen_ai.request.seed",
89
+ RequestChoiceCount = "gen_ai.request.choice.count",
90
+ RequestStream = "gen_ai.request.stream",
91
+ // Response shape
92
+ ResponseModel = "gen_ai.response.model",
93
+ ResponseId = "gen_ai.response.id",
94
+ ResponseFinishReasons = "gen_ai.response.finish_reasons",
95
+ ResponseTimeToFirstChunk = "gen_ai.response.time_to_first_chunk",
96
+ // Usage
97
+ UsageInputTokens = "gen_ai.usage.input_tokens",
98
+ UsageOutputTokens = "gen_ai.usage.output_tokens",
99
+ UsageCacheReadInputTokens = "gen_ai.usage.cache_read.input_tokens",
100
+ UsageCacheCreationInputTokens = "gen_ai.usage.cache_creation.input_tokens",
101
+ UsageReasoningOutputTokens = "gen_ai.usage.reasoning.output_tokens",
102
+ // Tools
103
+ ToolCallId = "gen_ai.tool.call.id",
104
+ ToolName = "gen_ai.tool.name",
105
+ ToolDescription = "gen_ai.tool.description",
106
+ ToolType = "gen_ai.tool.type",
107
+ ToolCallArguments = "gen_ai.tool.call.arguments",
108
+ ToolCallResult = "gen_ai.tool.call.result",
109
+ ToolDefinitions = "gen_ai.tool.definitions",
110
+ // Content capture (opt-in)
111
+ InputMessages = "gen_ai.input.messages",
112
+ OutputMessages = "gen_ai.output.messages",
113
+ SystemInstructions = "gen_ai.system_instructions",
114
+ // Errors
115
+ ErrorType = "error.type",
116
+ }
117
+
118
+ /** OpenAI semantic-convention attribute keys. */
119
+ export const enum OpenAIAttr {
120
+ RequestServiceTier = "openai.request.service_tier",
121
+ ResponseServiceTier = "openai.response.service_tier",
122
+ }
123
+
124
+ /** Project extension attributes. Kept out of the reserved `gen_ai.*` namespace. */
125
+ export const enum PiGenAIAttr {
126
+ AgentStepNumber = "pi.gen_ai.agent.step.number",
127
+ AgentStepCount = "pi.gen_ai.agent.step.count",
128
+ RequestReasoningEffort = "pi.gen_ai.request.reasoning.effort",
129
+ RequestToolChoice = "pi.gen_ai.request.tool.choice",
130
+ RequestAvailableTools = "pi.gen_ai.request.available_tools",
131
+ RequestMessages = "pi.gen_ai.request.messages",
132
+ ResponseText = "pi.gen_ai.response.text",
133
+ ResponseToolCalls = "pi.gen_ai.response.tool_calls",
134
+ UsageTotalTokens = "pi.gen_ai.usage.total_tokens",
135
+ UsageServerSideTools = "pi.gen_ai.usage.server_tool_requests",
136
+ CostEstimatedUsd = "pi.gen_ai.cost.estimated_usd",
137
+ CostInputUsd = "pi.gen_ai.cost.input_usd",
138
+ CostOutputUsd = "pi.gen_ai.cost.output_usd",
139
+ CostUnavailableReason = "pi.gen_ai.cost.unavailable_reason",
140
+ ToolStatus = "pi.gen_ai.tool.status",
141
+ ToolCallIntent = "pi.gen_ai.tool.call.intent",
142
+ HandoffFromAgentName = "pi.gen_ai.handoff.from_agent.name",
143
+ HandoffFromAgentId = "pi.gen_ai.handoff.from_agent.id",
144
+ HandoffToAgentName = "pi.gen_ai.handoff.to_agent.name",
145
+ HandoffToAgentId = "pi.gen_ai.handoff.to_agent.id",
146
+ // Marks chat spans emitted outside the agent loop (compaction, handoff, branch
147
+ // summary, image inspection, …). Lets dashboards split oneshot cost / latency
148
+ // from main-turn cost without overloading the semconv `gen_ai.operation.name`.
149
+ OneshotKind = "pi.gen_ai.oneshot.kind",
150
+ // Gateway / proxy (LiteLLM, Helicone, Portkey, …) — populated when a known
151
+ // gateway header pattern is detected on the upstream response. The base
152
+ // `gen_ai.provider.name` continues to track the *upstream* provider (e.g.
153
+ // `anthropic`) that the gateway routed to.
154
+ GatewayName = "pi.gen_ai.gateway.name",
155
+ GatewayEndpoint = "pi.gen_ai.gateway.endpoint",
156
+ GatewayCallId = "pi.gen_ai.gateway.call_id",
157
+ GatewayRoutedTo = "pi.gen_ai.gateway.routed_to",
158
+ }
159
+
160
+ /** GenAI operation names — values for {@link GenAIAttr.OperationName}. */
161
+ export const GenAIOperation = {
162
+ Chat: "chat",
163
+ ExecuteTool: "execute_tool",
164
+ InvokeAgent: "invoke_agent",
165
+ Handoff: "handoff",
166
+ GenerateContent: "generate_content",
167
+ TextCompletion: "text_completion",
168
+ CreateAgent: "create_agent",
169
+ Embeddings: "embeddings",
170
+ } as const;
171
+
172
+ export type GenAIOperationName = (typeof GenAIOperation)[keyof typeof GenAIOperation];
173
+
174
+ /** Identifies which agent span a callback is reporting on. */
175
+ export type TelemetrySpanKind = "invoke_agent" | "chat" | "execute_tool" | "handoff";
176
+
177
+ /**
178
+ * Aggregated usage + cost surface passed to {@link AgentTelemetryConfig.costEstimator}.
179
+ * Mirrors the bucketed shape we already emit as span attributes so the
180
+ * estimator never has to re-derive cache-read vs cache-write breakdowns.
181
+ */
182
+ export interface ChatUsageSnapshot {
183
+ readonly inputTokens: number;
184
+ readonly outputTokens: number;
185
+ readonly totalTokens: number;
186
+ readonly cachedInputTokens: number | undefined;
187
+ readonly cacheWriteTokens: number | undefined;
188
+ readonly reasoningOutputTokens: number | undefined;
189
+ }
190
+
191
+ /** Context passed to the cost estimator. */
192
+ export interface CostEstimatorContext {
193
+ readonly provider: string;
194
+ readonly model: string;
195
+ readonly serviceTier: ServiceTier | undefined;
196
+ readonly usage: ChatUsageSnapshot;
197
+ }
198
+
199
+ /**
200
+ * Cost estimator result.
201
+ * { usd: number } — cost is known; emitted as pi.gen_ai.cost.estimated_usd
202
+ * { unavailable: string } — cost is intentionally unknown; emitted as
203
+ * pi.gen_ai.cost.unavailable_reason
204
+ * undefined — no opinion; nothing emitted
205
+ */
206
+ export type CostEstimate =
207
+ | { readonly usd: number; readonly inputUsd?: number; readonly outputUsd?: number }
208
+ | { readonly unavailable: string };
209
+
210
+ export interface CostDelta {
211
+ readonly conversationId: string | undefined;
212
+ readonly agent: AgentIdentity | undefined;
213
+ readonly stepNumber: number | undefined;
214
+ readonly provider: string;
215
+ readonly model: string;
216
+ readonly serviceTier: ServiceTier | undefined;
217
+ readonly usage: ChatUsageSnapshot;
218
+ readonly costUsd: number | undefined;
219
+ readonly inputUsd: number | undefined;
220
+ readonly outputUsd: number | undefined;
221
+ readonly costUnavailableReason: string | undefined;
222
+ }
223
+
224
+ /**
225
+ * Event fired for every chat step that produced usage, regardless of whether
226
+ * a {@link AgentTelemetryConfig.costEstimator} is configured. Use this to
227
+ * forward token usage to metrics pipelines or dashboards without taking a
228
+ * dependency on the cost estimator path.
229
+ */
230
+ export interface ChatUsageEvent {
231
+ readonly span: Span;
232
+ readonly agent: AgentIdentity | undefined;
233
+ readonly conversationId: string | undefined;
234
+ readonly stepNumber: number | undefined;
235
+ readonly model: string;
236
+ readonly provider: string | undefined;
237
+ readonly serviceTier: ServiceTier | undefined;
238
+ readonly usage: ChatUsageSnapshot;
239
+ readonly cost: CostEstimate | undefined;
240
+ /** Resolved dynamic attributes for this chat span (from `resolveAttributes`). */
241
+ readonly attributes: Attributes | undefined;
242
+ /**
243
+ * Response headers captured from the upstream HTTP response, with keys
244
+ * lowercased (mirrors {@link ProviderResponseMetadata.headers}). `undefined`
245
+ * when the provider transport did not surface headers (non-HTTP providers,
246
+ * mocked streams, requests that aborted before headers arrived).
247
+ *
248
+ * Use this to reconcile gateway-issued ids (e.g. `x-litellm-call-id`) with
249
+ * downstream billing / spend dashboards. Known gateway patterns are also
250
+ * auto-stamped on the chat span as `pi.gen_ai.gateway.*` attributes.
251
+ */
252
+ readonly headers: Readonly<Record<string, string>> | undefined;
253
+ }
254
+
255
+ export type TelemetryContentCapture = boolean | "none" | "summary" | "full";
256
+
257
+ export type ResolvedTelemetryContentCapture = "none" | "summary" | "full";
258
+
259
+ export interface TelemetryContentSerializer {
260
+ readonly requestMessages?: (request: ChatRequestSnapshot) => string | undefined;
261
+ readonly responseText?: (message: AssistantMessage) => string | undefined;
262
+ readonly responseToolCalls?: (message: AssistantMessage) => string | undefined;
263
+ readonly toolCallArguments?: (args: unknown) => string | undefined;
264
+ readonly toolCallResult?: (result: unknown) => string | undefined;
265
+ }
266
+
267
+ /** Identity recorded on every invoke_agent and on emitted handoff spans. */
268
+ export interface AgentIdentity {
269
+ readonly id?: string;
270
+ readonly name?: string;
271
+ readonly description?: string;
272
+ }
273
+
274
+ export interface AgentTelemetryWarning {
275
+ readonly code:
276
+ | "resolve_attributes_failed"
277
+ | "content_serializer_failed"
278
+ | "on_cost_delta_failed"
279
+ | "on_chat_usage_failed"
280
+ | "cost_estimator_failed"
281
+ | "on_run_end_failed"
282
+ | "on_span_start_failed"
283
+ | "on_span_end_failed"
284
+ | "normalize_agent_name_failed"
285
+ | "normalize_provider_failed"
286
+ | "on_telemetry_warning_failed";
287
+ readonly message: string;
288
+ readonly error?: unknown;
289
+ }
290
+
291
+ /** Context passed to attribute resolvers and lifecycle hooks. */
292
+ export interface TelemetryAttributeContext {
293
+ readonly kind: TelemetrySpanKind;
294
+ readonly model: Model | undefined;
295
+ readonly agent: AgentIdentity | undefined;
296
+ readonly conversationId: string | undefined;
297
+ /** Per-step number on chat spans (0-indexed); undefined on other kinds. */
298
+ readonly stepNumber?: number;
299
+ /** Tool call info on execute_tool spans. */
300
+ readonly toolCallId?: string;
301
+ readonly toolName?: string;
302
+ }
303
+
304
+ /** Context passed to {@link AgentTelemetryConfig.onSpanStart} / `onSpanEnd`. */
305
+ export interface TelemetryHookContext extends TelemetryAttributeContext {
306
+ readonly span: Span;
307
+ }
308
+ /**
309
+ * Opt-in OpenTelemetry configuration accepted by the agent loop.
310
+ *
311
+ * All fields are optional. Passing the empty object `{}` enables
312
+ * instrumentation with sensible defaults. Pass `undefined` (or omit the
313
+ * `telemetry` field entirely) to disable everything — the loop performs zero
314
+ * tracer lookups in that case.
315
+ */
316
+ export interface AgentTelemetryConfig {
317
+ /**
318
+ * Override the tracer instance. When omitted, the loop calls
319
+ * `trace.getTracer(tracerName ?? DEFAULT_TRACER_NAME)` lazily on first use.
320
+ */
321
+ readonly tracer?: Tracer;
322
+ /** Override the tracer name passed to `trace.getTracer`. */
323
+ readonly tracerName?: string;
324
+ /**
325
+ * Capture request/response content. `true` preserves the historical full
326
+ * payload capture; `"summary"` emits bounded dashboard-friendly summaries;
327
+ * `"full"` emits both summaries and full OTEL message payloads.
328
+ *
329
+ * Defaults to the value of the `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`
330
+ * env var (`true`/`1`/`yes` => `"full"`, `"summary"` => `"summary"`).
331
+ */
332
+ readonly captureMessageContent?: TelemetryContentCapture;
333
+ /** Extra attributes merged onto every emitted span. */
334
+ readonly attributes?: Attributes;
335
+ /**
336
+ * Attribute resolver merged onto every emitted span after static
337
+ * `attributes` and before span-specific attributes. Use this for ambient
338
+ * run, tenant, deployment, or request metadata.
339
+ */
340
+ readonly resolveAttributes?: (ctx: TelemetryAttributeContext) => Attributes | undefined;
341
+ /** Agent identity stamped onto invoke_agent + propagated to children. */
342
+ readonly agent?: AgentIdentity;
343
+ /**
344
+ * Conversation identifier. When omitted, the loop falls back to
345
+ * `AgentLoopConfig.sessionId` for the `gen_ai.conversation.id` attribute.
346
+ */
347
+ readonly conversationId?: string;
348
+ /**
349
+ * Per-step cost estimator. Synchronous on purpose — runs inside the chat
350
+ * span's finish path. Return `undefined` to emit no cost attribute.
351
+ */
352
+ readonly costEstimator?: (input: CostEstimatorContext) => CostEstimate | undefined;
353
+ /** Called after cost estimation for a chat step. */
354
+ readonly onCostDelta?: (delta: CostDelta) => void;
355
+ /**
356
+ * Fired once per chat step that produced usage, regardless of whether a
357
+ * {@link costEstimator} is configured. Use this for usage-only metrics
358
+ * pipelines (token counters, cache-hit ratios) without paying the cost of
359
+ * estimating dollars per call.
360
+ *
361
+ * **Non-fatal.** Synchronous and asynchronous failures are caught, surfaced
362
+ * via {@link onTelemetryWarning}, and swallowed.
363
+ */
364
+ readonly onChatUsage?: (event: ChatUsageEvent) => void | Promise<void>;
365
+ /** Override provider labels before they are emitted or passed to cost hooks. */
366
+ readonly normalizeProvider?: (provider: string | undefined) => string | undefined;
367
+ /** Override agent names before they are emitted on spans. */
368
+ readonly normalizeAgentName?: (name: string | undefined) => string | undefined;
369
+ /** Override the default bounded JSON serializers used by summary capture. */
370
+ readonly contentSerializer?: TelemetryContentSerializer;
371
+ /**
372
+ * Called immediately after a span starts. Use to stamp request-side
373
+ * context (user id, deployment id, route name) without forking the loop.
374
+ */
375
+ readonly onSpanStart?: (ctx: TelemetryHookContext) => void;
376
+ /**
377
+ * Called just before `span.end()`. Use to stamp response-side context
378
+ * that depends on the final result.
379
+ */
380
+ readonly onSpanEnd?: (ctx: TelemetryHookContext) => void;
381
+ /**
382
+ * Fired once per `invoke_agent`, immediately after the run-level summary
383
+ * is built and aggregate attributes are stamped on the `invoke_agent`
384
+ * span. Use this to persist, log, or forward the {@link AgentRunSummary} /
385
+ * {@link AgentRunCoverage} value without parsing OTEL spans.
386
+ *
387
+ * **Non-fatal.** Exceptions thrown from this callback are caught, logged
388
+ * via `console.warn`, and swallowed — a misbehaving telemetry consumer can
389
+ * NEVER turn a successful agent run into a failed one.
390
+ */
391
+ readonly onRunEnd?: (summary: AgentRunSummary, coverage: AgentRunCoverage) => void;
392
+ /** Receives non-fatal telemetry callback failures and host-defined warnings. */
393
+ readonly onTelemetryWarning?: (warning: AgentTelemetryWarning) => void;
394
+ }
395
+
396
+ /**
397
+ * Public handle used internally to thread the resolved tracer + config
398
+ * through the loop. Constructed once per `agentLoop` invocation.
399
+ */
400
+ export interface AgentTelemetry {
401
+ readonly config: AgentTelemetryConfig;
402
+ readonly tracer: Tracer;
403
+ readonly captureMessageContent: boolean;
404
+ readonly contentCapture: ResolvedTelemetryContentCapture;
405
+ readonly conversationId: string | undefined;
406
+ readonly agent: AgentIdentity | undefined;
407
+ /** Per-invocation event collector. See {@link AgentRunCollector}. */
408
+ readonly collector: AgentRunCollector;
409
+ }
410
+
411
+ /** Lazily resolve the {@link AgentTelemetry} handle. Returns `undefined` when disabled. */
412
+ export function resolveTelemetry(
413
+ config: AgentTelemetryConfig | undefined,
414
+ sessionId: string | undefined,
415
+ ): AgentTelemetry | undefined {
416
+ if (!config) return undefined;
417
+ const tracer = config.tracer ?? trace.getTracer(config.tracerName ?? DEFAULT_TRACER_NAME);
418
+ const contentCapture = resolveContentCapture(config.captureMessageContent);
419
+ return {
420
+ config,
421
+ tracer,
422
+ captureMessageContent: contentCapture === "full",
423
+ contentCapture,
424
+ conversationId: config.conversationId ?? sessionId,
425
+ agent: config.agent,
426
+ collector: new AgentRunCollector(),
427
+ };
428
+ }
429
+
430
+ let contentCaptureEnvCache: ResolvedTelemetryContentCapture | undefined;
431
+ function readContentCaptureEnv(): ResolvedTelemetryContentCapture {
432
+ if (contentCaptureEnvCache !== undefined) return contentCaptureEnvCache;
433
+ const raw = process.env[CONTENT_CAPTURE_ENV];
434
+ if (!raw) {
435
+ contentCaptureEnvCache = "none";
436
+ return "none";
437
+ }
438
+ const normalized = raw.trim().toLowerCase();
439
+ if (normalized === "summary") {
440
+ contentCaptureEnvCache = "summary";
441
+ } else {
442
+ contentCaptureEnvCache =
443
+ normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "full" ? "full" : "none";
444
+ }
445
+ return contentCaptureEnvCache;
446
+ }
447
+
448
+ function resolveContentCapture(value: TelemetryContentCapture | undefined): ResolvedTelemetryContentCapture {
449
+ const capture = value ?? readContentCaptureEnv();
450
+ if (capture === true || capture === "full") return "full";
451
+ if (capture === "summary") return "summary";
452
+ return "none";
453
+ }
454
+
455
+ /**
456
+ * Start a span with the standard attribute envelope (provider, operation,
457
+ * conversation, agent identity, user-supplied extras) pre-applied. Returns
458
+ * `undefined` when telemetry is disabled.
459
+ */
460
+ function startSpan(
461
+ telemetry: AgentTelemetry | undefined,
462
+ kind: TelemetrySpanKind,
463
+ name: string,
464
+ options: {
465
+ readonly spanKind: SpanKind;
466
+ readonly model?: Model;
467
+ readonly parent?: Span;
468
+ readonly attributes?: Attributes;
469
+ readonly stepNumber?: number;
470
+ readonly toolCallId?: string;
471
+ readonly toolName?: string;
472
+ },
473
+ ): Span | undefined {
474
+ if (!telemetry) return undefined;
475
+ const attrCtx = buildTelemetryAttributeContext(telemetry, kind, options);
476
+ const attrs: Attributes = {};
477
+ const operation = kindToOperation(kind);
478
+ if (operation) attrs[GenAIAttr.OperationName] = operation;
479
+ if (options.model) {
480
+ attrs[GenAIAttr.RequestModel] = options.model.id;
481
+ const provider = normalizeProviderName(telemetry, options.model.provider);
482
+ if (provider) attrs[GenAIAttr.ProviderName] = provider;
483
+ }
484
+ if (telemetry.conversationId) {
485
+ attrs[GenAIAttr.ConversationId] = telemetry.conversationId;
486
+ }
487
+ if (attrCtx.agent) applyAgentAttributes(attrs, attrCtx.agent);
488
+ if (telemetry.config.attributes) Object.assign(attrs, telemetry.config.attributes);
489
+ const dynamicAttributes = resolveDynamicAttributes(telemetry, attrCtx);
490
+ if (dynamicAttributes) Object.assign(attrs, dynamicAttributes);
491
+ if (options.attributes) Object.assign(attrs, options.attributes);
492
+
493
+ const ctx = options.parent ? trace.setSpan(context.active(), options.parent) : context.active();
494
+ const span = telemetry.tracer.startSpan(name, { kind: options.spanKind, attributes: attrs }, ctx);
495
+ safeOnSpanStart(telemetry, { ...attrCtx, span });
496
+ return span;
497
+ }
498
+
499
+ function buildTelemetryAttributeContext(
500
+ telemetry: AgentTelemetry,
501
+ kind: TelemetrySpanKind,
502
+ options: {
503
+ readonly model?: Model;
504
+ readonly stepNumber?: number;
505
+ readonly toolCallId?: string;
506
+ readonly toolName?: string;
507
+ },
508
+ ): TelemetryAttributeContext {
509
+ return {
510
+ kind,
511
+ model: options.model,
512
+ agent: normalizedTelemetryAgent(telemetry),
513
+ conversationId: telemetry.conversationId,
514
+ stepNumber: options.stepNumber,
515
+ toolCallId: options.toolCallId,
516
+ toolName: options.toolName,
517
+ };
518
+ }
519
+
520
+ function resolveDynamicAttributes(telemetry: AgentTelemetry, ctx: TelemetryAttributeContext): Attributes | undefined {
521
+ const resolver = telemetry.config.resolveAttributes;
522
+ if (!resolver) return undefined;
523
+ try {
524
+ return resolver(ctx);
525
+ } catch (err) {
526
+ emitTelemetryWarning(telemetry, {
527
+ code: "resolve_attributes_failed",
528
+ message: "resolveAttributes threw; ignoring dynamic telemetry attributes",
529
+ error: err,
530
+ });
531
+ return undefined;
532
+ }
533
+ }
534
+
535
+ function kindToOperation(kind: TelemetrySpanKind): GenAIOperationName | undefined {
536
+ switch (kind) {
537
+ case "invoke_agent":
538
+ return GenAIOperation.InvokeAgent;
539
+ case "chat":
540
+ return GenAIOperation.Chat;
541
+ case "execute_tool":
542
+ return GenAIOperation.ExecuteTool;
543
+ case "handoff":
544
+ return GenAIOperation.Handoff;
545
+ }
546
+ }
547
+
548
+ function applyAgentAttributes(attrs: Attributes, agent: AgentIdentity): void {
549
+ if (agent.id) attrs[GenAIAttr.AgentId] = agent.id;
550
+ if (agent.name) attrs[GenAIAttr.AgentName] = agent.name;
551
+ if (agent.description) attrs[GenAIAttr.AgentDescription] = agent.description;
552
+ }
553
+
554
+ function normalizeProviderName(
555
+ telemetry: AgentTelemetry | undefined,
556
+ provider: string | undefined,
557
+ ): string | undefined {
558
+ const otelProvider = mapProviderNameToOtel(provider);
559
+ const normalize = telemetry?.config.normalizeProvider;
560
+ if (!normalize) return otelProvider;
561
+ try {
562
+ return normalize(provider) ?? otelProvider;
563
+ } catch (err) {
564
+ emitTelemetryWarning(telemetry, {
565
+ code: "normalize_provider_failed",
566
+ message: "normalizeProvider threw; using the OTEL provider label",
567
+ error: err,
568
+ });
569
+ return otelProvider;
570
+ }
571
+ }
572
+
573
+ function mapProviderNameToOtel(provider: string | undefined): string | undefined {
574
+ switch (provider) {
575
+ case undefined:
576
+ case "":
577
+ return provider;
578
+ case "amazon-bedrock":
579
+ return "aws.bedrock";
580
+ case "google":
581
+ case "google-antigravity":
582
+ case "google-gemini-cli":
583
+ return "gcp.gemini";
584
+ case "google-vertex":
585
+ return "gcp.vertex_ai";
586
+ case "mistral":
587
+ return "mistral_ai";
588
+ case "openai-codex":
589
+ return "openai";
590
+ case "xai":
591
+ return "x_ai";
592
+ default:
593
+ return provider;
594
+ }
595
+ }
596
+
597
+ function normalizeAgentIdentity(telemetry: AgentTelemetry, agent: AgentIdentity): AgentIdentity {
598
+ const normalize = telemetry.config.normalizeAgentName;
599
+ if (!normalize || !agent.name) return agent;
600
+ try {
601
+ const name = normalize(agent.name);
602
+ if (name === agent.name) return agent;
603
+ return {
604
+ ...agent,
605
+ name,
606
+ };
607
+ } catch (err) {
608
+ emitTelemetryWarning(telemetry, {
609
+ code: "normalize_agent_name_failed",
610
+ message: "normalizeAgentName threw; using the original agent name",
611
+ error: err,
612
+ });
613
+ return agent;
614
+ }
615
+ }
616
+
617
+ function normalizedTelemetryAgent(telemetry: AgentTelemetry | undefined): AgentIdentity | undefined {
618
+ return telemetry?.agent ? normalizeAgentIdentity(telemetry, telemetry.agent) : undefined;
619
+ }
620
+
621
+ export function recordTelemetryWarning(telemetry: AgentTelemetry | undefined, warning: AgentTelemetryWarning): void {
622
+ emitTelemetryWarning(telemetry, warning);
623
+ }
624
+
625
+ function emitTelemetryWarning(telemetry: AgentTelemetry | undefined, warning: AgentTelemetryWarning): void {
626
+ const hook = telemetry?.config.onTelemetryWarning;
627
+ if (!hook) {
628
+ if (warning.error === undefined) console.warn(`[pi-agent] ${warning.message}`);
629
+ else console.warn(`[pi-agent] ${warning.message}`, warning.error);
630
+ return;
631
+ }
632
+ try {
633
+ hook(warning);
634
+ } catch (err) {
635
+ console.warn("[pi-agent] onTelemetryWarning threw; swallowing:", err);
636
+ }
637
+ }
638
+
639
+ function safeOnSpanStart(telemetry: AgentTelemetry | undefined, ctx: TelemetryHookContext): void {
640
+ const hook = telemetry?.config.onSpanStart;
641
+ if (!hook) return;
642
+ try {
643
+ hook(ctx);
644
+ } catch (err) {
645
+ emitTelemetryWarning(telemetry, {
646
+ code: "on_span_start_failed",
647
+ message: "onSpanStart threw; swallowing telemetry hook failure",
648
+ error: err,
649
+ });
650
+ }
651
+ }
652
+
653
+ function safeOnSpanEnd(telemetry: AgentTelemetry | undefined, ctx: TelemetryHookContext): void {
654
+ const hook = telemetry?.config.onSpanEnd;
655
+ if (!hook) return;
656
+ try {
657
+ hook(ctx);
658
+ } catch (err) {
659
+ emitTelemetryWarning(telemetry, {
660
+ code: "on_span_end_failed",
661
+ message: "onSpanEnd threw; swallowing telemetry hook failure",
662
+ error: err,
663
+ });
664
+ }
665
+ }
666
+
667
+ /**
668
+ * Start the outer `invoke_agent` span that wraps a full `runLoop` invocation.
669
+ * Returns `undefined` when telemetry is disabled.
670
+ */
671
+ export function startInvokeAgentSpan(telemetry: AgentTelemetry | undefined, model: Model): Span | undefined {
672
+ const agentName = telemetry?.agent ? normalizeAgentIdentity(telemetry, telemetry.agent).name : undefined;
673
+ const name = agentName ? `invoke_agent ${agentName}` : "invoke_agent";
674
+ return startSpan(telemetry, "invoke_agent", name, { spanKind: SpanKind.INTERNAL, model });
675
+ }
676
+
677
+ /** Stamp the final step count on the `invoke_agent` span. */
678
+ export function applyInvokeAgentFinish(span: Span | undefined, stepCount: number): void {
679
+ if (!span) return;
680
+ span.setAttribute(PiGenAIAttr.AgentStepCount, stepCount);
681
+ }
682
+
683
+ /**
684
+ * Start a `chat` span representing one provider call. Parented under the
685
+ * supplied `invoke_agent` span (or whatever is active if none is passed).
686
+ */
687
+ export function startChatSpan(
688
+ telemetry: AgentTelemetry | undefined,
689
+ model: Model,
690
+ options: {
691
+ readonly parent?: Span;
692
+ readonly stepNumber: number;
693
+ readonly request: ChatRequestSnapshot;
694
+ },
695
+ ): Span | undefined {
696
+ const span = startSpan(telemetry, "chat", `chat ${model.id}`, {
697
+ spanKind: SpanKind.CLIENT,
698
+ model,
699
+ parent: options.parent,
700
+ stepNumber: options.stepNumber,
701
+ attributes: buildChatRequestAttributes(options.stepNumber, options.request, model.provider),
702
+ });
703
+ if (span) {
704
+ telemetry?.collector.beginChat(span, {
705
+ stepNumber: options.stepNumber,
706
+ model,
707
+ provider: normalizeProviderName(telemetry, model.provider),
708
+ });
709
+ telemetry?.collector.noteAvailableTools(options.request.tools);
710
+ if (telemetry && telemetry.contentCapture !== "none") {
711
+ applyContentCaptureForRequest(telemetry, span, options.request);
712
+ }
713
+ }
714
+ return span;
715
+ }
716
+
717
+ /** Mutable snapshot of every request-side field worth recording. */
718
+ export interface ChatRequestSnapshot {
719
+ readonly maxTokens?: number;
720
+ readonly temperature?: number;
721
+ readonly topP?: number;
722
+ readonly topK?: number;
723
+ readonly frequencyPenalty?: number;
724
+ readonly presencePenalty?: number;
725
+ readonly stopSequences?: readonly string[];
726
+ readonly seed?: number;
727
+ readonly serviceTier?: ServiceTier;
728
+ readonly reasoningEffort?: string;
729
+ readonly toolChoice?: ToolChoice;
730
+ readonly tools?: readonly { readonly name: string }[];
731
+ readonly systemPrompt?: readonly string[];
732
+ readonly messages?: readonly Message[];
733
+ }
734
+
735
+ function buildChatRequestAttributes(stepNumber: number, request: ChatRequestSnapshot, provider: string): Attributes {
736
+ const attrs: Attributes = {
737
+ [PiGenAIAttr.AgentStepNumber]: stepNumber,
738
+ [GenAIAttr.OutputType]: "text",
739
+ [GenAIAttr.RequestStream]: true,
740
+ };
741
+ if (request.maxTokens != null) attrs[GenAIAttr.RequestMaxTokens] = request.maxTokens;
742
+ if (request.temperature != null) attrs[GenAIAttr.RequestTemperature] = request.temperature;
743
+ if (request.topP != null) attrs[GenAIAttr.RequestTopP] = request.topP;
744
+ if (request.topK != null) attrs[GenAIAttr.RequestTopK] = request.topK;
745
+ if (request.frequencyPenalty != null) attrs[GenAIAttr.RequestFrequencyPenalty] = request.frequencyPenalty;
746
+ if (request.presencePenalty != null) attrs[GenAIAttr.RequestPresencePenalty] = request.presencePenalty;
747
+ if (request.seed != null) attrs[GenAIAttr.RequestSeed] = request.seed;
748
+ if (request.stopSequences && request.stopSequences.length > 0) {
749
+ attrs[GenAIAttr.RequestStopSequences] = [...request.stopSequences];
750
+ }
751
+ if (request.serviceTier && shouldSendServiceTier(request.serviceTier, provider)) {
752
+ attrs[OpenAIAttr.RequestServiceTier] = request.serviceTier;
753
+ }
754
+ if (request.reasoningEffort) attrs[PiGenAIAttr.RequestReasoningEffort] = request.reasoningEffort;
755
+ const toolChoice = serializeToolChoice(request.toolChoice);
756
+ if (toolChoice) attrs[PiGenAIAttr.RequestToolChoice] = toolChoice;
757
+ if (request.tools && request.tools.length > 0) {
758
+ attrs[PiGenAIAttr.RequestAvailableTools] = request.tools.map(tool => tool.name);
759
+ }
760
+ return attrs;
761
+ }
762
+
763
+ function serializeToolChoice(toolChoice: ToolChoice | undefined): string | undefined {
764
+ if (toolChoice == null) return undefined;
765
+ if (typeof toolChoice === "string") return toolChoice;
766
+ if (typeof toolChoice === "object") {
767
+ // `{ type: "tool", name: "foo" }` shapes used across providers.
768
+ if ("name" in toolChoice && typeof toolChoice.name === "string") return toolChoice.name;
769
+ if ("type" in toolChoice && typeof toolChoice.type === "string") return toolChoice.type;
770
+ }
771
+ return undefined;
772
+ }
773
+
774
+ function applyContentCaptureForRequest(telemetry: AgentTelemetry, span: Span, request: ChatRequestSnapshot): void {
775
+ const requestMessages = serializeRequestMessagesForTelemetry(telemetry, request);
776
+ if (requestMessages) span.setAttribute(PiGenAIAttr.RequestMessages, requestMessages);
777
+ if (telemetry.contentCapture !== "full") return;
778
+ const systemInstructions = serializeFullSystemInstructionsForTelemetry(request);
779
+ if (systemInstructions) span.setAttribute(GenAIAttr.SystemInstructions, systemInstructions);
780
+ const inputMessages = serializeFullInputMessagesForTelemetry(request);
781
+ if (inputMessages) span.setAttribute(GenAIAttr.InputMessages, inputMessages);
782
+ }
783
+
784
+ function applyContentCaptureForResponse(telemetry: AgentTelemetry, span: Span, message: AssistantMessage): void {
785
+ const responseText = serializeResponseTextForTelemetry(telemetry, message);
786
+ if (responseText) span.setAttribute(PiGenAIAttr.ResponseText, responseText);
787
+ const responseToolCalls = serializeResponseToolCallsForTelemetry(telemetry, message);
788
+ if (responseToolCalls) span.setAttribute(PiGenAIAttr.ResponseToolCalls, responseToolCalls);
789
+ if (telemetry.contentCapture === "full") {
790
+ const outputMessages = serializeFullOutputMessagesForTelemetry(message);
791
+ if (outputMessages) span.setAttribute(GenAIAttr.OutputMessages, outputMessages);
792
+ }
793
+ }
794
+
795
+ function serializeRequestMessagesForTelemetry(
796
+ telemetry: AgentTelemetry,
797
+ request: ChatRequestSnapshot,
798
+ ): string | undefined {
799
+ const serializer = telemetry.config.contentSerializer?.requestMessages;
800
+ if (serializer) return callContentSerializer(telemetry, "requestMessages", () => serializer(request));
801
+ const messages: TelemetryMessageSummary[] = [];
802
+ if (request.systemPrompt) {
803
+ for (const text of request.systemPrompt)
804
+ messages.push({ role: "system", content: summarizeTelemetryValue(text) });
805
+ }
806
+ if (request.messages) {
807
+ for (const message of request.messages) {
808
+ messages.push({ role: message.role, content: summarizeTelemetryValue(message.content) });
809
+ }
810
+ }
811
+ return messages.length === 0 ? undefined : stringifyJsonAttribute(limitTelemetryMessages(messages));
812
+ }
813
+
814
+ function serializeResponseTextForTelemetry(telemetry: AgentTelemetry, message: AssistantMessage): string | undefined {
815
+ const serializer = telemetry.config.contentSerializer?.responseText;
816
+ if (serializer) return callContentSerializer(telemetry, "responseText", () => serializer(message));
817
+ const texts: string[] = [];
818
+ for (const part of message.content) {
819
+ if (part.type === "text") texts.push(part.text);
820
+ }
821
+ return texts.length === 0 ? undefined : stringifyJsonAttribute(summarizeTelemetryTexts(texts));
822
+ }
823
+
824
+ function serializeResponseToolCallsForTelemetry(
825
+ telemetry: AgentTelemetry,
826
+ message: AssistantMessage,
827
+ ): string | undefined {
828
+ const serializer = telemetry.config.contentSerializer?.responseToolCalls;
829
+ if (serializer) return callContentSerializer(telemetry, "responseToolCalls", () => serializer(message));
830
+ const toolCalls: TelemetryToolCallSummary[] = [];
831
+ for (const part of message.content) {
832
+ if (part.type === "toolCall") {
833
+ toolCalls.push({
834
+ input: summarizeTelemetryValue(part.arguments),
835
+ toolCallId: part.id,
836
+ toolName: part.name,
837
+ });
838
+ }
839
+ }
840
+ return toolCalls.length === 0 ? undefined : stringifyJsonAttribute(limitTelemetryToolCalls(toolCalls));
841
+ }
842
+
843
+ interface TelemetryMessageSummary {
844
+ readonly role: string;
845
+ readonly content: unknown;
846
+ }
847
+
848
+ interface TelemetryToolCallSummary {
849
+ readonly toolCallId: string;
850
+ readonly toolName: string;
851
+ readonly input: unknown;
852
+ }
853
+
854
+ type OtelMessagePart =
855
+ | { readonly type: "text"; readonly content: string }
856
+ | { readonly type: "reasoning"; readonly content: string }
857
+ | { readonly type: "blob"; readonly modality: "image"; readonly mime_type: string; readonly content: string }
858
+ | { readonly type: "tool_call"; readonly id?: string; readonly name: string; readonly arguments?: unknown }
859
+ | { readonly type: "tool_call_response"; readonly id?: string; readonly response: unknown }
860
+ | { readonly type: string; readonly [key: string]: unknown };
861
+
862
+ interface OtelInputMessage {
863
+ readonly role: string;
864
+ readonly parts: readonly OtelMessagePart[];
865
+ readonly name?: string;
866
+ }
867
+
868
+ interface OtelOutputMessage extends OtelInputMessage {
869
+ readonly finish_reason: string;
870
+ }
871
+
872
+ function serializeFullSystemInstructionsForTelemetry(request: ChatRequestSnapshot): string | undefined {
873
+ const systemPrompt = request.systemPrompt;
874
+ if (!systemPrompt || systemPrompt.length === 0) return undefined;
875
+ return stringifyJsonAttribute(systemPrompt.map(text => ({ type: "text", content: text }) satisfies OtelMessagePart));
876
+ }
877
+
878
+ function serializeFullInputMessagesForTelemetry(request: ChatRequestSnapshot): string | undefined {
879
+ const messages = request.messages;
880
+ if (!messages || messages.length === 0) return undefined;
881
+ return stringifyJsonAttribute(messages.map(messageToOtelInputMessage));
882
+ }
883
+
884
+ function serializeFullOutputMessagesForTelemetry(message: AssistantMessage): string | undefined {
885
+ return stringifyJsonAttribute([assistantMessageToOtelOutputMessage(message)]);
886
+ }
887
+
888
+ function messageToOtelInputMessage(message: Message): OtelInputMessage {
889
+ switch (message.role) {
890
+ case "assistant":
891
+ return { role: "assistant", parts: assistantContentToOtelParts(message.content) };
892
+ case "toolResult":
893
+ return {
894
+ role: "tool",
895
+ name: message.toolName,
896
+ parts: [
897
+ {
898
+ type: "tool_call_response",
899
+ id: message.toolCallId,
900
+ response: {
901
+ content: textOrImageContentToOtelParts(message.content),
902
+ details: message.details,
903
+ is_error: message.isError,
904
+ },
905
+ },
906
+ ],
907
+ };
908
+ default:
909
+ return { role: message.role, parts: textOrImageContentToOtelParts(message.content) };
910
+ }
911
+ }
912
+
913
+ function assistantMessageToOtelOutputMessage(message: AssistantMessage): OtelOutputMessage {
914
+ return {
915
+ role: "assistant",
916
+ parts: assistantContentToOtelParts(message.content),
917
+ finish_reason: mapStopReason(message.stopReason) ?? message.stopReason ?? "stop",
918
+ };
919
+ }
920
+
921
+ function textOrImageContentToOtelParts(content: Message["content"]): OtelMessagePart[] {
922
+ if (typeof content === "string") return [{ type: "text", content }];
923
+ const parts: OtelMessagePart[] = [];
924
+ for (const part of content) {
925
+ switch (part.type) {
926
+ case "text":
927
+ parts.push({ type: "text", content: part.text });
928
+ break;
929
+ case "image":
930
+ parts.push({ type: "blob", modality: "image", mime_type: part.mimeType, content: part.data });
931
+ break;
932
+ case "thinking":
933
+ parts.push({ type: "reasoning", content: part.thinking });
934
+ break;
935
+ case "redactedThinking":
936
+ parts.push({ type: "reasoning", content: part.data });
937
+ break;
938
+ case "toolCall":
939
+ parts.push({ type: "tool_call", id: part.id, name: part.name, arguments: part.arguments });
940
+ break;
941
+ default:
942
+ break;
943
+ }
944
+ }
945
+ return parts;
946
+ }
947
+
948
+ function assistantContentToOtelParts(content: AssistantMessage["content"]): OtelMessagePart[] {
949
+ const parts: OtelMessagePart[] = [];
950
+ for (const part of content) {
951
+ switch (part.type) {
952
+ case "text":
953
+ parts.push({ type: "text", content: part.text });
954
+ break;
955
+ case "thinking":
956
+ parts.push({ type: "reasoning", content: part.thinking });
957
+ break;
958
+ case "redactedThinking":
959
+ parts.push({ type: "reasoning", content: part.data });
960
+ break;
961
+ case "toolCall":
962
+ parts.push({ type: "tool_call", id: part.id, name: part.name, arguments: part.arguments });
963
+ break;
964
+ }
965
+ }
966
+ return parts;
967
+ }
968
+
969
+ function callContentSerializer(
970
+ telemetry: AgentTelemetry,
971
+ name: keyof TelemetryContentSerializer,
972
+ serialize: () => string | undefined,
973
+ ): string | undefined {
974
+ try {
975
+ return serialize();
976
+ } catch (err) {
977
+ emitTelemetryWarning(telemetry, {
978
+ code: "content_serializer_failed",
979
+ message: `${name} content serializer threw; omitting telemetry content`,
980
+ error: err,
981
+ });
982
+ return undefined;
983
+ }
984
+ }
985
+
986
+ function limitTelemetryMessages(messages: readonly TelemetryMessageSummary[]): TelemetryMessageSummary[] {
987
+ const limited = messages.slice(0, MAX_TELEMETRY_MESSAGE_COUNT);
988
+ if (messages.length > MAX_TELEMETRY_MESSAGE_COUNT) {
989
+ limited.push({
990
+ role: "system",
991
+ content: { kind: "truncated", omittedMessages: messages.length - MAX_TELEMETRY_MESSAGE_COUNT },
992
+ });
993
+ }
994
+ return limited;
995
+ }
996
+
997
+ function limitTelemetryToolCalls(toolCalls: readonly TelemetryToolCallSummary[]): TelemetryToolCallSummary[] {
998
+ const limited = toolCalls.slice(0, MAX_TELEMETRY_ARRAY_ITEMS);
999
+ if (toolCalls.length > MAX_TELEMETRY_ARRAY_ITEMS) {
1000
+ limited.push({
1001
+ toolCallId: "[truncated]",
1002
+ toolName: "[truncated]",
1003
+ input: { kind: "truncated", omittedToolCalls: toolCalls.length - MAX_TELEMETRY_ARRAY_ITEMS },
1004
+ });
1005
+ }
1006
+ return limited;
1007
+ }
1008
+
1009
+ function summarizeTelemetryTexts(texts: readonly string[]): string[] {
1010
+ const summarized = texts.slice(0, MAX_TELEMETRY_ARRAY_ITEMS).map(text => summarizeTelemetryText(text));
1011
+ if (texts.length > MAX_TELEMETRY_ARRAY_ITEMS) {
1012
+ summarized.push(`[${texts.length - MAX_TELEMETRY_ARRAY_ITEMS} additional text entries omitted]`);
1013
+ }
1014
+ return summarized;
1015
+ }
1016
+
1017
+ function summarizeTelemetryText(text: string): string {
1018
+ if (text.length <= MAX_TELEMETRY_TEXT_CHARS) return text;
1019
+ return `${text.slice(0, MAX_TELEMETRY_TEXT_CHARS)} [${text.length - MAX_TELEMETRY_TEXT_CHARS} chars omitted]`;
1020
+ }
1021
+
1022
+ function summarizeTelemetryValue(value: unknown, depth = 0, seen?: Set<object>): unknown {
1023
+ if (typeof value === "string") return summarizeTelemetryText(value);
1024
+ if (typeof value === "number" || typeof value === "boolean" || value == null) return value;
1025
+ if (typeof value === "bigint") return value.toString();
1026
+ if (typeof value === "function") return "[Function]";
1027
+ if (value instanceof Error) {
1028
+ return { name: value.name, message: summarizeTelemetryText(value.message) };
1029
+ }
1030
+ if (Array.isArray(value)) {
1031
+ // Cap array recursion at the same depth as plain-object recursion so
1032
+ // pathological nested-array shapes (or arrays containing themselves)
1033
+ // cannot blow the stack via `summarizeTelemetryValue`.
1034
+ if (depth >= MAX_TELEMETRY_OBJECT_DEPTH) {
1035
+ return { kind: "array", length: value.length };
1036
+ }
1037
+ const ancestors = seen ?? new Set<object>();
1038
+ if (ancestors.has(value)) return "[Circular]";
1039
+ ancestors.add(value);
1040
+ const items = value
1041
+ .slice(0, MAX_TELEMETRY_ARRAY_ITEMS)
1042
+ .map(item => summarizeTelemetryValue(item, depth + 1, ancestors));
1043
+ if (value.length > MAX_TELEMETRY_ARRAY_ITEMS) {
1044
+ items.push({ kind: "truncated", omittedItems: value.length - MAX_TELEMETRY_ARRAY_ITEMS });
1045
+ }
1046
+ ancestors.delete(value);
1047
+ return items;
1048
+ }
1049
+ if (!isPlainTelemetryRecord(value)) return String(value);
1050
+ const ancestors = seen ?? new Set<object>();
1051
+ if (ancestors.has(value)) return "[Circular]";
1052
+ const entries = Object.entries(value);
1053
+ if (depth >= MAX_TELEMETRY_OBJECT_DEPTH) {
1054
+ return summarizeTelemetryObjectKeys(entries);
1055
+ }
1056
+ ancestors.add(value);
1057
+ const summary: Record<string, unknown> = {};
1058
+ for (const [key, item] of entries.slice(0, MAX_TELEMETRY_OBJECT_KEYS)) {
1059
+ summary[key] = summarizeTelemetryValue(item, depth + 1, ancestors);
1060
+ }
1061
+ if (entries.length > MAX_TELEMETRY_OBJECT_KEYS) {
1062
+ summary.telemetrySummary = { omittedKeys: entries.length - MAX_TELEMETRY_OBJECT_KEYS };
1063
+ }
1064
+ ancestors.delete(value);
1065
+ return summary;
1066
+ }
1067
+
1068
+ function summarizeTelemetryObjectKeys(entries: readonly (readonly [string, unknown])[]): Record<string, unknown> {
1069
+ const keys = entries.slice(0, MAX_TELEMETRY_OBJECT_KEYS).map(([key]) => key);
1070
+ return entries.length > MAX_TELEMETRY_OBJECT_KEYS
1071
+ ? { kind: "object", keys, telemetrySummary: { omittedKeys: entries.length - MAX_TELEMETRY_OBJECT_KEYS } }
1072
+ : { kind: "object", keys };
1073
+ }
1074
+
1075
+ function isPlainTelemetryRecord(value: unknown): value is Record<string, unknown> {
1076
+ if (typeof value !== "object" || value === null) return false;
1077
+ const prototype = Object.getPrototypeOf(value);
1078
+ return prototype === Object.prototype || prototype === null;
1079
+ }
1080
+
1081
+ function stringifyJsonAttribute(value: unknown): string | undefined {
1082
+ const serialized = JSON.stringify(value);
1083
+ return serialized === undefined ? undefined : serialized;
1084
+ }
1085
+
1086
+ function serializeToolCallArgumentsForTelemetry(telemetry: AgentTelemetry, args: unknown): string | undefined {
1087
+ const serializer = telemetry.config.contentSerializer?.toolCallArguments;
1088
+ if (serializer) return callContentSerializer(telemetry, "toolCallArguments", () => serializer(args));
1089
+ return telemetry.contentCapture === "full" ? safeJson(args) : stringifyJsonAttribute(summarizeTelemetryValue(args));
1090
+ }
1091
+
1092
+ function serializeToolCallResultForTelemetry(telemetry: AgentTelemetry, result: unknown): string | undefined {
1093
+ const serializer = telemetry.config.contentSerializer?.toolCallResult;
1094
+ if (serializer) return callContentSerializer(telemetry, "toolCallResult", () => serializer(result));
1095
+ return telemetry.contentCapture === "full"
1096
+ ? safeJson(result)
1097
+ : stringifyJsonAttribute(summarizeTelemetryValue(result));
1098
+ }
1099
+
1100
+ /**
1101
+ * Stamp the final response onto a chat span, fire the cost estimator hook,
1102
+ * and end the span. No-op when `span` is undefined.
1103
+ */
1104
+ export async function finishChatSpan(
1105
+ telemetry: AgentTelemetry | undefined,
1106
+ span: Span | undefined,
1107
+ message: AssistantMessage,
1108
+ options: {
1109
+ readonly stepNumber: number;
1110
+ readonly serviceTier?: ServiceTier;
1111
+ readonly responseHeaders?: Readonly<Record<string, string>>;
1112
+ readonly baseUrl?: string;
1113
+ },
1114
+ ): Promise<void> {
1115
+ if (!span) return;
1116
+ applyChatResponseAttributes(span, message);
1117
+ applyUsageAttributes(span, message.usage);
1118
+ applyGatewayAttributes(span, options.responseHeaders, options.baseUrl);
1119
+ const cost = applyCostEstimate(telemetry, span, message, options.serviceTier, options.stepNumber);
1120
+ if (telemetry) {
1121
+ await emitChatUsage(telemetry, span, {
1122
+ model: message.model,
1123
+ provider: message.provider,
1124
+ serviceTier: options.serviceTier,
1125
+ stepNumber: options.stepNumber,
1126
+ usage: message.usage,
1127
+ applied: cost,
1128
+ headers: options.responseHeaders,
1129
+ }).catch(err => {
1130
+ emitTelemetryWarning(telemetry, {
1131
+ code: "on_chat_usage_failed",
1132
+ message: "onChatUsage rejected; swallowing telemetry callback failure",
1133
+ error: err,
1134
+ });
1135
+ });
1136
+ }
1137
+ if (telemetry && telemetry.contentCapture !== "none") {
1138
+ applyContentCaptureForResponse(telemetry, span, message);
1139
+ }
1140
+ safeOnSpanEnd(telemetry, {
1141
+ span,
1142
+ kind: "chat",
1143
+ model: undefined,
1144
+ agent: normalizedTelemetryAgent(telemetry),
1145
+ conversationId: telemetry?.conversationId,
1146
+ stepNumber: options.stepNumber,
1147
+ });
1148
+ applyTerminalStatus(span, message.stopReason, message.errorMessage);
1149
+ telemetry?.collector.endChat(span, message, cost);
1150
+ span.end();
1151
+ }
1152
+
1153
+ /**
1154
+ * Record a chat that failed before producing a final `AssistantMessage`
1155
+ * (e.g. the provider stream threw mid-iteration). Mirrors `finishChatSpan`'s
1156
+ * span-end side effects and pushes a failed `ChatRecord` to the collector so
1157
+ * the run summary still reflects the failed step.
1158
+ */
1159
+ export function failChatSpan(
1160
+ telemetry: AgentTelemetry | undefined,
1161
+ span: Span | undefined,
1162
+ options: {
1163
+ readonly errorObject: unknown;
1164
+ readonly errorType?: string;
1165
+ readonly responseHeaders?: Readonly<Record<string, string>>;
1166
+ readonly baseUrl?: string;
1167
+ },
1168
+ ): void {
1169
+ if (!span) return;
1170
+ applyGatewayAttributes(span, options.responseHeaders, options.baseUrl);
1171
+ const err = options.errorObject;
1172
+ if (err instanceof Error) {
1173
+ span.recordException(err);
1174
+ span.setAttribute(GenAIAttr.ErrorType, options.errorType ?? err.name ?? "Error");
1175
+ span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
1176
+ } else {
1177
+ span.setAttribute(GenAIAttr.ErrorType, options.errorType ?? "Error");
1178
+ span.setStatus({ code: SpanStatusCode.ERROR, message: String(err) });
1179
+ }
1180
+ telemetry?.collector.failChat(span, {
1181
+ errorType: options.errorType ?? (err instanceof Error ? err.name || "Error" : "Error"),
1182
+ });
1183
+ span.end();
1184
+ }
1185
+
1186
+ function applyChatResponseAttributes(span: Span, message: AssistantMessage): void {
1187
+ span.setAttribute(GenAIAttr.ResponseModel, message.model);
1188
+ if (message.responseId) span.setAttribute(GenAIAttr.ResponseId, message.responseId);
1189
+ if (message.ttft != null) span.setAttribute(GenAIAttr.ResponseTimeToFirstChunk, message.ttft / 1000);
1190
+ const finishReason = mapStopReason(message.stopReason);
1191
+ if (finishReason) span.setAttribute(GenAIAttr.ResponseFinishReasons, [finishReason]);
1192
+ }
1193
+
1194
+ function applyUsageAttributes(span: Span, usage: Usage | undefined): void {
1195
+ if (!usage) return;
1196
+ const cacheReadTokens = usage.cacheRead ?? 0;
1197
+ const cacheCreationTokens = usage.cacheWrite ?? 0;
1198
+ const inputTokens = (usage.input ?? 0) + cacheReadTokens + cacheCreationTokens;
1199
+ const outputTokens = usage.output ?? 0;
1200
+ span.setAttribute(GenAIAttr.UsageInputTokens, inputTokens);
1201
+ span.setAttribute(GenAIAttr.UsageOutputTokens, outputTokens);
1202
+ const total = usage.totalTokens ?? inputTokens + outputTokens;
1203
+ span.setAttribute(PiGenAIAttr.UsageTotalTokens, total);
1204
+ if (usage.cacheRead != null) span.setAttribute(GenAIAttr.UsageCacheReadInputTokens, usage.cacheRead);
1205
+ if (usage.cacheWrite != null) span.setAttribute(GenAIAttr.UsageCacheCreationInputTokens, usage.cacheWrite);
1206
+ if (usage.reasoningTokens != null) {
1207
+ span.setAttribute(GenAIAttr.UsageReasoningOutputTokens, usage.reasoningTokens);
1208
+ }
1209
+ if (usage.server) {
1210
+ const sums = (usage.server.webSearch ?? 0) + (usage.server.webFetch ?? 0);
1211
+ if (sums > 0) span.setAttribute(PiGenAIAttr.UsageServerSideTools, sums);
1212
+ }
1213
+ }
1214
+
1215
+ /**
1216
+ * Result of {@link detectGatewayFromHeaders}. `callId` and `routedTo` are
1217
+ * populated only when the gateway surfaces them; consumers should treat
1218
+ * `undefined` as "unknown for this gateway" rather than "no value".
1219
+ */
1220
+ export interface GatewayHeaderDetection {
1221
+ readonly name: string;
1222
+ readonly callId: string | undefined;
1223
+ readonly routedTo: string | undefined;
1224
+ }
1225
+
1226
+ /**
1227
+ * Identify a known LLM gateway / proxy from response headers (LiteLLM,
1228
+ * Helicone, Portkey). Returns `undefined` when no recognizable pattern is
1229
+ * present so direct-API traffic stays unaffected.
1230
+ *
1231
+ * Header keys are matched case-insensitively against the lowercased map that
1232
+ * {@link ProviderResponseMetadata.headers} produces.
1233
+ */
1234
+ export function detectGatewayFromHeaders(
1235
+ headers: Readonly<Record<string, string>> | undefined,
1236
+ ): GatewayHeaderDetection | undefined {
1237
+ if (!headers) return undefined;
1238
+ const litellmCallId = headers["x-litellm-call-id"];
1239
+ if (litellmCallId) {
1240
+ return {
1241
+ name: "litellm",
1242
+ callId: litellmCallId,
1243
+ routedTo: headers["x-litellm-model-id"] ?? headers["x-litellm-model-group"],
1244
+ };
1245
+ }
1246
+ const heliconeId = headers["helicone-id"];
1247
+ if (heliconeId) {
1248
+ return { name: "helicone", callId: heliconeId, routedTo: headers["helicone-target-provider"] };
1249
+ }
1250
+ const portkeyId = headers["x-portkey-trace-id"] ?? headers["x-portkey-request-id"];
1251
+ if (portkeyId) {
1252
+ return {
1253
+ name: "portkey",
1254
+ callId: portkeyId,
1255
+ routedTo: headers["x-portkey-llm-provider"] ?? headers["x-portkey-provider"],
1256
+ };
1257
+ }
1258
+ const openRouterGenerationId = headers["x-generation-id"];
1259
+ if (openRouterGenerationId?.startsWith("gen-")) {
1260
+ // OpenRouter does not surface the upstream provider in response headers
1261
+ // (only the body's `provider` field carries it), so `routedTo` is left
1262
+ // undefined here. The `gen-` prefix on `x-generation-id` is OpenRouter-
1263
+ // specific and disambiguates from other proxies that also expose a
1264
+ // `x-generation-id` header.
1265
+ return { name: "openrouter", callId: openRouterGenerationId, routedTo: undefined };
1266
+ }
1267
+ return undefined;
1268
+ }
1269
+
1270
+ function applyGatewayAttributes(
1271
+ span: Span,
1272
+ headers: Readonly<Record<string, string>> | undefined,
1273
+ baseUrl: string | undefined,
1274
+ ): void {
1275
+ const gateway = detectGatewayFromHeaders(headers);
1276
+ if (!gateway) return;
1277
+ span.setAttribute(PiGenAIAttr.GatewayName, gateway.name);
1278
+ if (baseUrl) span.setAttribute(PiGenAIAttr.GatewayEndpoint, baseUrl);
1279
+ if (gateway.callId) span.setAttribute(PiGenAIAttr.GatewayCallId, gateway.callId);
1280
+ if (gateway.routedTo) span.setAttribute(PiGenAIAttr.GatewayRoutedTo, gateway.routedTo);
1281
+ }
1282
+
1283
+ interface AppliedCostEstimate {
1284
+ readonly costUsd: number | undefined;
1285
+ readonly inputUsd: number | undefined;
1286
+ readonly outputUsd: number | undefined;
1287
+ readonly costUnavailableReason: string | undefined;
1288
+ }
1289
+
1290
+ function applyCostEstimate(
1291
+ telemetry: AgentTelemetry | undefined,
1292
+ span: Span,
1293
+ message: AssistantMessage,
1294
+ serviceTier: ServiceTier | undefined,
1295
+ stepNumber: number | undefined,
1296
+ ): AppliedCostEstimate {
1297
+ if (!telemetry) return EMPTY_COST;
1298
+ return applyCostEstimateForUsage(telemetry, span, {
1299
+ model: message.model,
1300
+ provider: message.provider,
1301
+ serviceTier,
1302
+ stepNumber,
1303
+ usage: message.usage,
1304
+ });
1305
+ }
1306
+
1307
+ function applyCostEstimateForUsage(
1308
+ telemetry: AgentTelemetry,
1309
+ span: Span,
1310
+ input: {
1311
+ readonly model: string;
1312
+ readonly provider: string | undefined;
1313
+ readonly serviceTier: ServiceTier | undefined;
1314
+ readonly stepNumber: number | undefined;
1315
+ readonly usage: Usage | undefined;
1316
+ },
1317
+ ): AppliedCostEstimate {
1318
+ const estimator = telemetry.config.costEstimator;
1319
+ if (!estimator || !input.usage) return EMPTY_COST;
1320
+ const provider = normalizeProviderName(telemetry, input.provider);
1321
+ if (!provider) return EMPTY_COST;
1322
+ const usage = buildUsageSnapshot(input.usage);
1323
+ let result: CostEstimate | undefined;
1324
+ try {
1325
+ result = estimator({
1326
+ provider,
1327
+ model: input.model,
1328
+ serviceTier: input.serviceTier,
1329
+ usage,
1330
+ });
1331
+ } catch (err) {
1332
+ emitTelemetryWarning(telemetry, {
1333
+ code: "cost_estimator_failed",
1334
+ message: "costEstimator threw; omitting cost telemetry",
1335
+ error: err,
1336
+ });
1337
+ return EMPTY_COST;
1338
+ }
1339
+ if (!result) return EMPTY_COST;
1340
+ if ("unavailable" in result) {
1341
+ span.setAttribute(PiGenAIAttr.CostUnavailableReason, result.unavailable);
1342
+ const cost: AppliedCostEstimate = {
1343
+ costUsd: undefined,
1344
+ inputUsd: undefined,
1345
+ outputUsd: undefined,
1346
+ costUnavailableReason: result.unavailable,
1347
+ };
1348
+ emitCostDelta(telemetry, {
1349
+ agent: normalizedTelemetryAgent(telemetry),
1350
+ conversationId: telemetry.conversationId,
1351
+ costUsd: undefined,
1352
+ costUnavailableReason: result.unavailable,
1353
+ inputUsd: undefined,
1354
+ model: input.model,
1355
+ outputUsd: undefined,
1356
+ provider,
1357
+ serviceTier: input.serviceTier,
1358
+ stepNumber: input.stepNumber,
1359
+ usage,
1360
+ });
1361
+ return cost;
1362
+ }
1363
+ span.setAttribute(PiGenAIAttr.CostEstimatedUsd, result.usd);
1364
+ if (result.inputUsd != null) span.setAttribute(PiGenAIAttr.CostInputUsd, result.inputUsd);
1365
+ if (result.outputUsd != null) span.setAttribute(PiGenAIAttr.CostOutputUsd, result.outputUsd);
1366
+ const cost: AppliedCostEstimate = {
1367
+ costUsd: result.usd,
1368
+ inputUsd: result.inputUsd,
1369
+ outputUsd: result.outputUsd,
1370
+ costUnavailableReason: undefined,
1371
+ };
1372
+ emitCostDelta(telemetry, {
1373
+ agent: normalizedTelemetryAgent(telemetry),
1374
+ conversationId: telemetry.conversationId,
1375
+ costUsd: result.usd,
1376
+ costUnavailableReason: undefined,
1377
+ inputUsd: result.inputUsd,
1378
+ model: input.model,
1379
+ outputUsd: result.outputUsd,
1380
+ provider,
1381
+ serviceTier: input.serviceTier,
1382
+ stepNumber: input.stepNumber,
1383
+ usage,
1384
+ });
1385
+ return cost;
1386
+ }
1387
+
1388
+ function buildUsageSnapshot(usage: Usage): ChatUsageSnapshot {
1389
+ return {
1390
+ inputTokens: (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0),
1391
+ outputTokens: usage.output ?? 0,
1392
+ totalTokens:
1393
+ usage.totalTokens ??
1394
+ (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0) + (usage.output ?? 0),
1395
+ cachedInputTokens: usage.cacheRead,
1396
+ cacheWriteTokens: usage.cacheWrite,
1397
+ reasoningOutputTokens: usage.reasoningTokens,
1398
+ };
1399
+ }
1400
+
1401
+ function emitCostDelta(telemetry: AgentTelemetry, delta: CostDelta): void {
1402
+ const hook = telemetry.config.onCostDelta;
1403
+ if (!hook) return;
1404
+ try {
1405
+ hook(delta);
1406
+ } catch (err) {
1407
+ emitTelemetryWarning(telemetry, {
1408
+ code: "on_cost_delta_failed",
1409
+ message: "onCostDelta threw; swallowing telemetry callback failure",
1410
+ error: err,
1411
+ });
1412
+ }
1413
+ }
1414
+
1415
+ async function emitChatUsage(
1416
+ telemetry: AgentTelemetry,
1417
+ span: Span,
1418
+ input: {
1419
+ readonly model: string;
1420
+ readonly provider: string | undefined;
1421
+ readonly serviceTier: ServiceTier | undefined;
1422
+ readonly stepNumber: number | undefined;
1423
+ readonly usage: Usage | undefined;
1424
+ readonly applied: AppliedCostEstimate;
1425
+ readonly headers: Readonly<Record<string, string>> | undefined;
1426
+ },
1427
+ ): Promise<void> {
1428
+ const hook = telemetry.config.onChatUsage;
1429
+ if (!hook || !input.usage) return;
1430
+ const event: ChatUsageEvent = {
1431
+ span,
1432
+ agent: normalizedTelemetryAgent(telemetry),
1433
+ conversationId: telemetry.conversationId,
1434
+ stepNumber: input.stepNumber,
1435
+ model: input.model,
1436
+ provider: normalizeProviderName(telemetry, input.provider),
1437
+ serviceTier: input.serviceTier,
1438
+ usage: buildUsageSnapshot(input.usage),
1439
+ cost: costEstimateFromApplied(input.applied),
1440
+ attributes: resolveDynamicAttributes(
1441
+ telemetry,
1442
+ buildTelemetryAttributeContext(telemetry, "chat", { stepNumber: input.stepNumber }),
1443
+ ),
1444
+ headers: input.headers,
1445
+ };
1446
+ try {
1447
+ await hook(event);
1448
+ } catch (err) {
1449
+ emitTelemetryWarning(telemetry, {
1450
+ code: "on_chat_usage_failed",
1451
+ message: "onChatUsage threw; swallowing telemetry callback failure",
1452
+ error: err,
1453
+ });
1454
+ }
1455
+ }
1456
+
1457
+ function costEstimateFromApplied(applied: AppliedCostEstimate): CostEstimate | undefined {
1458
+ if (applied.costUsd != null) {
1459
+ return { usd: applied.costUsd, inputUsd: applied.inputUsd, outputUsd: applied.outputUsd };
1460
+ }
1461
+ if (applied.costUnavailableReason != null) {
1462
+ return { unavailable: applied.costUnavailableReason };
1463
+ }
1464
+ return undefined;
1465
+ }
1466
+
1467
+ const EMPTY_COST: AppliedCostEstimate = Object.freeze({
1468
+ costUsd: undefined,
1469
+ inputUsd: undefined,
1470
+ outputUsd: undefined,
1471
+ costUnavailableReason: undefined,
1472
+ });
1473
+
1474
+ function mapStopReason(reason: StopReason | undefined): string | undefined {
1475
+ switch (reason) {
1476
+ case "stop":
1477
+ return "stop";
1478
+ case "length":
1479
+ return "length";
1480
+ case "toolUse":
1481
+ return "tool_calls";
1482
+ case "error":
1483
+ case "aborted":
1484
+ return "error";
1485
+ default:
1486
+ return undefined;
1487
+ }
1488
+ }
1489
+
1490
+ function applyTerminalStatus(span: Span, stopReason: StopReason | undefined, errorMessage: string | undefined): void {
1491
+ if (stopReason === "error" || stopReason === "aborted") {
1492
+ span.setAttribute(GenAIAttr.ErrorType, stopReason);
1493
+ span.setStatus({ code: SpanStatusCode.ERROR, message: errorMessage ?? stopReason });
1494
+ }
1495
+ }
1496
+
1497
+ export interface ManualChatToolCallTelemetry {
1498
+ readonly toolCallId: string;
1499
+ readonly toolName: string;
1500
+ readonly input?: unknown;
1501
+ }
1502
+
1503
+ export interface ManualChatTelemetryOptions {
1504
+ readonly span?: Span;
1505
+ readonly parent?: Span;
1506
+ readonly model: Model;
1507
+ readonly usage?: Usage;
1508
+ readonly finishReason?: StopReason;
1509
+ readonly serviceTier?: ServiceTier;
1510
+ readonly stepNumber?: number;
1511
+ readonly responseId?: string;
1512
+ readonly responseModel?: string;
1513
+ readonly responseText?: string;
1514
+ readonly responseToolCalls?: readonly ManualChatToolCallTelemetry[];
1515
+ readonly attributes?: Attributes;
1516
+ readonly responseHeaders?: Readonly<Record<string, string>>;
1517
+ readonly endSpan?: boolean;
1518
+ }
1519
+
1520
+ export async function recordManualChatTelemetry(
1521
+ telemetry: AgentTelemetry | undefined,
1522
+ options: ManualChatTelemetryOptions,
1523
+ ): Promise<Span | undefined> {
1524
+ const span =
1525
+ options.span ??
1526
+ startSpan(telemetry, "chat", `chat ${options.model.id}`, {
1527
+ spanKind: SpanKind.CLIENT,
1528
+ model: options.model,
1529
+ parent: options.parent,
1530
+ stepNumber: options.stepNumber,
1531
+ attributes: options.attributes,
1532
+ });
1533
+ if (!span) return undefined;
1534
+ if (options.span && options.attributes) span.setAttributes(options.attributes);
1535
+ if (options.stepNumber != null) span.setAttribute(PiGenAIAttr.AgentStepNumber, options.stepNumber);
1536
+ span.setAttribute(GenAIAttr.ResponseModel, options.responseModel ?? options.model.name);
1537
+ if (options.responseId) span.setAttribute(GenAIAttr.ResponseId, options.responseId);
1538
+ const finishReason = mapStopReason(options.finishReason);
1539
+ if (finishReason) span.setAttribute(GenAIAttr.ResponseFinishReasons, [finishReason]);
1540
+ applyUsageAttributes(span, options.usage);
1541
+ applyGatewayAttributes(span, options.responseHeaders, options.model.baseUrl);
1542
+ if (telemetry) {
1543
+ const applied = applyCostEstimateForUsage(telemetry, span, {
1544
+ model: options.responseModel ?? options.model.id,
1545
+ provider: options.model.provider,
1546
+ serviceTier: options.serviceTier,
1547
+ stepNumber: options.stepNumber,
1548
+ usage: options.usage,
1549
+ });
1550
+ await emitChatUsage(telemetry, span, {
1551
+ model: options.responseModel ?? options.model.id,
1552
+ provider: options.model.provider,
1553
+ serviceTier: options.serviceTier,
1554
+ stepNumber: options.stepNumber,
1555
+ usage: options.usage,
1556
+ applied,
1557
+ headers: options.responseHeaders,
1558
+ }).catch(err => {
1559
+ emitTelemetryWarning(telemetry, {
1560
+ code: "on_chat_usage_failed",
1561
+ message: "onChatUsage rejected; swallowing telemetry callback failure",
1562
+ error: err,
1563
+ });
1564
+ });
1565
+ }
1566
+ if (options.responseText) {
1567
+ const responseText = stringifyJsonAttribute(summarizeTelemetryTexts([options.responseText]));
1568
+ if (responseText) span.setAttribute(PiGenAIAttr.ResponseText, responseText);
1569
+ }
1570
+ if (options.responseToolCalls && options.responseToolCalls.length > 0) {
1571
+ const calls = options.responseToolCalls.map(call => ({
1572
+ toolCallId: call.toolCallId,
1573
+ toolName: call.toolName,
1574
+ input: summarizeTelemetryValue(call.input),
1575
+ }));
1576
+ const responseToolCalls = stringifyJsonAttribute(limitTelemetryToolCalls(calls));
1577
+ if (responseToolCalls) span.setAttribute(PiGenAIAttr.ResponseToolCalls, responseToolCalls);
1578
+ }
1579
+ applyTerminalStatus(span, options.finishReason, undefined);
1580
+ if (options.endSpan ?? options.span === undefined) span.end();
1581
+ return span;
1582
+ }
1583
+
1584
+ /**
1585
+ * Options accepted by {@link instrumentedCompleteSimple}. Mirrors the
1586
+ * `streamAssistantResponse` chat-span lifecycle for oneshot LLM calls
1587
+ * (compaction summaries, handoff document, branch summary, inspect_image).
1588
+ */
1589
+ export interface InstrumentedChatSpanOptions {
1590
+ readonly telemetry: AgentTelemetry | undefined;
1591
+ /** Optional explicit parent span. Defaults to `context.active()`. */
1592
+ readonly parent?: Span;
1593
+ /** Step index recorded on the span; defaults to `-1` for non-loop calls. */
1594
+ readonly stepNumber?: number;
1595
+ /**
1596
+ * Tag stamped onto `pi.gen_ai.oneshot.kind`. Values used by the agent:
1597
+ * `compaction_summary`, `compaction_short_summary`, `compaction_turn_prefix`,
1598
+ * `handoff`, `branch_summary`, `inspect_image`. Free-form to allow callers
1599
+ * outside this package to add new kinds without bumping the helper.
1600
+ */
1601
+ readonly oneshotKind?: string;
1602
+ /** Extra span attributes applied verbatim. */
1603
+ readonly attributes?: Attributes;
1604
+ /**
1605
+ * Override for the underlying {@link completeSimple} call. Defaults to
1606
+ * `completeSimple` from `@gajae-code/ai`. Use to retain a test injection
1607
+ * seam while still going through the chat-span lifecycle.
1608
+ */
1609
+ readonly completeImpl?: <TApi extends Api>(
1610
+ model: Model<TApi>,
1611
+ ctx: Context,
1612
+ options: SimpleStreamOptions,
1613
+ ) => Promise<AssistantMessage>;
1614
+ }
1615
+
1616
+ /**
1617
+ * Wrap a {@link completeSimple} round-trip with the same chat-span lifecycle
1618
+ * the agent loop uses for streamed turns: `startChatSpan` → run inside the
1619
+ * active span → `finishChatSpan` on success, `failChatSpan` on throw.
1620
+ *
1621
+ * Short-circuits when `telemetry` is `undefined` so cost / overhead stays at
1622
+ * zero for installations without an OTEL SDK.
1623
+ */
1624
+ export async function instrumentedCompleteSimple<TApi extends Api>(
1625
+ model: Model<TApi>,
1626
+ ctx: Context,
1627
+ options: SimpleStreamOptions,
1628
+ span: InstrumentedChatSpanOptions,
1629
+ ): Promise<AssistantMessage> {
1630
+ const { telemetry, parent, oneshotKind } = span;
1631
+ const stepNumber = span.stepNumber ?? -1;
1632
+ const reasoning = options.reasoning;
1633
+ const chatSpan = startChatSpan(telemetry, model, {
1634
+ parent,
1635
+ stepNumber,
1636
+ request: {
1637
+ maxTokens: options.maxTokens,
1638
+ temperature: options.temperature,
1639
+ topP: options.topP,
1640
+ topK: options.topK,
1641
+ presencePenalty: options.presencePenalty,
1642
+ serviceTier: options.serviceTier,
1643
+ reasoningEffort: typeof reasoning === "string" ? reasoning : undefined,
1644
+ toolChoice: options.toolChoice,
1645
+ tools: ctx.tools,
1646
+ systemPrompt: ctx.systemPrompt,
1647
+ messages: ctx.messages,
1648
+ },
1649
+ });
1650
+ if (chatSpan) {
1651
+ if (oneshotKind) chatSpan.setAttribute(PiGenAIAttr.OneshotKind, oneshotKind);
1652
+ if (span.attributes) chatSpan.setAttributes(span.attributes);
1653
+ }
1654
+
1655
+ // Wrap the user-supplied onResponse so we always capture response headers
1656
+ // for the cost / gateway hooks without stealing them from the caller.
1657
+ let capturedHeaders: Readonly<Record<string, string>> | undefined;
1658
+ const userOnResponse = options.onResponse;
1659
+ const captureOnResponse: NonNullable<SimpleStreamOptions["onResponse"]> = (response, modelInfo) => {
1660
+ capturedHeaders = response.headers;
1661
+ return userOnResponse?.(response, modelInfo);
1662
+ };
1663
+
1664
+ try {
1665
+ return await runInActiveSpan(chatSpan, async () => {
1666
+ const complete = span.completeImpl ?? completeSimple;
1667
+ const message = await complete(model, ctx, {
1668
+ ...options,
1669
+ onResponse: captureOnResponse,
1670
+ });
1671
+ await finishChatSpan(telemetry, chatSpan, message, {
1672
+ stepNumber,
1673
+ serviceTier: options.serviceTier,
1674
+ responseHeaders: capturedHeaders,
1675
+ baseUrl: model.baseUrl,
1676
+ });
1677
+ return message;
1678
+ });
1679
+ } catch (err) {
1680
+ failChatSpan(telemetry, chatSpan, {
1681
+ errorObject: err,
1682
+ responseHeaders: capturedHeaders,
1683
+ baseUrl: model.baseUrl,
1684
+ });
1685
+ throw err;
1686
+ }
1687
+ }
1688
+
1689
+ /**
1690
+ * Start an `execute_tool` span representing one tool invocation. Parented
1691
+ * under the supplied `invoke_agent` span by default — pass `parent` to
1692
+ * override.
1693
+ */
1694
+ export function startExecuteToolSpan(
1695
+ telemetry: AgentTelemetry | undefined,
1696
+ options: {
1697
+ readonly tool: AgentTool | undefined;
1698
+ readonly toolName: string;
1699
+ readonly toolCallId: string;
1700
+ readonly args: unknown;
1701
+ readonly parent?: Span;
1702
+ },
1703
+ ): Span | undefined {
1704
+ const attrs: Attributes = {
1705
+ [GenAIAttr.ToolName]: options.toolName,
1706
+ [GenAIAttr.ToolCallId]: options.toolCallId,
1707
+ [GenAIAttr.ToolType]: "function",
1708
+ };
1709
+ if (options.tool?.description) attrs[GenAIAttr.ToolDescription] = options.tool.description;
1710
+ const span = startSpan(telemetry, "execute_tool", `execute_tool ${options.toolName}`, {
1711
+ spanKind: SpanKind.INTERNAL,
1712
+ parent: options.parent,
1713
+ toolCallId: options.toolCallId,
1714
+ toolName: options.toolName,
1715
+ attributes: attrs,
1716
+ });
1717
+ if (span) {
1718
+ telemetry?.collector.beginTool(span, { toolCallId: options.toolCallId, toolName: options.toolName });
1719
+ if (telemetry && telemetry.contentCapture !== "none") {
1720
+ const args = serializeToolCallArgumentsForTelemetry(telemetry, options.args);
1721
+ if (args) span.setAttribute(GenAIAttr.ToolCallArguments, args);
1722
+ }
1723
+ }
1724
+ return span;
1725
+ }
1726
+
1727
+ /**
1728
+ * End an `execute_tool` span. Pass `status` to specify the terminal status
1729
+ * explicitly (`"ok" | "error" | "skipped" | "blocked" | "timeout" |
1730
+ * "aborted"`); when omitted, `status` is derived from `isError`. Passing
1731
+ * `errorObject` (the thrown value) additionally records an exception with
1732
+ * stack.
1733
+ */
1734
+ export function finishExecuteToolSpan(
1735
+ telemetry: AgentTelemetry | undefined,
1736
+ span: Span | undefined,
1737
+ options: {
1738
+ readonly result?: unknown;
1739
+ readonly isError: boolean;
1740
+ readonly status?: ToolStatus;
1741
+ readonly errorMessage?: string;
1742
+ readonly errorObject?: unknown;
1743
+ readonly toolCallId: string;
1744
+ readonly toolName: string;
1745
+ },
1746
+ ): void {
1747
+ if (!span) return;
1748
+ if (telemetry && telemetry.contentCapture !== "none" && options.result !== undefined) {
1749
+ const result = serializeToolCallResultForTelemetry(telemetry, options.result);
1750
+ if (result) span.setAttribute(GenAIAttr.ToolCallResult, result);
1751
+ }
1752
+ safeOnSpanEnd(telemetry, {
1753
+ span,
1754
+ kind: "execute_tool",
1755
+ model: undefined,
1756
+ agent: normalizedTelemetryAgent(telemetry),
1757
+ conversationId: telemetry?.conversationId,
1758
+ toolCallId: options.toolCallId,
1759
+ toolName: options.toolName,
1760
+ });
1761
+ const status: ToolStatus = options.status ?? (options.isError ? "error" : "ok");
1762
+ let errorType: string | undefined;
1763
+ // `status` is the source of truth for the wire-level `error.type`. The
1764
+ // underlying `errorObject` (if any) still gets a `recordException` so the
1765
+ // stack trace is preserved, but the attribute reflects the run-level
1766
+ // category (`tool_blocked`, `tool_aborted`, …) instead of the JS class
1767
+ // name. This keeps dashboards groupable on one column.
1768
+ if (status !== "ok") {
1769
+ errorType =
1770
+ status === "error" && options.errorObject instanceof Error
1771
+ ? options.errorObject.name || "Error"
1772
+ : STATUS_ERROR_TYPE[status];
1773
+ span.setAttribute(GenAIAttr.ErrorType, errorType);
1774
+ span.setAttribute(EXECUTE_TOOL_STATUS_ATTR, status);
1775
+ const msg =
1776
+ options.errorObject instanceof Error ? options.errorObject.message : (options.errorMessage ?? errorType);
1777
+ span.setStatus({ code: SpanStatusCode.ERROR, message: msg });
1778
+ } else {
1779
+ span.setAttribute(EXECUTE_TOOL_STATUS_ATTR, status);
1780
+ }
1781
+ if (options.errorObject instanceof Error) {
1782
+ span.recordException(options.errorObject);
1783
+ }
1784
+ telemetry?.collector.endTool(span, { status, errorType });
1785
+ span.end();
1786
+ }
1787
+
1788
+ /** Span attribute carrying the terminal {@link ToolStatus}. */
1789
+ export const EXECUTE_TOOL_STATUS_ATTR = PiGenAIAttr.ToolStatus;
1790
+
1791
+ /**
1792
+ * Mapping from non-ok {@link ToolStatus} values to the `error.type` attribute
1793
+ * string written on the span when no thrown error is available. The wire
1794
+ * format intentionally matches the status string so dashboards can group on
1795
+ * one column.
1796
+ */
1797
+ const STATUS_ERROR_TYPE: Record<Exclude<ToolStatus, "ok">, string> = {
1798
+ error: "tool_error",
1799
+ skipped: "tool_skipped",
1800
+ blocked: "tool_blocked",
1801
+ timeout: "tool_timeout",
1802
+ aborted: "tool_aborted",
1803
+ };
1804
+
1805
+ /**
1806
+ * Record a tool that bypassed the span lifecycle entirely (pre-run
1807
+ * interrupt, post-execution tail sweep for calls that never produced a
1808
+ * result message). The LLM still asked for the tool, so it counts toward
1809
+ * coverage and toward the relevant `tools.<status>` counter; no span is
1810
+ * emitted because the loop never started one.
1811
+ */
1812
+ export function recordSkippedTool(
1813
+ telemetry: AgentTelemetry | undefined,
1814
+ options: {
1815
+ readonly toolCallId: string;
1816
+ readonly toolName: string;
1817
+ readonly status: Extract<ToolStatus, "skipped" | "aborted" | "error">;
1818
+ },
1819
+ ): void {
1820
+ telemetry?.collector.recordOrphanTool(options);
1821
+ }
1822
+
1823
+ /**
1824
+ * End an `invoke_agent` span. Snapshots the run collector, stamps aggregate
1825
+ * `gen_ai.agent.*` attributes on the span, fires the non-fatal
1826
+ * {@link AgentTelemetryConfig.onRunEnd} hook, then records any uncaught
1827
+ * error and ends the span.
1828
+ */
1829
+ export function finishInvokeAgentSpan(
1830
+ telemetry: AgentTelemetry | undefined,
1831
+ span: Span | undefined,
1832
+ options: { readonly stepCount: number; readonly errorObject?: unknown },
1833
+ ): { readonly summary: AgentRunSummary; readonly coverage: AgentRunCoverage } | undefined {
1834
+ if (!span) return undefined;
1835
+ applyInvokeAgentFinish(span, options.stepCount);
1836
+ let snapshot: { readonly summary: AgentRunSummary; readonly coverage: AgentRunCoverage } | undefined;
1837
+ if (telemetry) {
1838
+ snapshot = telemetry.collector.snapshot({ stepCount: options.stepCount });
1839
+ applyAggregateAttributes(span, snapshot.summary, snapshot.coverage);
1840
+ }
1841
+ safeOnSpanEnd(telemetry, {
1842
+ span,
1843
+ kind: "invoke_agent",
1844
+ model: undefined,
1845
+ agent: normalizedTelemetryAgent(telemetry),
1846
+ conversationId: telemetry?.conversationId,
1847
+ });
1848
+ if (telemetry && snapshot && telemetry.collector.markRunEnded()) {
1849
+ fireOnRunEnd(telemetry, snapshot.summary, snapshot.coverage);
1850
+ }
1851
+ if (options.errorObject instanceof Error) {
1852
+ span.recordException(options.errorObject);
1853
+ span.setAttribute(GenAIAttr.ErrorType, options.errorObject.name || "Error");
1854
+ span.setStatus({ code: SpanStatusCode.ERROR, message: options.errorObject.message });
1855
+ }
1856
+ span.end();
1857
+ return snapshot;
1858
+ }
1859
+
1860
+ /**
1861
+ * Invoke {@link AgentTelemetryConfig.onRunEnd} on `telemetry` if set. Throws
1862
+ are caught and logged via `console.warn` — telemetry callbacks NEVER turn a
1863
+ * successful agent run into a failed one. Idempotent at the call site via
1864
+ * {@link AgentRunCollector.markRunEnded}; callers must check that before
1865
+ * calling this helper.
1866
+ */
1867
+ export function fireOnRunEnd(telemetry: AgentTelemetry, summary: AgentRunSummary, coverage: AgentRunCoverage): void {
1868
+ const hook = telemetry.config.onRunEnd;
1869
+ if (!hook) return;
1870
+ try {
1871
+ hook(summary, coverage);
1872
+ } catch (err) {
1873
+ emitTelemetryWarning(telemetry, {
1874
+ code: "on_run_end_failed",
1875
+ message: "onRunEnd threw; swallowing telemetry callback failure",
1876
+ error: err,
1877
+ });
1878
+ }
1879
+ }
1880
+
1881
+ /** Aggregate `pi.gen_ai.agent.*` attributes stamped on the `invoke_agent` span. */
1882
+ export const enum PiGenAIAggregateAttr {
1883
+ ChatsCount = "pi.gen_ai.agent.chats.count",
1884
+ ChatsTotalLatencyMs = "pi.gen_ai.agent.chats.total_latency_ms",
1885
+ ChatsStopReasonPrefix = "pi.gen_ai.agent.chats.stop_reason.",
1886
+ ToolsCount = "pi.gen_ai.agent.tools.count",
1887
+ ToolsOkCount = "pi.gen_ai.agent.tools.ok.count",
1888
+ ToolsErrorCount = "pi.gen_ai.agent.tools.error.count",
1889
+ ToolsSkippedCount = "pi.gen_ai.agent.tools.skipped.count",
1890
+ ToolsBlockedCount = "pi.gen_ai.agent.tools.blocked.count",
1891
+ ToolsTimeoutCount = "pi.gen_ai.agent.tools.timeout.count",
1892
+ ToolsAbortedCount = "pi.gen_ai.agent.tools.aborted.count",
1893
+ ToolsTotalLatencyMs = "pi.gen_ai.agent.tools.total_latency_ms",
1894
+ ToolsInvoked = "pi.gen_ai.agent.tools.invoked",
1895
+ ToolsAvailable = "pi.gen_ai.agent.tools.available",
1896
+ ToolsUnused = "pi.gen_ai.agent.tools.unused",
1897
+ UsageInputTokensTotal = "pi.gen_ai.agent.usage.input_tokens.total",
1898
+ UsageOutputTokensTotal = "pi.gen_ai.agent.usage.output_tokens.total",
1899
+ UsageCacheReadInputTokensTotal = "pi.gen_ai.agent.usage.cache_read.input_tokens.total",
1900
+ UsageCacheCreationInputTokensTotal = "pi.gen_ai.agent.usage.cache_creation.input_tokens.total",
1901
+ UsageReasoningOutputTokensTotal = "pi.gen_ai.agent.usage.reasoning.output_tokens.total",
1902
+ UsageTotalTokensTotal = "pi.gen_ai.agent.usage.total_tokens.total",
1903
+ CostEstimatedUsdTotal = "pi.gen_ai.agent.cost.estimated_usd.total",
1904
+ ErrorsCount = "pi.gen_ai.agent.errors.count",
1905
+ }
1906
+
1907
+ /** Stamp the aggregate `pi.gen_ai.agent.*` attributes on the given span. */
1908
+ function applyAggregateAttributes(span: Span, summary: AgentRunSummary, coverage: AgentRunCoverage): void {
1909
+ span.setAttribute(PiGenAIAggregateAttr.ChatsCount, summary.chats.total);
1910
+ span.setAttribute(PiGenAIAggregateAttr.ChatsTotalLatencyMs, summary.chats.totalLatencyMs);
1911
+ for (const [reason, count] of Object.entries(summary.chats.byStopReason)) {
1912
+ span.setAttribute(`${PiGenAIAggregateAttr.ChatsStopReasonPrefix}${reason}.count`, count);
1913
+ }
1914
+ span.setAttribute(PiGenAIAggregateAttr.ToolsCount, summary.tools.total);
1915
+ span.setAttribute(PiGenAIAggregateAttr.ToolsOkCount, summary.tools.ok);
1916
+ span.setAttribute(PiGenAIAggregateAttr.ToolsErrorCount, summary.tools.error);
1917
+ span.setAttribute(PiGenAIAggregateAttr.ToolsSkippedCount, summary.tools.skipped);
1918
+ span.setAttribute(PiGenAIAggregateAttr.ToolsBlockedCount, summary.tools.blocked);
1919
+ span.setAttribute(PiGenAIAggregateAttr.ToolsTimeoutCount, summary.tools.timeout);
1920
+ span.setAttribute(PiGenAIAggregateAttr.ToolsAbortedCount, summary.tools.aborted);
1921
+ span.setAttribute(PiGenAIAggregateAttr.ToolsTotalLatencyMs, summary.tools.totalLatencyMs);
1922
+ if (coverage.toolsInvoked.length > 0) {
1923
+ span.setAttribute(PiGenAIAggregateAttr.ToolsInvoked, [...coverage.toolsInvoked]);
1924
+ }
1925
+ if (coverage.toolsAvailable.length > 0) {
1926
+ span.setAttribute(PiGenAIAggregateAttr.ToolsAvailable, [...coverage.toolsAvailable]);
1927
+ }
1928
+ if (coverage.toolsUnused.length > 0) {
1929
+ span.setAttribute(PiGenAIAggregateAttr.ToolsUnused, [...coverage.toolsUnused]);
1930
+ }
1931
+ span.setAttribute(PiGenAIAggregateAttr.UsageInputTokensTotal, summary.usage.inputTokens);
1932
+ span.setAttribute(PiGenAIAggregateAttr.UsageOutputTokensTotal, summary.usage.outputTokens);
1933
+ span.setAttribute(PiGenAIAggregateAttr.UsageCacheReadInputTokensTotal, summary.usage.cachedInputTokens);
1934
+ span.setAttribute(PiGenAIAggregateAttr.UsageCacheCreationInputTokensTotal, summary.usage.cacheWriteTokens);
1935
+ span.setAttribute(PiGenAIAggregateAttr.UsageReasoningOutputTokensTotal, summary.usage.reasoningOutputTokens);
1936
+ span.setAttribute(PiGenAIAggregateAttr.UsageTotalTokensTotal, summary.usage.totalTokens);
1937
+ if (summary.cost.estimatedUsd > 0) {
1938
+ span.setAttribute(PiGenAIAggregateAttr.CostEstimatedUsdTotal, summary.cost.estimatedUsd);
1939
+ }
1940
+ span.setAttribute(PiGenAIAggregateAttr.ErrorsCount, summary.errors.total);
1941
+ }
1942
+
1943
+ /**
1944
+ * Run `fn` with `span` activated on the OTEL context. Spans created
1945
+ * downstream (provider HTTP clients, MCP tools, user code) attach as
1946
+ * children. No-op when `span` is undefined.
1947
+ *
1948
+ * Required because `tracer.startSpan` creates the span object but does not
1949
+ * activate it — without this wrapper, downstream spans attach to whatever
1950
+ * context was active before and the parent linkage we advertise is lost.
1951
+ */
1952
+ export function runInActiveSpan<T>(span: Span | undefined, fn: () => Promise<T>): Promise<T> {
1953
+ if (!span) return fn();
1954
+ return context.with(trace.setSpan(context.active(), span), fn);
1955
+ }
1956
+
1957
+ /**
1958
+ * Emit a one-shot `handoff` span describing a transition between two named
1959
+ * agents. Pass `parent` to make the span a child of an in-flight
1960
+ * invoke_agent span; otherwise the active context's span is used.
1961
+ */
1962
+ export function recordHandoff(
1963
+ telemetry: AgentTelemetry | undefined,
1964
+ options: {
1965
+ readonly fromAgent: AgentIdentity | undefined;
1966
+ readonly toAgent: AgentIdentity;
1967
+ readonly parent?: Span;
1968
+ readonly attributes?: Attributes;
1969
+ },
1970
+ ): void {
1971
+ if (!telemetry) return;
1972
+ const attrs: Attributes = {};
1973
+ const fromAgent = options.fromAgent ? normalizeAgentIdentity(telemetry, options.fromAgent) : undefined;
1974
+ const toAgent = normalizeAgentIdentity(telemetry, options.toAgent);
1975
+ if (fromAgent?.name) attrs[PiGenAIAttr.HandoffFromAgentName] = fromAgent.name;
1976
+ if (fromAgent?.id) attrs[PiGenAIAttr.HandoffFromAgentId] = fromAgent.id;
1977
+ if (toAgent.name) attrs[PiGenAIAttr.HandoffToAgentName] = toAgent.name;
1978
+ if (toAgent.id) attrs[PiGenAIAttr.HandoffToAgentId] = toAgent.id;
1979
+ const name = toAgent.name
1980
+ ? fromAgent?.name
1981
+ ? `handoff ${fromAgent.name} → ${toAgent.name}`
1982
+ : `handoff to ${toAgent.name}`
1983
+ : "handoff";
1984
+ const span = startSpan(telemetry, "handoff", name, {
1985
+ spanKind: SpanKind.INTERNAL,
1986
+ parent: options.parent,
1987
+ attributes: { ...attrs, ...options.attributes },
1988
+ });
1989
+ if (!span) return;
1990
+ safeOnSpanEnd(telemetry, {
1991
+ span,
1992
+ kind: "handoff",
1993
+ model: undefined,
1994
+ agent: toAgent,
1995
+ conversationId: telemetry.conversationId,
1996
+ });
1997
+ span.end();
1998
+ }
1999
+
2000
+ /**
2001
+ * Set a single attribute on a possibly-undefined span. Use when the caller
2002
+ * needs to attach context outside the standard helpers without a branch.
2003
+ */
2004
+ export function setSpanAttribute(span: Span | undefined, key: string, value: AttributeValue): void {
2005
+ if (!span) return;
2006
+ span.setAttribute(key, value);
2007
+ }
2008
+
2009
+ /** Re-exports so consumers can write hooks without depending on @opentelemetry/api directly. */
2010
+ export { type Attributes, type Span, SpanKind, SpanStatusCode, type Tracer, trace };
2011
+
2012
+ function safeJson(value: unknown): string {
2013
+ try {
2014
+ return JSON.stringify(value);
2015
+ } catch {
2016
+ return String(value);
2017
+ }
2018
+ }