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.
- sovara_langchain/__init__.py +48 -0
- sovara_langchain/_chat_model.py +346 -0
- sovara_langchain/_log.py +20 -0
- sovara_langchain/_messages.py +278 -0
- sovara_langchain/_middleware.py +143 -0
- sovara_langchain/_tool_patch.py +150 -0
- sovara_langchain-0.1.0.dist-info/METADATA +59 -0
- sovara_langchain-0.1.0.dist-info/RECORD +11 -0
- sovara_langchain-0.1.0.dist-info/WHEEL +5 -0
- sovara_langchain-0.1.0.dist-info/licenses/LICENSE +21 -0
- sovara_langchain-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|
sovara_langchain/_log.py
ADDED
|
@@ -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,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
|