struct-sdk 0.1.0__tar.gz → 0.2.1__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: struct-sdk
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: Struct agent observability SDK — auto-instruments AI agent frameworks with OpenTelemetry
5
5
  Project-URL: Homepage, https://struct.ai
6
6
  Project-URL: Documentation, https://struct.ai/docs
@@ -30,8 +30,9 @@ Provides-Extra: claude-agent-sdk
30
30
  Requires-Dist: claude-agent-sdk>=0.1.59; extra == 'claude-agent-sdk'
31
31
  Provides-Extra: demo
32
32
  Requires-Dist: langchain-anthropic>=0.3.0; extra == 'demo'
33
- Requires-Dist: langchain-core>=0.3.0; extra == 'demo'
33
+ Requires-Dist: langchain-core>=1.3.3; extra == 'demo'
34
34
  Requires-Dist: langchain-openai>=0.2.0; extra == 'demo'
35
+ Requires-Dist: langchain>=1.3.0; extra == 'demo'
35
36
  Requires-Dist: langgraph>=0.2.0; extra == 'demo'
36
37
  Requires-Dist: python-dotenv>=1.0.0; extra == 'demo'
37
38
  Provides-Extra: dev
@@ -40,7 +41,7 @@ Requires-Dist: pytest-asyncio>=1.3.0; extra == 'dev'
40
41
  Requires-Dist: pytest>=9.0.3; extra == 'dev'
41
42
  Requires-Dist: ruff>=0.5; extra == 'dev'
42
43
  Provides-Extra: langchain
43
- Requires-Dist: langchain-core>=0.2.0; extra == 'langchain'
44
+ Requires-Dist: langchain-core>=1.3.3; extra == 'langchain'
44
45
  Description-Content-Type: text/markdown
45
46
 
46
47
  # struct-sdk
@@ -107,7 +108,7 @@ async with struct.agent(name="checkout"):
107
108
  | `langchain_core` `BaseChatModel` | `chat {model}` | Skipped when an underlying provider SDK is also instrumented (e.g. `ChatAnthropic` + `anthropic` → a single span). |
108
109
  | `langchain_core` `BaseTool` | `execute_tool {name}` | `tool_call_id` extracted from the LangChain ToolCall when present. |
109
110
  | `langchain_core` `BaseRetriever` | `retrieval {name}` | |
