@oh-my-pi/pi-agent-core 15.1.2 → 15.1.3

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/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.1.3] - 2026-05-17
6
+ ### Added
7
+
8
+ - Added optional `telemetry` support to `generateSummary`, `generateHandoff`, `generateBranchSummary`, and `compact` options so compaction, handoff, and branch summary one-shot LLM calls can emit OpenTelemetry chat telemetry when enabled
9
+ - Added shared oneshot telemetry instrumentation for compaction, handoff, and branch summary calls, tagging spans with `pi.gen_ai.oneshot.kind` values such as `compaction_summary`, `compaction_short_summary`, `compaction_turn_prefix`, `handoff`, and `branch_summary`
10
+
5
11
  ## [15.1.2] - 2026-05-15
6
12
  ### Added
7
13
 
package/README.md CHANGED
@@ -279,7 +279,7 @@ const agent = new Agent({
279
279
 
280
280
  ## Tools
281
281
 
282
- Define tools using `AgentTool` with a Zod parameter schema (via `z` from `@oh-my-pi/pi-ai`). Legacy TypeBox-authored schemas are still accepted at runtime and are lifted to Zod internally.
282
+ Define tools using `AgentTool` with a Zod parameter schema (via `z` from `@oh-my-pi/pi-ai`).
283
283
 
284
284
  ```typescript
285
285
  import { z } from "@oh-my-pi/pi-ai";
@@ -5,6 +5,7 @@
5
5
  * a summary of the branch being left so context isn't lost.
6
6
  */
7
7
  import type { Model } from "@oh-my-pi/pi-ai";
8
+ import { type AgentTelemetry } from "../telemetry";
8
9
  import type { AgentMessage } from "../types";
9
10
  import type { ReadonlySessionManager, SessionEntry } from "./entries";
10
11
  import { type ConvertToLlm } from "./messages";
@@ -51,6 +52,11 @@ export interface GenerateBranchSummaryOptions {
51
52
  metadata?: Record<string, unknown>;
52
53
  /** Convert app-specific messages before serializing the branch summary prompt. */
53
54
  convertToLlm?: ConvertToLlm;
55
+ /**
56
+ * Optional telemetry handle. When provided, the branch summary LLM call is
57
+ * wrapped in an OTEL chat span tagged with `pi.gen_ai.oneshot.kind = "branch_summary"`.
58
+ */
59
+ telemetry?: AgentTelemetry;
54
60
  }
55
61
  /**
56
62
  * Collect entries that should be summarized when navigating from one position to another.
@@ -5,6 +5,7 @@
5
5
  * and after compaction the session is reloaded.
6
6
  */
7
7
  import { type MessageAttribution, type Model, type Usage } from "@oh-my-pi/pi-ai";
8
+ import { type AgentTelemetry } from "../telemetry";
8
9
  import type { AgentMessage, AgentTool } from "../types";
9
10
  import type { SessionEntry } from "./entries";
10
11
  import { type ConvertToLlm } from "./messages";
@@ -107,6 +108,13 @@ export interface SummaryOptions {
107
108
  initiatorOverride?: MessageAttribution;
108
109
  metadata?: Record<string, unknown>;
109
110
  convertToLlm?: ConvertToLlm;
111
+ /**
112
+ * Optional telemetry handle. When provided, every LLM call emitted during
113
+ * compaction is wrapped in an OTEL chat span tagged with
114
+ * `pi.gen_ai.oneshot.kind` (`compaction_summary`, `compaction_short_summary`,
115
+ * or `compaction_turn_prefix`). `undefined` keeps the call paths zero-cost.
116
+ */
117
+ telemetry?: AgentTelemetry;
110
118
  }
111
119
  export declare function generateSummary(currentMessages: AgentMessage[], model: Model, reserveTokens: number, apiKey: string, signal?: AbortSignal, customInstructions?: string, previousSummary?: string, options?: SummaryOptions): Promise<string>;
112
120
  export interface HandoffOptions {
@@ -118,6 +126,11 @@ export interface HandoffOptions {
118
126
  convertToLlm?: ConvertToLlm;
119
127
  initiatorOverride?: MessageAttribution;
120
128
  metadata?: Record<string, unknown>;
129
+ /**
130
+ * Optional telemetry handle. When provided, the handoff LLM call is
131
+ * wrapped in an OTEL chat span tagged with `pi.gen_ai.oneshot.kind = "handoff"`.
132
+ */
133
+ telemetry?: AgentTelemetry;
121
134
  }
122
135
  export declare function renderHandoffPrompt(customInstructions?: string): string;
123
136
  export declare function generateHandoff(messages: AgentMessage[], model: Model, apiKey: string, options: HandoffOptions, signal?: AbortSignal): Promise<string>;
@@ -108,16 +108,6 @@ export interface AgentRunCoverage {
108
108
  readonly modelsUsed: readonly string[];
109
109
  readonly providersUsed: readonly string[];
110
110
  }
111
- /**
112
- * Per-invocation event buffer. Constructed unconditionally inside
113
- * {@link resolveTelemetry}; cost is one allocation per `agentLoop` call.
114
- *
115
- * Methods are intentionally non-throwing — telemetry must never turn a
116
- * successful agent run into a failed one. WeakMap keys keep span-state
117
- * lookups bounded; if a finish path is somehow reached without a matching
118
- * begin (provider crash, tracer swap mid-run), the corresponding record is
119
- * still emitted with `latencyMs: 0` rather than throwing.
120
- */
121
111
  export declare class AgentRunCollector {
122
112
  #private;
123
113
  /** True once `markRunEnded()` has been called for this invocation. */
@@ -22,7 +22,7 @@
22
22
  * registered, `@opentelemetry/api` returns a no-op tracer and all calls are
23
23
  * cheap pass-throughs.
24
24
  */
25
- import { type AssistantMessage, type Message, type Model, type ServiceTier, type StopReason, type ToolChoice, type Usage } from "@oh-my-pi/pi-ai";
25
+ import { type Api, type AssistantMessage, type Context, type Message, type Model, type ServiceTier, type SimpleStreamOptions, type StopReason, type ToolChoice, type Usage } from "@oh-my-pi/pi-ai";
26
26
  import { type Attributes, type AttributeValue, type Span, SpanKind, SpanStatusCode, type Tracer, trace } from "@opentelemetry/api";
27
27
  import { AgentRunCollector, type AgentRunCoverage, type AgentRunSummary, type ToolStatus } from "./run-collector";
28
28
  import type { AgentTool } from "./types";
@@ -99,6 +99,7 @@ export declare const enum PiGenAIAttr {
99
99
  HandoffFromAgentId = "pi.gen_ai.handoff.from_agent.id",
100
100
  HandoffToAgentName = "pi.gen_ai.handoff.to_agent.name",
101
101
  HandoffToAgentId = "pi.gen_ai.handoff.to_agent.id",
102
+ OneshotKind = "pi.gen_ai.oneshot.kind",
102
103
  GatewayName = "pi.gen_ai.gateway.name",
103
104
  GatewayEndpoint = "pi.gen_ai.gateway.endpoint",
104
105
  GatewayCallId = "pi.gen_ai.gateway.call_id",
@@ -433,6 +434,42 @@ export interface ManualChatTelemetryOptions {
433
434
  readonly endSpan?: boolean;
434
435
  }
435
436
  export declare function recordManualChatTelemetry(telemetry: AgentTelemetry | undefined, options: ManualChatTelemetryOptions): Promise<Span | undefined>;
437
+ /**
438
+ * Options accepted by {@link instrumentedCompleteSimple}. Mirrors the
439
+ * `streamAssistantResponse` chat-span lifecycle for oneshot LLM calls
440
+ * (compaction summaries, handoff document, branch summary, inspect_image).
441
+ */
442
+ export interface InstrumentedChatSpanOptions {
443
+ readonly telemetry: AgentTelemetry | undefined;
444
+ /** Optional explicit parent span. Defaults to `context.active()`. */
445
+ readonly parent?: Span;
446
+ /** Step index recorded on the span; defaults to `-1` for non-loop calls. */
447
+ readonly stepNumber?: number;
448
+ /**
449
+ * Tag stamped onto `pi.gen_ai.oneshot.kind`. Values used by the agent:
450
+ * `compaction_summary`, `compaction_short_summary`, `compaction_turn_prefix`,
451
+ * `handoff`, `branch_summary`, `inspect_image`. Free-form to allow callers
452
+ * outside this package to add new kinds without bumping the helper.
453
+ */
454
+ readonly oneshotKind?: string;
455
+ /** Extra span attributes applied verbatim. */
456
+ readonly attributes?: Attributes;
457
+ /**
458
+ * Override for the underlying {@link completeSimple} call. Defaults to
459
+ * `completeSimple` from `@oh-my-pi/pi-ai`. Use to retain a test injection
460
+ * seam while still going through the chat-span lifecycle.
461
+ */
462
+ readonly completeImpl?: <TApi extends Api>(model: Model<TApi>, ctx: Context, options: SimpleStreamOptions) => Promise<AssistantMessage>;
463
+ }
464
+ /**
465
+ * Wrap a {@link completeSimple} round-trip with the same chat-span lifecycle
466
+ * the agent loop uses for streamed turns: `startChatSpan` → run inside the
467
+ * active span → `finishChatSpan` on success, `failChatSpan` on throw.
468
+ *
469
+ * Short-circuits when `telemetry` is `undefined` so cost / overhead stays at
470
+ * zero for installations without an OTEL SDK.
471
+ */
472
+ export declare function instrumentedCompleteSimple<TApi extends Api>(model: Model<TApi>, ctx: Context, options: SimpleStreamOptions, span: InstrumentedChatSpanOptions): Promise<AssistantMessage>;
436
473
  /**
437
474
  * Start an `execute_tool` span representing one tool invocation. Parented
438
475
  * under the supplied `invoke_agent` span by default — pass `parent` to
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-agent-core",
4
- "version": "15.1.2",
4
+ "version": "15.1.3",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -35,9 +35,9 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "15.1.2",
39
- "@oh-my-pi/pi-natives": "15.1.2",
40
- "@oh-my-pi/pi-utils": "15.1.2",
38
+ "@oh-my-pi/pi-ai": "15.1.3",
39
+ "@oh-my-pi/pi-natives": "15.1.3",
40
+ "@oh-my-pi/pi-utils": "15.1.3",
41
41
  "@opentelemetry/api": "^1.9.0"
42
42
  },
43
43
  "devDependencies": {
package/src/agent-loop.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  validateToolArguments,
15
15
  zodToWireSchema,
16
16
  } from "@oh-my-pi/pi-ai";
17
- import { sanitizeText } from "@oh-my-pi/pi-natives";
17
+ import { sanitizeText } from "@oh-my-pi/pi-utils";
18
18
  import {
19
19
  createHarmonyAuditEvent,
20
20
  type HarmonyDetection,
@@ -6,8 +6,8 @@
6
6
  */
7
7
 
8
8
  import type { Model } from "@oh-my-pi/pi-ai";
9
- import { completeSimple } from "@oh-my-pi/pi-ai";
10
9
  import { prompt } from "@oh-my-pi/pi-utils";
10
+ import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry";
11
11
  import type { AgentMessage } from "../types";
12
12
  import { estimateTokens } from "./compaction";
13
13
  import type { ReadonlySessionManager, SessionEntry } from "./entries";
@@ -81,6 +81,11 @@ export interface GenerateBranchSummaryOptions {
81
81
  metadata?: Record<string, unknown>;
82
82
  /** Convert app-specific messages before serializing the branch summary prompt. */
83
83
  convertToLlm?: ConvertToLlm;
84
+ /**
85
+ * Optional telemetry handle. When provided, the branch summary LLM call is
86
+ * wrapped in an OTEL chat span tagged with `pi.gen_ai.oneshot.kind = "branch_summary"`.
87
+ */
88
+ telemetry?: AgentTelemetry;
84
89
  }
85
90
 
86
91
  // ============================================================================
@@ -299,10 +304,11 @@ export async function generateBranchSummary(
299
304
  ];
300
305
 
301
306
  // Call LLM for summarization
302
- const response = await completeSimple(
307
+ const response = await instrumentedCompleteSimple(
303
308
  model,
304
309
  { systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT], messages: summarizationMessages },
305
310
  { apiKey, signal, maxTokens: 2048, metadata },
311
+ { telemetry: options.telemetry, oneshotKind: "branch_summary" },
306
312
  );
307
313
 
308
314
  // Check if aborted or errored
@@ -7,7 +7,6 @@
7
7
 
8
8
  import {
9
9
  type AssistantMessage,
10
- completeSimple,
11
10
  Effort,
12
11
  type Message,
13
12
  type MessageAttribution,
@@ -16,6 +15,7 @@ import {
16
15
  } from "@oh-my-pi/pi-ai";
17
16
  import { countTokens } from "@oh-my-pi/pi-natives";
18
17
  import { logger, prompt } from "@oh-my-pi/pi-utils";
18
+ import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry";
19
19
  import type { AgentMessage, AgentTool } from "../types";
20
20
  import type { CompactionEntry, SessionEntry } from "./entries";
21
21
  import { type ConvertToLlm, convertToLlm, createBranchSummaryMessage, createCustomMessage } from "./messages";
@@ -514,6 +514,13 @@ export interface SummaryOptions {
514
514
  initiatorOverride?: MessageAttribution;
515
515
  metadata?: Record<string, unknown>;
516
516
  convertToLlm?: ConvertToLlm;
517
+ /**
518
+ * Optional telemetry handle. When provided, every LLM call emitted during
519
+ * compaction is wrapped in an OTEL chat span tagged with
520
+ * `pi.gen_ai.oneshot.kind` (`compaction_summary`, `compaction_short_summary`,
521
+ * or `compaction_turn_prefix`). `undefined` keeps the call paths zero-cost.
522
+ */
523
+ telemetry?: AgentTelemetry;
517
524
  }
518
525
 
519
526
  export async function generateSummary(
@@ -570,7 +577,7 @@ export async function generateSummary(
570
577
  return remote.summary;
571
578
  }
572
579
 
573
- const response = await completeSimple(
580
+ const response = await instrumentedCompleteSimple(
574
581
  model,
575
582
  { systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT], messages: summarizationMessages },
576
583
  {
@@ -581,6 +588,7 @@ export async function generateSummary(
581
588
  initiatorOverride: options?.initiatorOverride,
582
589
  metadata: options?.metadata,
583
590
  },
591
+ { telemetry: options?.telemetry, oneshotKind: "compaction_summary" },
584
592
  );
585
593
 
586
594
  if (response.stopReason === "error") {
@@ -608,6 +616,11 @@ export interface HandoffOptions {
608
616
  convertToLlm?: ConvertToLlm;
609
617
  initiatorOverride?: MessageAttribution;
610
618
  metadata?: Record<string, unknown>;
619
+ /**
620
+ * Optional telemetry handle. When provided, the handoff LLM call is
621
+ * wrapped in an OTEL chat span tagged with `pi.gen_ai.oneshot.kind = "handoff"`.
622
+ */
623
+ telemetry?: AgentTelemetry;
611
624
  }
612
625
 
613
626
  export function renderHandoffPrompt(customInstructions?: string): string {
@@ -635,7 +648,7 @@ export async function generateHandoff(
635
648
  },
636
649
  ];
637
650
 
638
- const response = await completeSimple(
651
+ const response = await instrumentedCompleteSimple(
639
652
  model,
640
653
  {
641
654
  systemPrompt: options.systemPrompt,
@@ -650,6 +663,7 @@ export async function generateHandoff(
650
663
  initiatorOverride: options.initiatorOverride,
651
664
  metadata: options.metadata,
652
665
  },
666
+ { telemetry: options.telemetry, oneshotKind: "handoff" },
653
667
  );
654
668
 
655
669
  if (response.stopReason === "error") {
@@ -694,7 +708,7 @@ async function generateShortSummary(
694
708
  return remote.summary;
695
709
  }
696
710
 
697
- const response = await completeSimple(
711
+ const response = await instrumentedCompleteSimple(
698
712
  model,
699
713
  {
700
714
  systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT],
@@ -708,6 +722,7 @@ async function generateShortSummary(
708
722
  initiatorOverride: options?.initiatorOverride,
709
723
  metadata: options?.metadata,
710
724
  },
725
+ { telemetry: options?.telemetry, oneshotKind: "compaction_short_summary" },
711
726
  );
712
727
 
713
728
  if (response.stopReason === "error") {
@@ -889,6 +904,7 @@ export async function compact(
889
904
  initiatorOverride: options?.initiatorOverride,
890
905
  metadata: options?.metadata,
891
906
  convertToLlm: options?.convertToLlm,
907
+ telemetry: options?.telemetry,
892
908
  };
893
909
 
894
910
  let preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined);
@@ -978,6 +994,7 @@ export async function compact(
978
994
  remoteEndpoint: summaryOptions.remoteEndpoint,
979
995
  initiatorOverride: summaryOptions.initiatorOverride,
980
996
  metadata: summaryOptions.metadata,
997
+ telemetry: summaryOptions.telemetry,
981
998
  },
982
999
  );
983
1000
 
@@ -1023,7 +1040,7 @@ async function generateTurnPrefixSummary(
1023
1040
  },
1024
1041
  ];
1025
1042
 
1026
- const response = await completeSimple(
1043
+ const response = await instrumentedCompleteSimple(
1027
1044
  model,
1028
1045
  { systemPrompt: [SUMMARIZATION_SYSTEM_PROMPT], messages: summarizationMessages },
1029
1046
  {
@@ -1034,6 +1051,7 @@ async function generateTurnPrefixSummary(
1034
1051
  initiatorOverride: options?.initiatorOverride,
1035
1052
  metadata: options?.metadata,
1036
1053
  },
1054
+ { telemetry: options?.telemetry, oneshotKind: "compaction_turn_prefix" },
1037
1055
  );
1038
1056
 
1039
1057
  if (response.stopReason === "error") {
@@ -139,9 +139,12 @@ interface ToolStart {
139
139
  * begin (provider crash, tracer swap mid-run), the corresponding record is
140
140
  * still emitted with `latencyMs: 0` rather than throwing.
141
141
  */
142
+ const kChatStart = Symbol("agent.run-collector.chatStart");
143
+ const kToolStart = Symbol("agent.run-collector.toolStart");
144
+ type SpanWithChatStart = Span & { [kChatStart]?: ChatStart };
145
+ type SpanWithToolStart = Span & { [kToolStart]?: ToolStart };
146
+
142
147
  export class AgentRunCollector {
143
- readonly #chatStarts = new WeakMap<Span, ChatStart>();
144
- readonly #toolStarts = new WeakMap<Span, ToolStart>();
145
148
  readonly #chats: ChatRecord[] = [];
146
149
  readonly #tools: ToolRecord[] = [];
147
150
  readonly #availableTools = new Set<string>();
@@ -179,12 +182,12 @@ export class AgentRunCollector {
179
182
  init: { readonly stepNumber: number; readonly model: Model; readonly provider?: string },
180
183
  ): void {
181
184
  const provider = init.provider ?? init.model.provider;
182
- this.#chatStarts.set(span, {
185
+ (span as SpanWithChatStart)[kChatStart] = {
183
186
  stepNumber: init.stepNumber,
184
187
  startedAtMs: performance.now(),
185
188
  model: init.model.id,
186
189
  provider,
187
- });
190
+ };
188
191
  this.#modelsUsed.add(init.model.id);
189
192
  if (provider) this.#providersUsed.add(provider);
190
193
  }
@@ -197,8 +200,8 @@ export class AgentRunCollector {
197
200
  readonly costUnavailableReason: string | undefined;
198
201
  },
199
202
  ): void {
200
- const start = this.#chatStarts.get(span);
201
- this.#chatStarts.delete(span);
203
+ const start = (span as SpanWithChatStart)[kChatStart];
204
+ (span as SpanWithChatStart)[kChatStart] = undefined;
202
205
  const usage = message.usage;
203
206
  // Public surface: `inputTokens` is the total cost-bearing input the
204
207
  // provider charged for, so it must include cache_read + cache_write.
@@ -237,8 +240,8 @@ export class AgentRunCollector {
237
240
  * appear in the run summary.
238
241
  */
239
242
  failChat(span: Span, fields: { readonly errorType: string }): void {
240
- const start = this.#chatStarts.get(span);
241
- this.#chatStarts.delete(span);
243
+ const start = (span as SpanWithChatStart)[kChatStart];
244
+ (span as SpanWithChatStart)[kChatStart] = undefined;
242
245
  this.#chats.push({
243
246
  stepNumber: start?.stepNumber ?? -1,
244
247
  model: start?.model ?? "",
@@ -258,17 +261,17 @@ export class AgentRunCollector {
258
261
  }
259
262
 
260
263
  beginTool(span: Span, init: { readonly toolCallId: string; readonly toolName: string }): void {
261
- this.#toolStarts.set(span, {
264
+ (span as SpanWithToolStart)[kToolStart] = {
262
265
  toolCallId: init.toolCallId,
263
266
  toolName: init.toolName,
264
267
  startedAtMs: performance.now(),
265
- });
268
+ };
266
269
  this.#invokedTools.add(init.toolName);
267
270
  }
268
271
 
269
272
  endTool(span: Span, fields: { readonly status: ToolStatus; readonly errorType: string | undefined }): void {
270
- const start = this.#toolStarts.get(span);
271
- this.#toolStarts.delete(span);
273
+ const start = (span as SpanWithToolStart)[kToolStart];
274
+ (span as SpanWithToolStart)[kToolStart] = undefined;
272
275
  this.#tools.push({
273
276
  toolCallId: start?.toolCallId ?? "",
274
277
  toolName: start?.toolName ?? "",
package/src/telemetry.ts CHANGED
@@ -24,10 +24,14 @@
24
24
  */
25
25
 
26
26
  import {
27
+ type Api,
27
28
  type AssistantMessage,
29
+ type Context,
30
+ completeSimple,
28
31
  type Message,
29
32
  type Model,
30
33
  type ServiceTier,
34
+ type SimpleStreamOptions,
31
35
  type StopReason,
32
36
  shouldSendServiceTier,
33
37
  type ToolChoice,
@@ -139,6 +143,10 @@ export const enum PiGenAIAttr {
139
143
  HandoffFromAgentId = "pi.gen_ai.handoff.from_agent.id",
140
144
  HandoffToAgentName = "pi.gen_ai.handoff.to_agent.name",
141
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",
142
150
  // Gateway / proxy (LiteLLM, Helicone, Portkey, …) — populated when a known
143
151
  // gateway header pattern is detected on the upstream response. The base
144
152
  // `gen_ai.provider.name` continues to track the *upstream* provider (e.g.
@@ -1573,6 +1581,111 @@ export async function recordManualChatTelemetry(
1573
1581
  return span;
1574
1582
  }
1575
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 `@oh-my-pi/pi-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
+
1576
1689
  /**
1577
1690
  * Start an `execute_tool` span representing one tool invocation. Parented
1578
1691
  * under the supplied `invoke_agent` span by default — pass `parent` to