@struct-ai/sdk 0.3.0 → 0.4.2

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 (58) hide show
  1. package/README.md +101 -16
  2. package/dist/commonjs/context.d.ts +45 -0
  3. package/dist/commonjs/context.js +78 -1
  4. package/dist/commonjs/core.js +184 -29
  5. package/dist/commonjs/events.d.ts +17 -6
  6. package/dist/commonjs/events.js +82 -59
  7. package/dist/commonjs/genai-content.d.ts +52 -0
  8. package/dist/commonjs/genai-content.js +143 -0
  9. package/dist/commonjs/instrument.d.ts +47 -0
  10. package/dist/commonjs/instrument.js +158 -0
  11. package/dist/commonjs/integrations/anthropic-content.js +18 -6
  12. package/dist/commonjs/integrations/anthropic.d.ts +8 -1
  13. package/dist/commonjs/integrations/anthropic.js +515 -104
  14. package/dist/commonjs/integrations/index.js +8 -0
  15. package/dist/commonjs/integrations/langchain-callback.d.ts +182 -27
  16. package/dist/commonjs/integrations/langchain-callback.js +754 -87
  17. package/dist/commonjs/integrations/langchain-content.js +1 -1
  18. package/dist/commonjs/integrations/langchain.d.ts +3 -0
  19. package/dist/commonjs/integrations/langchain.js +353 -7
  20. package/dist/commonjs/integrations/openai-content.d.ts +34 -0
  21. package/dist/commonjs/integrations/openai-content.js +375 -0
  22. package/dist/commonjs/integrations/openai.d.ts +39 -0
  23. package/dist/commonjs/integrations/openai.js +305 -0
  24. package/dist/commonjs/semconv.d.ts +12 -0
  25. package/dist/commonjs/semconv.js +13 -1
  26. package/dist/commonjs/truncation.d.ts +29 -0
  27. package/dist/commonjs/truncation.js +184 -10
  28. package/dist/commonjs/version.d.ts +2 -0
  29. package/dist/commonjs/version.js +6 -0
  30. package/dist/esm/context.d.ts +45 -0
  31. package/dist/esm/context.js +74 -1
  32. package/dist/esm/core.js +185 -30
  33. package/dist/esm/events.d.ts +17 -6
  34. package/dist/esm/events.js +82 -61
  35. package/dist/esm/genai-content.d.ts +52 -0
  36. package/dist/esm/genai-content.js +137 -0
  37. package/dist/esm/instrument.d.ts +47 -0
  38. package/dist/esm/instrument.js +155 -0
  39. package/dist/esm/integrations/anthropic-content.js +19 -7
  40. package/dist/esm/integrations/anthropic.d.ts +8 -1
  41. package/dist/esm/integrations/anthropic.js +514 -107
  42. package/dist/esm/integrations/index.js +8 -0
  43. package/dist/esm/integrations/langchain-callback.d.ts +182 -27
  44. package/dist/esm/integrations/langchain-callback.js +756 -89
  45. package/dist/esm/integrations/langchain-content.js +1 -1
  46. package/dist/esm/integrations/langchain.d.ts +3 -0
  47. package/dist/esm/integrations/langchain.js +352 -7
  48. package/dist/esm/integrations/openai-content.d.ts +34 -0
  49. package/dist/esm/integrations/openai-content.js +360 -0
  50. package/dist/esm/integrations/openai.d.ts +39 -0
  51. package/dist/esm/integrations/openai.js +296 -0
  52. package/dist/esm/semconv.d.ts +12 -0
  53. package/dist/esm/semconv.js +12 -0
  54. package/dist/esm/truncation.d.ts +29 -0
  55. package/dist/esm/truncation.js +182 -10
  56. package/dist/esm/version.d.ts +2 -0
  57. package/dist/esm/version.js +3 -0
  58. package/package.json +11 -3
