memtrace-ai 0.2.0__py3-none-any.whl

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 (93) hide show
  1. memtrace/__init__.py +31 -0
  2. memtrace/_version.py +1 -0
  3. memtrace/adapters/__init__.py +0 -0
  4. memtrace/adapters/inbound/__init__.py +0 -0
  5. memtrace/adapters/inbound/asgi/__init__.py +3 -0
  6. memtrace/adapters/inbound/asgi/prompt_override.py +39 -0
  7. memtrace/adapters/inbound/auto_instrumentation.py +22 -0
  8. memtrace/adapters/inbound/langchain/__init__.py +5 -0
  9. memtrace/adapters/inbound/langchain/auto.py +43 -0
  10. memtrace/adapters/inbound/langchain/callback.py +142 -0
  11. memtrace/adapters/inbound/langchain/mapping.py +110 -0
  12. memtrace/adapters/inbound/langchain/prompt_middleware.py +45 -0
  13. memtrace/adapters/inbound/manual/__init__.py +5 -0
  14. memtrace/adapters/inbound/manual/llm.py +47 -0
  15. memtrace/adapters/inbound/manual/retrieval.py +14 -0
  16. memtrace/adapters/inbound/manual/step.py +81 -0
  17. memtrace/adapters/inbound/openai_agents/__init__.py +4 -0
  18. memtrace/adapters/inbound/openai_agents/auto.py +35 -0
  19. memtrace/adapters/inbound/openai_agents/callback.py +193 -0
  20. memtrace/adapters/inbound/pydantic_ai/__init__.py +3 -0
  21. memtrace/adapters/inbound/pydantic_ai/auto.py +42 -0
  22. memtrace/adapters/inbound/pydantic_ai/mcp.py +56 -0
  23. memtrace/adapters/inbound/pydantic_ai/normalization.py +48 -0
  24. memtrace/adapters/outbound/__init__.py +0 -0
  25. memtrace/adapters/outbound/file_prompt_cache.py +49 -0
  26. memtrace/adapters/outbound/http/__init__.py +0 -0
  27. memtrace/adapters/outbound/http/eval_api_client.py +204 -0
  28. memtrace/adapters/outbound/http/feedback_client.py +45 -0
  29. memtrace/adapters/outbound/http/prompt_client.py +138 -0
  30. memtrace/adapters/outbound/llm/__init__.py +0 -0
  31. memtrace/adapters/outbound/llm/anthropic_client.py +43 -0
  32. memtrace/adapters/outbound/noop.py +37 -0
  33. memtrace/adapters/outbound/otel/__init__.py +0 -0
  34. memtrace/adapters/outbound/otel/attribute_sanitizer.py +29 -0
  35. memtrace/adapters/outbound/otel/exporter_factory.py +40 -0
  36. memtrace/adapters/outbound/otel/factory.py +74 -0
  37. memtrace/adapters/outbound/otel/sanitizing_exporter.py +80 -0
  38. memtrace/adapters/outbound/otel/session_processor.py +36 -0
  39. memtrace/adapters/outbound/otel/span_adapter.py +70 -0
  40. memtrace/adapters/outbound/otel/switchable_provider.py +55 -0
  41. memtrace/application/__init__.py +0 -0
  42. memtrace/application/context.py +47 -0
  43. memtrace/application/eval_ports.py +160 -0
  44. memtrace/application/experiment_runner.py +188 -0
  45. memtrace/application/judges/__init__.py +5 -0
  46. memtrace/application/judges/base.py +122 -0
  47. memtrace/application/judges/correctness.py +27 -0
  48. memtrace/application/judges/faithfulness.py +34 -0
  49. memtrace/application/ports.py +38 -0
  50. memtrace/application/prompt_fix.py +113 -0
  51. memtrace/application/prompt_override.py +27 -0
  52. memtrace/application/prompt_ports.py +48 -0
  53. memtrace/application/prompt_registry.py +378 -0
  54. memtrace/application/redteam/__init__.py +0 -0
  55. memtrace/application/redteam/attacks.py +68 -0
  56. memtrace/application/redteam/judges.py +84 -0
  57. memtrace/application/redteam/runner.py +33 -0
  58. memtrace/application/retrieval_capture.py +31 -0
  59. memtrace/application/retrieval_metrics.py +98 -0
  60. memtrace/application/run_lifecycle.py +94 -0
  61. memtrace/application/run_registry.py +50 -0
  62. memtrace/application/security/__init__.py +0 -0
  63. memtrace/application/security/detectors.py +106 -0
  64. memtrace/application/security/policy.py +61 -0
  65. memtrace/application/security/recorder.py +40 -0
  66. memtrace/application/span_normalization.py +35 -0
  67. memtrace/application/tracing_service.py +223 -0
  68. memtrace/config.py +194 -0
  69. memtrace/dependency_container.py +194 -0
  70. memtrace/domain/__init__.py +0 -0
  71. memtrace/domain/attributes.py +129 -0
  72. memtrace/domain/evaluation.py +167 -0
  73. memtrace/domain/model.py +64 -0
  74. memtrace/domain/prompt.py +98 -0
  75. memtrace/domain/redaction.py +138 -0
  76. memtrace/domain/security.py +160 -0
  77. memtrace/domain/semconv.py +39 -0
  78. memtrace/domain/serialization.py +101 -0
  79. memtrace/eval.py +188 -0
  80. memtrace/eval_judges.py +21 -0
  81. memtrace/langchain.py +12 -0
  82. memtrace/openai_agents.py +8 -0
  83. memtrace/pii.py +149 -0
  84. memtrace/prompts.py +151 -0
  85. memtrace/py.typed +0 -0
  86. memtrace/pydantic_ai.py +8 -0
  87. memtrace/redteam.py +108 -0
  88. memtrace/security.py +88 -0
  89. memtrace/user_feedback.py +82 -0
  90. memtrace_ai-0.2.0.dist-info/METADATA +137 -0
  91. memtrace_ai-0.2.0.dist-info/RECORD +93 -0
  92. memtrace_ai-0.2.0.dist-info/WHEEL +5 -0
  93. memtrace_ai-0.2.0.dist-info/top_level.txt +1 -0