110
- | `langgraph` `Pregel` | `invoke_agent {name}` | Covers `create_react_agent` and custom graphs. `thread_id` `gen_ai.conversation.id`. |
111
+ | `langgraph` `Pregel` | `invoke_agent {name}` | Covers `create_react_agent`, `langchain.agents.create_agent`, and custom graphs. Reads conversation id from any of: `configurable.thread_id` (LangGraph canonical), or `metadata.{thread_id, session_id, conversation_id}` (LangSmith conventions). For multi-turn HTTP-style threading, wrap your entry point in [`struct.agent(session_id=conv_id)`](#recommended-pattern-wrap-langchain-entry-points-in-structagent) — that's the struct-native replacement for `ls.tracing_context(parent=run_tree)`. |
111
112
 
112
113
  ## Framework integration
113
114
 
@@ -155,6 +156,40 @@ from langgraph.prebuilt import create_react_agent
155
156
  # execute_tool spans. BaseRetriever.invoke gets retrieval spans.
156
157
  ```
157
158
 
159
+ ##### Recommended pattern: wrap LangChain entry points in `struct.agent`
160
+
161
+ For multi-turn HTTP-style usage (every request continues the same
162
+ conversation), wrap your request handler in
163
+ `struct.agent(session_id=conversation_id)`. This is the struct-native
164
+ replacement for `with ls.tracing_context(parent=run_tree):` and gives
165
+ you two things you can't get from `configurable.thread_id` alone:
166
+
167
+ 1. **Threading without per-call config plumbing.** Every nested
168
+ LangChain call inherits the conversation id via the SDK's ambient
169
+ contextvar — you don't have to ensure each `compiled_graph.ainvoke`
170
+ gets `thread_id` on its config.
171
+ 2. **One trace per request.** `struct.agent` creates a parent OTel span
172
+ so all the LangChain work for the request nests under one trace
173
+ (clean tree, "Subagents" / "Spawned by" UI links work). Without it,
174
+ each `.invoke()` becomes its own root trace, and the UI's session
175
+ list shows a non-deterministic agent name (`omni_agent`,
176
+ `LangGraph`, the first sub-agent it sees…).
177
+
178
+ Migrating from LangSmith:
179
+
180
+ ```python
181
+ # Before — LangSmith convention, fragments under struct-sdk
182
+ with ls.tracing_context(parent=run_tree):
183
+ await orchestrator.ainvoke(inputs, config=config)
184
+
185
+ # After — struct-native, threads correctly, no langsmith dep
186
+ async with struct.agent(session_id=conversation_id):
187
+ await orchestrator.ainvoke(inputs, config=config)
188
+ ```
189
+
190
+ Also works as a sync context manager (`with struct.agent(...)`) for
191
+ non-async handlers.
192
+
158
193
  ### LLM SDKs used directly — manual agent + tool scopes required
159
194
 
160
195
  When you call an LLM SDK directly (no agent framework wrapping it), only
@@ -268,8 +303,19 @@ async def run_checkout(order_id: str):
268
303
  return await charge()
269
304
  ```
270
305
 
271
- Nested agents are linked to their parent via the
272
- `struct.agent.parent_session_id` attribute on the inner span.
306
+ Sub-agents (e.g. a `create_agent` graph invoked from inside another
307
+ agent's tool body) record their parent via the
308
+ `struct.agent.parent_session_id` attribute on the inner `invoke_agent`
309
+ span. This powers the UI's "Spawned by" backlink, which works for any
310
+ nested invocation.
311
+
312
+ The parent's "Subagents" forward list — the inverse direction —
313
+ additionally requires that the nested invoke shares the outer agent's
314
+ trace. This works automatically when the outer tool is built with the
315
+ `@tool` decorator (or any callback-aware wrapping) since the nested
316
+ invoke inherits the parent's run state. Bare `Tool(...)` constructors
317
+ that bypass the callback chain can break the forward link; the
318
+ backlink still renders.
273
319
 
274
320
  ## Semantic conventions
275
321
 
@@ -62,7 +62,7 @@ async with struct.agent(name="checkout"):
62
62
  | `langchain_core` `BaseChatModel` | `chat {model}` | Skipped when an underlying provider SDK is also instrumented (e.g. `ChatAnthropic` + `anthropic` → a single span). |
63
63
  | `langchain_core` `BaseTool` | `execute_tool {name}` | `tool_call_id` extracted from the LangChain ToolCall when present. |
64
64
  | `langchain_core` `BaseRetriever` | `retrieval {name}` | |
65
- | `langgraph` `Pregel` | `invoke_agent {name}` | Covers `create_react_agent` and custom graphs. `thread_id` `gen_ai.conversation.id`. |
65
+ | `langgraph` `Pregel` | `invoke_agent {name}` | Covers `create_react_agent`, `langchain.agents.create_agent`, and custom graphs. Reads conversation id from any of: `configurable.thread_id` (LangGraph canonical), or `metadata.{thread_id, session_id, conversation_id}` (LangSmith conventions). For multi-turn HTTP-style threading, wrap your entry point in [`struct.agent(session_id=conv_id)`](#recommended-pattern-wrap-langchain-entry-points-in-structagent) — that's the struct-native replacement for `ls.tracing_context(parent=run_tree)`. |
66
66
 
67
67
  ## Framework integration
68
68
 
@@ -110,6 +110,40 @@ from langgraph.prebuilt import create_react_agent
110
110
  # execute_tool spans. BaseRetriever.invoke gets retrieval spans.
111
111
  ```
112
112
 
113
+ ##### Recommended pattern: wrap LangChain entry points in `struct.agent`
114
+
115
+ For multi-turn HTTP-style usage (every request continues the same
116
+ conversation), wrap your request handler in
117
+ `struct.agent(session_id=conversation_id)`. This is the struct-native
118
+ replacement for `with ls.tracing_context(parent=run_tree):` and gives
119
+ you two things you can't get from `configurable.thread_id` alone:
120
+
121
+ 1. **Threading without per-call config plumbing.** Every nested
122
+ LangChain call inherits the conversation id via the SDK's ambient
123
+ contextvar — you don't have to ensure each `compiled_graph.ainvoke`
124
+ gets `thread_id` on its config.
125
+ 2. **One trace per request.** `struct.agent` creates a parent OTel span
126
+ so all the LangChain work for the request nests under one trace
127
+ (clean tree, "Subagents" / "Spawned by" UI links work). Without it,
128
+ each `.invoke()` becomes its own root trace, and the UI's session
129
+ list shows a non-deterministic agent name (`omni_agent`,
130
+ `LangGraph`, the first sub-agent it sees…).
131
+
132
+ Migrating from LangSmith:
133
+
134
+ ```python
135
+ # Before — LangSmith convention, fragments under struct-sdk
136
+ with ls.tracing_context(parent=run_tree):
137
+ await orchestrator.ainvoke(inputs, config=config)
138
+
139
+ # After — struct-native, threads correctly, no langsmith dep
140
+ async with struct.agent(session_id=conversation_id):
141
+ await orchestrator.ainvoke(inputs, config=config)
142
+ ```
143
+
144
+ Also works as a sync context manager (`with struct.agent(...)`) for
145
+ non-async handlers.
146
+
113
147
  ### LLM SDKs used directly — manual agent + tool scopes required
114
148
 
115
149
  When you call an LLM SDK directly (no agent framework wrapping it), only
@@ -223,8 +257,19 @@ async def run_checkout(order_id: str):
223
257
  return await charge()
224
258
  ```
225
259
 
226
- Nested agents are linked to their parent via the
227
- `struct.agent.parent_session_id` attribute on the inner span.
260
+ Sub-agents (e.g. a `create_agent` graph invoked from inside another
261
+ agent's tool body) record their parent via the
262
+ `struct.agent.parent_session_id` attribute on the inner `invoke_agent`
263
+ span. This powers the UI's "Spawned by" backlink, which works for any
264
+ nested invocation.
265
+
266
+ The parent's "Subagents" forward list — the inverse direction —
267
+ additionally requires that the nested invoke shares the outer agent's
268
+ trace. This works automatically when the outer tool is built with the
269
+ `@tool` decorator (or any callback-aware wrapping) since the nested
270
+ invoke inherits the parent's run state. Bare `Tool(...)` constructors
271
+ that bypass the callback chain can break the forward link; the
272
+ backlink still renders.
228
273
 
229
274
  ## Semantic conventions
230
275
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "struct-sdk"
7
- version = "0.1.0"
7
+ version = "0.2.1"
8
8
  description = "Struct agent observability SDK — auto-instruments AI agent frameworks with OpenTelemetry"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -38,9 +38,10 @@ Issues = "https://struct.ai/support"
38
38
  [project.optional-dependencies]
39
39
  anthropic = ["anthropic>=0.30.0"]
40
40
  claude-agent-sdk = ["claude-agent-sdk>=0.1.59"]
41
- langchain = ["langchain-core>=0.2.0"]
41
+ langchain = ["langchain-core>=1.3.3"]
42
42
  demo = [
43
- "langchain-core>=0.3.0",
43
+ "langchain>=1.3.0",
44
+ "langchain-core>=1.3.3",
44
45
  "langchain-openai>=0.2.0",
45
46
  "langchain-anthropic>=0.3.0",
46
47
  "langgraph>=0.2.0",
@@ -64,10 +65,10 @@ dev = [
64
65
  [tool.uv]
65
66
  override-dependencies = [
66
67
  "cryptography>=46.0.7",
67
- "langgraph>=1.0.10,!=1.1.7",
68
- "langgraph-checkpoint>=4.0.0",
68
+ "langgraph>=1.2.0,<1.3.0",
69
+ "langgraph-checkpoint>=4.1.0,<5.0.0",
69
70
  "langchain-text-splitters>=1.1.2",
70
- "python-multipart>=0.0.26",
71
+ "python-multipart>=0.0.27",
71
72
  ]
72
73
 
73
74
  [tool.mypy]
@@ -116,11 +116,49 @@ def _create_common(
116
116
  extraction, error-path attribute writes) are wrapped in ``_safe`` so a
117
117
  failure inside instrumentation can never replace the user's response or
118
118
  mask the user's API exception.
119
+
120
+ Two paths:
121
+
122
+ 1. **Enrich** — when ``_current_langchain_chat_span`` is set, this call
123
+ is happening underneath a LangChain handler that's already created
124
+ a ``chat <model>`` span. We do NOT create our own span (that's the
125
+ duplicate-Anthropic-spans issue). Instead we attach HTTP-layer
126
+ attrs (the real provider msg_id, exact response_model, usage,
127
+ finish_reasons, error info on failure) onto the langchain span.
128
+ Pre-call attrs are skipped — LangChain already set them.
129
+
130
+ 2. **Standalone** — no LangChain in the picture. Create our own span
131
+ and set the full attribute set as before.
119
132
  """
120
- from struct_sdk.core import _safe
133
+ from struct_sdk.core import _safe, _current_langchain_chat_span
121
134
 
122
135
  model = kwargs.get("model", "unknown")
123
136
 
137
+ # Enrich path: a LangChain handler upstream already created a ``chat
138
+ # <model>`` span for this call. Attach Anthropic HTTP-layer detail to it
139
+ # without creating a duplicate span.
140
+ host_span = _current_langchain_chat_span.get(None)
141
+ if host_span is not None:
142
+ try:
143
+ result = yield f, args, kwargs
144
+ except Exception as e:
145
+ # Capture the type name OUTSIDE the lambda — ``except X as e``
146
+ # binds ``e`` only for the duration of the except block, but
147
+ # ``_safe`` is opaque to static analysis (ruff flags F841 +
148
+ # F821 thinking the lambda outlives the binding). Snapshotting
149
+ # to a local makes the closure capture trivially correct.
150
+ err_type = type(e).__name__
151
+ _safe(
152
+ lambda: host_span.set_attribute("error.type", err_type),
153
+ site="anthropic.create.enrich.error_type",
154
+ )
155
+ raise
156
+ _safe(
157
+ lambda: _set_response_attrs(host_span, sdk, model, result, otel_logger),
158
+ site="anthropic.create.enrich.set_response_attrs",
159
+ )
160
+ return result # noqa: B901
161
+
124
162
  with tracer.start_as_current_span(
125
163
  f"chat {model}", kind=trace.SpanKind.CLIENT
126
164
  ) as span:
@@ -171,12 +209,27 @@ def _create_common(
171
209
  try:
172
210
  result = yield f, args, kwargs
173
211
  except Exception as e:
174
- _safe(lambda: span.set_attribute("error.type", type(e).__name__),
175
- site="anthropic.create.error_type")
176
- _safe(lambda: span.set_status(StatusCode.ERROR, str(e)),
177
- site="anthropic.create.error_status")
178
- _safe(lambda: span.record_exception(e),
179
- site="anthropic.create.record_exception")
212
+ # Snapshot ``e`` outside the lambdas — ``except X as e`` unbinds
213
+ # ``e`` at end-of-block, and ruff (correctly) flags closures that
214
+ # reference it. The lambdas run synchronously inside the except
215
+ # via ``_safe``, so this is mechanically equivalent — just legible
216
+ # to static analysis. ``type(exc).__name__`` is cheap and won't
217
+ # raise; ``str(exc)`` runs inside ``_safe`` so a broken
218
+ # ``__str__`` can't mask the original exception we're re-raising.
219
+ exc = e
220
+ err_type = type(exc).__name__
221
+ _safe(
222
+ lambda: span.set_attribute("error.type", err_type),
223
+ site="anthropic.create.error_type",
224
+ )
225
+ _safe(
226
+ lambda: span.set_status(StatusCode.ERROR, str(exc)),
227
+ site="anthropic.create.error_status",
228
+ )
229
+ _safe(
230
+ lambda: span.record_exception(exc),
231
+ site="anthropic.create.record_exception",
232
+ )
180
233
  raise
181
234
  _safe(lambda: _set_response_attrs(span, sdk, model, result, otel_logger),
182
235
  site="anthropic.create.set_response_attrs")
@@ -273,9 +326,16 @@ def _wrap_stream(original: Any, tracer: trace.Tracer, sdk: StructSDK, otel_logge
273
326
  if is_async:
274
327
  @functools.wraps(original)
275
328
  async def wrapper(*args: Any, **kwargs: Any) -> Any:
276
- from struct_sdk.core import _safe, _current_session_id
329
+ from struct_sdk.core import _safe, _current_session_id, _current_langchain_chat_span
277
330
  model = kwargs.get("model", "unknown")
278
331
 
332
+ # Enrich path: a LangChain handler upstream already owns a chat
333
+ # span for this call. Don't create a duplicate; just pass through.
334
+ # (Stream end-handling will set response attrs on the host span
335
+ # when the LangChain handler's on_llm_end fires.)
336
+ if _current_langchain_chat_span.get(None) is not None:
337
+ return await original(*args, **kwargs) if _is_coroutine(original) else original(*args, **kwargs)
338
+
279
339
  span: Optional[trace.Span] = None
280
340
 
281
341
  def start() -> None:
@@ -318,9 +378,14 @@ def _wrap_stream(original: Any, tracer: trace.Tracer, sdk: StructSDK, otel_logge
318
378
  else:
319
379
  @functools.wraps(original)
320
380
  def wrapper(*args: Any, **kwargs: Any) -> Any:
321
- from struct_sdk.core import _safe, _current_session_id
381
+ from struct_sdk.core import _safe, _current_session_id, _current_langchain_chat_span
322
382
  model = kwargs.get("model", "unknown")
323
383
 
384
+ # Enrich path: a LangChain handler upstream already owns a chat
385
+ # span for this call. Don't create a duplicate; just pass through.
386
+ if _current_langchain_chat_span.get(None) is not None:
387
+ return original(*args, **kwargs)
388
+
324
389
  span: Optional[trace.Span] = None
325
390
 
326
391
  def start() -> None:
@@ -47,6 +47,27 @@ _current_session_id: contextvars.ContextVar[Optional[str]] = contextvars.Context
47
47
  _current_conversation_id: contextvars.ContextVar[Optional[str]] = contextvars.ContextVar("_current_conversation_id", default=None)
48
48
  _current_agent_span: contextvars.ContextVar[Optional[trace.Span]] = contextvars.ContextVar("_current_agent_span", default=None)
49
49
 
50
+ # When the LangChain handler is creating a ``chat <model>`` span for an LLM
51
+ # call that LangChain will dispatch through a provider SDK (anthropic,
52
+ # openai, etc.) that we ALSO instrument, set this contextvar to the
53
+ # in-progress langchain chat span. Provider-SDK instrumentations check it
54
+ # at the top of their ``messages.create`` / equivalent wrapper:
55
+ #
56
+ # - If set: enrich the existing langchain span with HTTP-layer attributes
57
+ # (real provider response.id, exact retries, rate-limit headers, etc.)
58
+ # and SKIP creating their own span — there's already a span for this
59
+ # call, we just want to attach more data to it.
60
+ #
61
+ # - If not set: this is a standalone provider-SDK invocation (no LangChain
62
+ # in the picture); the provider instrumentation creates its own span as
63
+ # usual.
64
+ #
65
+ # This eliminates the duplicate-span / orphan-Anthropic-span problem while
66
+ # preserving both layers' data on a single span.
67
+ _current_langchain_chat_span: contextvars.ContextVar[Optional[trace.Span]] = contextvars.ContextVar(
68
+ "_current_langchain_chat_span", default=None
69
+ )
70
+
50
71
  # Pending tool_use ids keyed by tool name (FIFO per name).
51
72
  # Populated by the Anthropic monkey-patch when a chat response arrives with
52
73
  # tool_use blocks, consumed by @struct.tool() / struct.tool(...) when the
@@ -251,6 +251,7 @@ _AGENT_CLASSES = {
251
251
  # ``serialized`` is usually ``None`` for these, so we filter on run_name via a
252
252
  # denylist. Matches LangSmith's promotion heuristic.
253
253
  _INTERNAL_RUN_NAMES = {
254
+ # Runnable wiring/plumbing
254
255
  "RunnableSequence",
255
256
  "RunnableLambda",
256
257
  "RunnablePassthrough",
@@ -263,15 +264,31 @@ _INTERNAL_RUN_NAMES = {
263
264
  "RunnableEach",
264
265
  "RunnablePick",
265
266
  "RunnableGenerator",
267
+ # Prompt templates
266
268
  "Prompt",
267
269
  "ChatPromptTemplate",
268
270
  "PromptTemplate",
271
+ # langchain.agents (1.x) / legacy create_react_agent internal node names
269
272
  "agent",
270
273
  "tools",
271
274
  "call_model",
272
275
  "should_continue",
273
276
  "__start__",
274
277
  "__end__",
278
+ # Output parsers — invoked as Runnables but not agents. LangChain's
279
+ # ``langchain.agents.create_agent`` with ``ToolStrategy`` (or fallback
280
+ # from ``ProviderStrategy`` on models without native structured output)
281
+ # invokes these as a separate step and they fire on_chain_start.
282
+ "PydanticToolsParser",
283
+ "PydanticOutputParser",
284
+ "JsonOutputParser",
285
+ "JsonOutputToolsParser",
286
+ "JsonOutputKeyToolsParser",
287
+ "StrOutputParser",
288
+ "OutputParser",
289
+ "BaseOutputParser",
290
+ "OpenAIToolsAgentOutputParser",
291
+ "OpenAIFunctionsAgentOutputParser",
275
292
  }
276
293
 
277
294
  _INTERNAL_RUN_NAME_PREFIXES = (
@@ -281,17 +298,68 @@ _INTERNAL_RUN_NAME_PREFIXES = (
281
298
  )
282
299
 
283
300
 
301
+ # Threading-id metadata keys, in resolution order.
302
+ #
303
+ # ``thread_id`` is LangGraph's canonical name (checkpointer key); ``session_id``
304
+ # and ``conversation_id`` are the LangSmith conventions documented at
305
+ # https://docs.langchain.com/langsmith/threads — when users tag a run with any
306
+ # of these, we treat it as the conversation grouping key. This makes
307
+ # struct-sdk drop-in compatible for users following either naming.
308
+ _THREAD_KEYS: tuple[str, ...] = ("thread_id", "session_id", "conversation_id")
309
+
310
+
311
+ def _metadata_thread_id(metadata: Optional[dict[str, Any]]) -> Optional[str]:
312
+ """Pull the conversation/thread id from a LangChain ``metadata`` dict.
313
+
314
+ Reads ``thread_id`` first (LangGraph canonical), then falls back to
315
+ ``session_id`` and ``conversation_id`` (LangSmith conventions). Returns
316
+ ``None`` if none are present as non-empty strings, so callers can chain
317
+ to their own fallbacks (ambient ``_current_session_id``, fresh UUID).
318
+ """
319
+ if not metadata:
320
+ return None
321
+ for key in _THREAD_KEYS:
322
+ v = metadata.get(key)
323
+ if isinstance(v, str) and v:
324
+ return v
325
+ return None
326
+
327
+
284
328
  def _is_agent_chain(
285
329
  serialized: Optional[dict[str, Any]],
286
330
  run_type: Optional[str],
287
331
  run_name: Optional[str],
332
+ metadata: Optional[dict[str, Any]] = None,
288
333
  ) -> bool:
289
- """Only promote user-meaningful chains to ``invoke_agent`` spans."""
334
+ """Only promote user-meaningful chains to ``invoke_agent`` spans.
335
+
336
+ Decision order:
337
+
338
+ 1. Explicit ``run_type='agent'`` (legacy AgentExecutor) → agent.
339
+ 2. ``serialized`` class identifies a Pregel/CompiledStateGraph → agent.
340
+ 3. ``metadata['langgraph_node'] == run_name`` → INTERNAL Pregel node
341
+ (every internal step of a ``create_agent`` Pregel fires on_chain_start
342
+ with metadata.langgraph_node set to its node name; for real top-level
343
+ agents or sub-agents the names differ or langgraph_node is absent).
344
+ 4. Known LangChain plumbing run names (denylist) → not agent.
345
+ 5. Otherwise, if there's a run_name → agent (user-named chain).
346
+ """
290
347
  if run_type == "agent":
291
348
  return True
292
349
  cls = _extract_class_name(serialized)
293
350
  if cls in _AGENT_CLASSES:
294
351
  return True
352
+ # Internal Pregel node detection: LangGraph populates metadata with
353
+ # ``langgraph_node`` (and ``langgraph_step``) on every internal node
354
+ # callback. The run_name of an internal node matches its langgraph_node;
355
+ # for the top-level Pregel invocation, langgraph_node is absent; for a
356
+ # sub-agent invoked from a tool body, langgraph_node may be set BUT
357
+ # contains the *parent's* node name (e.g. "tools"), which differs from
358
+ # the sub-agent's own run_name. So equality is the discriminator.
359
+ if metadata and run_name:
360
+ lg_node = metadata.get("langgraph_node")
361
+ if isinstance(lg_node, str) and lg_node and lg_node == run_name:
362
+ return False
295
363
  if run_name:
296
364
  if run_name in _INTERNAL_RUN_NAMES:
297
365
  return False
@@ -526,7 +594,7 @@ class StructCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
526
594
  key = str(run_id)
527
595
  parent_key = str(parent_run_id) if parent_run_id else None
528
596
 
529
- if not _is_agent_chain(serialized or {}, run_type, run_name):
597
+ if not _is_agent_chain(serialized or {}, run_type, run_name, metadata):
530
598
  # Skipped chain — record entry so descendants can walk the parent
531
599
  # chain and find the nearest agent ancestor's session id.
532
600
  effective_parent = self._resolve_parent(parent_key).span
@@ -590,10 +658,19 @@ class StructCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
590
658
  # Don't set gen_ai.agent.id from session_id — the spec uses agent.id
591
659
  # for a stable agent-definition identifier, not per-invocation.
592
660
  span.set_attribute("gen_ai.conversation.id", session_id)
593
- if (
594
- parent_agent_session_id
595
- and parent_agent_session_id != session_id
596
- ):
661
+ # Always set parent_session_id when there's a parent agent — even
662
+ # if it matches our own session_id (which happens when the SDK's
663
+ # ambient ``_current_session_id`` propagates through nested
664
+ # invoke_agent spans, e.g. ``struct.agent(session_id=conv_id)``
665
+ # wrapping multiple inner agents).
666
+ #
667
+ # The attribute encodes "this span has a parent agent" as
668
+ # structural information — the UI uses it to decide whether to
669
+ # inline a subagent under its triggering tool call vs render it
670
+ # as a top-level turn. When the values match, this signals an
671
+ # *intentionally inlined* subagent; when they differ, the UI's
672
+ # existing "View sub-agent →" drill-in flow kicks in.
673
+ if parent_agent_session_id:
597
674
  span.set_attribute("struct.agent.parent_session_id", parent_agent_session_id)
598
675
 
599
676
  _safe(set_attrs, site="langchain.on_chain_start.start_attrs")
@@ -722,6 +799,17 @@ class StructCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
722
799
 
723
800
  _safe(set_attrs, site="langchain.on_chat_model_start.start_attrs")
724
801
 
802
+ # Announce this langchain chat span to any provider-SDK instrumentation
803
+ # (anthropic, etc.) that runs UNDER LangChain — they'll enrich this
804
+ # span with HTTP-layer attrs instead of creating their own duplicate.
805
+ # The token is saved on the RunState so on_llm_end can reset it.
806
+ from struct_sdk.core import _current_langchain_chat_span
807
+ enrich_token = None
808
+ try:
809
+ enrich_token = _current_langchain_chat_span.set(span)
810
+ except Exception: # noqa: BLE001 — never fail the host call on instrumentation
811
+ enrich_token = None
812
+
725
813
  self._runs[key] = _RunState(
726
814
  span=span,
727
815
  effective_parent_span=span,
@@ -729,6 +817,7 @@ class StructCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
729
817
  nearest_agent_session_id=self._inherited_agent_session_id(parent_key),
730
818
  nearest_agent_span=self._inherited_agent_span(parent_key),
731
819
  kind="llm",
820
+ enrich_token=enrich_token,
732
821
  )
733
822
 
734
823
  def on_llm_start(
@@ -761,13 +850,31 @@ class StructCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
761
850
  parent_run_id: Optional[UUID] = None,
762
851
  **kwargs: Any,
763
852
  ) -> None:
764
- from struct_sdk.core import _safe
853
+ from struct_sdk.core import _safe, _current_langchain_chat_span
765
854
 
766
855
  r = self._runs.pop(str(run_id), None)
767
856
  if not r or not r.span:
857
+ # Even if the span was never created (telemetry-disabled fallback),
858
+ # we still need to reset the enrich-contextvar token so it doesn't
859
+ # leak into the next operation in this task.
860
+ if r is not None and r.enrich_token is not None:
861
+ _safe(
862
+ lambda: _current_langchain_chat_span.reset(r.enrich_token),
863
+ site="langchain.on_llm_end.reset_enrich_token",
864
+ )
768
865
  return
769
866
  span = r.span
770
867
 
868
+ # Reset the enrich-token contextvar BEFORE ending the span. Any
869
+ # post-end attribute set by provider-SDK instrumentation would race
870
+ # against ``span.end()`` and likely no-op anyway, so we close the
871
+ # door before we close the span.
872
+ if r.enrich_token is not None:
873
+ _safe(
874
+ lambda: _current_langchain_chat_span.reset(r.enrich_token),
875
+ site="langchain.on_llm_end.reset_enrich_token",
876
+ )
877
+
771
878
  def set_response_attrs() -> None:
772
879
  generations = getattr(response, "generations", None) or []
773
880
  first = generations[0][0] if generations and generations[0] else None
@@ -811,10 +918,20 @@ class StructCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
811
918
  parent_run_id: Optional[UUID] = None,
812
919
  **kwargs: Any,
813
920
  ) -> None:
814
- from struct_sdk.core import _safe
921
+ from struct_sdk.core import _safe, _current_langchain_chat_span
815
922
 
816
923
  r = self._runs.pop(str(run_id), None)
817
- if not r or not r.span:
924
+ if not r:
925
+ return
926
+ # Always reset the enrich-token contextvar, even when there's no
927
+ # span — leaving it set would leak the (now-defunct) span into the
928
+ # next operation in this task.
929
+ if r.enrich_token is not None:
930
+ _safe(
931
+ lambda: _current_langchain_chat_span.reset(r.enrich_token),
932
+ site="langchain.on_llm_error.reset_enrich_token",
933
+ )
934
+ if not r.span:
818
935
  return
819
936
  span = r.span
820
937
  _safe(lambda: _record_error(span, error),
@@ -1134,8 +1251,9 @@ class StructCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
1134
1251
  p = self._runs.get(parent_run_id)
1135
1252
  if p and p.session_id:
1136
1253
  return p.session_id
1137
- if metadata and isinstance(metadata.get("thread_id"), str) and metadata["thread_id"]:
1138
- return metadata["thread_id"]
1254
+ thread_id = _metadata_thread_id(metadata)
1255
+ if thread_id:
1256
+ return thread_id
1139
1257
  from struct_sdk.core import _current_session_id
1140
1258
  ambient = _current_session_id.get(None)
1141
1259
  if ambient:
@@ -1159,8 +1277,8 @@ class StructCallbackHandler(BaseCallbackHandler): # type: ignore[misc]
1159
1277
  against the nearest-agent-ancestor's session and assign a fresh
1160
1278
  UUID if they match.
1161
1279
  """
1162
- thread_id = metadata.get("thread_id") if metadata else None
1163
- if isinstance(thread_id, str) and thread_id:
1280
+ thread_id = _metadata_thread_id(metadata)
1281
+ if thread_id:
1164
1282
  if parent_agent_session_id and thread_id == parent_agent_session_id:
1165
1283
  return str(uuid.uuid4())
1166
1284
  return thread_id
@@ -1186,6 +1304,13 @@ class _RunState:
1186
1304
  "nearest_agent_session_id",
1187
1305
  "nearest_agent_span",
1188
1306
  "kind",
1307
+ # Only set on LLM / chat runs. Holds the ``contextvars.Token`` returned
1308
+ # by ``_current_langchain_chat_span.set(span)`` so on_llm_end /
1309
+ # on_llm_error can reset the contextvar. The contextvar's purpose:
1310
+ # tell provider-SDK instrumentations (anthropic, openai, etc.)
1311
+ # "you're running underneath this LangChain chat span — enrich it
1312
+ # with your HTTP-layer attrs, don't create a duplicate span."
1313
+ "enrich_token",
1189
1314
  )
1190
1315
 
1191
1316
  def __init__(
@@ -1197,6 +1322,7 @@ class _RunState:
1197
1322
  nearest_agent_session_id: Optional[str],
1198
1323
  nearest_agent_span: Optional[trace.Span],
1199
1324
  kind: str,
1325
+ enrich_token: Any = None,
1200
1326
  ) -> None:
1201
1327
  self.span = span
1202
1328
  self.effective_parent_span = effective_parent_span
@@ -1204,6 +1330,7 @@ class _RunState:
1204
1330
  self.nearest_agent_session_id = nearest_agent_session_id
1205
1331
  self.nearest_agent_span = nearest_agent_span
1206
1332
  self.kind = kind
1333
+ self.enrich_token = enrich_token
1207
1334
 
1208
1335
 
1209
1336
  class _ParentInfo:
@@ -1282,7 +1409,23 @@ def _set_llm_response_attrs(
1282
1409
 
1283
1410
  resp_id = getattr(message, "id", None) or resp_meta.get("id")
1284
1411
  if isinstance(resp_id, str):
1285
- span.set_attribute("gen_ai.response.id", resp_id)
1412
+ # If a provider-SDK instrumentation (e.g. struct-sdk-anthropic via
1413
+ # the enrich path) has already set gen_ai.response.id to the
1414
+ # real provider message id (e.g. ``msg_…``), don't clobber it
1415
+ # with LangChain's run UUID (``lc_run--…``). The provider id is
1416
+ # more useful for API-level debugging. LangChain's run UUID is
1417
+ # preserved under ``langchain.run.id`` for joining back to
1418
+ # LangSmith / LangChain run data.
1419
+ try:
1420
+ existing = (span.attributes or {}).get("gen_ai.response.id") \
1421
+ if hasattr(span, "attributes") else None
1422
+ except Exception: # noqa: BLE001
1423
+ existing = None
1424
+ if not existing:
1425
+ span.set_attribute("gen_ai.response.id", resp_id)
1426
+ elif resp_id != existing:
1427
+ # LangChain's id is distinct from the provider's — keep both.
1428
+ span.set_attribute("langchain.run.id", resp_id)
1286
1429
 
1287
1430
  if sdk.emit_events and otel_logger:
1288
1431
  _emit_choice_event(otel_logger, message, provider or "langchain", session_id, span)
File without changes
File without changes