@@ -4,12 +4,38 @@ export interface StructContext {
4
4
  conversationId?: string;
5
5
  agentSpan?: Span;
6
6
  pendingToolCalls?: Record<string, string[]>;
7
+ /**
8
+ * When true, a framework-layer integration (e.g. LangChain's
9
+ * BaseChatModel.generate/.stream) already owns the chat span for the call
10
+ * in progress. Provider-SDK patches (anthropic.ts) check this and skip
11
+ * emitting their own chat span to avoid a duplicate.
12
+ */
13
+ suppressGenAi?: boolean;
14
+ /**
15
+ * Set by `struct.agent()` while its body is running. Signals ownership:
16
+ * when the LangChain callback handler sees a TOP-LEVEL chain start (no
17
+ * parentRunId) while this is set, the manual `struct.agent()` call already
18
+ * owns the `invoke_agent` span for this run — the handler must not emit a
19
+ * duplicate ("twin") span, only register the run so descendants parent
20
+ * under the manual span. Parity: python `_manual_agent_active` contextvar
21
+ * (core.py:64-68).
22
+ */
23
+ manualAgentSpan?: Span;
7
24
  }
8
25
  export declare function getStore(): StructContext | undefined;
9
26
  export declare function getSessionId(): string | undefined;
10
27
  export declare function getConversationId(): string | undefined;
11
28
  export declare function getAgentSpan(): Span | undefined;
29
+ /**
30
+ * The manually-created `struct.agent()` span for the currently-running
31
+ * scope, if any. Read by the LangChain callback handler to detect
32
+ * "manual struct.agent() wraps a top-level LangChain chain" and suppress
33
+ * the twin `invoke_agent` span it would otherwise emit.
34
+ */
35
+ export declare function getManualAgentSpan(): Span | undefined;
12
36
  export declare function getPendingToolCalls(): Record<string, string[]> | undefined;
37
+ /** True when a framework layer (e.g. LangChain) already owns the chat span. */
38
+ export declare function isGenAiSuppressed(): boolean;
13
39
  export declare function runWithContext<T>(patch: Partial<StructContext>, fn: () => T): T;
14
40
  export declare function ensurePendingToolCallsSlot(): Record<string, string[]>;
15
41
  /**
@@ -27,4 +53,23 @@ export declare function snapshotStore(): StructContext | undefined;
27
53
  export declare function runWithStore<T>(store: StructContext | undefined, fn: () => T): T;
28
54
  /** Test-only: run `fn` inside a completely fresh context (no parent store). */
29
55
  export declare function runInFreshContext<T>(fn: () => T): T;
56
+ /**
57
+ * Write-once `gen_ai.provider.name` on an invoke_agent span.
58
+ *
59
+ * CONTRACT: `agentSpan` is always an SDK-OWNED span — created by our own
60
+ * tracer in `struct.agent()` or the LangChain handler and delivered via the
61
+ * ALS store / run map, which nothing else writes. Never a host object. So
62
+ * the industry-standard owned-object pattern applies (state lives ON the
63
+ * object — Sentry/dd-trace private span fields, OTel JS symbol markers): a
64
+ * private Symbol sentinel set after a successful write. No registries or
65
+ * lifecycle bookkeeping — those are for FOREIGN objects.
66
+ *
67
+ * Semantics: "a real child provider" — racing children with different
68
+ * providers may pick either; the sentinel is set only after a successful
69
+ * write so a transient failure can be retried. Parity: python
70
+ * `stamp_provider_once`.
71
+ */
72
+ export declare function stampProviderOnce(agentSpan: Span | undefined, provider: string | undefined): void;
73
+ /** Stamp the ambient agent span with the child call's provider (write-once). */
74
+ export declare function propagateProviderToParent(provider: string | undefined): void;
30
75
  //# sourceMappingURL=context.d.ts.map