memtrace/__init__.py ADDED
@@ -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
+ ]
memtrace/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
File without changes
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
@@ -0,0 +1,45 @@
1
+ """Gives a registry prompt to a LangChain agent without freezing its version (ADR-068).
2
+
3
+ `create_agent(system_prompt="...")` takes a string, so a prompt compiled when the agent is built would stay on the
4
+ version it had at that moment. LangChain's middleware runs on every model call, which is where the version has to be
5
+ decided: `prompt_middleware(handle)` replaces the system message with `handle.compile(...)` each time.
6
+ """
7
+ from typing import Any, Callable, Mapping
8
+
9
+ from memtrace.application.prompt_registry import PromptHandle
10
+
11
+
12
+ def prompt_middleware(handle: PromptHandle, **variables: Any) -> Any:
13
+ """Middleware for `create_agent(..., middleware=[...])` that uses `handle` as the agent's system prompt.
14
+
15
+ Every model call asks the handle for the version in use at that moment (in memory, no network), so moving the tag
16
+ in MemTrace changes the agent without rebuilding it. The trace is linked to the version used (the prompt name and
17
+ version are written on the current span).
18
+
19
+ `variables` fill the `{{placeholders}}` of the prompt. A value that is a function is called on every model call with
20
+ LangChain's `ModelRequest` (`request.state`, `request.runtime.context`…) and its result is used::
21
+
22
+ agent = create_agent(
23
+ model,
24
+ tools,
25
+ middleware=[prompt_middleware(weather, city=lambda request: request.runtime.context["city"], tone="friendly")],
26
+ )
27
+
28
+ It *replaces* the system message: do not also pass `system_prompt=` to `create_agent`.
29
+ Needs `langchain>=1.0` (`pip install langchain`).
30
+ """
31
+ try:
32
+ from langchain.agents.middleware import dynamic_prompt
33
+ except ImportError as exc:
34
+ raise ImportError("prompt_middleware needs langchain>=1.0: pip install 'langchain>=1.0'") from exc
35
+
36
+ def resolve(request: Any) -> str:
37
+ return handle.compile(**_values(variables, request))
38
+
39
+ # LangChain names a middleware after its function and refuses two with the same name in one agent
40
+ resolve.__name__ = "memtrace_prompt_" + "".join(c if c.isalnum() else "_" for c in handle.name)
41
+ return dynamic_prompt(resolve)
42
+
43
+
44
+ def _values(variables: Mapping[str, Any], request: Any) -> Mapping[str, Any]:
45
+ return {name: value(request) if isinstance(value, Callable) else value for name, value in variables.items()} # type: ignore[arg-type]
@@ -0,0 +1,5 @@
1
+ from memtrace.adapters.inbound.manual.llm import trace_llm_call
2
+ from memtrace.adapters.inbound.manual.retrieval import record_retrieved_chunks
3
+ from memtrace.adapters.inbound.manual.step import trace_step, trace_step_context
4
+
5
+ __all__ = ["trace_step", "trace_step_context", "trace_llm_call", "record_retrieved_chunks"]
@@ -0,0 +1,47 @@
1
+ from typing import Any, Dict, List, Optional, Sequence
2
+
3
+ from memtrace.application.tracing_service import TracingService
4
+ from memtrace.dependency_container import get_service
5
+ from memtrace.domain.model import LlmCall
6
+
7
+
8
+ def trace_llm_call(
9
+ provider: str,
10
+ model: str,
11
+ *,
12
+ operation: str = "chat",
13
+ input_messages: Optional[List[Dict[str, Any]]] = None,
14
+ output_messages: Optional[List[Dict[str, Any]]] = None,
15
+ input_tokens: Optional[int] = None,
16
+ output_tokens: Optional[int] = None,
17
+ response_model: Optional[str] = None,
18
+ response_id: Optional[str] = None,
19
+ finish_reasons: Sequence[str] = (),
20
+ temperature: Optional[float] = None,
21
+ max_tokens: Optional[int] = None,
22
+ top_p: Optional[float] = None,
23
+ frequency_penalty: Optional[float] = None,
24
+ presence_penalty: Optional[float] = None,
25
+ attributes: Optional[Dict[str, Any]] = None,
26
+ service: Optional[TracingService] = None,
27
+ ) -> None:
28
+ """Records the standardized GenAI attributes on the current span. Never raises.
29
+
30
+ Everything after `provider` and `model` is keyword-only.
31
+ """
32
+ call = LlmCall(
33
+ provider=provider,
34
+ model=model,
35
+ operation=operation,
36
+ response_model=response_model,
37
+ response_id=response_id,
38
+ finish_reasons=finish_reasons,
39
+ input_tokens=input_tokens,
40
+ output_tokens=output_tokens,
41
+ temperature=temperature,
42
+ max_tokens=max_tokens,
43
+ top_p=top_p,
44
+ frequency_penalty=frequency_penalty,
45
+ presence_penalty=presence_penalty,
46
+ )
47
+ (service or get_service()).record_llm_call(call, input_messages, output_messages, attributes)
@@ -0,0 +1,14 @@
1
+ from typing import Any, Optional, Sequence
2
+
3
+ from memtrace.application.tracing_service import TracingService
4
+ from memtrace.dependency_container import get_service
5
+
6
+
7
+ def record_retrieved_chunks(documents: Sequence[Any], *, service: Optional[TracingService] = None) -> None:
8
+ """Records the chunks a retrieval step returned on the current span (use inside a `retriever` step). Never raises.
9
+
10
+ `documents`: strings, dicts (`text`, `id`?, `source`?, `score`?) or LangChain-like documents
11
+ (`page_content` + `metadata`). The list order is the rank. The chunk text is exported only when
12
+ content capture is enabled; the count is always recorded.
13
+ """
14
+ (service or get_service()).record_retrieved_chunks(documents)
@@ -0,0 +1,81 @@
1
+ import functools
2
+ import inspect
3
+ from contextlib import AbstractContextManager
4
+ from typing import Any, Dict, Optional, Union
5
+
6
+ from memtrace.application.tracing_service import TracingService
7
+ from memtrace.dependency_container import get_service
8
+ from memtrace.domain.attributes import step_input_attributes, step_output_attributes
9
+ from memtrace.domain.model import StepType
10
+
11
+
12
+ def trace_step_context(
13
+ name: str,
14
+ step_type: Union[StepType, str] = StepType.CHAIN,
15
+ attributes: Optional[Dict[str, Any]] = None,
16
+ *,
17
+ service: Optional[TracingService] = None,
18
+ ) -> AbstractContextManager:
19
+ """Instruments a block; spans created inside it (including those of LangChain or of
20
+ auto-instrumented libraries) hang from it. Yields the `run_id`.
21
+
22
+ `service` selects an explicit tracer (from `init_tracer`); by default the global one is used.
23
+ """
24
+ return (service or get_service()).step(name, step_type, attributes)
25
+
26
+
27
+ def _describe_call(func: Any, args: tuple, kwargs: dict) -> Any:
28
+ try:
29
+ bound = inspect.signature(func).bind_partial(*args, **kwargs)
30
+ return {k: v for k, v in bound.arguments.items() if k not in ("self", "cls")}
31
+ except Exception:
32
+ return {"args": list(args), "kwargs": kwargs}
33
+
34
+
35
+ def trace_step(
36
+ name: Optional[str] = None,
37
+ step_type: Union[StepType, str] = StepType.CHAIN,
38
+ *,
39
+ service: Optional[TracingService] = None,
40
+ ):
41
+ """Decorator for sync or async functions and methods of agents and tools.
42
+
43
+ With MEMTRACE_CAPTURE_CONTENT=true it also records the arguments and the return value.
44
+ `service` selects an explicit tracer; by default the global one is used.
45
+ """
46
+
47
+ def decorator(func):
48
+ span_name = name or func.__name__
49
+
50
+ def record_input(svc, run_id, args, kwargs) -> None:
51
+ payload = _describe_call(func, args, kwargs) if svc.captures_content else None
52
+ svc.annotate_run(run_id, step_input_attributes(step_type, span_name, svc.capture(payload)))
53
+
54
+ def record_output(svc, run_id, result) -> None:
55
+ svc.annotate_run(run_id, step_output_attributes(step_type, svc.capture(result)))
56
+
57
+ if inspect.iscoroutinefunction(func):
58
+
59
+ @functools.wraps(func)
60
+ async def async_wrapper(*args, **kwargs):
61
+ svc = service or get_service()
62
+ with svc.step(span_name, step_type) as run_id:
63
+ record_input(svc, run_id, args, kwargs)
64
+ result = await func(*args, **kwargs)
65
+ record_output(svc, run_id, result)
66
+ return result
67
+
68
+ return async_wrapper
69
+
70
+ @functools.wraps(func)
71
+ def wrapper(*args, **kwargs):
72
+ svc = service or get_service()
73
+ with svc.step(span_name, step_type) as run_id:
74
+ record_input(svc, run_id, args, kwargs)
75
+ result = func(*args, **kwargs)
76
+ record_output(svc, run_id, result)
77
+ return result
78
+
79
+ return wrapper
80
+
81
+ return decorator
@@ -0,0 +1,4 @@
1
+ from memtrace.adapters.inbound.openai_agents.auto import enable_openai_agents_instrumentation
2
+ from memtrace.adapters.inbound.openai_agents.callback import MemTraceTracingProcessor
3
+
4
+ __all__ = ["MemTraceTracingProcessor", "enable_openai_agents_instrumentation"]
@@ -0,0 +1,35 @@
1
+ """One call to send every OpenAI Agents SDK run to MemTrace."""
2
+ import logging
3
+ from typing import Optional
4
+
5
+ from memtrace.application.tracing_service import TracingService
6
+
7
+ logger = logging.getLogger("memtrace")
8
+
9
+ _INSTALL_HINT = "pip install 'memtrace-ai[openai-agents]'"
10
+ _installed: Optional[object] = None
11
+
12
+
13
+ def enable_openai_agents_instrumentation(service: Optional[TracingService] = None, *, keep_openai_export: bool = True) -> None:
14
+ """Registers a MemTrace tracing processor with the OpenAI Agents SDK, for all agents.
15
+
16
+ Requires: pip install 'memtrace-ai[openai-agents]'. Raises ImportError if it is missing. Safe to call more than once.
17
+
18
+ `keep_openai_export`: the Agents SDK also uploads its traces to the OpenAI dashboard by default. `True` (default) keeps
19
+ that; `False` replaces it, so traces only go to MemTrace. Prompts and completions are recorded only with
20
+ MEMTRACE_CAPTURE_CONTENT=true.
21
+ """
22
+ global _installed
23
+ try:
24
+ from agents import add_trace_processor, set_trace_processors
25
+ except ImportError as exc:
26
+ raise ImportError(f"OpenAI Agents instrumentation requires: {_INSTALL_HINT}") from exc
27
+ from memtrace.adapters.inbound.openai_agents.callback import MemTraceTracingProcessor
28
+
29
+ if _installed is not None:
30
+ logger.debug("[MemTrace] OpenAI Agents instrumentation was already enabled")
31
+ return
32
+ processor = MemTraceTracingProcessor(service)
33
+ (add_trace_processor if keep_openai_export else lambda p: set_trace_processors([p]))(processor)
34
+ _installed = processor
35
+ logger.info("[MemTrace] OpenAI Agents instrumentation enabled")