sovara-langchain 0.1.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.
@@ -0,0 +1,48 @@
1
+ """Record LangChain and LangGraph model and tool calls, with lessons per model call.
2
+
3
+ Use one of these per model, not both:
4
+
5
+ - ``SovaraMiddleware`` for ``create_agent`` (needs ``langchain>=1.1``); it also records tool
6
+ calls.
7
+ - ``wrap_chat_model(model)`` for any other use of a chat model, such as custom LangGraph
8
+ nodes, ``with_structured_output`` or prebuilt agents (needs only ``langchain-core``).
9
+
10
+ Call ``instrument_tools()`` once at startup to also record tool calls that the middleware does not
11
+ own, such as the tools of a LangGraph agent built on a wrapped model. Each export loads on first
12
+ access, so the wrapper does not import ``langchain``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from importlib import import_module
18
+ from typing import TYPE_CHECKING, Any
19
+
20
+ if TYPE_CHECKING:
21
+ from sovara_langchain._chat_model import SovaraChatModel, wrap_chat_model
22
+ from sovara_langchain._middleware import SovaraMiddleware
23
+
24
+ __all__ = ["SovaraChatModel", "SovaraMiddleware", "instrument_tools", "wrap_chat_model"]
25
+
26
+ _MODULES = {
27
+ "SovaraChatModel": "sovara_langchain._chat_model",
28
+ "wrap_chat_model": "sovara_langchain._chat_model",
29
+ "SovaraMiddleware": "sovara_langchain._middleware",
30
+ }
31
+
32
+
33
+ def instrument_tools() -> None:
34
+ """Record LangChain tool calls inside Sovara traces; idempotent.
35
+
36
+ Patches ``langchain_core`` tool execution. Tool calls the middleware already records are
37
+ skipped, so using both never records a tool twice.
38
+ """
39
+ from sovara_langchain._tool_patch import langchain_patch
40
+
41
+ langchain_patch()
42
+
43
+
44
+ def __getattr__(name: str) -> Any:
45
+ module = _MODULES.get(name)
46
+ if module is None:
47
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
48
+ return getattr(import_module(module), name)
@@ -0,0 +1,346 @@
1
+ """Wrap a LangChain chat model so every call it makes is recorded with lessons.
2
+
3
+ ``wrap_chat_model(model)`` returns a chat model that records each ``invoke``, ``stream``,
4
+ ``batch`` and async call, including calls through ``bind_tools`` and
5
+ ``with_structured_output``. Lessons go into one temporary user message on that request
6
+ only; the caller's messages and graph state are never changed. Generation, tool binding,
7
+ structured output and parsing stay with the wrapped model.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from collections.abc import AsyncIterator, Iterator, Mapping, Sequence
13
+ from typing import Any
14
+
15
+ from langchain_core.language_models.chat_models import BaseChatModel
16
+ from langchain_core.messages import BaseMessage
17
+ from langchain_core.outputs import ChatGenerationChunk, ChatResult
18
+ from langchain_core.runnables import (
19
+ Runnable,
20
+ RunnableBinding,
21
+ RunnableParallel,
22
+ RunnableSequence,
23
+ RunnableWithFallbacks,
24
+ )
25
+ from langchain_core.runnables.passthrough import RunnableAssign
26
+ from pydantic import PrivateAttr
27
+
28
+ from sovara import ConversationOutput, Execution, SovaraConfigError, Step
29
+ from sovara import integration as instrumentation
30
+ from sovara_langchain import _messages as convert
31
+
32
+ __all__ = ["SovaraChatModel", "wrap_chat_model"]
33
+
34
+
35
+ def _prepare(
36
+ model: BaseChatModel, messages: list[BaseMessage], kwargs: dict[str, Any]
37
+ ) -> tuple[Step | None, list[BaseMessage], Execution | None]:
38
+ call = convert.llm_call(model, messages, kwargs.get("tools") or ())
39
+ step = instrumentation.begin(call, lessons=True) if call is not None else None
40
+ return _with_lessons(step, messages, kwargs)
41
+
42
+
43
+ async def _aprepare(
44
+ model: BaseChatModel, messages: list[BaseMessage], kwargs: dict[str, Any]
45
+ ) -> tuple[Step | None, list[BaseMessage], Execution | None]:
46
+ call = convert.llm_call(model, messages, kwargs.get("tools") or ())
47
+ step = await instrumentation.abegin(call, lessons=True) if call is not None else None
48
+ return _with_lessons(step, messages, kwargs)
49
+
50
+
51
+ def _with_lessons(
52
+ step: Step | None, messages: list[BaseMessage], kwargs: dict[str, Any]
53
+ ) -> tuple[Step | None, list[BaseMessage], Execution | None]:
54
+ context = step.lesson_context if step is not None else None
55
+ if context is None:
56
+ return step, messages, None
57
+ sent = convert.with_lessons(messages, context.text)
58
+ execution = Execution(
59
+ input=convert.conversation(sent, kwargs.get("tools") or ()),
60
+ injected_retrieval_id=context.retrieval_id,
61
+ )
62
+ return step, sent, execution
63
+
64
+
65
+ def _result_output(result: ChatResult) -> ConversationOutput:
66
+ if not result.generations:
67
+ return convert.safe_output(None)
68
+ generation = result.generations[0]
69
+ return convert.safe_output(generation.message, generation.generation_info) # type: ignore[arg-type]
70
+
71
+
72
+ def _chunks_output(chunks: ChatGenerationChunk | None) -> ConversationOutput:
73
+ if chunks is None:
74
+ return convert.safe_output(None)
75
+ return convert.safe_output(chunks.message, chunks.generation_info) # type: ignore[arg-type]
76
+
77
+
78
+ def _rebind(node: Any, old: Any, new: Any) -> tuple[Any, bool]:
79
+ """Return ``node`` with the runnable ``old`` replaced by ``new``, and whether it was found."""
80
+ if node is old:
81
+ return new, True
82
+ if isinstance(node, RunnableBinding):
83
+ bound, found = _rebind(node.bound, old, new)
84
+ return (node.model_copy(update={"bound": bound}), True) if found else (node, False)
85
+ if isinstance(node, RunnableSequence):
86
+ first, in_first = _rebind(node.first, old, new)
87
+ middle = [_rebind(step, old, new) for step in node.middle]
88
+ last, in_last = _rebind(node.last, old, new)
89
+ found = in_first or in_last or any(hit for _, hit in middle)
90
+ update = {"first": first, "middle": [step for step, _ in middle], "last": last}
91
+ return (node.model_copy(update=update), True) if found else (node, False)
92
+ if isinstance(node, RunnableParallel):
93
+ steps = {key: _rebind(step, old, new) for key, step in node.steps__.items()}
94
+ if not any(hit for _, hit in steps.values()):
95
+ return node, False
96
+ return node.model_copy(update={"steps__": {k: s for k, (s, _) in steps.items()}}), True
97
+ if isinstance(node, RunnableWithFallbacks):
98
+ runnable, in_runnable = _rebind(node.runnable, old, new)
99
+ fallbacks = [_rebind(step, old, new) for step in node.fallbacks]
100
+ if not in_runnable and not any(hit for _, hit in fallbacks):
101
+ return node, False
102
+ update = {"runnable": runnable, "fallbacks": [step for step, _ in fallbacks]}
103
+ return node.model_copy(update=update), True
104
+ if isinstance(node, RunnableAssign):
105
+ mapper, found = _rebind(node.mapper, old, new)
106
+ return (node.model_copy(update={"mapper": mapper}), True) if found else (node, False)
107
+ return node, False
108
+
109
+
110
+ class SovaraChatModel(BaseChatModel):
111
+ """A chat model that records each call of the wrapped model as a Sovara step.
112
+
113
+ Create it with ``wrap_chat_model``. Calls go to the wrapped model's own generation and
114
+ streaming methods; outside a recording they pass through unchanged.
115
+
116
+ Public attributes the wrapper does not define, such as ``temperature`` or ``model_name``,
117
+ are read from and set on the wrapped model. Chat-model settings (callbacks, tags, cache and
118
+ the other ``BaseChatModel`` fields) are copied when wrapping; setting one later sets it on
119
+ both models.
120
+ """
121
+
122
+ # Private, because providers such as ChatAnthropic use a public ``model`` field.
123
+ _model: BaseChatModel = PrivateAttr()
124
+
125
+ def __init__(self, model: BaseChatModel, **settings: Any) -> None:
126
+ """Wrap ``model``; ``settings`` are its ``BaseChatModel`` fields."""
127
+ super().__init__(**settings)
128
+ self._model = model
129
+
130
+ def __getattr__(self, name: str) -> Any:
131
+ try:
132
+ return super().__getattr__(name) # type: ignore[misc]
133
+ except AttributeError:
134
+ if name.startswith("_"):
135
+ raise
136
+ private = self.__pydantic_private__ or {}
137
+ if "_model" not in private:
138
+ raise
139
+ return getattr(private["_model"], name)
140
+
141
+ def __setattr__(self, name: str, value: Any) -> None:
142
+ if name.startswith("_"):
143
+ super().__setattr__(name, value)
144
+ elif name in BaseChatModel.model_fields:
145
+ super().__setattr__(name, value)
146
+ setattr(self._model, name, value)
147
+ else:
148
+ setattr(self._model, name, value)
149
+
150
+ @property
151
+ def wrapped_model(self) -> BaseChatModel:
152
+ """Return the model whose calls this wrapper records."""
153
+ return self._model
154
+
155
+ @property
156
+ def _llm_type(self) -> str:
157
+ return self._model._llm_type
158
+
159
+ @property
160
+ def _identifying_params(self) -> Mapping[str, Any]:
161
+ return self._model._identifying_params
162
+
163
+ def _get_ls_params(self, stop: list[str] | None = None, **kwargs: Any) -> Any:
164
+ return self._model._get_ls_params(stop=stop, **kwargs)
165
+
166
+ def _should_stream(self, *, async_api: bool, run_manager: Any = None, **kwargs: Any) -> bool:
167
+ return self._model._should_stream(async_api=async_api, run_manager=run_manager, **kwargs)
168
+
169
+ def _generate(
170
+ self,
171
+ messages: list[BaseMessage],
172
+ stop: list[str] | None = None,
173
+ run_manager: Any = None,
174
+ **kwargs: Any,
175
+ ) -> ChatResult:
176
+ step, sent, execution = _prepare(self._model, messages, kwargs)
177
+ if step is None:
178
+ return self._model._generate(messages, stop=stop, run_manager=run_manager, **kwargs)
179
+ try:
180
+ with step.activate():
181
+ result = self._model._generate(sent, stop=stop, run_manager=run_manager, **kwargs)
182
+ except BaseException as exc:
183
+ instrumentation.report_raised(step, exc, classify=convert.raised, execution=execution)
184
+ raise
185
+ instrumentation.report(step, "finish", _result_output(result), execution=execution)
186
+ return result
187
+
188
+ async def _agenerate(
189
+ self,
190
+ messages: list[BaseMessage],
191
+ stop: list[str] | None = None,
192
+ run_manager: Any = None,
193
+ **kwargs: Any,
194
+ ) -> ChatResult:
195
+ step, sent, execution = await _aprepare(self._model, messages, kwargs)
196
+ if step is None:
197
+ return await self._model._agenerate(
198
+ messages, stop=stop, run_manager=run_manager, **kwargs
199
+ )
200
+ try:
201
+ with step.activate():
202
+ result = await self._model._agenerate(
203
+ sent, stop=stop, run_manager=run_manager, **kwargs
204
+ )
205
+ except BaseException as exc:
206
+ await instrumentation.areport_raised(
207
+ step, exc, classify=convert.raised, execution=execution
208
+ )
209
+ raise
210
+ await instrumentation.areport(step, "finish", _result_output(result), execution=execution)
211
+ return result
212
+
213
+ def _stream(
214
+ self,
215
+ messages: list[BaseMessage],
216
+ stop: list[str] | None = None,
217
+ run_manager: Any = None,
218
+ **kwargs: Any,
219
+ ) -> Iterator[ChatGenerationChunk]:
220
+ step, sent, execution = _prepare(self._model, messages, kwargs)
221
+ if step is None:
222
+ yield from self._model._stream(messages, stop=stop, run_manager=run_manager, **kwargs)
223
+ return
224
+ chunks: ChatGenerationChunk | None = None
225
+ iterator = self._model._stream(sent, stop=stop, run_manager=run_manager, **kwargs)
226
+ try:
227
+ while True:
228
+ # Activation covers each pull from the model, never the consumer's code.
229
+ with step.activate():
230
+ chunk = next(iterator, None)
231
+ if chunk is None:
232
+ break
233
+ chunks = chunk if chunks is None else chunks + chunk
234
+ yield chunk
235
+ except BaseException as exc:
236
+ ending, args = (
237
+ ("cancel", ("Closed",)) if isinstance(exc, GeneratorExit) else (convert.raised(exc))
238
+ )
239
+ instrumentation.report(
240
+ step,
241
+ ending,
242
+ *args,
243
+ output=_chunks_output(chunks),
244
+ execution=execution,
245
+ propagating=True,
246
+ )
247
+ close = getattr(iterator, "close", None)
248
+ if close is not None:
249
+ close()
250
+ raise
251
+ instrumentation.report(step, "finish", _chunks_output(chunks), execution=execution)
252
+
253
+ async def _astream(
254
+ self,
255
+ messages: list[BaseMessage],
256
+ stop: list[str] | None = None,
257
+ run_manager: Any = None,
258
+ **kwargs: Any,
259
+ ) -> AsyncIterator[ChatGenerationChunk]:
260
+ step, sent, execution = await _aprepare(self._model, messages, kwargs)
261
+ if step is None:
262
+ async for chunk in self._model._astream(
263
+ messages, stop=stop, run_manager=run_manager, **kwargs
264
+ ):
265
+ yield chunk
266
+ return
267
+ chunks: ChatGenerationChunk | None = None
268
+ iterator = self._model._astream(sent, stop=stop, run_manager=run_manager, **kwargs)
269
+ try:
270
+ while True:
271
+ with step.activate():
272
+ chunk = await anext(iterator, None)
273
+ if chunk is None:
274
+ break
275
+ chunks = chunk if chunks is None else chunks + chunk
276
+ yield chunk
277
+ except BaseException as exc:
278
+ ending, args = (
279
+ ("cancel", ("Closed",)) if isinstance(exc, GeneratorExit) else (convert.raised(exc))
280
+ )
281
+ partial = _chunks_output(chunks)
282
+ # A closed async generator may still await; a cancelled task must not.
283
+ if isinstance(exc, (Exception, GeneratorExit)):
284
+ await instrumentation.areport(
285
+ step, ending, *args, output=partial, execution=execution, propagating=True
286
+ )
287
+ else:
288
+ instrumentation.report_in_background(
289
+ step, ending, *args, output=partial, execution=execution
290
+ )
291
+ aclose = getattr(iterator, "aclose", None)
292
+ if aclose is not None:
293
+ await aclose()
294
+ raise
295
+ await instrumentation.areport(step, "finish", _chunks_output(chunks), execution=execution)
296
+
297
+ def bind_tools(self, tools: Sequence[Any], **kwargs: Any) -> Runnable:
298
+ """Bind tools with the wrapped model's own implementation, keeping calls recorded.
299
+
300
+ Raises:
301
+ SovaraConfigError: The wrapped model did not return a binding of itself, so its
302
+ calls could not be recorded.
303
+ """
304
+ native = self._model.bind_tools(tools, **kwargs)
305
+ if not (isinstance(native, RunnableBinding) and native.bound is self._model):
306
+ raise SovaraConfigError(
307
+ f"{type(self._model).__name__}.bind_tools did not return a binding of the "
308
+ "model, so Sovara cannot record its calls."
309
+ )
310
+ return native.model_copy(update={"bound": self})
311
+
312
+ def with_structured_output(self, schema: Any, **kwargs: Any) -> Runnable:
313
+ """Use the wrapped model's own structured output, keeping its calls recorded.
314
+
315
+ Parsing, ``include_raw`` and errors behave exactly as for the wrapped model.
316
+
317
+ Raises:
318
+ SovaraConfigError: The wrapped model's runnable does not call the model in a form
319
+ Sovara can redirect.
320
+ """
321
+ native = self._model.with_structured_output(schema, **kwargs)
322
+ rebound, found = _rebind(native, self._model, self)
323
+ if not found:
324
+ raise SovaraConfigError(
325
+ f"{type(self._model).__name__}.with_structured_output returned a runnable "
326
+ "Sovara cannot record."
327
+ )
328
+ return rebound
329
+
330
+
331
+ def wrap_chat_model(model: BaseChatModel) -> SovaraChatModel:
332
+ """Return a chat model that records every call of ``model``; wrapping twice is a no-op.
333
+
334
+ The wrapper takes the wrapped model's callbacks, cache, rate limiter and other chat-model
335
+ settings, since calls go to its generation methods directly. Other attributes are
336
+ forwarded to ``model``.
337
+
338
+ Raises:
339
+ SovaraConfigError: ``model`` is not a LangChain chat model.
340
+ """
341
+ if isinstance(model, SovaraChatModel):
342
+ return model
343
+ if not isinstance(model, BaseChatModel):
344
+ raise SovaraConfigError("wrap_chat_model() needs a LangChain BaseChatModel.")
345
+ settings = {name: getattr(model, name) for name in BaseChatModel.model_fields}
346
+ return SovaraChatModel(model=model, **settings)
@@ -0,0 +1,20 @@
1
+ """Logging for the LangChain integration: metadata only, never payloads or tokens."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+
8
+ logger = logging.getLogger("sovara_langchain")
9
+
10
+ _warned: set[str] = set()
11
+ _warned_lock = threading.Lock()
12
+
13
+
14
+ def warn_once(key: str, message: str) -> None:
15
+ """Log a warning the first time ``key`` is seen in this process."""
16
+ with _warned_lock:
17
+ if key in _warned:
18
+ return
19
+ _warned.add(key)
20
+ logger.warning(message)
@@ -0,0 +1,278 @@
1
+ """Convert LangChain messages, tools and responses into Sovara conversation data.
2
+
3
+ Content is read through LangChain's standard ``content_blocks``, so provider-specific
4
+ formats are normalized by LangChain. Tool calls come from ``AIMessage.tool_calls``, which
5
+ LangChain keeps authoritative for every provider. Also holds the step helpers the
6
+ middleware and the chat-model wrapper share.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Iterable, Sequence
12
+ from typing import Any
13
+
14
+ from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, ToolMessage
15
+ from langchain_core.runnables.config import ensure_config
16
+ from langchain_core.utils.function_calling import convert_to_openai_tool
17
+
18
+ from sovara import (
19
+ ContentBlock,
20
+ ConversationInput,
21
+ ConversationOutput,
22
+ LlmCall,
23
+ MediaBlock,
24
+ Message,
25
+ ProviderBlock,
26
+ ReasoningBlock,
27
+ TextBlock,
28
+ ToolCallBlock,
29
+ ToolResultBlock,
30
+ ToolSpec,
31
+ Usage,
32
+ )
33
+ from sovara import integration as instrumentation
34
+ from sovara.integration import Ending, json_value
35
+ from sovara_langchain._log import warn_once
36
+
37
+ try:
38
+ from langgraph.errors import GraphBubbleUp as _BUBBLE_UP
39
+ except ImportError: # LangGraph is optional for the chat-model wrapper.
40
+ _BUBBLE_UP = None
41
+
42
+ METADATA = {"framework": "langchain"}
43
+
44
+
45
+ def graph_location() -> dict[str, Any]:
46
+ """Return where in a LangGraph graph the current call runs, or nothing outside one.
47
+
48
+ ``langgraph_nodes`` lists the node names from the outermost graph in, so a supervisor's
49
+ sub-assistant shows as ``["sql_assistant", "agent"]``. ``langgraph_namespace`` also
50
+ carries LangGraph's task IDs, so it tells repeated or parallel delegations apart.
51
+ """
52
+ metadata = ensure_config().get("metadata") or {}
53
+ namespace = metadata.get("langgraph_checkpoint_ns")
54
+ if not isinstance(namespace, str) or not namespace:
55
+ return {}
56
+ nodes = [part.split(":", 1)[0] for part in namespace.split("|") if part]
57
+ return {"langgraph_nodes": nodes, "langgraph_namespace": namespace}
58
+
59
+
60
+ def step_metadata() -> dict[str, Any]:
61
+ """Return the metadata of a recorded LangChain model or tool call."""
62
+ return {**METADATA, **graph_location()}
63
+
64
+
65
+ _ROLES = {"system": "system", "human": "user", "ai": "assistant", "tool": "tool"}
66
+ _CHAT_ROLES = {"system", "developer", "user", "assistant", "tool"}
67
+ _MEDIA = {"image", "audio", "video", "file"}
68
+
69
+
70
+ def call_id(value: Any) -> str | None:
71
+ """Return a tool call ID, or None when it is missing or blank."""
72
+ return (str(value).strip() or None) if value is not None else None
73
+
74
+
75
+ def _role(message: BaseMessage) -> Any:
76
+ # Streamed chunks report types such as "AIMessageChunk".
77
+ kind = message.type.removesuffix("MessageChunk").lower()
78
+ if kind == "chat":
79
+ role = getattr(message, "role", None)
80
+ return role if role in _CHAT_ROLES else "user"
81
+ return _ROLES.get(kind, "user")
82
+
83
+
84
+ def _provider(block: dict[str, Any]) -> ProviderBlock:
85
+ return ProviderBlock(format=f"langchain.{block.get('type')}", data=json_value(block))
86
+
87
+
88
+ def _media(block: dict[str, Any]) -> ContentBlock:
89
+ url, data = block.get("url") or None, block.get("base64") or None
90
+ if (url is None) == (data is None):
91
+ return _provider(block)
92
+ kind = block["type"]
93
+ default = "application/octet-stream" if kind == "file" else f"{kind}/*"
94
+ return MediaBlock(mime_type=block.get("mime_type") or default, url=url, data=data)
95
+
96
+
97
+ def _block(block: Any) -> ContentBlock | None:
98
+ if not isinstance(block, dict):
99
+ return TextBlock(text=block) if isinstance(block, str) and block else None
100
+ kind = block.get("type")
101
+ if kind == "text":
102
+ return TextBlock(text=str(block["text"])) if block.get("text") else None
103
+ if kind == "reasoning":
104
+ return ReasoningBlock(text=str(block.get("reasoning", "")))
105
+ if kind == "tool_call":
106
+ return None
107
+ if kind in _MEDIA:
108
+ return _media(block)
109
+ return _provider(block)
110
+
111
+
112
+ def _blocks(message: BaseMessage) -> list[ContentBlock]:
113
+ return [block for raw in message.content_blocks if (block := _block(raw)) is not None]
114
+
115
+
116
+ def message(message: BaseMessage) -> Message:
117
+ """Convert one LangChain message."""
118
+ if isinstance(message, ToolMessage):
119
+ blocks = _blocks(message)
120
+ texts = [block.text for block in blocks if isinstance(block, TextBlock)]
121
+ output = "".join(texts) if len(texts) == len(blocks) else json_value(message.content)
122
+ result = ToolResultBlock(
123
+ output=output,
124
+ call_id=call_id(message.tool_call_id),
125
+ is_error=message.status == "error",
126
+ )
127
+ return Message(role="tool", content=(result,))
128
+ content = _blocks(message)
129
+ if isinstance(message, AIMessage):
130
+ content += [
131
+ ToolCallBlock(
132
+ name=call["name"],
133
+ arguments=json_value(call["args"]),
134
+ call_id=call_id(call.get("id")),
135
+ )
136
+ for call in message.tool_calls
137
+ ]
138
+ return Message(role=_role(message), content=tuple(content))
139
+
140
+
141
+ def _tool(tool: Any) -> ToolSpec:
142
+ try:
143
+ function = convert_to_openai_tool(tool)["function"]
144
+ except Exception:
145
+ name = tool.get("name") or tool.get("type") if isinstance(tool, dict) else None
146
+ return ToolSpec(name=str(name or type(tool).__name__))
147
+ parameters = json_value(function.get("parameters") or {})
148
+ return ToolSpec(
149
+ name=function["name"],
150
+ description=function.get("description"),
151
+ parameters=parameters if isinstance(parameters, dict) else {},
152
+ )
153
+
154
+
155
+ def conversation(
156
+ messages: Iterable[BaseMessage], tools: Sequence[Any] = (), system: BaseMessage | None = None
157
+ ) -> ConversationInput:
158
+ """Convert a model request: optional system message, messages and tools."""
159
+ prefix = [system] if system is not None and system.content_blocks else []
160
+ return ConversationInput(
161
+ messages=tuple(message(item) for item in [*prefix, *messages]),
162
+ tools=tuple(_tool(tool) for tool in tools),
163
+ )
164
+
165
+
166
+ def _count(value: Any) -> int:
167
+ return value if isinstance(value, int) and value >= 0 else 0
168
+
169
+
170
+ def _usage(message: AIMessage) -> Usage | None:
171
+ usage = message.usage_metadata
172
+ if not usage:
173
+ return None
174
+ details = usage.get("input_token_details") or {}
175
+ cache_read, cache_write = details.get("cache_read"), details.get("cache_creation")
176
+ raw = usage.get("input_tokens")
177
+ return Usage(
178
+ input_tokens=max(0, raw - _count(cache_read) - _count(cache_write))
179
+ if isinstance(raw, int)
180
+ else None,
181
+ output_tokens=usage.get("output_tokens"),
182
+ cache_read_tokens=cache_read,
183
+ cache_write_tokens=cache_write,
184
+ )
185
+
186
+
187
+ def output(
188
+ response: AIMessage | None, generation_info: dict[str, Any] | None = None
189
+ ) -> ConversationOutput:
190
+ """Convert a model response; None becomes an empty assistant message.
191
+
192
+ Args:
193
+ response: The model's message.
194
+ generation_info: Metadata LangChain merges into the message only after the model
195
+ returns, such as the OpenAI finish reason.
196
+ """
197
+ if response is None:
198
+ return ConversationOutput(message=Message(role="assistant", content=()))
199
+ metadata = {**(generation_info or {}), **(response.response_metadata or {})}
200
+ stop = metadata.get("finish_reason") or metadata.get("stop_reason")
201
+ return ConversationOutput(
202
+ message=message(response),
203
+ usage=_usage(response),
204
+ stop_reason=stop if isinstance(stop, str) else None,
205
+ )
206
+
207
+
208
+ def model_identity(model: Any) -> tuple[str | None, str | None]:
209
+ """Return the model's provider and name from its LangSmith parameters."""
210
+ try:
211
+ params = model._get_ls_params()
212
+ except Exception:
213
+ return None, None
214
+
215
+ def text(key: str) -> str | None:
216
+ value = params.get(key)
217
+ return value if isinstance(value, str) and value.strip() else None
218
+
219
+ return text("ls_provider"), text("ls_model_name")
220
+
221
+
222
+ def with_lessons(messages: Sequence[Any], text: str) -> list[Any]:
223
+ """Return a copy with one user message holding lessons, before an assistant prefill."""
224
+ temporary = list(messages)
225
+ last = temporary[-1] if temporary else None
226
+ prefill = isinstance(last, AIMessage) and not last.tool_calls and bool(last.content)
227
+ temporary.insert(len(temporary) - 1 if prefill else len(temporary), HumanMessage(content=text))
228
+ return temporary
229
+
230
+
231
+ def llm_call(
232
+ model: Any,
233
+ messages: Sequence[BaseMessage],
234
+ tools: Sequence[Any] = (),
235
+ system: BaseMessage | None = None,
236
+ ) -> LlmCall | None:
237
+ """Describe a model call, or return None to run it unrecorded.
238
+
239
+ Returns None outside a recording, while capture is suppressed (for example inside a
240
+ call the middleware owns) or when the request cannot be converted.
241
+ """
242
+ if not instrumentation.capture_active():
243
+ return None
244
+ try:
245
+ provider, name = model_identity(model)
246
+ return LlmCall(
247
+ input=conversation(messages, tools, system),
248
+ provider=provider,
249
+ model=name,
250
+ metadata=step_metadata(),
251
+ )
252
+ except Exception as exc:
253
+ warn_once(
254
+ "langchain-convert",
255
+ f"Sovara could not record a LangChain model call. error_type={type(exc).__name__}",
256
+ )
257
+ return None
258
+
259
+
260
+ def safe_output(
261
+ response: AIMessage | None, generation_info: dict[str, Any] | None = None
262
+ ) -> ConversationOutput:
263
+ """Convert a model response, recording an empty one if conversion fails."""
264
+ try:
265
+ return output(response, generation_info)
266
+ except Exception as exc:
267
+ warn_once(
268
+ "langchain-output",
269
+ f"Sovara could not record a LangChain model response. error_type={type(exc).__name__}",
270
+ )
271
+ return output(None)
272
+
273
+
274
+ def raised(exc: BaseException) -> tuple[Ending, tuple[Any, ...]]:
275
+ """Return the step method and arguments for an exception leaving a call."""
276
+ if _BUBBLE_UP is not None and isinstance(exc, _BUBBLE_UP):
277
+ return "cancel", ("Interrupted",)
278
+ return instrumentation.raised(exc)
@@ -0,0 +1,143 @@
1
+ """Record LangChain ``create_agent`` model and tool calls, with lessons per model call.
2
+
3
+ Requires ``langchain>=1.1`` (``pip install "sovara-langchain[agent]"``). Add the middleware
4
+ last, so it records the request other middleware finalized (model, prompt, messages) and each
5
+ attempt of a middleware retry as its own step::
6
+
7
+ agent = create_agent(model, tools, middleware=[*other_middleware, SovaraMiddleware()])
8
+ with client.trace("support"), client.turn(turn_input=question):
9
+ agent.invoke({"messages": [{"role": "user", "content": question}]})
10
+
11
+ Outside a recording the middleware passes calls through. Lessons go into one temporary
12
+ user message on the model request only; the agent's messages are never changed.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections.abc import Awaitable, Callable
18
+ from typing import Any
19
+
20
+ from langchain.agents.middleware import AgentMiddleware, ModelRequest
21
+ from langchain_core.messages import AIMessage, ToolMessage
22
+ from langgraph.types import Command
23
+
24
+ from sovara import Execution, LlmCall, Step, ToolCall
25
+ from sovara import integration as instrumentation
26
+ from sovara.integration import Ending, json_value
27
+ from sovara_langchain import _messages as convert
28
+ from sovara_langchain._tool_patch import own_tool_call, tool_outcome
29
+
30
+ __all__ = ["SovaraMiddleware"]
31
+
32
+
33
+ def _llm_call(request: ModelRequest) -> LlmCall | None:
34
+ return convert.llm_call(request.model, request.messages, request.tools, request.system_message)
35
+
36
+
37
+ def _with_lessons(request: ModelRequest, step: Step) -> tuple[ModelRequest, Execution | None]:
38
+ context = step.lesson_context
39
+ if context is None:
40
+ return request, None
41
+ messages = convert.with_lessons(request.messages, context.text)
42
+ execution = Execution(
43
+ input=convert.conversation(messages, request.tools, request.system_message),
44
+ injected_retrieval_id=context.retrieval_id,
45
+ )
46
+ return request.override(messages=messages), execution
47
+
48
+
49
+ def _model_output(response: Any) -> Any:
50
+ # Newer langchain versions may wrap it in an ExtendedModelResponse.
51
+ response = getattr(response, "model_response", response)
52
+ if not isinstance(response, AIMessage):
53
+ result = getattr(response, "result", ())
54
+ response = next((m for m in result if isinstance(m, AIMessage)), None)
55
+ return convert.safe_output(response)
56
+
57
+
58
+ def _tool_call(request: Any) -> ToolCall:
59
+ call = request.tool_call
60
+ return ToolCall(
61
+ name=call["name"],
62
+ arguments=json_value(call.get("args")),
63
+ call_id=convert.call_id(call.get("id")),
64
+ metadata=convert.step_metadata(),
65
+ )
66
+
67
+
68
+ def _tool_result(result: Any) -> tuple[Ending, tuple[Any, ...], dict[str, Any]]:
69
+ if not isinstance(result, Command):
70
+ return tool_outcome(result)
71
+ update = result.update if isinstance(result.update, dict) else {}
72
+ messages = [m.content for m in update.get("messages", ()) if isinstance(m, ToolMessage)]
73
+ return "finish", (json_value(messages) if messages else None,), {}
74
+
75
+
76
+ class SovaraMiddleware(AgentMiddleware):
77
+ """Record each model and tool call of a LangChain agent as a Sovara step."""
78
+
79
+ def wrap_model_call(self, request: ModelRequest, handler: Callable[[ModelRequest], Any]) -> Any:
80
+ """Record the model call, adding retrieved lessons to this request only."""
81
+ call = _llm_call(request)
82
+ step = instrumentation.begin(call, lessons=True) if call is not None else None
83
+ if step is None:
84
+ return handler(request)
85
+ request, execution = _with_lessons(request, step)
86
+ try:
87
+ with step.activate():
88
+ response = handler(request)
89
+ except BaseException as exc:
90
+ instrumentation.report_raised(step, exc, classify=convert.raised, execution=execution)
91
+ raise
92
+ instrumentation.report(step, "finish", _model_output(response), execution=execution)
93
+ return response
94
+
95
+ async def awrap_model_call(
96
+ self, request: ModelRequest, handler: Callable[[ModelRequest], Awaitable[Any]]
97
+ ) -> Any:
98
+ """Asynchronous form of wrap_model_call()."""
99
+ call = _llm_call(request)
100
+ step = await instrumentation.abegin(call, lessons=True) if call is not None else None
101
+ if step is None:
102
+ return await handler(request)
103
+ request, execution = _with_lessons(request, step)
104
+ try:
105
+ with step.activate():
106
+ response = await handler(request)
107
+ except BaseException as exc:
108
+ await instrumentation.areport_raised(
109
+ step, exc, classify=convert.raised, execution=execution
110
+ )
111
+ raise
112
+ await instrumentation.areport(step, "finish", _model_output(response), execution=execution)
113
+ return response
114
+
115
+ def wrap_tool_call(self, request: Any, handler: Callable[[Any], Any]) -> Any:
116
+ """Record the tool call; the existing tool patch skips it."""
117
+ step = instrumentation.begin(_tool_call(request))
118
+ if step is None:
119
+ return handler(request)
120
+ try:
121
+ with own_tool_call(request.tool_call.get("id")):
122
+ result = handler(request)
123
+ except BaseException as exc:
124
+ instrumentation.report_raised(step, exc, classify=convert.raised)
125
+ raise
126
+ ending, args, kwargs = _tool_result(result)
127
+ instrumentation.report(step, ending, *args, **kwargs)
128
+ return result
129
+
130
+ async def awrap_tool_call(self, request: Any, handler: Callable[[Any], Awaitable[Any]]) -> Any:
131
+ """Asynchronous form of wrap_tool_call()."""
132
+ step = await instrumentation.abegin(_tool_call(request))
133
+ if step is None:
134
+ return await handler(request)
135
+ try:
136
+ with own_tool_call(request.tool_call.get("id")):
137
+ result = await handler(request)
138
+ except BaseException as exc:
139
+ await instrumentation.areport_raised(step, exc, classify=convert.raised)
140
+ raise
141
+ ending, args, kwargs = _tool_result(result)
142
+ await instrumentation.areport(step, ending, *args, **kwargs)
143
+ return result
@@ -0,0 +1,150 @@
1
+ """Record synchronous and asynchronous LangChain tool execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from contextlib import contextmanager
6
+ from contextvars import ContextVar
7
+ from functools import wraps
8
+ from typing import Any
9
+
10
+ from sovara import ErrorInfo, ToolCall
11
+ from sovara import integration as instrumentation
12
+ from sovara.integration import Ending, json_value
13
+ from sovara_langchain._log import logger
14
+ from sovara_langchain._messages import graph_location
15
+
16
+ # Tool call ID recorded by SovaraMiddleware. Only that tool is skipped here; model and
17
+ # HTTP calls inside it are still recorded.
18
+ _owned_tool_call_id: ContextVar[str | None] = ContextVar("owned_tool_call_id", default=None)
19
+ _handled_errors: ContextVar[list[str] | None] = ContextVar("handled_tool_errors", default=None)
20
+
21
+
22
+ @contextmanager
23
+ def own_tool_call(tool_call_id: str | None):
24
+ """Mark a tool call as recorded by the caller, so this patch skips it."""
25
+ token = _owned_tool_call_id.set(tool_call_id)
26
+ try:
27
+ yield
28
+ finally:
29
+ _owned_tool_call_id.reset(token)
30
+
31
+
32
+ @contextmanager
33
+ def _capture_tool_errors():
34
+ """Keep handled errors local to this invocation, including across copied contexts."""
35
+ errors: list[str] = []
36
+ token = _handled_errors.set(errors)
37
+ try:
38
+ yield errors
39
+ finally:
40
+ _handled_errors.reset(token)
41
+
42
+
43
+ def _skip(tool_call_id: Any) -> bool:
44
+ if not instrumentation.capture_active():
45
+ return True
46
+ return bool(tool_call_id) and str(tool_call_id) == _owned_tool_call_id.get()
47
+
48
+
49
+ def _tool_call(tool: Any, tool_input: Any, tool_call_id: Any) -> ToolCall:
50
+ metadata = json_value(
51
+ {
52
+ "tool_class": tool.__class__.__name__,
53
+ "response_format": getattr(tool, "response_format", None),
54
+ **graph_location(),
55
+ }
56
+ )
57
+ return ToolCall(
58
+ name=tool.name,
59
+ arguments=json_value(tool_input),
60
+ call_id=(str(tool_call_id).strip() or None) if tool_call_id is not None else None,
61
+ metadata=metadata if isinstance(metadata, dict) else {},
62
+ )
63
+
64
+
65
+ def tool_outcome(
66
+ result: Any, *, failed: bool = False
67
+ ) -> tuple[Ending, tuple[Any, ...], dict[str, Any]]:
68
+ """Return the step method and arguments for a tool result, unwrapping ToolMessage."""
69
+ is_message = getattr(result, "tool_call_id", None) is not None and hasattr(result, "content")
70
+ content = result.content if is_message else result
71
+ output = json_value(content)
72
+ if failed or (is_message and getattr(result, "status", None) == "error"):
73
+ message = content if isinstance(content, str) else None
74
+ return "fail", (ErrorInfo(type="ToolError", message=message),), {"output": output}
75
+ return "finish", (output,), {}
76
+
77
+
78
+ def langchain_patch():
79
+ """Wrap LangChain tool run/arun methods once, if langchain_core is installed.
80
+
81
+ Capture requires a recording and enabled instrumentation. Tool
82
+ input and results pass through unchanged; exceptions are reported before being
83
+ re-raised. Methods are replaced globally on BaseTool.
84
+ """
85
+ try:
86
+ from langchain_core.tools import base
87
+ from langchain_core.tools.base import BaseTool
88
+ except ImportError:
89
+ logger.info("langchain_core not installed, skipping LangChain tool patches")
90
+ return
91
+
92
+ if getattr(BaseTool.run, "__sovara_langchain_patched__", False):
93
+ return
94
+
95
+ original_run = BaseTool.run
96
+ original_arun = BaseTool.arun
97
+ original_format_output = base._format_output
98
+
99
+ @wraps(original_format_output)
100
+ def format_output(content, artifact, tool_call_id, name, status):
101
+ # LangChain drops the status when an ID-less call returns plain content.
102
+ errors = _handled_errors.get()
103
+ if errors is not None and status == "error":
104
+ errors.append(status)
105
+ return original_format_output(content, artifact, tool_call_id, name, status)
106
+
107
+ @wraps(original_run)
108
+ def patched_run(self, tool_input, *args, tool_call_id=None, **kwargs):
109
+ def run():
110
+ return original_run(self, tool_input, *args, tool_call_id=tool_call_id, **kwargs)
111
+
112
+ with _capture_tool_errors() as errors:
113
+ if _skip(tool_call_id):
114
+ with own_tool_call(None):
115
+ return run()
116
+ step = instrumentation.begin(_tool_call(self, tool_input, tool_call_id))
117
+ try:
118
+ result = run()
119
+ except BaseException as exc:
120
+ instrumentation.report_raised(step, exc)
121
+ raise
122
+ ending, report_args, report_kwargs = tool_outcome(result, failed=bool(errors))
123
+ instrumentation.report(step, ending, *report_args, **report_kwargs)
124
+ return result
125
+
126
+ @wraps(original_arun)
127
+ async def patched_arun(self, tool_input, *args, tool_call_id=None, **kwargs):
128
+ async def arun():
129
+ return await original_arun(self, tool_input, *args, tool_call_id=tool_call_id, **kwargs)
130
+
131
+ with _capture_tool_errors() as errors:
132
+ if _skip(tool_call_id):
133
+ with own_tool_call(None):
134
+ return await arun()
135
+ step = await instrumentation.abegin(_tool_call(self, tool_input, tool_call_id))
136
+ try:
137
+ result = await arun()
138
+ except BaseException as exc:
139
+ await instrumentation.areport_raised(step, exc)
140
+ raise
141
+ ending, report_args, report_kwargs = tool_outcome(result, failed=bool(errors))
142
+ await instrumentation.areport(step, ending, *report_args, **report_kwargs)
143
+ return result
144
+
145
+ patched_run.__sovara_langchain_patched__ = True # pyright: ignore[reportAttributeAccessIssue]
146
+ patched_arun.__sovara_langchain_patched__ = True # pyright: ignore[reportAttributeAccessIssue]
147
+ base._format_output = format_output
148
+ BaseTool.run = patched_run
149
+ BaseTool.arun = patched_arun
150
+ logger.info("langchain_core tool patch applied")
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.4
2
+ Name: sovara-langchain
3
+ Version: 0.1.0
4
+ Summary: Sovara recording and lessons for LangChain and LangGraph
5
+ License-Expression: MIT
6
+ Project-URL: Documentation, https://docs.sovara-labs.com/integrations/langchain
7
+ Project-URL: Repository, https://github.com/SovaraLabs/sovara-langchain/tree/main/python
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: sovara<0.11,>=0.10
12
+ Requires-Dist: langchain-core<2,>=1
13
+ Provides-Extra: agent
14
+ Requires-Dist: langchain<2,>=1.1; extra == "agent"
15
+ Dynamic: license-file
16
+
17
+ # Sovara for LangChain (Python)
18
+
19
+ Record LangChain and LangGraph model and tool calls in Sovara, and add relevant lessons to each
20
+ model request.
21
+
22
+ ```bash
23
+ uv add "sovara-langchain[agent]"
24
+ # or: pip install "sovara-langchain[agent]"
25
+ ```
26
+
27
+ Omit `[agent]` if you only use `wrap_chat_model`.
28
+
29
+ ## Use
30
+
31
+ Inside a Sovara trace, pick one per model:
32
+
33
+ ```python
34
+ import sovara_langchain
35
+ from sovara_langchain import SovaraMiddleware, wrap_chat_model
36
+
37
+ # create_agent: add the middleware last. It records model and tool calls.
38
+ agent = create_agent(model, tools, middleware=[*other_middleware, SovaraMiddleware()])
39
+
40
+ # Anything else (LangGraph nodes, create_react_agent, with_structured_output): wrap the model,
41
+ # and call instrument_tools() once at startup to record tool calls too.
42
+ sovara_langchain.instrument_tools()
43
+ model = wrap_chat_model(ChatOpenAI(model="gpt-5.4")).bind_tools(tools)
44
+ ```
45
+
46
+ - `instrument_tools()` also records tools called directly, without a model-generated tool call.
47
+ - Lessons are one temporary user message at the end of the request. The agent's messages and
48
+ graph state are never changed.
49
+ - Outside a trace, calls pass through unrecorded. If Sovara is unavailable, calls still run.
50
+ - Last in the list, the middleware records the model, prompt and messages that other middleware
51
+ settled on, and each retry by other middleware as its own step.
52
+ - Don't use the middleware and the wrapper for the same model.
53
+ - Model calls are recorded as conversations, so **Rerun** is not available for them.
54
+
55
+ Requires `langchain-core` 1.x (`langchain` 1.1+ for the middleware).
56
+
57
+ ## License
58
+
59
+ MIT.
@@ -0,0 +1,11 @@
1
+ sovara_langchain/__init__.py,sha256=RCdDxsjfea2DTjVp1FzYWLnHMV85ZTeLdTjj7EMFj6g,1764
2
+ sovara_langchain/_chat_model.py,sha256=krg5ZLgQviFhFl9KEgtgPdZMagac_TB9x4gBPdW5gMo,14662
3
+ sovara_langchain/_log.py,sha256=stsnhIRDcDbRHZ8Cvij4qsYTI10Nyy8wd-05GEvCuyY,505
4
+ sovara_langchain/_messages.py,sha256=jYz9lUQA6tgisUTspK2qaHWVO9mCSB73BDAb8xdNmDA,10103
5
+ sovara_langchain/_middleware.py,sha256=HsEVSCYTmEXC43izfdJkJ5xLXSzXTD3rrOOWESeMWoQ,6110
6
+ sovara_langchain/_tool_patch.py,sha256=lAZn3XJucCS6BsYjY8Jenp-Oo1ro3iKwQJieCaZgplk,5929
7
+ sovara_langchain-0.1.0.dist-info/licenses/LICENSE,sha256=pHqKd1w5Do29Q1QUMye-4Tz6olJxRgwzNYqcjjfN524,1068
8
+ sovara_langchain-0.1.0.dist-info/METADATA,sha256=Lpb7p5MuLlF7zQoBX4KHqi4zcpZwxns8SGED3R0fJFk,2161
9
+ sovara_langchain-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
10
+ sovara_langchain-0.1.0.dist-info/top_level.txt,sha256=9lzuHbox-IktVl9Gay63na8AGHxGlDgNb0GgAXVmuVY,17
11
+ sovara_langchain-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sovara Labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ sovara_langchain