memtrace-ai 0.2.0__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 (131) hide show
  1. memtrace_ai-0.2.0/PKG-INFO +137 -0
  2. memtrace_ai-0.2.0/README.md +73 -0
  3. memtrace_ai-0.2.0/memtrace/__init__.py +31 -0
  4. memtrace_ai-0.2.0/memtrace/_version.py +1 -0
  5. memtrace_ai-0.2.0/memtrace/adapters/__init__.py +0 -0
  6. memtrace_ai-0.2.0/memtrace/adapters/inbound/__init__.py +0 -0
  7. memtrace_ai-0.2.0/memtrace/adapters/inbound/asgi/__init__.py +3 -0
  8. memtrace_ai-0.2.0/memtrace/adapters/inbound/asgi/prompt_override.py +39 -0
  9. memtrace_ai-0.2.0/memtrace/adapters/inbound/auto_instrumentation.py +22 -0
  10. memtrace_ai-0.2.0/memtrace/adapters/inbound/langchain/__init__.py +5 -0
  11. memtrace_ai-0.2.0/memtrace/adapters/inbound/langchain/auto.py +43 -0
  12. memtrace_ai-0.2.0/memtrace/adapters/inbound/langchain/callback.py +142 -0
  13. memtrace_ai-0.2.0/memtrace/adapters/inbound/langchain/mapping.py +110 -0
  14. memtrace_ai-0.2.0/memtrace/adapters/inbound/langchain/prompt_middleware.py +45 -0
  15. memtrace_ai-0.2.0/memtrace/adapters/inbound/manual/__init__.py +5 -0
  16. memtrace_ai-0.2.0/memtrace/adapters/inbound/manual/llm.py +47 -0
  17. memtrace_ai-0.2.0/memtrace/adapters/inbound/manual/retrieval.py +14 -0
  18. memtrace_ai-0.2.0/memtrace/adapters/inbound/manual/step.py +81 -0
  19. memtrace_ai-0.2.0/memtrace/adapters/inbound/openai_agents/__init__.py +4 -0
  20. memtrace_ai-0.2.0/memtrace/adapters/inbound/openai_agents/auto.py +35 -0
  21. memtrace_ai-0.2.0/memtrace/adapters/inbound/openai_agents/callback.py +193 -0
  22. memtrace_ai-0.2.0/memtrace/adapters/inbound/pydantic_ai/__init__.py +3 -0
  23. memtrace_ai-0.2.0/memtrace/adapters/inbound/pydantic_ai/auto.py +42 -0
  24. memtrace_ai-0.2.0/memtrace/adapters/inbound/pydantic_ai/mcp.py +56 -0
  25. memtrace_ai-0.2.0/memtrace/adapters/inbound/pydantic_ai/normalization.py +48 -0
  26. memtrace_ai-0.2.0/memtrace/adapters/outbound/__init__.py +0 -0
  27. memtrace_ai-0.2.0/memtrace/adapters/outbound/file_prompt_cache.py +49 -0
  28. memtrace_ai-0.2.0/memtrace/adapters/outbound/http/__init__.py +0 -0
  29. memtrace_ai-0.2.0/memtrace/adapters/outbound/http/eval_api_client.py +204 -0
  30. memtrace_ai-0.2.0/memtrace/adapters/outbound/http/feedback_client.py +45 -0
  31. memtrace_ai-0.2.0/memtrace/adapters/outbound/http/prompt_client.py +138 -0
  32. memtrace_ai-0.2.0/memtrace/adapters/outbound/llm/__init__.py +0 -0
  33. memtrace_ai-0.2.0/memtrace/adapters/outbound/llm/anthropic_client.py +43 -0
  34. memtrace_ai-0.2.0/memtrace/adapters/outbound/noop.py +37 -0
  35. memtrace_ai-0.2.0/memtrace/adapters/outbound/otel/__init__.py +0 -0
  36. memtrace_ai-0.2.0/memtrace/adapters/outbound/otel/attribute_sanitizer.py +29 -0
  37. memtrace_ai-0.2.0/memtrace/adapters/outbound/otel/exporter_factory.py +40 -0
  38. memtrace_ai-0.2.0/memtrace/adapters/outbound/otel/factory.py +74 -0
  39. memtrace_ai-0.2.0/memtrace/adapters/outbound/otel/sanitizing_exporter.py +80 -0
  40. memtrace_ai-0.2.0/memtrace/adapters/outbound/otel/session_processor.py +36 -0
  41. memtrace_ai-0.2.0/memtrace/adapters/outbound/otel/span_adapter.py +70 -0
  42. memtrace_ai-0.2.0/memtrace/adapters/outbound/otel/switchable_provider.py +55 -0
  43. memtrace_ai-0.2.0/memtrace/application/__init__.py +0 -0
  44. memtrace_ai-0.2.0/memtrace/application/context.py +47 -0
  45. memtrace_ai-0.2.0/memtrace/application/eval_ports.py +160 -0
  46. memtrace_ai-0.2.0/memtrace/application/experiment_runner.py +188 -0
  47. memtrace_ai-0.2.0/memtrace/application/judges/__init__.py +5 -0
  48. memtrace_ai-0.2.0/memtrace/application/judges/base.py +122 -0
  49. memtrace_ai-0.2.0/memtrace/application/judges/correctness.py +27 -0
  50. memtrace_ai-0.2.0/memtrace/application/judges/faithfulness.py +34 -0
  51. memtrace_ai-0.2.0/memtrace/application/ports.py +38 -0
  52. memtrace_ai-0.2.0/memtrace/application/prompt_fix.py +113 -0
  53. memtrace_ai-0.2.0/memtrace/application/prompt_override.py +27 -0
  54. memtrace_ai-0.2.0/memtrace/application/prompt_ports.py +48 -0
  55. memtrace_ai-0.2.0/memtrace/application/prompt_registry.py +378 -0
  56. memtrace_ai-0.2.0/memtrace/application/redteam/__init__.py +0 -0
  57. memtrace_ai-0.2.0/memtrace/application/redteam/attacks.py +68 -0
  58. memtrace_ai-0.2.0/memtrace/application/redteam/judges.py +84 -0
  59. memtrace_ai-0.2.0/memtrace/application/redteam/runner.py +33 -0
  60. memtrace_ai-0.2.0/memtrace/application/retrieval_capture.py +31 -0
  61. memtrace_ai-0.2.0/memtrace/application/retrieval_metrics.py +98 -0
  62. memtrace_ai-0.2.0/memtrace/application/run_lifecycle.py +94 -0
  63. memtrace_ai-0.2.0/memtrace/application/run_registry.py +50 -0
  64. memtrace_ai-0.2.0/memtrace/application/security/__init__.py +0 -0
  65. memtrace_ai-0.2.0/memtrace/application/security/detectors.py +106 -0
  66. memtrace_ai-0.2.0/memtrace/application/security/policy.py +61 -0
  67. memtrace_ai-0.2.0/memtrace/application/security/recorder.py +40 -0
  68. memtrace_ai-0.2.0/memtrace/application/span_normalization.py +35 -0
  69. memtrace_ai-0.2.0/memtrace/application/tracing_service.py +223 -0
  70. memtrace_ai-0.2.0/memtrace/config.py +194 -0
  71. memtrace_ai-0.2.0/memtrace/dependency_container.py +194 -0
  72. memtrace_ai-0.2.0/memtrace/domain/__init__.py +0 -0
  73. memtrace_ai-0.2.0/memtrace/domain/attributes.py +129 -0
  74. memtrace_ai-0.2.0/memtrace/domain/evaluation.py +167 -0
  75. memtrace_ai-0.2.0/memtrace/domain/model.py +64 -0
  76. memtrace_ai-0.2.0/memtrace/domain/prompt.py +98 -0
  77. memtrace_ai-0.2.0/memtrace/domain/redaction.py +138 -0
  78. memtrace_ai-0.2.0/memtrace/domain/security.py +160 -0
  79. memtrace_ai-0.2.0/memtrace/domain/semconv.py +39 -0
  80. memtrace_ai-0.2.0/memtrace/domain/serialization.py +101 -0
  81. memtrace_ai-0.2.0/memtrace/eval.py +188 -0
  82. memtrace_ai-0.2.0/memtrace/eval_judges.py +21 -0
  83. memtrace_ai-0.2.0/memtrace/langchain.py +12 -0
  84. memtrace_ai-0.2.0/memtrace/openai_agents.py +8 -0
  85. memtrace_ai-0.2.0/memtrace/pii.py +149 -0
  86. memtrace_ai-0.2.0/memtrace/prompts.py +151 -0
  87. memtrace_ai-0.2.0/memtrace/py.typed +0 -0
  88. memtrace_ai-0.2.0/memtrace/pydantic_ai.py +8 -0
  89. memtrace_ai-0.2.0/memtrace/redteam.py +108 -0
  90. memtrace_ai-0.2.0/memtrace/security.py +88 -0
  91. memtrace_ai-0.2.0/memtrace/user_feedback.py +82 -0
  92. memtrace_ai-0.2.0/memtrace_ai.egg-info/PKG-INFO +137 -0
  93. memtrace_ai-0.2.0/memtrace_ai.egg-info/SOURCES.txt +129 -0
  94. memtrace_ai-0.2.0/memtrace_ai.egg-info/dependency_links.txt +1 -0
  95. memtrace_ai-0.2.0/memtrace_ai.egg-info/requires.txt +58 -0
  96. memtrace_ai-0.2.0/memtrace_ai.egg-info/top_level.txt +1 -0
  97. memtrace_ai-0.2.0/pyproject.toml +121 -0
  98. memtrace_ai-0.2.0/setup.cfg +4 -0
  99. memtrace_ai-0.2.0/tests/test_architecture.py +35 -0
  100. memtrace_ai-0.2.0/tests/test_auto_instrumentation.py +163 -0
  101. memtrace_ai-0.2.0/tests/test_concurrency.py +41 -0
  102. memtrace_ai-0.2.0/tests/test_config.py +75 -0
  103. memtrace_ai-0.2.0/tests/test_conformance.py +242 -0
  104. memtrace_ai-0.2.0/tests/test_container.py +59 -0
  105. memtrace_ai-0.2.0/tests/test_decorators.py +151 -0
  106. memtrace_ai-0.2.0/tests/test_domain.py +40 -0
  107. memtrace_ai-0.2.0/tests/test_eval_builtins.py +46 -0
  108. memtrace_ai-0.2.0/tests/test_eval_http_adapter.py +208 -0
  109. memtrace_ai-0.2.0/tests/test_experiment_runner.py +360 -0
  110. memtrace_ai-0.2.0/tests/test_integration.py +66 -0
  111. memtrace_ai-0.2.0/tests/test_langchain.py +124 -0
  112. memtrace_ai-0.2.0/tests/test_langgraph.py +50 -0
  113. memtrace_ai-0.2.0/tests/test_llm_judge.py +142 -0
  114. memtrace_ai-0.2.0/tests/test_openai_agents.py +150 -0
  115. memtrace_ai-0.2.0/tests/test_otel_adapters.py +69 -0
  116. memtrace_ai-0.2.0/tests/test_pii.py +165 -0
  117. memtrace_ai-0.2.0/tests/test_prompt_adapters.py +249 -0
  118. memtrace_ai-0.2.0/tests/test_prompt_fix.py +192 -0
  119. memtrace_ai-0.2.0/tests/test_prompt_frameworks.py +65 -0
  120. memtrace_ai-0.2.0/tests/test_prompt_langchain.py +142 -0
  121. memtrace_ai-0.2.0/tests/test_prompt_override.py +314 -0
  122. memtrace_ai-0.2.0/tests/test_prompts.py +453 -0
  123. memtrace_ai-0.2.0/tests/test_pydantic_ai_mcp.py +87 -0
  124. memtrace_ai-0.2.0/tests/test_redaction.py +86 -0
  125. memtrace_ai-0.2.0/tests/test_redteam.py +80 -0
  126. memtrace_ai-0.2.0/tests/test_retrieval_metrics.py +76 -0
  127. memtrace_ai-0.2.0/tests/test_sanitizing_exporter.py +211 -0
  128. memtrace_ai-0.2.0/tests/test_security.py +159 -0
  129. memtrace_ai-0.2.0/tests/test_span_normalization.py +90 -0
  130. memtrace_ai-0.2.0/tests/test_tracing_service.py +149 -0
  131. memtrace_ai-0.2.0/tests/test_user_feedback.py +63 -0
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.4
2
+ Name: memtrace-ai
3
+ Version: 0.2.0
4
+ Summary: OpenTelemetry-based tracing and observability SDK for AI agents
5
+ Author: MemTrace Authors
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/martia-es/memtrace
8
+ Project-URL: Documentation, https://github.com/martia-es/memtrace/tree/main/docs-site
9
+ Project-URL: Changelog, https://github.com/martia-es/memtrace/blob/main/sdk/python/CHANGELOG.md
10
+ Keywords: observability,tracing,opentelemetry,llm,ai-agents,langchain,pydantic-ai,openai-agents
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: System :: Monitoring
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ Requires-Dist: opentelemetry-api>=1.20.0
24
+ Requires-Dist: opentelemetry-sdk>=1.20.0
25
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.20.0
26
+ Provides-Extra: grpc
27
+ Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.20.0; extra == "grpc"
28
+ Provides-Extra: http
29
+ Provides-Extra: langchain
30
+ Requires-Dist: langchain-core>=0.1.0; extra == "langchain"
31
+ Requires-Dist: langchain>=1.0.0; python_version >= "3.10" and extra == "langchain"
32
+ Requires-Dist: opentelemetry-instrumentation-langchain<1,>=0.45.0; extra == "langchain"
33
+ Requires-Dist: wrapt<2; python_version < "3.10" and extra == "langchain"
34
+ Provides-Extra: pydantic-ai
35
+ Requires-Dist: pydantic-ai-slim>=1.0.0; extra == "pydantic-ai"
36
+ Provides-Extra: openai-agents
37
+ Requires-Dist: openai-agents>=0.2.0; extra == "openai-agents"
38
+ Provides-Extra: pii
39
+ Requires-Dist: presidio-analyzer>=2.2.35; extra == "pii"
40
+ Requires-Dist: presidio-anonymizer>=2.2.35; extra == "pii"
41
+ Requires-Dist: spacy>=3.7; extra == "pii"
42
+ Provides-Extra: security
43
+ Requires-Dist: detect-secrets>=1.5; extra == "security"
44
+ Requires-Dist: presidio-analyzer>=2.2.35; extra == "security"
45
+ Provides-Extra: eval
46
+ Requires-Dist: httpx>=0.27; extra == "eval"
47
+ Requires-Dist: anthropic>=0.30; extra == "eval"
48
+ Provides-Extra: dev
49
+ Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.20.0; extra == "dev"
50
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
51
+ Requires-Dist: pytest-asyncio>=0.21.0; extra == "dev"
52
+ Requires-Dist: langchain-core>=0.1.0; extra == "dev"
53
+ Requires-Dist: langchain>=1.0.0; python_version >= "3.10" and extra == "dev"
54
+ Requires-Dist: opentelemetry-instrumentation-langchain<1,>=0.45.0; extra == "dev"
55
+ Requires-Dist: wrapt<2; python_version < "3.10" and extra == "dev"
56
+ Requires-Dist: pydantic-ai-slim>=1.0.0; python_version >= "3.10" and extra == "dev"
57
+ Requires-Dist: openai-agents>=0.2.0; extra == "dev"
58
+ Requires-Dist: httpx>=0.27; extra == "dev"
59
+ Requires-Dist: anthropic>=0.30; extra == "dev"
60
+ Requires-Dist: detect-secrets>=1.5; extra == "dev"
61
+ Requires-Dist: presidio-analyzer>=2.2.35; extra == "dev"
62
+ Requires-Dist: mypy>=1.10; extra == "dev"
63
+ Requires-Dist: ruff>=0.5; extra == "dev"
64
+
65
+ # memtrace-ai (Python SDK)
66
+
67
+ Instruments AI agents and exports traces over OTLP (OpenTelemetry GenAI conventions). It does not know the storage: it only talks to the OTel Collector.
68
+
69
+ > The package is named `memtrace-ai` (the Python import is `memtrace`). It is **not on PyPI yet**: install it from this repository.
70
+
71
+ ```bash
72
+ pip install -e sdk/python # from the repository root; PyPI publication is pending
73
+ # extras (pip install -e "sdk/python[eval]"): grpc (OTLP/gRPC), langchain, eval, pydantic-ai, pii, dev
74
+ ```
75
+
76
+ ```python
77
+ from memtrace import init_tracer, trace_step, trace_llm_call, shutdown
78
+
79
+ init_tracer(service_name="my-agent") # or MEMTRACE_* environment variables
80
+
81
+ @trace_step(name="search", step_type="tool")
82
+ def search(q: str) -> str: ...
83
+
84
+ # LangChain / LangGraph - Option 1: manual callback handler
85
+ from memtrace.langchain import MemTraceCallbackHandler
86
+ chain.invoke(x, config={"callbacks": [MemTraceCallbackHandler()]})
87
+
88
+ # LangChain / LangGraph - Option 2: automatic (pip install 'memtrace-ai[langchain]')
89
+ # from memtrace.langchain import enable_langchain_instrumentation
90
+ # enable_langchain_instrumentation() # once, after init_tracer(); do not combine with option 1
91
+
92
+ # Pydantic AI - automatic (pip install 'memtrace-ai[pydantic-ai]')
93
+ # from memtrace.pydantic_ai import enable_pydantic_ai_instrumentation
94
+ # enable_pydantic_ai_instrumentation()
95
+
96
+ shutdown() # optional: the OTel SDK also flushes at exit; use flush() in short scripts / serverless
97
+ ```
98
+
99
+ **Error policy**: the tracing calls never raise into your code. The only exceptions are the `enable_*_instrumentation()` functions, which raise `ImportError` if their extra is not installed.
100
+
101
+ **Conversations**: wrap each turn with `with memtrace.session("conversation-id"):` (or pass `thread_id` / `session_id` / `conversation_id` in LangChain's `metadata`) and the dashboard groups the traces into one conversation. The id must be stable across turns, unique per conversation and free of personal data (it shows up in URLs). Without it, every turn is a standalone trace. `session()` also applies to spans emitted by auto-instrumented libraries.
102
+
103
+ Spans created inside a `@trace_step` (or inside `with memtrace.session("id")`) hang from it, including those of the LangChain handler and of auto-instrumented libraries.
104
+
105
+ `trace_step`, `trace_step_context` and `trace_llm_call` accept `service=` (the value returned by `init_tracer`) to use an explicit tracer instead of the global one.
106
+
107
+ ## Configuration
108
+
109
+ | Variable | Default | |
110
+ |---|---|---|
111
+ | `MEMTRACE_ENABLED` | `true` | `false` disables everything (no-op) |
112
+ | `MEMTRACE_SERVICE_NAME` | `default-agent` | `service.name` |
113
+ | `MEMTRACE_SERVICE_VERSION`, `MEMTRACE_ENVIRONMENT` | - | Resource attributes |
114
+ | `MEMTRACE_OTLP_ENDPOINT` | `http://localhost:4318` (http) / `:4317` (grpc) | any OTLP endpoint |
115
+ | `MEMTRACE_OTLP_PROTOCOL` | `http/protobuf` | or `grpc` (extra `grpc`) |
116
+ | `MEMTRACE_OTLP_HEADERS` | - | `k1=v1,k2=v2` |
117
+ | `MEMTRACE_CAPTURE_CONTENT` | `false` | store prompts/completions/arguments (ADR-004); also gates the auto-instrumentations |
118
+ | `MEMTRACE_MAX_CONTENT_LENGTH` | `16384` | truncation of captured content |
119
+ | `MEMTRACE_REDACT_KEYS` | - | extra key names to mask, on top of the built-in list (ADR-021) |
120
+ | `MEMTRACE_SPAN_TTL_SECONDS` | `3600` | closes orphaned spans |
121
+ | `MEMTRACE_MAX_ACTIVE_RUNS` | `10000` | cap on in-flight spans |
122
+ | `MEMTRACE_EXPORT_TIMEOUT_MS`, `MEMTRACE_BATCH_MAX_QUEUE_SIZE`, `MEMTRACE_BATCH_SCHEDULE_DELAY_MS`, `MEMTRACE_BATCH_MAX_EXPORT_SIZE` | 5000 / 2048 / 5000 / 512 | `BatchSpanProcessor` |
123
+
124
+ **Redaction**: secrets are masked in every exported span, by key name (`api_key`, `password`, …) and by shape (`sk-…`, JWTs, `Bearer …`, URL credentials). Personal data (emails, names, IDs) is anonymized only if you opt in with the `pii` extra: `init_tracer(redact=presidio_redactor(language="es"))` (`from memtrace.pii import presidio_redactor`; also `python -m spacy download <model>`), or with your own `init_tracer(redact=fn)`.
125
+
126
+ Self-signed TLS: `OTEL_EXPORTER_OTLP_CERTIFICATE` (standard OTel) with an `https://` endpoint.
127
+
128
+ ## Architecture
129
+
130
+ Hexagonal (ADR-008): `domain` (pure) → `application` (`SpanPort` port + `TracingService`) ← `adapters/inbound` (decorators, LangChain, Pydantic AI) and `adapters/outbound` (OpenTelemetry, no-op). `dependency_container.py` is the composition root. The dependency rule is checked by `tests/test_architecture.py`. Auto-instrumentors receive MemTrace's tracer provider explicitly, never a global one, and every span passes through a redacting exporter before it leaves the process (ADR-021).
131
+
132
+ ## Development
133
+
134
+ ```bash
135
+ pip install -e ".[dev]"
136
+ ruff check memtrace tests && mypy memtrace && pytest
137
+ ```
@@ -0,0 +1,73 @@
1
+ # memtrace-ai (Python SDK)
2
+
3
+ Instruments AI agents and exports traces over OTLP (OpenTelemetry GenAI conventions). It does not know the storage: it only talks to the OTel Collector.
4
+
5
+ > The package is named `memtrace-ai` (the Python import is `memtrace`). It is **not on PyPI yet**: install it from this repository.
6
+
7
+ ```bash
8
+ pip install -e sdk/python # from the repository root; PyPI publication is pending
9
+ # extras (pip install -e "sdk/python[eval]"): grpc (OTLP/gRPC), langchain, eval, pydantic-ai, pii, dev
10
+ ```
11
+
12
+ ```python
13
+ from memtrace import init_tracer, trace_step, trace_llm_call, shutdown
14
+
15
+ init_tracer(service_name="my-agent") # or MEMTRACE_* environment variables
16
+
17
+ @trace_step(name="search", step_type="tool")
18
+ def search(q: str) -> str: ...
19
+
20
+ # LangChain / LangGraph - Option 1: manual callback handler
21
+ from memtrace.langchain import MemTraceCallbackHandler
22
+ chain.invoke(x, config={"callbacks": [MemTraceCallbackHandler()]})
23
+
24
+ # LangChain / LangGraph - Option 2: automatic (pip install 'memtrace-ai[langchain]')
25
+ # from memtrace.langchain import enable_langchain_instrumentation
26
+ # enable_langchain_instrumentation() # once, after init_tracer(); do not combine with option 1
27
+
28
+ # Pydantic AI - automatic (pip install 'memtrace-ai[pydantic-ai]')
29
+ # from memtrace.pydantic_ai import enable_pydantic_ai_instrumentation
30
+ # enable_pydantic_ai_instrumentation()
31
+
32
+ shutdown() # optional: the OTel SDK also flushes at exit; use flush() in short scripts / serverless
33
+ ```
34
+
35
+ **Error policy**: the tracing calls never raise into your code. The only exceptions are the `enable_*_instrumentation()` functions, which raise `ImportError` if their extra is not installed.
36
+
37
+ **Conversations**: wrap each turn with `with memtrace.session("conversation-id"):` (or pass `thread_id` / `session_id` / `conversation_id` in LangChain's `metadata`) and the dashboard groups the traces into one conversation. The id must be stable across turns, unique per conversation and free of personal data (it shows up in URLs). Without it, every turn is a standalone trace. `session()` also applies to spans emitted by auto-instrumented libraries.
38
+
39
+ Spans created inside a `@trace_step` (or inside `with memtrace.session("id")`) hang from it, including those of the LangChain handler and of auto-instrumented libraries.
40
+
41
+ `trace_step`, `trace_step_context` and `trace_llm_call` accept `service=` (the value returned by `init_tracer`) to use an explicit tracer instead of the global one.
42
+
43
+ ## Configuration
44
+
45
+ | Variable | Default | |
46
+ |---|---|---|
47
+ | `MEMTRACE_ENABLED` | `true` | `false` disables everything (no-op) |
48
+ | `MEMTRACE_SERVICE_NAME` | `default-agent` | `service.name` |
49
+ | `MEMTRACE_SERVICE_VERSION`, `MEMTRACE_ENVIRONMENT` | - | Resource attributes |
50
+ | `MEMTRACE_OTLP_ENDPOINT` | `http://localhost:4318` (http) / `:4317` (grpc) | any OTLP endpoint |
51
+ | `MEMTRACE_OTLP_PROTOCOL` | `http/protobuf` | or `grpc` (extra `grpc`) |
52
+ | `MEMTRACE_OTLP_HEADERS` | - | `k1=v1,k2=v2` |
53
+ | `MEMTRACE_CAPTURE_CONTENT` | `false` | store prompts/completions/arguments (ADR-004); also gates the auto-instrumentations |
54
+ | `MEMTRACE_MAX_CONTENT_LENGTH` | `16384` | truncation of captured content |
55
+ | `MEMTRACE_REDACT_KEYS` | - | extra key names to mask, on top of the built-in list (ADR-021) |
56
+ | `MEMTRACE_SPAN_TTL_SECONDS` | `3600` | closes orphaned spans |
57
+ | `MEMTRACE_MAX_ACTIVE_RUNS` | `10000` | cap on in-flight spans |
58
+ | `MEMTRACE_EXPORT_TIMEOUT_MS`, `MEMTRACE_BATCH_MAX_QUEUE_SIZE`, `MEMTRACE_BATCH_SCHEDULE_DELAY_MS`, `MEMTRACE_BATCH_MAX_EXPORT_SIZE` | 5000 / 2048 / 5000 / 512 | `BatchSpanProcessor` |
59
+
60
+ **Redaction**: secrets are masked in every exported span, by key name (`api_key`, `password`, …) and by shape (`sk-…`, JWTs, `Bearer …`, URL credentials). Personal data (emails, names, IDs) is anonymized only if you opt in with the `pii` extra: `init_tracer(redact=presidio_redactor(language="es"))` (`from memtrace.pii import presidio_redactor`; also `python -m spacy download <model>`), or with your own `init_tracer(redact=fn)`.
61
+
62
+ Self-signed TLS: `OTEL_EXPORTER_OTLP_CERTIFICATE` (standard OTel) with an `https://` endpoint.
63
+
64
+ ## Architecture
65
+
66
+ Hexagonal (ADR-008): `domain` (pure) → `application` (`SpanPort` port + `TracingService`) ← `adapters/inbound` (decorators, LangChain, Pydantic AI) and `adapters/outbound` (OpenTelemetry, no-op). `dependency_container.py` is the composition root. The dependency rule is checked by `tests/test_architecture.py`. Auto-instrumentors receive MemTrace's tracer provider explicitly, never a global one, and every span passes through a redacting exporter before it leaves the process (ADR-021).
67
+
68
+ ## Development
69
+
70
+ ```bash
71
+ pip install -e ".[dev]"
72
+ ruff check memtrace tests && mypy memtrace && pytest
73
+ ```
@@ -0,0 +1,31 @@
1
+ from memtrace import prompts
2
+ from memtrace._version import __version__
3
+ from memtrace.adapters.inbound.manual import record_retrieved_chunks, trace_llm_call, trace_step, trace_step_context
4
+ from memtrace.application.context import get_current_run_id, session, user
5
+ from memtrace.dependency_container import flush, init_tracer, shutdown
6
+ from memtrace.langchain import MemTraceCallbackHandler, enable_langchain_instrumentation
7
+ from memtrace.openai_agents import enable_openai_agents_instrumentation
8
+ from memtrace.pydantic_ai import enable_pydantic_ai_instrumentation
9
+ from memtrace.user_feedback import current_trace_id, feedback, retract_feedback
10
+
11
+ __all__ = [
12
+ "__version__",
13
+ "init_tracer",
14
+ "flush",
15
+ "shutdown",
16
+ "session",
17
+ "user",
18
+ "trace_step",
19
+ "trace_step_context",
20
+ "trace_llm_call",
21
+ "record_retrieved_chunks",
22
+ "get_current_run_id",
23
+ "current_trace_id",
24
+ "feedback",
25
+ "retract_feedback",
26
+ "prompts",
27
+ "MemTraceCallbackHandler",
28
+ "enable_langchain_instrumentation",
29
+ "enable_pydantic_ai_instrumentation",
30
+ "enable_openai_agents_instrumentation",
31
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
File without changes
@@ -0,0 +1,3 @@
1
+ from memtrace.adapters.inbound.asgi.prompt_override import PromptOverrideMiddleware
2
+
3
+ __all__ = ["PromptOverrideMiddleware"]
@@ -0,0 +1,39 @@
1
+ """ASGI middleware that lets MemTrace's playground test a prompt version in this agent (ADR-071).
2
+
3
+ MemTrace calls the agent's chat with the token of a short-lived override in the `x-memtrace-prompt-override` header. This
4
+ middleware reads it and, for the duration of that request only, makes `handle.compile()` ask MemTrace for the version the
5
+ token grants. It does nothing unless the agent opted in with `MEMTRACE_ALLOW_PROMPT_OVERRIDE=true`, and a token MemTrace
6
+ does not recognize changes nothing.
7
+ """
8
+ from typing import Any, Awaitable, Callable, MutableMapping
9
+
10
+ from memtrace.application.prompt_override import use_override
11
+ from memtrace.config import settings
12
+ from memtrace.domain.prompt import PROMPT_OVERRIDE_HEADER, is_override_token
13
+
14
+ Scope = MutableMapping[str, Any]
15
+ Receive = Callable[[], Awaitable[MutableMapping[str, Any]]]
16
+ Send = Callable[[MutableMapping[str, Any]], Awaitable[None]]
17
+
18
+
19
+ class PromptOverrideMiddleware:
20
+ """Pure ASGI (FastAPI, Starlette, Quart…): `app.add_middleware(PromptOverrideMiddleware)`."""
21
+
22
+ def __init__(self, app: Callable[[Scope, Receive, Send], Awaitable[None]]) -> None:
23
+ self.app = app
24
+ self._header = PROMPT_OVERRIDE_HEADER.encode("latin-1")
25
+
26
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
27
+ token = self._token(scope) if scope.get("type") in ("http", "websocket") and settings.allow_prompt_override else None
28
+ if token is None:
29
+ await self.app(scope, receive, send)
30
+ return
31
+ with use_override(token):
32
+ await self.app(scope, receive, send)
33
+
34
+ def _token(self, scope: Scope) -> "str | None":
35
+ for name, value in scope.get("headers") or ():
36
+ if name.lower() == self._header:
37
+ candidate = value.decode("latin-1")
38
+ return candidate if is_override_token(candidate) else None
39
+ return None
@@ -0,0 +1,22 @@
1
+ """Shared plumbing for "auto" instrumentors (LangChain, Pydantic AI…): each just points a
2
+ third-party library at the tracer provider configured by `init_tracer`, no callbacks needed.
3
+ """
4
+ import logging
5
+ from typing import Any, Optional, Tuple
6
+
7
+ from memtrace.application.tracing_service import TracingService
8
+ from memtrace.dependency_container import get_service
9
+
10
+ logger = logging.getLogger("memtrace")
11
+
12
+
13
+ def resolve_tracer_provider(feature: str, service: Optional[TracingService] = None) -> Tuple[TracingService, Optional[Any]]:
14
+ """The `(service, tracer_provider)` to instrument with, or `(service, None)` if tracing is off.
15
+
16
+ `feature`: human-readable name for the log line (e.g. "LangChain", "Pydantic AI").
17
+ """
18
+ service = service or get_service()
19
+ provider = service.tracer_provider
20
+ if provider is None:
21
+ logger.info("[MemTrace] Tracing is off; %s instrumentation not enabled.", feature)
22
+ return service, provider
@@ -0,0 +1,5 @@
1
+ from memtrace.adapters.inbound.langchain.auto import enable_langchain_instrumentation
2
+ from memtrace.adapters.inbound.langchain.callback import MemTraceCallbackHandler
3
+ from memtrace.adapters.inbound.langchain.prompt_middleware import prompt_middleware
4
+
5
+ __all__ = ["MemTraceCallbackHandler", "enable_langchain_instrumentation", "prompt_middleware"]
@@ -0,0 +1,43 @@
1
+ """Automatic LangChain / LangGraph instrumentation through OpenTelemetry's instrumentor."""
2
+ import logging
3
+ import os
4
+ from typing import Optional
5
+
6
+ from memtrace.adapters.inbound.auto_instrumentation import resolve_tracer_provider
7
+ from memtrace.application.tracing_service import TracingService
8
+
9
+ logger = logging.getLogger("memtrace")
10
+
11
+ _INSTALL_HINT = "pip install 'memtrace-ai[langchain]'"
12
+ _CONTENT_ENV = "TRACELOOP_TRACE_CONTENT" # honored by opentelemetry-instrumentation-langchain
13
+
14
+
15
+ def enable_langchain_instrumentation(service: Optional[TracingService] = None) -> None:
16
+ """Enables automatic LangChain / LangGraph instrumentation (no callbacks needed).
17
+
18
+ Requires: pip install 'memtrace-ai[langchain]'. Raises ImportError if it is missing.
19
+
20
+ Spans go to the tracer configured by `init_tracer`, and prompts/completions are only
21
+ recorded when MEMTRACE_CAPTURE_CONTENT=true. Safe to call more than once. It is an alternative to
22
+ MemTraceCallbackHandler; use one of them, not both, or every run is traced twice.
23
+ """
24
+ try:
25
+ from opentelemetry.instrumentation.langchain import LangchainInstrumentor
26
+ except ImportError as exc:
27
+ raise ImportError(f"LangChain instrumentation requires: {_INSTALL_HINT}") from exc
28
+
29
+ # provider is process-wide, survives shutdown()/init_tracer()
30
+ service, provider = resolve_tracer_provider("LangChain", service)
31
+ if provider is None:
32
+ return
33
+
34
+ # Respect the privacy default of ADR-004 unless the user set the instrumentor's own switch
35
+ os.environ.setdefault(_CONTENT_ENV, "true" if service.captures_content else "false")
36
+
37
+ instrumentor = LangchainInstrumentor()
38
+ if instrumentor.is_instrumented_by_opentelemetry:
39
+ # Already bound to MemTrace's switchable provider, which follows init_tracer()/shutdown()
40
+ logger.debug("[MemTrace] LangChain auto-instrumentation was already enabled")
41
+ return
42
+ instrumentor.instrument(tracer_provider=provider)
43
+ logger.info("[MemTrace] LangChain auto-instrumentation enabled")
@@ -0,0 +1,142 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from memtrace.adapters.inbound.langchain.mapping import (
7
+ generation_dicts,
8
+ message_dicts,
9
+ request_to_call,
10
+ response_to_call,
11
+ step_name,
12
+ )
13
+ from memtrace.application.retrieval_capture import note_retrieved
14
+ from memtrace.application.tracing_service import TracingService
15
+ from memtrace.dependency_container import get_service
16
+ from memtrace.domain import semconv as sc
17
+ from memtrace.domain.attributes import llm_attributes, retrieved_chunks
18
+ from memtrace.domain.model import StepType
19
+ from memtrace.domain.serialization import to_json
20
+
21
+ logger = logging.getLogger("memtrace")
22
+
23
+ _INSTALL_HINT = "pip install 'memtrace-ai[langchain]'"
24
+
25
+ try:
26
+ from langchain_core.callbacks import BaseCallbackHandler
27
+ except ImportError:
28
+ class BaseCallbackHandler: # type: ignore[no-redef]
29
+ """Stand-in so importing this module never requires LangChain; instantiating does."""
30
+
31
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
32
+ raise ImportError(f"MemTraceCallbackHandler requires LangChain: {_INSTALL_HINT}")
33
+
34
+
35
+ class MemTraceCallbackHandler(BaseCallbackHandler):
36
+ """LangChain / LangGraph callback handler.
37
+
38
+ Usage: `chain.invoke(x, config={"callbacks": [MemTraceCallbackHandler()]})`.
39
+ Inside a `@trace_step`, its spans hang from it.
40
+ """
41
+
42
+ def __init__(self, service: Optional[TracingService] = None) -> None:
43
+ super().__init__()
44
+ self._service = service or get_service()
45
+
46
+ # ----- helpers -----
47
+
48
+ def _context_attrs(self, tags: Optional[List[str]], metadata: Optional[Dict[str, Any]]) -> Dict[str, Any]:
49
+ md = metadata or {}
50
+ return {
51
+ sc.MEMTRACE_TAGS: [str(t) for t in tags] if tags else None,
52
+ sc.MEMTRACE_METADATA: to_json(md, self._service.max_content_length) if md else None,
53
+ # `thread_id` is the LangGraph conversation identifier
54
+ sc.GEN_AI_CONVERSATION_ID: md.get("session_id") or md.get("conversation_id") or md.get("thread_id"),
55
+ sc.ENDUSER_ID: md.get("user_id"),
56
+ # LangGraph injects `langgraph_node`/`langgraph_step` into each run's metadata (ADR-011)
57
+ # the graph's own root run has no `langgraph_*` keys, only `ls_integration` (ADR-011)
58
+ sc.MEMTRACE_FRAMEWORK: "langgraph" if md.get("ls_integration") == "langgraph" or any(k.startswith("langgraph_") for k in md) else "langchain",
59
+ }
60
+
61
+ def _start(self, name, step_type, run_id, parent_run_id, tags, metadata, extra=None) -> None:
62
+ attrs = {**self._context_attrs(tags, metadata), **(extra or {})}
63
+ self._service.start_run(name, step_type, run_id=run_id, parent_run_id=parent_run_id, attributes=attrs)
64
+
65
+ def _end(self, run_id, attrs=None, error=None) -> None:
66
+ self._service.end_run(run_id, error=error, attributes=attrs)
67
+
68
+ # ----- chains / agents -----
69
+
70
+ def on_chain_start(self, serialized, inputs, *, run_id, parent_run_id=None, tags=None, metadata=None, **kwargs) -> Any:
71
+ self._start(step_name(serialized, kwargs, "Chain"), StepType.CHAIN, run_id, parent_run_id, tags, metadata,
72
+ {sc.MEMTRACE_INPUT: self._service.capture(inputs)})
73
+
74
+ def on_chain_end(self, outputs, *, run_id, parent_run_id=None, **kwargs) -> Any:
75
+ self._end(run_id, {sc.MEMTRACE_OUTPUT: self._service.capture(outputs)})
76
+
77
+ def on_chain_error(self, error, *, run_id, parent_run_id=None, **kwargs) -> Any:
78
+ self._end(run_id, error=error)
79
+
80
+ # ----- tools -----
81
+
82
+ def on_tool_start(self, serialized, input_str, *, run_id, parent_run_id=None, tags=None, metadata=None, inputs=None, **kwargs) -> Any:
83
+ name = step_name(serialized, kwargs, "Tool")
84
+ self._start(name, StepType.TOOL, run_id, parent_run_id, tags, metadata, {
85
+ sc.GEN_AI_TOOL_NAME: name,
86
+ sc.GEN_AI_TOOL_CALL_ID: kwargs.get("tool_call_id"),
87
+ sc.GEN_AI_TOOL_CALL_ARGUMENTS: self._service.capture(inputs if inputs is not None else input_str),
88
+ })
89
+
90
+ def on_tool_end(self, output, *, run_id, parent_run_id=None, **kwargs) -> Any:
91
+ result = getattr(output, "content", output) # ToolMessage or plain value
92
+ self._end(run_id, {sc.GEN_AI_TOOL_CALL_RESULT: self._service.capture(result)})
93
+
94
+ def on_tool_error(self, error, *, run_id, parent_run_id=None, **kwargs) -> Any:
95
+ self._end(run_id, error=error)
96
+
97
+ # ----- retrievers -----
98
+
99
+ def on_retriever_start(self, serialized, query, *, run_id, parent_run_id=None, tags=None, metadata=None, **kwargs) -> Any:
100
+ self._start(step_name(serialized, kwargs, "Retriever"), StepType.RETRIEVER, run_id, parent_run_id, tags, metadata,
101
+ {sc.MEMTRACE_INPUT: self._service.capture(query)})
102
+
103
+ def on_retriever_end(self, documents, *, run_id, parent_run_id=None, **kwargs) -> Any:
104
+ chunks = retrieved_chunks(documents)
105
+ note_retrieved(chunks)
106
+ self._end(run_id, {
107
+ sc.MEMTRACE_RETRIEVER_DOCUMENTS: len(documents or []),
108
+ sc.MEMTRACE_RETRIEVER_CHUNKS: self._service.capture(chunks),
109
+ })
110
+
111
+ def on_retriever_error(self, error, *, run_id, parent_run_id=None, **kwargs) -> Any:
112
+ self._end(run_id, error=error)
113
+
114
+ # ----- LLMs -----
115
+
116
+ def _llm_start(self, serialized, run_id, parent_run_id, tags, metadata, invocation_params, kwargs, input_messages) -> None:
117
+ call = request_to_call(metadata, invocation_params or kwargs.get("invocation_params") or {})
118
+ base = step_name(serialized, {}, "llm")
119
+ self._start(
120
+ f"{base}: {call.model}" if call.model else base, StepType.LLM, run_id, parent_run_id, tags, metadata,
121
+ llm_attributes(call, self._service.capture(input_messages)),
122
+ )
123
+
124
+ def on_chat_model_start(self, serialized, messages, *, run_id, parent_run_id=None, tags=None, metadata=None, invocation_params=None, **kwargs) -> Any:
125
+ self._llm_start(serialized, run_id, parent_run_id, tags, metadata, invocation_params, kwargs,
126
+ message_dicts(messages) if self._service.captures_content else None)
127
+
128
+ def on_llm_start(self, serialized, prompts, *, run_id, parent_run_id=None, tags=None, metadata=None, invocation_params=None, **kwargs) -> Any:
129
+ self._llm_start(serialized, run_id, parent_run_id, tags, metadata, invocation_params, kwargs,
130
+ [{"role": "user", "content": p} for p in prompts])
131
+
132
+ def on_llm_end(self, response, *, run_id, parent_run_id=None, **kwargs) -> Any:
133
+ try:
134
+ output = generation_dicts(response) if self._service.captures_content else None
135
+ attrs = llm_attributes(response_to_call(response), output_messages=self._service.capture(output))
136
+ except Exception as exc: # an unexpectedly shaped response must not lose the span
137
+ logger.warning("[MemTrace] Error extracting data from the LLM response: %s", exc)
138
+ attrs = None
139
+ self._end(run_id, attrs)
140
+
141
+ def on_llm_error(self, error, *, run_id, parent_run_id=None, **kwargs) -> Any:
142
+ self._end(run_id, error=error)
@@ -0,0 +1,110 @@
1
+ """Pure translation of LangChain structures into domain concepts. No `TracingService`, no OTel."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Dict, List, Optional, Tuple
5
+
6
+ from memtrace.domain.model import LlmCall
7
+
8
+
9
+ def _get(obj: Any, key: str) -> Any:
10
+ return obj.get(key) if isinstance(obj, dict) else getattr(obj, key, None)
11
+
12
+
13
+ def _as_int(val: Any) -> Optional[int]:
14
+ return val if isinstance(val, int) and not isinstance(val, bool) else None
15
+
16
+
17
+ def _tokens(usage: Any) -> Tuple[Optional[int], Optional[int], Optional[int]]:
18
+ """(input, output, total) from `usage_metadata` (LangChain), `token_usage` (OpenAI) or `usage` (Anthropic)."""
19
+ if not usage:
20
+ return None, None, None
21
+ inp = _as_int(_get(usage, "input_tokens"))
22
+ inp = inp if inp is not None else _as_int(_get(usage, "prompt_tokens"))
23
+ out = _as_int(_get(usage, "output_tokens"))
24
+ out = out if out is not None else _as_int(_get(usage, "completion_tokens"))
25
+ return inp, out, _as_int(_get(usage, "total_tokens"))
26
+
27
+
28
+ def response_to_call(response: Any) -> LlmCall:
29
+ """Response data of an `LLMResult` (duck-typed): messages first, `llm_output` as a fallback."""
30
+ inp = out = total = None
31
+ model = resp_id = None
32
+ finish: List[str] = []
33
+
34
+ for gens in getattr(response, "generations", None) or []:
35
+ for gen in gens or []:
36
+ msg = _get(gen, "message")
37
+ meta = _get(msg, "response_metadata") or {}
38
+ info = _get(gen, "generation_info") or {}
39
+ if inp is None and out is None:
40
+ inp, out, total = _tokens(_get(msg, "usage_metadata"))
41
+ model = model or _get(meta, "model_name") or _get(meta, "model")
42
+ resp_id = resp_id or _get(msg, "id") or _get(meta, "id")
43
+ reason = _get(info, "finish_reason") or _get(meta, "finish_reason") or _get(meta, "stop_reason")
44
+ if reason:
45
+ finish.append(str(reason))
46
+
47
+ llm_output = getattr(response, "llm_output", None) or {}
48
+ if inp is None and out is None:
49
+ inp, out, total = _tokens(_get(llm_output, "token_usage") or _get(llm_output, "usage"))
50
+ model = model or _get(llm_output, "model_name") or _get(llm_output, "model")
51
+ return LlmCall(
52
+ response_model=model, response_id=resp_id, finish_reasons=finish,
53
+ input_tokens=inp, output_tokens=out, total_tokens=total,
54
+ )
55
+
56
+
57
+ def request_to_call(metadata: Optional[Dict[str, Any]], params: Dict[str, Any]) -> LlmCall:
58
+ md = metadata or {}
59
+ return LlmCall(
60
+ provider=md.get("ls_provider") or params.get("_type"),
61
+ model=md.get("ls_model_name") or params.get("model_name") or params.get("model") or params.get("model_id"),
62
+ temperature=md.get("ls_temperature", params.get("temperature")),
63
+ max_tokens=md.get("ls_max_tokens", params.get("max_tokens") or params.get("max_tokens_to_sample")),
64
+ top_p=params.get("top_p"),
65
+ frequency_penalty=params.get("frequency_penalty"),
66
+ presence_penalty=params.get("presence_penalty"),
67
+ )
68
+
69
+
70
+ def step_name(serialized: Optional[Dict[str, Any]], kwargs: Dict[str, Any], default: str) -> str:
71
+ """`serialized` may be None (LangGraph, runnables); LangChain passes `name` as a kwarg."""
72
+ ser = serialized or {}
73
+ ident = ser.get("id")
74
+ return kwargs.get("name") or ser.get("name") or (ident[-1] if ident else None) or default
75
+
76
+
77
+ def _tool_calls_of(msg: Any) -> Any:
78
+ """`tool_calls` of the model message (or of `additional_kwargs` for some providers)."""
79
+ calls = getattr(msg, "tool_calls", None)
80
+ if not calls:
81
+ calls = (getattr(msg, "additional_kwargs", None) or {}).get("tool_calls")
82
+ return calls or None
83
+
84
+
85
+ def message_dicts(messages: List[List[Any]]) -> List[Dict[str, Any]]:
86
+ out = []
87
+ for batch in messages:
88
+ for m in batch:
89
+ d: Dict[str, Any] = {
90
+ "role": getattr(m, "type", None) or getattr(m, "role", None) or "user",
91
+ "content": getattr(m, "content", str(m)),
92
+ }
93
+ calls = _tool_calls_of(m)
94
+ if calls:
95
+ d["tool_calls"] = calls
96
+ out.append(d)
97
+ return out
98
+
99
+
100
+ def generation_dicts(response: Any) -> List[Dict[str, Any]]:
101
+ out = []
102
+ for gens in getattr(response, "generations", None) or []:
103
+ for g in gens:
104
+ msg = getattr(g, "message", None)
105
+ d: Dict[str, Any] = {"role": getattr(msg, "type", "assistant"), "content": getattr(g, "text", "")}
106
+ calls = _tool_calls_of(msg)
107
+ if calls:
108
+ d["tool_calls"] = calls
109
+ out.append(d)
110
+ return out