microsoft-agents-a365-observability-extensions-langchain 0.2.1.dev9__tar.gz → 0.2.1.dev10__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.
Files changed (14) hide show
  1. {microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev9 → microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev10}/PKG-INFO +1 -1
  2. {microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev9 → microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev10}/microsoft_agents_a365/observability/extensions/langchain/tracer.py +61 -20
  3. {microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev9 → microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev10}/microsoft_agents_a365/observability/extensions/langchain/tracer_instrumentor.py +1 -1
  4. {microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev9 → microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev10}/microsoft_agents_a365/observability/extensions/langchain/utils.py +200 -28
  5. {microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev9 → microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev10}/microsoft_agents_a365_observability_extensions_langchain.egg-info/PKG-INFO +1 -1
  6. {microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev9 → microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev10}/README.md +0 -0
  7. {microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev9 → microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev10}/microsoft_agents_a365/observability/extensions/langchain/__init__.py +0 -0
  8. {microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev9 → microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev10}/microsoft_agents_a365_observability_extensions_langchain.egg-info/SOURCES.txt +0 -0
  9. {microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev9 → microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev10}/microsoft_agents_a365_observability_extensions_langchain.egg-info/dependency_links.txt +0 -0
  10. {microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev9 → microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev10}/microsoft_agents_a365_observability_extensions_langchain.egg-info/requires.txt +0 -0
  11. {microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev9 → microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev10}/microsoft_agents_a365_observability_extensions_langchain.egg-info/top_level.txt +0 -0
  12. {microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev9 → microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev10}/pyproject.toml +0 -0
  13. {microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev9 → microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev10}/setup.cfg +0 -0
  14. {microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev9 → microsoft_agents_a365_observability_extensions_langchain-0.2.1.dev10}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: microsoft-agents-a365-observability-extensions-langchain
3
- Version: 0.2.1.dev9
3
+ Version: 0.2.1.dev10
4
4
  Summary: LangChain observability and tracing extensions for Microsoft Agent 365
5
5
  Author-email: Microsoft <support@microsoft.com>
6
6
  License: MIT
@@ -15,6 +15,10 @@ from uuid import UUID
15
15
 
16
16
  from langchain_core.tracers import BaseTracer, LangChainTracer
17
17
  from langchain_core.tracers.schemas import Run
18
+ from microsoft_agents_a365.observability.core.constants import (
19
+ GEN_AI_AGENT_NAME_KEY,
20
+ INVOKE_AGENT_OPERATION_NAME,
21
+ )
18
22
  from microsoft_agents_a365.observability.core.inference_operation_type import InferenceOperationType
