@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.
- package/README.md +101 -16
- package/dist/commonjs/context.d.ts +45 -0
- package/dist/commonjs/context.js +78 -1
- package/dist/commonjs/core.js +184 -29
- package/dist/commonjs/events.d.ts +17 -6
- package/dist/commonjs/events.js +82 -59
- package/dist/commonjs/genai-content.d.ts +52 -0
- package/dist/commonjs/genai-content.js +143 -0
- package/dist/commonjs/instrument.d.ts +47 -0
- package/dist/commonjs/instrument.js +158 -0
- package/dist/commonjs/integrations/anthropic-content.js +18 -6
- package/dist/commonjs/integrations/anthropic.d.ts +8 -1
- package/dist/commonjs/integrations/anthropic.js +515 -104
- package/dist/commonjs/integrations/index.js +8 -0
- package/dist/commonjs/integrations/langchain-callback.d.ts +182 -27
- package/dist/commonjs/integrations/langchain-callback.js +754 -87
- package/dist/commonjs/integrations/langchain-content.js +1 -1
- package/dist/commonjs/integrations/langchain.d.ts +3 -0
- package/dist/commonjs/integrations/langchain.js +353 -7
- package/dist/commonjs/integrations/openai-content.d.ts +34 -0
- package/dist/commonjs/integrations/openai-content.js +375 -0
- package/dist/commonjs/integrations/openai.d.ts +39 -0
- package/dist/commonjs/integrations/openai.js +305 -0
- package/dist/commonjs/semconv.d.ts +12 -0
- package/dist/commonjs/semconv.js +13 -1
- package/dist/commonjs/truncation.d.ts +29 -0
- package/dist/commonjs/truncation.js +184 -10
- package/dist/commonjs/version.d.ts +2 -0
- package/dist/commonjs/version.js +6 -0
- package/dist/esm/context.d.ts +45 -0
- package/dist/esm/context.js +74 -1
- package/dist/esm/core.js +185 -30
- package/dist/esm/events.d.ts +17 -6
- package/dist/esm/events.js +82 -61
- package/dist/esm/genai-content.d.ts +52 -0
- package/dist/esm/genai-content.js +137 -0
- package/dist/esm/instrument.d.ts +47 -0
- package/dist/esm/instrument.js +155 -0
- package/dist/esm/integrations/anthropic-content.js +19 -7
- package/dist/esm/integrations/anthropic.d.ts +8 -1
- package/dist/esm/integrations/anthropic.js +514 -107
- package/dist/esm/integrations/index.js +8 -0
- package/dist/esm/integrations/langchain-callback.d.ts +182 -27
- package/dist/esm/integrations/langchain-callback.js +756 -89
- package/dist/esm/integrations/langchain-content.js +1 -1
- package/dist/esm/integrations/langchain.d.ts +3 -0
- package/dist/esm/integrations/langchain.js +352 -7
- package/dist/esm/integrations/openai-content.d.ts +34 -0
- package/dist/esm/integrations/openai-content.js +360 -0
- package/dist/esm/integrations/openai.d.ts +39 -0
- package/dist/esm/integrations/openai.js +296 -0
- package/dist/esm/semconv.d.ts +12 -0
- package/dist/esm/semconv.js +12 -0
- package/dist/esm/truncation.d.ts +29 -0
- package/dist/esm/truncation.js +182 -10
- package/dist/esm/version.d.ts +2 -0
- package/dist/esm/version.js +3 -0
- package/package.json +11 -3
package/dist/esm/context.d.ts
CHANGED
|
@@ -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
|
package/dist/esm/context.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
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
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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 },
|
|
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
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/dist/esm/events.d.ts
CHANGED
|
@@ -1,12 +1,23 @@
|
|
|
1
|
-
import {
|
|
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
|
-
*
|
|
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
|
|
9
|
-
*
|
|
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
|
|
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
|