@@ -12,9 +12,22 @@ export function getConversationId() {
12
12
  export function getAgentSpan() {
13
13
  return als.getStore()?.agentSpan;
14
14
  }
15
+ /**
16
+ * The manually-created `struct.agent()` span for the currently-running
17
+ * scope, if any. Read by the LangChain callback handler to detect
18
+ * "manual struct.agent() wraps a top-level LangChain chain" and suppress
19
+ * the twin `invoke_agent` span it would otherwise emit.
20
+ */
21
+ export function getManualAgentSpan() {
22
+ return als.getStore()?.manualAgentSpan;
23
+ }
15
24
  export function getPendingToolCalls() {
16
25
  return als.getStore()?.pendingToolCalls;
17
26
  }
27
+ /** True when a framework layer (e.g. LangChain) already owns the chat span. */
28
+ export function isGenAiSuppressed() {
29
+ return als.getStore()?.suppressGenAi === true;
30
+ }
18
31
  export function runWithContext(patch, fn) {
19
32
  const current = als.getStore() ?? {};
20
33
  const next = { ...current, ...patch };
@@ -66,7 +79,11 @@ export function pushPendingToolCalls(pairs) {
66
79
  /** Snapshot the store for wrapping async iterators. */
67
80
  export function snapshotStore() {
68
81
  const store = als.getStore();
69
- return store ? { ...store } : undefined;
82
+ if (!store)
83
+ return undefined;
84
+ if (!store.pendingToolCalls)
85
+ store.pendingToolCalls = {}; // materialize BEFORE copying so the queue object is shared by reference
86
+ return { ...store };
70
87
  }
71
88
  export function runWithStore(store, fn) {
72
89
  if (!store)
@@ -77,4 +94,60 @@ export function runWithStore(store, fn) {
77
94
  export function runInFreshContext(fn) {
78
95
  return als.run({}, fn);
79
96
  }
97
+ /**
98
+ * Write-once `gen_ai.provider.name` on an invoke_agent span.
99
+ *
100
+ * The GenAI spec puts `gen_ai.provider.name` on invoke_agent spans, but the
101
+ * layer that CREATES those spans (`struct.agent()`, the LangChain handler) is
102
+ * provider-agnostic and can't know the value yet — only the child inference
103
+ * call can. The first chat call within the agent scope stamps it (best
104
+ * knowledge; first one wins); an agent that never reaches a model omits the
105
+ * attribute rather than carrying a framework name. Parity: python
106
+ * `_genai_content.stamp_provider_once`.
107
+ */
108
+ const PROVIDER_STAMPED = Symbol("struct.providerStamped");
109
+ /**
110
+ * Write-once `gen_ai.provider.name` on an invoke_agent span.
111
+ *
112
+ * CONTRACT: `agentSpan` is always an SDK-OWNED span — created by our own
113
+ * tracer in `struct.agent()` or the LangChain handler and delivered via the
114
+ * ALS store / run map, which nothing else writes. Never a host object. So
115
+ * the industry-standard owned-object pattern applies (state lives ON the
116
+ * object — Sentry/dd-trace private span fields, OTel JS symbol markers): a
117
+ * private Symbol sentinel set after a successful write. No registries or
118
+ * lifecycle bookkeeping — those are for FOREIGN objects.
119
+ *
120
+ * Semantics: "a real child provider" — racing children with different
121
+ * providers may pick either; the sentinel is set only after a successful
122
+ * write so a transient failure can be retried. Parity: python
123
+ * `stamp_provider_once`.
124
+ */
125
+ export function stampProviderOnce(agentSpan, provider) {
126
+ try {
127
+ if (!agentSpan || !provider)
128
+ return;
129
+ const marked = agentSpan;
130
+ if (marked[PROVIDER_STAMPED])
131
+ return;
132
+ agentSpan.setAttribute("gen_ai.provider.name", provider);
133
+ try {
134
+ marked[PROVIDER_STAMPED] = true;
135
+ }
136
+ catch {
137
+ /* frozen span — worst case a later child re-stamps a real provider */
138
+ }
139
+ }
140
+ catch {
141
+ /* never fail the application for telemetry */
142
+ }
143
+ }
144
+ /** Stamp the ambient agent span with the child call's provider (write-once). */
145
+ export function propagateProviderToParent(provider) {
146
+ try {
147
+ stampProviderOnce(getAgentSpan(), provider);
148
+ }
149
+ catch {
150
+ /* never fail the application for telemetry */
151
+ }
152
+ }
80
153
  //# sourceMappingURL=context.js.map
package/dist/esm/core.js CHANGED
@@ -1,5 +1,4 @@
1
- import { randomUUID } from "node:crypto";
2
- import { context as otelContext, SpanKind, SpanStatusCode, trace, } from "@opentelemetry/api";
1
+ import { context as otelContext, ROOT_CONTEXT, SpanKind, SpanStatusCode, trace, } from "@opentelemetry/api";
3
2
  import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
4
3
  import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
5
4
  import { resourceFromAttributes } from "@opentelemetry/resources";
@@ -10,6 +9,7 @@ import { getAgentSpan, getSessionId, popPendingToolCallId, runWithContext, } fro
10
9
  import { autoInstrument } from "./integrations/index.js";
11
10
  import { ERROR_TYPE, GEN_AI, STRUCT, } from "./semconv.js";
12
11
  import { safeJsonStringify } from "./truncation.js";
12
+ import { SDK_VERSION } from "./version.js";
13
13
  export const DEFAULT_ENDPOINT = "https://ingest.struct.ai";
14
14
  const LOG_ENABLED = process.env.STRUCT_SDK_LOG === "1";
15
15
  const defaultInternalLogger = {
@@ -84,12 +84,22 @@ export function safe(fn, site, logger) {
84
84
  fn();
85
85
  }
86
86
  catch (err) {
87
- if (firstFailureLogged.has(site)) {
88
- logger.debug(`Struct SDK suppressed exception at ${site}`, err);
87
+ // The diagnostic log MUST NOT itself throw into the host: the default
88
+ // logger reaches `console.warn`, which a host may have replaced with a
89
+ // throwing implementation, and callers pass custom loggers. A throw here
90
+ // would escape `safe()` and defeat its whole purpose (e.g. block the host
91
+ // call when span creation fails). Guard the logging too.
92
+ try {
93
+ if (firstFailureLogged.has(site)) {
94
+ logger.debug(`Struct SDK suppressed exception at ${site}`, err);
95
+ }
96
+ else {
97
+ firstFailureLogged.add(site);
98
+ logger.warn(`Struct SDK suppressed exception at ${site}`, err);
99
+ }
89
100
  }
90
- else {
91
- firstFailureLogged.add(site);
92
- logger.warn(`Struct SDK suppressed exception at ${site}`, err);
101
+ catch {
102
+ /* diagnostic logging failed — never propagate into the host path */
93
103
  }
94
104
  }
95
105
  }
@@ -228,13 +238,13 @@ export class StructSDK {
228
238
  if (!this._tracerProvider) {
229
239
  throw new Error("Call struct.init() before using the SDK");
230
240
  }
231
- return this._tracerProvider.getTracer(name);
241
+ return this._tracerProvider.getTracer(name, SDK_VERSION);
232
242
  }
233
243
  getLogger(name = "struct-sdk") {
234
244
  if (!this._loggerProvider) {
235
245
  throw new Error("Call struct.init() before using the SDK");
236
246
  }
237
- return this._loggerProvider.getLogger(name);
247
+ return this._loggerProvider.getLogger(name, SDK_VERSION);
238
248
  }
239
249
  getInternalLogger() {
240
250
  return this._internalLogger;
@@ -302,8 +312,7 @@ export class StructSDK {
302
312
  return await fn();
303
313
  }
304
314
  const agentName = options.name;
305
- const sessionId = options.sessionId ?? randomUUID();
306
- const parentSessionId = getSessionId();
315
+ const explicitSessionId = options.sessionId;
307
316
  const tracer = this.getTracer("struct-sdk");
308
317
  // Parent the new agent span on the nearest enclosing agent span (if
309
318
  // any) so nested `struct.agent()` / `struct.tool()` calls build a
@@ -312,16 +321,68 @@ export class StructSDK {
312
321
  // nothing to hang off. We do NOT rely on the global OTel context
313
322
  // manager being installed — the SDK manages its own parent chain via
314
323
  // StructContext ALS to stay neutral in multi-tenant OTel setups.
315
- const parentSpan = getAgentSpan();
316
- const parentCtx = parentSpan
317
- ? trace.setSpan(otelContext.active(), parentSpan)
318
- : otelContext.active();
324
+ //
325
+ // Captured BEFORE we resolve/overwrite anything below — mirrors
326
+ // python's `enclosing_session_id` / `enclosing_agent_span` capture at
327
+ // core.py:625-626. Used to (1) resolve this agent's own session id via
328
+ // ambient inheritance, (2) detect break-out, and (3) decide whether to
329
+ // stamp `struct.agent.parent_session_id`.
330
+ const enclosingSessionId = getSessionId();
331
+ const enclosingAgentSpan = getAgentSpan();
332
+ // ── Resolve sessionId (REVISION R1 grouping model) ──────────────────
333
+ // Resolution order: explicit caller arg > ambient (enclosing agent) >
334
+ // session-less. We never fabricate a uuid — a caller that omits
335
+ // sessionId with no enclosing agent gets a SESSION-LESS run (no
336
+ // `gen_ai.conversation.id` attribute at all). Ports core.py:628-644
337
+ // exactly (`self._session_id = ""` there is `undefined` here).
338
+ const sessionId = explicitSessionId !== undefined
339
+ ? explicitSessionId
340
+ : enclosingSessionId !== undefined
341
+ ? enclosingSessionId
342
+ : undefined;
343
+ // ── Break-out detection (spawned-by Link) ───────────────────────────
344
+ // Caller supplied an EXPLICIT session id that differs from the
345
+ // enclosing agent's session AND there IS an enclosing Struct agent
346
+ // span in scope. This agent starts a fresh ROOT trace (no OTel
347
+ // parent) and carries a causal Link back to the enclosing agent's
348
+ // span context. Ports struct_sdk.core._AgentContext._start_span's
349
+ // `break_out` branch (struct-sdk-python/src/struct_sdk/core.py:651-656)
350
+ // exactly.
351
+ const breakOut = explicitSessionId !== undefined &&
352
+ enclosingSessionId !== undefined &&
353
+ explicitSessionId !== enclosingSessionId &&
354
+ enclosingAgentSpan !== undefined;
355
+ // ── Parentage: PURE NEST (Option D) ─────────────────────────────────
356
+ // A non-break-out agent nests under whatever OTel span is active — the
357
+ // host's ambient context — like every peer LLM/agent SDK. We do NOT
358
+ // second-guess the active span or unilaterally re-root: the old
359
+ // `foreignRoot` heuristic detached from ANY active span and so ripped
360
+ // agents out of legitimate host request traces. Cross-conversation
361
+ // grouping is carried by gen_ai.conversation.id, never by trace
362
+ // parentage. A runtime that propagates a stale/unwanted span across a
363
+ // boundary (e.g. our own persistent_agent Temporal workflow span) clears
364
+ // it at the SOURCE, never here. (A provenance-gated detach — re-root only
365
+ // a leaked Struct-OWNED span — is a documented, deferred follow-up.)
366
+ let startContext;
367
+ let links;
368
+ if (breakOut) {
369
+ // Explicit different session under another Struct agent: fresh ROOT
370
+ // trace + a spawned-by Link. The one deliberate, opt-in re-root.
371
+ // enclosingAgentSpan is guaranteed defined by the breakOut guard above.
372
+ links = [{ context: enclosingAgentSpan.spanContext() }];
373
+ startContext = ROOT_CONTEXT;
374
+ }
375
+ else {
376
+ startContext = enclosingAgentSpan
377
+ ? trace.setSpan(otelContext.active(), enclosingAgentSpan)
378
+ : otelContext.active();
379
+ }
319
380
  // Span creation itself can fail (custom tracer, broken context). If it
320
381
  // does, fall through to running `fn` directly so the user's call
321
382
  // always runs uninstrumented rather than blowing up on telemetry.
322
383
  let span;
323
384
  safe(() => {
324
- span = tracer.startSpan(`invoke_agent ${agentName}`, { kind: SpanKind.INTERNAL }, parentCtx);
385
+ span = tracer.startSpan(`invoke_agent ${agentName}`, { kind: SpanKind.INTERNAL, links }, startContext);
325
386
  }, "agent.start_span", this._internalLogger);
326
387
  if (span === undefined) {
327
388
  return await fn();
@@ -329,7 +390,10 @@ export class StructSDK {
329
390
  const startedSpan = span;
330
391
  safe(() => {
331
392
  startedSpan.setAttribute(GEN_AI.OPERATION_NAME, "invoke_agent");
332
- startedSpan.setAttribute(GEN_AI.PROVIDER_NAME, "struct");
393
+ // gen_ai.provider.name is NOT set here: this layer is
394
+ // provider-agnostic, and a framework name ("struct") isn't a
395
+ // provider. The first child inference call stamps the real one via
396
+ // propagateProviderToParent (write-once, best knowledge).
333
397
  startedSpan.setAttribute(GEN_AI.AGENT_NAME, agentName);
334
398
  // gen_ai.agent.id is the stable identifier of the agent
335
399
  // DEFINITION. Only set it when the caller provides one — we do
@@ -342,17 +406,24 @@ export class StructSDK {
342
406
  startedSpan.setAttribute(GEN_AI.AGENT_VERSION, options.version);
343
407
  }
344
408
  // gen_ai.conversation.id is the spec-blessed name for
345
- // session/thread id.
346
- startedSpan.setAttribute(GEN_AI.CONVERSATION_ID, sessionId);
347
- // Always set parent_session_id when there's a parent agent — even
348
- // when the value matches sessionId (which happens when a nested
349
- // ``struct.agent()`` inherits ambient session). The attribute is
350
- // structural ("this agent has a parent agent"), not a uniqueness
351
- // marker. The UI uses it to render an inline subagent expansion
352
- // under the triggering call (vs the drill-in flow used when
353
- // sessionIds differ).
354
- if (parentSessionId) {
355
- startedSpan.setAttribute(STRUCT.AGENT_PARENT_SESSION_ID, parentSessionId);
409
+ // session/thread id. Omitted when session-less (never fabricated).
410
+ // Ports core.py:723-725 (`if self._session_id:`) exactly.
411
+ if (sessionId) {
412
+ startedSpan.setAttribute(GEN_AI.CONVERSATION_ID, sessionId);
413
+ }
414
+ // ``struct.agent.parent_session_id`` is a SPAWNED-BY marker — set it
415
+ // ONLY when this agent has an enclosing session that DIFFERS from
416
+ // its own resolved session id. An inline nested agent (same session
417
+ // as the enclosing agent, whether by explicit same id or ambient
418
+ // inheritance) already has its parent relationship encoded by the
419
+ // OTel span tree (ParentSpanId) — stamping
420
+ // parent_session_id === own sessionId would be self-referential
421
+ // noise. Structure comes from the tree/Link, not this attr
422
+ // (Link-canonical decision). Ports core.py:736-745 exactly (both
423
+ // the break-out and the "legacy" differing-id-no-span branches
424
+ // collapse to this one condition).
425
+ if (enclosingSessionId !== undefined && enclosingSessionId !== sessionId) {
426
+ startedSpan.setAttribute(STRUCT.AGENT_PARENT_SESSION_ID, enclosingSessionId);
356
427
  }
357
428
  if (options.metadata) {
358
429
  for (const [key, value] of Object.entries(options.metadata)) {
@@ -365,6 +436,7 @@ export class StructSDK {
365
436
  conversationId: sessionId,
366
437
  agentSpan: startedSpan,
367
438
  pendingToolCalls: {},
439
+ manualAgentSpan: startedSpan,
368
440
  }, async () => {
369
441
  const activeCtx = trace.setSpan(otelContext.active(), startedSpan);
370
442
  try {
@@ -416,7 +488,8 @@ export class StructSDK {
416
488
  const startedSpan = span;
417
489
  safe(() => {
418
490
  startedSpan.setAttribute(GEN_AI.OPERATION_NAME, "execute_tool");
419
- startedSpan.setAttribute(GEN_AI.PROVIDER_NAME, "struct");
491
+ // No gen_ai.provider.name: the spec's execute_tool span does not
492
+ // define that attribute.
420
493
  startedSpan.setAttribute(GEN_AI.TOOL_NAME, toolName);
421
494
  if (toolCallId) {
422
495
  startedSpan.setAttribute(GEN_AI.TOOL_CALL_ID, toolCallId);
@@ -432,7 +505,28 @@ export class StructSDK {
432
505
  if (this.captureContent && result !== undefined && result !== null) {
433
506
  safe(() => startedSpan.setAttribute(GEN_AI.TOOL_CALL_RESULT, safeJsonStringify(result).slice(0, 8192)), "tool.set_result_attr", this._internalLogger);
434
507
  }
435
- safe(() => startedSpan.setStatus({ code: SpanStatusCode.OK }), "tool.set_ok_status", this._internalLogger);
508
+ if (toolResultSignalsError(result)) {
509
+ safe(() => {
510
+ startedSpan.setAttribute(ERROR_TYPE, "tool_error");
511
+ // The status message is content placed on a SPAN — it follows
512
+ // the span-content routing gate (emitSpanContent), not merely
513
+ // "any capture on": under EventOnly (the default) content
514
+ // routes to log events, so span text gets the fixed literal.
515
+ // Deliberate boundary: gen_ai.tool.call.result above stays on
516
+ // captureContent — tool spans have no log-event equivalent for
517
+ // results, so the attribute is the sanctioned tool-content
518
+ // channel in every non-None mode. Mirrors python _note_result.
519
+ startedSpan.setStatus({
520
+ code: SpanStatusCode.ERROR,
521
+ message: this.emitSpanContent
522
+ ? toolErrorStatusMessage(result)
523
+ : "tool returned is_error=true",
524
+ });
525
+ }, "tool.set_tool_error_status", this._internalLogger);
526
+ }
527
+ else {
528
+ safe(() => startedSpan.setStatus({ code: SpanStatusCode.OK }), "tool.set_ok_status", this._internalLogger);
529
+ }
436
530
  return result;
437
531
  }
438
532
  catch (err) {
@@ -453,4 +547,65 @@ function recordError(span, err) {
453
547
  span.recordException(err);
454
548
  }
455
549
  }
550
+ /**
551
+ * Read one property from untrusted host data, isolating the read in its
552
+ * own try — a hostile getter/Proxy trap on ONE key must never throw into
553
+ * tool()'s try block (rejecting the host's successful call) nor mask a
554
+ * readable value on a SIBLING key (callers probe each supported alias
555
+ * independently). Mirrors python `_safe_probe` — keep in lockstep.
556
+ */
557
+ function safeProbe(obj, key) {
558
+ if (typeof obj !== "object" || obj === null)
559
+ return undefined;
560
+ try {
561
+ return obj[key];
562
+ }
563
+ catch {
564
+ return undefined;
565
+ }
566
+ }
567
+ /**
568
+ * Whether a tool's RETURN VALUE signals in-band failure (MCP
569
+ * CallToolResult.isError / Anthropic tool_result.is_error). Strictly
570
+ * boolean `true` on the top-level object — truthy strings/numbers and
571
+ * nested flags do not trigger (host data is untrusted; be conservative).
572
+ * Each alias is probed independently so a hostile getter on one cannot
573
+ * mask the other. Mirrors python `_tool_result_signals_error`.
574
+ */
575
+ function toolResultSignalsError(result) {
576
+ return (safeProbe(result, "isError") === true ||
577
+ safeProbe(result, "is_error") === true);
578
+ }
579
+ /** Short status message from an error result's content, else a fixed one.
580
+ *
581
+ * TOTAL FUNCTION: never throws and does bounded work. It runs inside the
582
+ * safe() closure that also sets span status — a hostile Proxy whose
583
+ * `length`/index traps throw must not abort that closure (which would
584
+ * leave the span UNSET instead of ERROR). Two layers: per-key safeProbe
585
+ * isolation (one hostile item cannot mask a later readable one) INSIDE a
586
+ * whole-body catch (collection machinery itself is untrusted), index
587
+ * loop instead of for..of (iterator protocol is trappable), traversal
588
+ * capped at 20 items. Mirrors python `_tool_error_status_message`. */
589
+ function toolErrorStatusMessage(result) {
590
+ try {
591
+ const content = safeProbe(result, "content");
592
+ if (typeof content === "string" && content)
593
+ return content.slice(0, 256);
594
+ if (Array.isArray(content)) {
595
+ const len = Math.min(content.length, 20);
596
+ for (let i = 0; i < len; i++) {
597
+ const item = safeProbe(content, String(i));
598
+ const text = safeProbe(item, "text");
599
+ // Truthiness (not just typeof) matches the python twin: empty-string
600
+ // text items are skipped, falling through to the fixed message.
601
+ if (typeof text === "string" && text)
602
+ return text.slice(0, 256);
603
+ }
604
+ }
605
+ }
606
+ catch {
607
+ // Hostile collection — fall through to the fixed message.
608
+ }
609
+ return "tool returned is_error=true";
610
+ }
456
611
  //# sourceMappingURL=core.js.map
@@ -1,12 +1,23 @@
1
- import { type Logger } from "@opentelemetry/api-logs";
1
+ import type { Logger } from "@opentelemetry/api-logs";
2
+ import type { Span } from "@opentelemetry/api";
2
3
  /**
3
4
  * Emit per-message log events for an Anthropic messages.create() call.
4
- * Port of _emit_message_events from anthropic.py.
5
+ * Delegates the LogRecord wiring to the shared genai-content emitters; this
6
+ * function is only the Anthropic message → parts mapping + ordering.
5
7
  */
6
- export declare function emitAnthropicMessageEvents(logger: Logger, messages: unknown, system: unknown): void;
8
+ export declare function emitAnthropicMessageEvents(logger: Logger, messages: unknown, system: unknown, span?: Span, provider?: string): void;
9
+ /** Emit a gen_ai.choice LogRecord for an Anthropic response. */
10
+ export declare function emitAnthropicChoiceEvent(logger: Logger, contentBlocks: unknown, stopReason: string | null | undefined, span?: Span, provider?: string): void;
7
11
  /**
8
- * Emit a gen_ai.choice LogRecord for an Anthropic response.
9
- * Port of _emit_choice_event from anthropic.py.
12
+ * Emit per-message log events for an OpenAI responses.create() call.
13
+ * `instructions` (the Responses system prompt) is emitted FIRST at index 0.
14
+ * Delegates LogRecord wiring to the shared emitters; only the Responses
15
+ * item → event mapping + ordering lives here.
10
16
  */
11
- export declare function emitAnthropicChoiceEvent(logger: Logger, contentBlocks: unknown, stopReason: string | null | undefined): void;
17
+ export declare function emitOpenAIInputMessageEvents(logger: Logger, input: unknown, instructions: unknown, span?: Span, provider?: string): void;
18
+ /**
19
+ * Emit the gen_ai.choice LogRecord from an OpenAI response.output.
20
+ * `finishReason` is mapped inside this emitter using mapChoiceFinishReason.
21
+ */
22
+ export declare function emitOpenAIChoiceEvent(logger: Logger, output: unknown, finishReason: string | undefined, span?: Span, provider?: string): void;
12
23
  //# sourceMappingURL=events.d.ts.map