19
23
  from microsoft_agents_a365.observability.core.utils import (
20
24
  DictWithLock,
@@ -37,11 +41,14 @@ from microsoft_agents_a365.observability.extensions.langchain.utils import (
37
41
  function_calls,
38
42
  input_messages,
39
43
  invocation_parameters,
44
+ invoke_agent_input_message,
45
+ invoke_agent_output_message,
40
46
  llm_provider,
41
47
  metadata,
42
48
  model_name,
43
49
  output_messages,
44
50
  prompts,
51
+ set_execution_type,
45
52
  token_counts,
46
53
  tools,
47
54
  )
@@ -111,8 +118,17 @@ class CustomLangChainTracer(BaseTracer):
111
118
  # We can't use real time because the handler may be
112
119
  # called in a background thread.
113
120
  start_time_utc_nano = as_utc_nano(run.start_time)
121
+
122
+ # Determine span name based on run type
123
+ if run.run_type == "chain" and run.name == "LangGraph":
124
+ span_name = f"invoke_agent {run.name}"
125
+ elif run.run_type.lower() == "tool":
126
+ span_name = f"execute_tool {run.name}"
127
+ else:
128
+ span_name = run.name
129
+
114
130
  span = self._tracer.start_span(
115
- name=run.name,
131
+ name=span_name,
116
132
  context=parent_context,
117
133
  start_time=start_time_utc_nano,
118
134
  )
@@ -197,27 +213,52 @@ def _update_span(span: Span, run: Run) -> None:
197
213
  else:
198
214
  span.set_status(trace_api.Status(trace_api.StatusCode.ERROR, run.error))
199
215
 
216
+ span.set_attributes(dict(get_attributes_from_context()))
217
+
200
218
  if run.run_type == "llm" and run.outputs.get("llm_output").get("id").startswith("chat"):
201
219
  span.update_name(f"{InferenceOperationType.CHAT.value.lower()} {span.name}")
202
- elif run.run_type.lower() == "tool":
203
- span.update_name(f"execute_tool {span.name}")
204
- span.set_attributes(dict(get_attributes_from_context()))
205
- span.set_attributes(
206
- dict(
207
- flatten(
208
- chain(
209
- add_operation_type(run),
210
- prompts(run.inputs),
211
- input_messages(run.inputs),
212
- output_messages(run.outputs),
213
- invocation_parameters(run),
214
- llm_provider(run.extra),
215
- model_name(run.outputs, run.extra),
216
- token_counts(run.outputs),
217
- function_calls(run.outputs),
218
- tools(run),
219
- metadata(run),
220
+ is_invoke_agent = span.name.startswith(INVOKE_AGENT_OPERATION_NAME)
221
+
222
+ # If this is an invoke_agent span, update span name with agent name
223
+ if is_invoke_agent:
224
+ agent_name = None
225
+ if hasattr(span, "_attributes") and span._attributes:
226
+ agent_name = span._attributes.get(GEN_AI_AGENT_NAME_KEY)
227
+ if agent_name:
228
+ span.update_name(f"{INVOKE_AGENT_OPERATION_NAME} {agent_name}")
229
+
230
+ # For invoke_agent spans, add input/output messages
231
+ if is_invoke_agent:
232
+ span.set_attributes(
233
+ dict(
234
+ flatten(
235
+ chain(
236
+ set_execution_type(),
237
+ add_operation_type(run),
238
+ invoke_agent_input_message(run.inputs),
239
+ invoke_agent_output_message(run.outputs),
240
+ metadata(run),
241
+ )
242
+ )
243
+ )
244
+ )
245
+ else:
246
+ span.set_attributes(
247
+ dict(
248
+ flatten(
249
+ chain(
250
+ add_operation_type(run),
251
+ prompts(run.inputs),
252
+ input_messages(run.inputs),
253
+ output_messages(run.outputs),
254
+ invocation_parameters(run),
255
+ llm_provider(run.extra),
256
+ model_name(run.outputs, run.extra),
257
+ token_counts(run.outputs),
258
+ function_calls(run.outputs),
259
+ tools(run),
260
+ metadata(run),
261
+ )
220
262
  )
221
263
  )
222
264
  )
223
- )
@@ -21,7 +21,7 @@ from wrapt import wrap_function_wrapper
21
21
 
22
22
  from microsoft_agents_a365.observability.extensions.langchain.tracer import CustomLangChainTracer
23
23
 
24
- _INSTRUMENTS: str = "langchain_core >= 0.1.0"
24
+ _INSTRUMENTS: str = "langchain_core >= 1.2.0"
25
25
 
26
26
 
27
27
  class CustomLangChainInstrumentor(BaseInstrumentor):
@@ -9,6 +9,8 @@ from typing import Any
9
9
  from langchain_core.messages import BaseMessage
10
10
  from langchain_core.tracers.schemas import Run
11
11
  from microsoft_agents_a365.observability.core.constants import (
12
+ EXECUTE_TOOL_OPERATION_NAME,
13
+ GEN_AI_EXECUTION_TYPE_KEY,
12
14
  GEN_AI_INPUT_MESSAGES_KEY,
13
15
  GEN_AI_OPERATION_NAME_KEY,
14
16
  GEN_AI_OUTPUT_MESSAGES_KEY,
@@ -25,8 +27,10 @@ from microsoft_agents_a365.observability.core.constants import (
25
27
  GEN_AI_TOOL_TYPE_KEY,
26
28
  GEN_AI_USAGE_INPUT_TOKENS_KEY,
27
29
  GEN_AI_USAGE_OUTPUT_TOKENS_KEY,
30
+ INVOKE_AGENT_OPERATION_NAME,
28
31
  SESSION_ID_KEY,
29
32
  )
33
+ from microsoft_agents_a365.observability.core.execution_type import ExecutionType
30
34
  from microsoft_agents_a365.observability.core.inference_operation_type import InferenceOperationType
31
35
  from microsoft_agents_a365.observability.core.utils import (
32
36
  get_first_value,
@@ -199,8 +203,8 @@ def _parse_message_data(message_data: Mapping[str, Any] | None) -> Iterator[tupl
199
203
  @stop_on_exception
200
204
  def input_messages(
201
205
  inputs: Mapping[str, Any] | None,
202
- ) -> Iterator[tuple[str, list[dict[str, Any]]]]:
203
- """Yields chat messages if present."""
206
+ ) -> Iterator[tuple[str, str]]:
207
+ """Yields chat messages as a JSON array of content strings."""
204
208
  if not inputs:
205
209
  return
206
210
  assert hasattr(inputs, "get"), f"expected Mapping, found {type(inputs)}"
@@ -213,27 +217,29 @@ def input_messages(
213
217
  # This will only get the first set of messages.
214
218
  if not (first_messages := next(iter(multiple_messages), None)):
215
219
  return
216
- parsed_messages = []
220
+ contents: list[str] = []
217
221
  if isinstance(first_messages, list):
218
222
  for message_data in first_messages:
219
223
  if isinstance(message_data, BaseMessage):
220
- parsed_messages.append(dict(_parse_message_data(message_data.to_json())))
224
+ if hasattr(message_data, "content") and message_data.content:
225
+ contents.append(str(message_data.content))
221
226
  elif hasattr(message_data, "get"):
222
- parsed_messages.append(dict(_parse_message_data(message_data)))
223
- else:
224
- raise ValueError(f"failed to parse message of type {type(message_data)}")
227
+ if content := message_data.get("content"):
228
+ contents.append(str(content))
229
+ elif kwargs := message_data.get("kwargs"):
230
+ if hasattr(kwargs, "get") and (content := kwargs.get("content")):
231
+ contents.append(str(content))
225
232
  elif isinstance(first_messages, BaseMessage):
226
- parsed_messages.append(dict(_parse_message_data(first_messages.to_json())))
233
+ if hasattr(first_messages, "content") and first_messages.content:
234
+ contents.append(str(first_messages.content))
227
235
  elif hasattr(first_messages, "get"):
228
- parsed_messages.append(dict(_parse_message_data(first_messages)))
236
+ if content := first_messages.get("content"):
237
+ contents.append(str(content))
229
238
  elif isinstance(first_messages, Sequence) and len(first_messages) == 2:
230
- # See e.g. https://github.com/langchain-ai/langchain/blob/18cf457eec106d99e0098b42712299f5d0daa798/libs/core/langchain_core/messages/utils.py#L317 # noqa: E501
231
239
  role, content = first_messages
232
- parsed_messages.append({"MESSAGE_ROLE": role, "MESSAGE_CONTENT": content})
233
- else:
234
- raise ValueError(f"failed to parse messages of type {type(first_messages)}")
235
- if parsed_messages:
236
- yield GEN_AI_INPUT_MESSAGES_KEY, parsed_messages
240
+ contents.append(str(content))
241
+ if contents:
242
+ yield GEN_AI_INPUT_MESSAGES_KEY, safe_json_dumps(contents)
237
243
 
238
244
 
239
245
  @stop_on_exception
@@ -255,8 +261,8 @@ def metadata(run: Run) -> Iterator[tuple[str, str]]:
255
261
  @stop_on_exception
256
262
  def output_messages(
257
263
  outputs: Mapping[str, Any] | None,
258
- ) -> Iterator[tuple[str, list[dict[str, Any]]]]:
259
- """Yields chat messages if present."""
264
+ ) -> Iterator[tuple[str, str]]:
265
+ """Yields chat messages as a JSON array of content strings."""
260
266
  if not outputs:
261
267
  return
262
268
  assert hasattr(outputs, "get"), f"expected Mapping, found {type(outputs)}"
@@ -279,18 +285,21 @@ def output_messages(
279
285
  assert isinstance(first_generations, Iterable), (
280
286
  f"expected Iterable, found {type(first_generations)}"
281
287
  )
282
- parsed_messages = []
288
+ contents: list[str] = []
283
289
  for generation in first_generations:
284
290
  assert hasattr(generation, "get"), f"expected Mapping, found {type(generation)}"
285
291
  if message_data := generation.get("message"):
286
292
  if isinstance(message_data, BaseMessage):
287
- parsed_messages.append(dict(_parse_message_data(message_data.to_json())))
293
+ if hasattr(message_data, "content") and message_data.content:
294
+ contents.append(str(message_data.content))
288
295
  elif hasattr(message_data, "get"):
289
- parsed_messages.append(dict(_parse_message_data(message_data)))
290
- else:
291
- raise ValueError(f"fail to parse message of type {type(message_data)}")
292
- if parsed_messages:
293
- yield GEN_AI_OUTPUT_MESSAGES_KEY, parsed_messages
296
+ if content := message_data.get("content"):
297
+ contents.append(str(content))
298
+ elif kwargs := message_data.get("kwargs"):
299
+ if hasattr(kwargs, "get") and (content := kwargs.get("content")):
300
+ contents.append(str(content))
301
+ if contents:
302
+ yield GEN_AI_OUTPUT_MESSAGES_KEY, safe_json_dumps(contents)
294
303
 
295
304
 
296
305
  @stop_on_exception
@@ -305,7 +314,6 @@ def invocation_parameters(run: Run) -> Iterator[tuple[str, str]]:
305
314
  assert isinstance(invocation_parameters, Mapping), (
306
315
  f"expected Mapping, found {type(invocation_parameters)}"
307
316
  )
308
- yield GEN_AI_INPUT_MESSAGES_KEY, safe_json_dumps(invocation_parameters)
309
317
  tools = invocation_parameters.get("tools", [])
310
318
  for idx, tool in enumerate(tools):
311
319
  yield f"{GEN_AI_TOOL_ARGS_KEY}.{idx}", safe_json_dumps(tool)
@@ -458,7 +466,7 @@ def function_calls(outputs: Mapping[str, Any] | None) -> Iterator[tuple[str, str
458
466
  return
459
467
 
460
468
  # Tool type (explicit)
461
- yield GEN_AI_OPERATION_NAME_KEY, "execute_tool"
469
+ yield GEN_AI_OPERATION_NAME_KEY, EXECUTE_TOOL_OPERATION_NAME
462
470
  yield GEN_AI_TOOL_TYPE_KEY, "function"
463
471
 
464
472
  name = fc.get("name")
@@ -505,6 +513,34 @@ def tools(run: Run) -> Iterator[tuple[str, str]]:
505
513
  if description := serialized.get("description"):
506
514
  yield GEN_AI_TOOL_DESCRIPTION_KEY, description
507
515
 
516
+ # Extract tool call ID from run.extra (LangGraph stores it there)
517
+ if run.extra and hasattr(run.extra, "get"):
518
+ if tool_call_id := run.extra.get("tool_call_id"):
519
+ yield GEN_AI_TOOL_CALL_ID_KEY, tool_call_id
520
+
521
+ # Extract tool arguments from inputs
522
+ if run.inputs and hasattr(run.inputs, "get"):
523
+ # LangGraph wraps args in 'input' key as a string
524
+ if input_val := run.inputs.get("input"):
525
+ if isinstance(input_val, str):
526
+ yield GEN_AI_TOOL_ARGS_KEY, input_val
527
+ else:
528
+ yield GEN_AI_TOOL_ARGS_KEY, safe_json_dumps(input_val)
529
+
530
+ # Extract tool result from outputs
531
+ if run.outputs and hasattr(run.outputs, "get"):
532
+ if result := run.outputs.get("output"):
533
+ # Handle ToolMessage or BaseMessage objects
534
+ if isinstance(result, BaseMessage):
535
+ result_content = result.content if hasattr(result, "content") else str(result)
536
+ elif hasattr(result, "content"):
537
+ result_content = result.content
538
+ elif isinstance(result, str):
539
+ result_content = result
540
+ else:
541
+ result_content = safe_json_dumps(result)
542
+ yield GEN_AI_TOOL_CALL_RESULT_KEY, result_content
543
+
508
544
 
509
545
  def add_operation_type(run: Run) -> Iterator[tuple[str, str]]:
510
546
  """Yields operation type based on run type."""
@@ -512,6 +548,142 @@ def add_operation_type(run: Run) -> Iterator[tuple[str, str]]:
512
548
  if run_type == "llm":
513
549
  yield GEN_AI_OPERATION_NAME_KEY, InferenceOperationType.CHAT.value.lower()
514
550
  elif run_type == "chat_model":
515
- yield GEN_AI_OPERATION_NAME_KEY, "chat"
551
+ yield GEN_AI_OPERATION_NAME_KEY, InferenceOperationType.CHAT.value.lower()
516
552
  elif run_type == "tool":
517
- yield GEN_AI_OPERATION_NAME_KEY, "execute_tool"
553
+ yield GEN_AI_OPERATION_NAME_KEY, EXECUTE_TOOL_OPERATION_NAME
554
+ elif run_type == "chain" and run.name.startswith(INVOKE_AGENT_OPERATION_NAME):
555
+ yield GEN_AI_OPERATION_NAME_KEY, INVOKE_AGENT_OPERATION_NAME
556
+
557
+
558
+ def _extract_content_from_message(message: Any) -> str | None:
559
+ """Extract content from a LangChain message object or dict."""
560
+ if message is None:
561
+ return None
562
+
563
+ # Handle BaseMessage objects
564
+ if isinstance(message, BaseMessage):
565
+ return message.content if hasattr(message, "content") else None
566
+
567
+ # Handle dict-like messages
568
+ if hasattr(message, "get"):
569
+ # Direct content field
570
+ if content := message.get("content"):
571
+ return content
572
+ # Nested in kwargs
573
+ if kwargs := message.get("kwargs"):
574
+ if hasattr(kwargs, "get") and (content := kwargs.get("content")):
575
+ return content
576
+
577
+ return None
578
+
579
+
580
+ def _get_message_role(message: Any) -> str | None:
581
+ """Extract role from a LangChain message object or dict."""
582
+ if message is None:
583
+ return None
584
+
585
+ # Handle BaseMessage objects
586
+ if isinstance(message, BaseMessage):
587
+ return message.type if hasattr(message, "type") else None
588
+
589
+ # Handle dict-like messages
590
+ if hasattr(message, "get"):
591
+ # Check various role indicators
592
+ if role := message.get("role"):
593
+ return role
594
+ if msg_type := message.get("type"):
595
+ return msg_type
596
+ # Check id field for type info (e.g., "HumanMessage", "AIMessage")
597
+ if id_field := message.get("id"):
598
+ if isinstance(id_field, list) and len(id_field) > 0:
599
+ type_name = id_field[-1]
600
+ if "Human" in type_name:
601
+ return "human"
602
+ elif "AI" in type_name or "Assistant" in type_name:
603
+ return "ai"
604
+ elif "System" in type_name:
605
+ return "system"
606
+
607
+ return None
608
+
609
+
610
+ @stop_on_exception
611
+ def invoke_agent_input_message(
612
+ inputs: Mapping[str, Any] | None,
613
+ ) -> Iterator[tuple[str, str]]:
614
+ """
615
+ Extract the user input message for invoke_agent spans (LangGraph root).
616
+ We want to find the first user/human message content.
617
+ """
618
+ if not inputs:
619
+ return
620
+
621
+ assert hasattr(inputs, "get"), f"expected Mapping, found {type(inputs)}"
622
+
623
+ messages = inputs.get("messages")
624
+ if not messages:
625
+ return
626
+
627
+ # Handle nested list structure: [[msg1, msg2, ...]]
628
+ if isinstance(messages, list) and len(messages) > 0:
629
+ first_item = messages[0]
630
+ # If first item is also a list, unwrap it
631
+ if isinstance(first_item, list):
632
+ messages = first_item
633
+
634
+ # Find the first user/human message
635
+ if isinstance(messages, list):
636
+ for message in messages:
637
+ role = _get_message_role(message)
638
+ if role and role.lower() in ("human", "user"):
639
+ content = _extract_content_from_message(message)
640
+ if content:
641
+ yield GEN_AI_INPUT_MESSAGES_KEY, content
642
+ return
643
+
644
+ # If no human message found, just get first message content
645
+ if len(messages) > 0:
646
+ content = _extract_content_from_message(messages[0])
647
+ if content:
648
+ yield GEN_AI_INPUT_MESSAGES_KEY, content
649
+
650
+
651
+ @stop_on_exception
652
+ def invoke_agent_output_message(
653
+ outputs: Mapping[str, Any] | None,
654
+ ) -> Iterator[tuple[str, str]]:
655
+ """
656
+ Extract the final output message for invoke_agent spans (LangGraph root).
657
+ We want the last AI/assistant message content.
658
+ """
659
+ if not outputs:
660
+ return
661
+
662
+ assert hasattr(outputs, "get"), f"expected Mapping, found {type(outputs)}"
663
+
664
+ messages = outputs.get("messages")
665
+ if not messages:
666
+ return
667
+
668
+ # Handle nested list structure if present
669
+ if isinstance(messages, list) and len(messages) > 0:
670
+ first_item = messages[0]
671
+ if isinstance(first_item, list):
672
+ messages = first_item
673
+
674
+ # Find the last AI/assistant message with content (not tool calls)
675
+ if isinstance(messages, list):
676
+ # Iterate in reverse to find the last AI message
677
+ for message in reversed(messages):
678
+ role = _get_message_role(message)
679
+ if role and role.lower() in ("ai", "assistant"):
680
+ content = _extract_content_from_message(message)
681
+ # Make sure it has actual content, not just tool calls
682
+ if content and isinstance(content, str) and content.strip():
683
+ yield GEN_AI_OUTPUT_MESSAGES_KEY, content
684
+ return
685
+
686
+
687
+ def set_execution_type() -> Iterator[tuple[str, str]]:
688
+ """Yields the execution type as human_to_agent."""
689
+ yield GEN_AI_EXECUTION_TYPE_KEY, ExecutionType.HUMAN_TO_AGENT.value
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: microsoft-agents-a365-observability-extensions-langchain
3
- Version: 0.2.1.dev9
3
+ Version: 0.2.1.dev10
4
4
  Summary: LangChain observability and tracing extensions for Microsoft Agent 365
5
5
  Author-email: Microsoft <support@microsoft.com>
6
6
  License: MIT