trace-agent-langchain 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- trace_agent_langchain-0.0.1/PKG-INFO +12 -0
- trace_agent_langchain-0.0.1/pyproject.toml +27 -0
- trace_agent_langchain-0.0.1/setup.cfg +4 -0
- trace_agent_langchain-0.0.1/src/trace_agent_langchain/__init__.py +5 -0
- trace_agent_langchain-0.0.1/src/trace_agent_langchain/callback.py +654 -0
- trace_agent_langchain-0.0.1/src/trace_agent_langchain.egg-info/PKG-INFO +12 -0
- trace_agent_langchain-0.0.1/src/trace_agent_langchain.egg-info/SOURCES.txt +10 -0
- trace_agent_langchain-0.0.1/src/trace_agent_langchain.egg-info/dependency_links.txt +1 -0
- trace_agent_langchain-0.0.1/src/trace_agent_langchain.egg-info/requires.txt +8 -0
- trace_agent_langchain-0.0.1/src/trace_agent_langchain.egg-info/top_level.txt +1 -0
- trace_agent_langchain-0.0.1/tests/test_langchain_callback.py +277 -0
- trace_agent_langchain-0.0.1/tests/test_langchain_integration.py +167 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trace-agent-langchain
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: LangChain callback integration for TraceAgent.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: trace-agent-sdk
|
|
7
|
+
Requires-Dist: langchain-core<0.4.0,>=0.3.0
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest<9.0.0,>=8.3.2; extra == "dev"
|
|
10
|
+
Requires-Dist: langchain<0.4.0,>=0.3.0; extra == "dev"
|
|
11
|
+
Requires-Dist: langchain-openai<0.3.0,>=0.2.0; extra == "dev"
|
|
12
|
+
Requires-Dist: trace-agent-server; extra == "dev"
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "trace-agent-langchain"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "LangChain callback integration for TraceAgent."
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"trace-agent-sdk",
|
|
12
|
+
"langchain-core>=0.3.0,<0.4.0",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
dev = [
|
|
17
|
+
"pytest>=8.3.2,<9.0.0",
|
|
18
|
+
"langchain>=0.3.0,<0.4.0",
|
|
19
|
+
"langchain-openai>=0.2.0,<0.3.0",
|
|
20
|
+
"trace-agent-server",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.packages.find]
|
|
24
|
+
where = ["src"]
|
|
25
|
+
|
|
26
|
+
[tool.pytest.ini_options]
|
|
27
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,654 @@
|
|
|
1
|
+
"""LangChain callback handler that streams execution events into TraceAgent.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
from trace_agent_sdk import TraceAgentClient
|
|
5
|
+
from trace_agent_langchain import TraceAgentLangChainCallback
|
|
6
|
+
|
|
7
|
+
client = TraceAgentClient(base_url="http://127.0.0.1:8000")
|
|
8
|
+
run = client.start_run(agent_name="my-agent", goal="Do something")
|
|
9
|
+
|
|
10
|
+
callback = run.as_langchain_callback()
|
|
11
|
+
chain.invoke({"input": "..."}, config={"callbacks": [callback]})
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import time
|
|
17
|
+
import traceback
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
22
|
+
from langchain_core.outputs import ChatResult, LLMResult
|
|
23
|
+
except ImportError: # pragma: no cover
|
|
24
|
+
BaseCallbackHandler = None # type: ignore[misc,assignment]
|
|
25
|
+
LLMResult = None # type: ignore[misc,assignment]
|
|
26
|
+
ChatResult = None # type: ignore[misc,assignment]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TraceAgentLangChainCallback(BaseCallbackHandler):
|
|
30
|
+
"""Record LangChain execution as TraceAgent observations.
|
|
31
|
+
|
|
32
|
+
The callback translates LangChain lifecycle events into HTTP POSTs to the
|
|
33
|
+
TraceAgent backend. If the backend is unreachable the callback logs a warning
|
|
34
|
+
but **never** crashes the calling chain/agent.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
run_session: Any,
|
|
40
|
+
*,
|
|
41
|
+
record_chain_events: bool = True,
|
|
42
|
+
record_retriever_events: bool = True,
|
|
43
|
+
) -> None:
|
|
44
|
+
if BaseCallbackHandler is None:
|
|
45
|
+
raise RuntimeError(
|
|
46
|
+
"langchain-core is not installed. "
|
|
47
|
+
"Install it with: uv pip install -e '.[langchain]'"
|
|
48
|
+
)
|
|
49
|
+
self.run_session = run_session
|
|
50
|
+
self.record_chain_events = record_chain_events
|
|
51
|
+
self.record_retriever_events = record_retriever_events
|
|
52
|
+
# langchain run_id -> start timestamp (for duration calculation)
|
|
53
|
+
self._starts: dict[str, float] = {}
|
|
54
|
+
|
|
55
|
+
# ── Helpers ─────────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
def _post_observations(self, observations: list[dict[str, Any]]) -> None:
|
|
58
|
+
"""Send observations to the TraceAgent backend."""
|
|
59
|
+
if not observations:
|
|
60
|
+
return
|
|
61
|
+
try:
|
|
62
|
+
self.run_session.client._request(
|
|
63
|
+
"POST",
|
|
64
|
+
f"/api/runs/{self.run_session.run_id}/observations",
|
|
65
|
+
{"observations": observations},
|
|
66
|
+
)
|
|
67
|
+
except Exception:
|
|
68
|
+
# Silently ignore backend errors so the agent keeps running.
|
|
69
|
+
traceback.print_exc()
|
|
70
|
+
|
|
71
|
+
def _post_tool_results(self, results: list[dict[str, Any]]) -> None:
|
|
72
|
+
"""Send tool results to the TraceAgent backend."""
|
|
73
|
+
if not results:
|
|
74
|
+
return
|
|
75
|
+
try:
|
|
76
|
+
self.run_session.client._request(
|
|
77
|
+
"POST",
|
|
78
|
+
f"/api/runs/{self.run_session.run_id}/tool-results",
|
|
79
|
+
{"results": results},
|
|
80
|
+
)
|
|
81
|
+
except Exception:
|
|
82
|
+
traceback.print_exc()
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def _extract_token_usage(result: LLMResult | ChatResult | None) -> dict[str, Any]:
|
|
86
|
+
"""Try to extract token usage from an LLM / Chat result."""
|
|
87
|
+
if result is None:
|
|
88
|
+
return {}
|
|
89
|
+
llm_output = getattr(result, "llm_output", None) or {}
|
|
90
|
+
usage = llm_output.get("token_usage") or {}
|
|
91
|
+
# OpenAI format
|
|
92
|
+
if "prompt_tokens" in usage:
|
|
93
|
+
return {
|
|
94
|
+
"prompt_tokens": usage.get("prompt_tokens"),
|
|
95
|
+
"completion_tokens": usage.get("completion_tokens"),
|
|
96
|
+
"total_tokens": usage.get("total_tokens"),
|
|
97
|
+
}
|
|
98
|
+
# Anthropic format
|
|
99
|
+
if "input_tokens" in usage:
|
|
100
|
+
return {
|
|
101
|
+
"prompt_tokens": usage.get("input_tokens"),
|
|
102
|
+
"completion_tokens": usage.get("output_tokens"),
|
|
103
|
+
"total_tokens": (
|
|
104
|
+
(usage.get("input_tokens") or 0)
|
|
105
|
+
+ (usage.get("output_tokens") or 0)
|
|
106
|
+
),
|
|
107
|
+
}
|
|
108
|
+
return dict(usage)
|
|
109
|
+
|
|
110
|
+
# ── Chain lifecycle ─────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
def on_chain_start(
|
|
113
|
+
self,
|
|
114
|
+
serialized: dict[str, Any] | None,
|
|
115
|
+
inputs: dict[str, Any],
|
|
116
|
+
*,
|
|
117
|
+
run_id: str,
|
|
118
|
+
parent_run_id: str | None = None,
|
|
119
|
+
tags: list[str] | None = None,
|
|
120
|
+
metadata: dict[str, Any] | None = None,
|
|
121
|
+
**kwargs: Any,
|
|
122
|
+
) -> None:
|
|
123
|
+
if not self.record_chain_events:
|
|
124
|
+
return
|
|
125
|
+
self._starts[run_id] = time.time()
|
|
126
|
+
chain_type = (serialized or {}).get("name", "unknown")
|
|
127
|
+
inputs_keys = list(inputs.keys()) if isinstance(inputs, dict) else []
|
|
128
|
+
self._post_observations(
|
|
129
|
+
[
|
|
130
|
+
{
|
|
131
|
+
"kind": "chain_step",
|
|
132
|
+
"step_id": str(run_id),
|
|
133
|
+
"parent_step_id": str(parent_run_id) if parent_run_id else None,
|
|
134
|
+
"status": "running",
|
|
135
|
+
"input_summary": f"Chain '{chain_type}' started",
|
|
136
|
+
"metadata": {
|
|
137
|
+
"chain_type": chain_type,
|
|
138
|
+
"inputs_keys": inputs_keys,
|
|
139
|
+
"tags": tags,
|
|
140
|
+
**(metadata or {}),
|
|
141
|
+
},
|
|
142
|
+
}
|
|
143
|
+
]
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def on_chain_end(
|
|
147
|
+
self,
|
|
148
|
+
outputs: dict[str, Any],
|
|
149
|
+
*,
|
|
150
|
+
run_id: str,
|
|
151
|
+
parent_run_id: str | None = None,
|
|
152
|
+
tags: list[str] | None = None,
|
|
153
|
+
metadata: dict[str, Any] | None = None,
|
|
154
|
+
**kwargs: Any,
|
|
155
|
+
) -> None:
|
|
156
|
+
if not self.record_chain_events:
|
|
157
|
+
return
|
|
158
|
+
duration_ms = int((time.time() - self._starts.pop(run_id, time.time())) * 1000)
|
|
159
|
+
output_keys = list(outputs.keys()) if isinstance(outputs, dict) else []
|
|
160
|
+
self._post_observations(
|
|
161
|
+
[
|
|
162
|
+
{
|
|
163
|
+
"kind": "chain_step",
|
|
164
|
+
"step_id": str(run_id),
|
|
165
|
+
"parent_step_id": str(parent_run_id) if parent_run_id else None,
|
|
166
|
+
"status": "succeeded",
|
|
167
|
+
"duration_ms": duration_ms,
|
|
168
|
+
"input_summary": f"Chain completed",
|
|
169
|
+
"metadata": {
|
|
170
|
+
"output_keys": output_keys,
|
|
171
|
+
"tags": tags,
|
|
172
|
+
**(metadata or {}),
|
|
173
|
+
},
|
|
174
|
+
}
|
|
175
|
+
]
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
def on_chain_error(
|
|
179
|
+
self,
|
|
180
|
+
error: BaseException,
|
|
181
|
+
*,
|
|
182
|
+
run_id: str,
|
|
183
|
+
parent_run_id: str | None = None,
|
|
184
|
+
tags: list[str] | None = None,
|
|
185
|
+
metadata: dict[str, Any] | None = None,
|
|
186
|
+
**kwargs: Any,
|
|
187
|
+
) -> None:
|
|
188
|
+
if not self.record_chain_events:
|
|
189
|
+
return
|
|
190
|
+
duration_ms = int((time.time() - self._starts.pop(run_id, time.time())) * 1000)
|
|
191
|
+
self._post_observations(
|
|
192
|
+
[
|
|
193
|
+
{
|
|
194
|
+
"kind": "chain_step",
|
|
195
|
+
"step_id": str(run_id),
|
|
196
|
+
"parent_step_id": str(parent_run_id) if parent_run_id else None,
|
|
197
|
+
"status": "failed",
|
|
198
|
+
"duration_ms": duration_ms,
|
|
199
|
+
"error_code": error.__class__.__name__,
|
|
200
|
+
"error_summary": str(error),
|
|
201
|
+
"metadata": {"tags": tags, **(metadata or {})},
|
|
202
|
+
}
|
|
203
|
+
]
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
# ── LLM / Chat model lifecycle ──────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
def _on_llm_start(
|
|
209
|
+
self,
|
|
210
|
+
*,
|
|
211
|
+
run_id: str,
|
|
212
|
+
parent_run_id: str | None,
|
|
213
|
+
provider: str | None,
|
|
214
|
+
model: str | None,
|
|
215
|
+
metadata: dict[str, Any] | None,
|
|
216
|
+
) -> None:
|
|
217
|
+
self._starts[run_id] = time.time()
|
|
218
|
+
self._post_observations(
|
|
219
|
+
[
|
|
220
|
+
{
|
|
221
|
+
"kind": "llm_call",
|
|
222
|
+
"step_id": str(run_id),
|
|
223
|
+
"parent_step_id": str(parent_run_id) if parent_run_id else None,
|
|
224
|
+
"status": "running",
|
|
225
|
+
"provider": provider,
|
|
226
|
+
"model": model,
|
|
227
|
+
"metadata": metadata or {},
|
|
228
|
+
}
|
|
229
|
+
]
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def _on_llm_end(
|
|
233
|
+
self,
|
|
234
|
+
result: LLMResult | ChatResult | None,
|
|
235
|
+
*,
|
|
236
|
+
run_id: str,
|
|
237
|
+
parent_run_id: str | None,
|
|
238
|
+
provider: str | None,
|
|
239
|
+
model: str | None,
|
|
240
|
+
metadata: dict[str, Any] | None,
|
|
241
|
+
) -> None:
|
|
242
|
+
duration_ms = int((time.time() - self._starts.pop(run_id, time.time())) * 1000)
|
|
243
|
+
usage = self._extract_token_usage(result)
|
|
244
|
+
self._post_observations(
|
|
245
|
+
[
|
|
246
|
+
{
|
|
247
|
+
"kind": "llm_call",
|
|
248
|
+
"step_id": str(run_id),
|
|
249
|
+
"parent_step_id": str(parent_run_id) if parent_run_id else None,
|
|
250
|
+
"status": "succeeded",
|
|
251
|
+
"duration_ms": duration_ms,
|
|
252
|
+
"provider": provider,
|
|
253
|
+
"model": model,
|
|
254
|
+
"metadata": {**usage, **(metadata or {})},
|
|
255
|
+
}
|
|
256
|
+
]
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
def _on_llm_error(
|
|
260
|
+
self,
|
|
261
|
+
error: BaseException,
|
|
262
|
+
*,
|
|
263
|
+
run_id: str,
|
|
264
|
+
parent_run_id: str | None,
|
|
265
|
+
provider: str | None,
|
|
266
|
+
model: str | None,
|
|
267
|
+
metadata: dict[str, Any] | None,
|
|
268
|
+
) -> None:
|
|
269
|
+
duration_ms = int((time.time() - self._starts.pop(run_id, time.time())) * 1000)
|
|
270
|
+
self._post_observations(
|
|
271
|
+
[
|
|
272
|
+
{
|
|
273
|
+
"kind": "llm_call",
|
|
274
|
+
"step_id": str(run_id),
|
|
275
|
+
"parent_step_id": str(parent_run_id) if parent_run_id else None,
|
|
276
|
+
"status": "failed",
|
|
277
|
+
"duration_ms": duration_ms,
|
|
278
|
+
"provider": provider,
|
|
279
|
+
"model": model,
|
|
280
|
+
"error_code": error.__class__.__name__,
|
|
281
|
+
"error_summary": str(error),
|
|
282
|
+
"metadata": metadata or {},
|
|
283
|
+
}
|
|
284
|
+
]
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
def on_llm_start(
|
|
288
|
+
self,
|
|
289
|
+
serialized: dict[str, Any] | None,
|
|
290
|
+
prompts: list[str],
|
|
291
|
+
*,
|
|
292
|
+
run_id: str,
|
|
293
|
+
parent_run_id: str | None = None,
|
|
294
|
+
tags: list[str] | None = None,
|
|
295
|
+
metadata: dict[str, Any] | None = None,
|
|
296
|
+
**kwargs: Any,
|
|
297
|
+
) -> None:
|
|
298
|
+
provider = (serialized or {}).get("name", "unknown")
|
|
299
|
+
self._on_llm_start(
|
|
300
|
+
run_id=run_id,
|
|
301
|
+
parent_run_id=parent_run_id,
|
|
302
|
+
provider=provider,
|
|
303
|
+
model=None,
|
|
304
|
+
metadata={"tags": tags, **(metadata or {})},
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
def on_llm_end(
|
|
308
|
+
self,
|
|
309
|
+
response: LLMResult,
|
|
310
|
+
*,
|
|
311
|
+
run_id: str,
|
|
312
|
+
parent_run_id: str | None = None,
|
|
313
|
+
tags: list[str] | None = None,
|
|
314
|
+
metadata: dict[str, Any] | None = None,
|
|
315
|
+
**kwargs: Any,
|
|
316
|
+
) -> None:
|
|
317
|
+
provider = (metadata or {}).get("name", "unknown")
|
|
318
|
+
self._on_llm_end(
|
|
319
|
+
response,
|
|
320
|
+
run_id=run_id,
|
|
321
|
+
parent_run_id=parent_run_id,
|
|
322
|
+
provider=provider,
|
|
323
|
+
model=None,
|
|
324
|
+
metadata={"tags": tags, **(metadata or {})},
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
def on_llm_error(
|
|
328
|
+
self,
|
|
329
|
+
error: BaseException,
|
|
330
|
+
*,
|
|
331
|
+
run_id: str,
|
|
332
|
+
parent_run_id: str | None = None,
|
|
333
|
+
tags: list[str] | None = None,
|
|
334
|
+
metadata: dict[str, Any] | None = None,
|
|
335
|
+
**kwargs: Any,
|
|
336
|
+
) -> None:
|
|
337
|
+
provider = (metadata or {}).get("name", "unknown")
|
|
338
|
+
self._on_llm_error(
|
|
339
|
+
error,
|
|
340
|
+
run_id=run_id,
|
|
341
|
+
parent_run_id=parent_run_id,
|
|
342
|
+
provider=provider,
|
|
343
|
+
model=None,
|
|
344
|
+
metadata={"tags": tags, **(metadata or {})},
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
def on_chat_model_start(
|
|
348
|
+
self,
|
|
349
|
+
serialized: dict[str, Any] | None,
|
|
350
|
+
messages: list[list[Any]],
|
|
351
|
+
*,
|
|
352
|
+
run_id: str,
|
|
353
|
+
parent_run_id: str | None = None,
|
|
354
|
+
tags: list[str] | None = None,
|
|
355
|
+
metadata: dict[str, Any] | None = None,
|
|
356
|
+
**kwargs: Any,
|
|
357
|
+
) -> None:
|
|
358
|
+
provider = (serialized or {}).get("name", "unknown")
|
|
359
|
+
model = (kwargs.get("invocation_params") or {}).get("model")
|
|
360
|
+
self._on_llm_start(
|
|
361
|
+
run_id=run_id,
|
|
362
|
+
parent_run_id=parent_run_id,
|
|
363
|
+
provider=provider,
|
|
364
|
+
model=model,
|
|
365
|
+
metadata={"tags": tags, **(metadata or {})},
|
|
366
|
+
)
|
|
367
|
+
|
|
368
|
+
def on_chat_model_end(
|
|
369
|
+
self,
|
|
370
|
+
response: ChatResult,
|
|
371
|
+
*,
|
|
372
|
+
run_id: str,
|
|
373
|
+
parent_run_id: str | None = None,
|
|
374
|
+
tags: list[str] | None = None,
|
|
375
|
+
metadata: dict[str, Any] | None = None,
|
|
376
|
+
**kwargs: Any,
|
|
377
|
+
) -> None:
|
|
378
|
+
provider = (metadata or {}).get("name", "unknown")
|
|
379
|
+
model = (kwargs.get("invocation_params") or {}).get("model")
|
|
380
|
+
self._on_llm_end(
|
|
381
|
+
response,
|
|
382
|
+
run_id=run_id,
|
|
383
|
+
parent_run_id=parent_run_id,
|
|
384
|
+
provider=provider,
|
|
385
|
+
model=model,
|
|
386
|
+
metadata={"tags": tags, **(metadata or {})},
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
# ── Tool lifecycle ──────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
def on_tool_start(
|
|
392
|
+
self,
|
|
393
|
+
serialized: dict[str, Any] | None,
|
|
394
|
+
input_str: str,
|
|
395
|
+
*,
|
|
396
|
+
run_id: str,
|
|
397
|
+
parent_run_id: str | None = None,
|
|
398
|
+
tags: list[str] | None = None,
|
|
399
|
+
metadata: dict[str, Any] | None = None,
|
|
400
|
+
**kwargs: Any,
|
|
401
|
+
) -> None:
|
|
402
|
+
self._starts[run_id] = time.time()
|
|
403
|
+
tool_name = (serialized or {}).get("name", "unknown")
|
|
404
|
+
self._post_observations(
|
|
405
|
+
[
|
|
406
|
+
{
|
|
407
|
+
"kind": "tool_call",
|
|
408
|
+
"step_id": str(run_id),
|
|
409
|
+
"parent_step_id": str(parent_run_id) if parent_run_id else None,
|
|
410
|
+
"status": "running",
|
|
411
|
+
"tool_name": tool_name,
|
|
412
|
+
"input_summary": f"Tool '{tool_name}' started",
|
|
413
|
+
"metadata": {
|
|
414
|
+
"input": input_str,
|
|
415
|
+
"tags": tags,
|
|
416
|
+
**(metadata or {}),
|
|
417
|
+
},
|
|
418
|
+
}
|
|
419
|
+
]
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
def on_tool_end(
|
|
423
|
+
self,
|
|
424
|
+
output: Any,
|
|
425
|
+
*,
|
|
426
|
+
run_id: str,
|
|
427
|
+
parent_run_id: str | None = None,
|
|
428
|
+
tags: list[str] | None = None,
|
|
429
|
+
metadata: dict[str, Any] | None = None,
|
|
430
|
+
**kwargs: Any,
|
|
431
|
+
) -> None:
|
|
432
|
+
duration_ms = int((time.time() - self._starts.pop(run_id, time.time())) * 1000)
|
|
433
|
+
tool_name = (metadata or {}).get("name", "unknown")
|
|
434
|
+
output_str = str(output) if output is not None else ""
|
|
435
|
+
self._post_tool_results(
|
|
436
|
+
[
|
|
437
|
+
{
|
|
438
|
+
"call_id": run_id,
|
|
439
|
+
"name": tool_name,
|
|
440
|
+
"step_id": run_id,
|
|
441
|
+
"status": "succeeded",
|
|
442
|
+
"output_summary": output_str[:500],
|
|
443
|
+
"output_hash": None,
|
|
444
|
+
"duration_ms": duration_ms,
|
|
445
|
+
}
|
|
446
|
+
]
|
|
447
|
+
)
|
|
448
|
+
self._post_observations(
|
|
449
|
+
[
|
|
450
|
+
{
|
|
451
|
+
"kind": "tool_call",
|
|
452
|
+
"step_id": str(run_id),
|
|
453
|
+
"parent_step_id": str(parent_run_id) if parent_run_id else None,
|
|
454
|
+
"status": "succeeded",
|
|
455
|
+
"tool_name": tool_name,
|
|
456
|
+
"duration_ms": duration_ms,
|
|
457
|
+
"input_summary": f"Tool '{tool_name}' succeeded",
|
|
458
|
+
"metadata": {"tags": tags, **(metadata or {})},
|
|
459
|
+
}
|
|
460
|
+
]
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
def on_tool_error(
|
|
464
|
+
self,
|
|
465
|
+
error: BaseException,
|
|
466
|
+
*,
|
|
467
|
+
run_id: str,
|
|
468
|
+
parent_run_id: str | None = None,
|
|
469
|
+
tags: list[str] | None = None,
|
|
470
|
+
metadata: dict[str, Any] | None = None,
|
|
471
|
+
**kwargs: Any,
|
|
472
|
+
) -> None:
|
|
473
|
+
duration_ms = int((time.time() - self._starts.pop(run_id, time.time())) * 1000)
|
|
474
|
+
tool_name = (metadata or {}).get("name", "unknown")
|
|
475
|
+
error_summary = str(error)
|
|
476
|
+
self._post_tool_results(
|
|
477
|
+
[
|
|
478
|
+
{
|
|
479
|
+
"call_id": run_id,
|
|
480
|
+
"name": tool_name,
|
|
481
|
+
"step_id": run_id,
|
|
482
|
+
"status": "failed",
|
|
483
|
+
"output_summary": error_summary[:500],
|
|
484
|
+
"output_hash": None,
|
|
485
|
+
"error_code": error.__class__.__name__,
|
|
486
|
+
"error_summary": error_summary,
|
|
487
|
+
"duration_ms": duration_ms,
|
|
488
|
+
}
|
|
489
|
+
]
|
|
490
|
+
)
|
|
491
|
+
self._post_observations(
|
|
492
|
+
[
|
|
493
|
+
{
|
|
494
|
+
"kind": "tool_call",
|
|
495
|
+
"step_id": str(run_id),
|
|
496
|
+
"parent_step_id": str(parent_run_id) if parent_run_id else None,
|
|
497
|
+
"status": "failed",
|
|
498
|
+
"tool_name": tool_name,
|
|
499
|
+
"duration_ms": duration_ms,
|
|
500
|
+
"error_code": error.__class__.__name__,
|
|
501
|
+
"error_summary": error_summary,
|
|
502
|
+
"metadata": {"tags": tags, **(metadata or {})},
|
|
503
|
+
}
|
|
504
|
+
]
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
# ── Retriever lifecycle ─────────────────────────────────────────────────
|
|
508
|
+
|
|
509
|
+
def on_retriever_start(
|
|
510
|
+
self,
|
|
511
|
+
serialized: dict[str, Any] | None,
|
|
512
|
+
query: str,
|
|
513
|
+
*,
|
|
514
|
+
run_id: str,
|
|
515
|
+
parent_run_id: str | None = None,
|
|
516
|
+
tags: list[str] | None = None,
|
|
517
|
+
metadata: dict[str, Any] | None = None,
|
|
518
|
+
**kwargs: Any,
|
|
519
|
+
) -> None:
|
|
520
|
+
if not self.record_retriever_events:
|
|
521
|
+
return
|
|
522
|
+
self._starts[run_id] = time.time()
|
|
523
|
+
self._post_observations(
|
|
524
|
+
[
|
|
525
|
+
{
|
|
526
|
+
"kind": "retriever_query",
|
|
527
|
+
"step_id": str(run_id),
|
|
528
|
+
"parent_step_id": str(parent_run_id) if parent_run_id else None,
|
|
529
|
+
"status": "running",
|
|
530
|
+
"input_summary": f"Retriever query: {query[:200]}",
|
|
531
|
+
"metadata": {"query": query, "tags": tags, **(metadata or {})},
|
|
532
|
+
}
|
|
533
|
+
]
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
def on_retriever_end(
|
|
537
|
+
self,
|
|
538
|
+
documents: list[Any],
|
|
539
|
+
*,
|
|
540
|
+
run_id: str,
|
|
541
|
+
parent_run_id: str | None = None,
|
|
542
|
+
tags: list[str] | None = None,
|
|
543
|
+
metadata: dict[str, Any] | None = None,
|
|
544
|
+
**kwargs: Any,
|
|
545
|
+
) -> None:
|
|
546
|
+
if not self.record_retriever_events:
|
|
547
|
+
return
|
|
548
|
+
duration_ms = int((time.time() - self._starts.pop(run_id, time.time())) * 1000)
|
|
549
|
+
self._post_observations(
|
|
550
|
+
[
|
|
551
|
+
{
|
|
552
|
+
"kind": "retriever_query",
|
|
553
|
+
"step_id": str(run_id),
|
|
554
|
+
"parent_step_id": str(parent_run_id) if parent_run_id else None,
|
|
555
|
+
"status": "succeeded",
|
|
556
|
+
"duration_ms": duration_ms,
|
|
557
|
+
"input_summary": f"Retriever returned {len(documents)} documents",
|
|
558
|
+
"metadata": {
|
|
559
|
+
"document_count": len(documents),
|
|
560
|
+
"tags": tags,
|
|
561
|
+
**(metadata or {}),
|
|
562
|
+
},
|
|
563
|
+
}
|
|
564
|
+
]
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
def on_retriever_error(
|
|
568
|
+
self,
|
|
569
|
+
error: BaseException,
|
|
570
|
+
*,
|
|
571
|
+
run_id: str,
|
|
572
|
+
parent_run_id: str | None = None,
|
|
573
|
+
tags: list[str] | None = None,
|
|
574
|
+
metadata: dict[str, Any] | None = None,
|
|
575
|
+
**kwargs: Any,
|
|
576
|
+
) -> None:
|
|
577
|
+
if not self.record_retriever_events:
|
|
578
|
+
return
|
|
579
|
+
duration_ms = int((time.time() - self._starts.pop(run_id, time.time())) * 1000)
|
|
580
|
+
self._post_observations(
|
|
581
|
+
[
|
|
582
|
+
{
|
|
583
|
+
"kind": "retriever_query",
|
|
584
|
+
"step_id": str(run_id),
|
|
585
|
+
"parent_step_id": str(parent_run_id) if parent_run_id else None,
|
|
586
|
+
"status": "failed",
|
|
587
|
+
"duration_ms": duration_ms,
|
|
588
|
+
"error_code": error.__class__.__name__,
|
|
589
|
+
"error_summary": str(error),
|
|
590
|
+
"metadata": {"tags": tags, **(metadata or {})},
|
|
591
|
+
}
|
|
592
|
+
]
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
# ── Agent lifecycle ─────────────────────────────────────────────────────
|
|
596
|
+
|
|
597
|
+
def on_agent_action(
|
|
598
|
+
self,
|
|
599
|
+
action: Any,
|
|
600
|
+
*,
|
|
601
|
+
run_id: str,
|
|
602
|
+
parent_run_id: str | None = None,
|
|
603
|
+
tags: list[str] | None = None,
|
|
604
|
+
metadata: dict[str, Any] | None = None,
|
|
605
|
+
**kwargs: Any,
|
|
606
|
+
) -> None:
|
|
607
|
+
tool = getattr(action, "tool", None) or "unknown"
|
|
608
|
+
tool_input = getattr(action, "tool_input", None) or {}
|
|
609
|
+
self._post_observations(
|
|
610
|
+
[
|
|
611
|
+
{
|
|
612
|
+
"kind": "agent_action",
|
|
613
|
+
"step_id": str(run_id),
|
|
614
|
+
"parent_step_id": str(parent_run_id) if parent_run_id else None,
|
|
615
|
+
"status": "running",
|
|
616
|
+
"tool_name": tool,
|
|
617
|
+
"input_summary": f"Agent action: {tool}",
|
|
618
|
+
"metadata": {
|
|
619
|
+
"tool": tool,
|
|
620
|
+
"tool_input": tool_input,
|
|
621
|
+
"tags": tags,
|
|
622
|
+
**(metadata or {}),
|
|
623
|
+
},
|
|
624
|
+
}
|
|
625
|
+
]
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
def on_agent_finish(
|
|
629
|
+
self,
|
|
630
|
+
finish: Any,
|
|
631
|
+
*,
|
|
632
|
+
run_id: str,
|
|
633
|
+
parent_run_id: str | None = None,
|
|
634
|
+
tags: list[str] | None = None,
|
|
635
|
+
metadata: dict[str, Any] | None = None,
|
|
636
|
+
**kwargs: Any,
|
|
637
|
+
) -> None:
|
|
638
|
+
output = getattr(finish, "return_values", {})
|
|
639
|
+
self._post_observations(
|
|
640
|
+
[
|
|
641
|
+
{
|
|
642
|
+
"kind": "agent_action",
|
|
643
|
+
"step_id": str(run_id),
|
|
644
|
+
"parent_step_id": str(parent_run_id) if parent_run_id else None,
|
|
645
|
+
"status": "succeeded",
|
|
646
|
+
"input_summary": "Agent finished",
|
|
647
|
+
"metadata": {
|
|
648
|
+
"output_keys": list(output.keys()),
|
|
649
|
+
"tags": tags,
|
|
650
|
+
**(metadata or {}),
|
|
651
|
+
},
|
|
652
|
+
}
|
|
653
|
+
]
|
|
654
|
+
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: trace-agent-langchain
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: LangChain callback integration for TraceAgent.
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Requires-Dist: trace-agent-sdk
|
|
7
|
+
Requires-Dist: langchain-core<0.4.0,>=0.3.0
|
|
8
|
+
Provides-Extra: dev
|
|
9
|
+
Requires-Dist: pytest<9.0.0,>=8.3.2; extra == "dev"
|
|
10
|
+
Requires-Dist: langchain<0.4.0,>=0.3.0; extra == "dev"
|
|
11
|
+
Requires-Dist: langchain-openai<0.3.0,>=0.2.0; extra == "dev"
|
|
12
|
+
Requires-Dist: trace-agent-server; extra == "dev"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
src/trace_agent_langchain/__init__.py
|
|
3
|
+
src/trace_agent_langchain/callback.py
|
|
4
|
+
src/trace_agent_langchain.egg-info/PKG-INFO
|
|
5
|
+
src/trace_agent_langchain.egg-info/SOURCES.txt
|
|
6
|
+
src/trace_agent_langchain.egg-info/dependency_links.txt
|
|
7
|
+
src/trace_agent_langchain.egg-info/requires.txt
|
|
8
|
+
src/trace_agent_langchain.egg-info/top_level.txt
|
|
9
|
+
tests/test_langchain_callback.py
|
|
10
|
+
tests/test_langchain_integration.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
trace_agent_langchain
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Unit tests for TraceAgentLangChainCallback.
|
|
2
|
+
|
|
3
|
+
These tests mock the HTTP layer and verify that LangChain callbacks are
|
|
4
|
+
correctly translated into TraceAgent observations.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from unittest.mock import MagicMock, patch
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
langchain_core = pytest.importorskip("langchain_core")
|
|
14
|
+
from trace_agent_langchain import TraceAgentLangChainCallback
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def mock_run_session() -> MagicMock:
|
|
19
|
+
session = MagicMock()
|
|
20
|
+
session.run_id = "run-123"
|
|
21
|
+
session.client._request = MagicMock(return_value={"status": "ok"})
|
|
22
|
+
return session
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def callback(mock_run_session) -> TraceAgentLangChainCallback:
|
|
27
|
+
return TraceAgentLangChainCallback(mock_run_session)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestChainCallbacks:
|
|
31
|
+
def test_on_chain_start_posts_observation(self, callback, mock_run_session) -> None:
|
|
32
|
+
callback.on_chain_start(
|
|
33
|
+
serialized={"name": "LLMChain"},
|
|
34
|
+
inputs={"input": "hello"},
|
|
35
|
+
run_id="chain-1",
|
|
36
|
+
parent_run_id=None,
|
|
37
|
+
)
|
|
38
|
+
mock_run_session.client._request.assert_called_once()
|
|
39
|
+
args = mock_run_session.client._request.call_args
|
|
40
|
+
assert args[0][0] == "POST"
|
|
41
|
+
assert "/observations" in args[0][1]
|
|
42
|
+
obs = args[0][2]["observations"][0]
|
|
43
|
+
assert obs["kind"] == "chain_step"
|
|
44
|
+
assert obs["status"] == "running"
|
|
45
|
+
assert obs["step_id"] == "chain-1"
|
|
46
|
+
assert obs["metadata"]["chain_type"] == "LLMChain"
|
|
47
|
+
|
|
48
|
+
def test_on_chain_end_posts_observation(self, callback, mock_run_session) -> None:
|
|
49
|
+
callback._starts["chain-1"] = 0.0
|
|
50
|
+
callback.on_chain_end(
|
|
51
|
+
outputs={"output": "world"},
|
|
52
|
+
run_id="chain-1",
|
|
53
|
+
parent_run_id=None,
|
|
54
|
+
)
|
|
55
|
+
args = mock_run_session.client._request.call_args
|
|
56
|
+
obs = args[0][2]["observations"][0]
|
|
57
|
+
assert obs["kind"] == "chain_step"
|
|
58
|
+
assert obs["status"] == "succeeded"
|
|
59
|
+
assert obs["duration_ms"] >= 0
|
|
60
|
+
|
|
61
|
+
def test_on_chain_error_posts_failed(self, callback, mock_run_session) -> None:
|
|
62
|
+
callback._starts["chain-1"] = 0.0
|
|
63
|
+
callback.on_chain_error(
|
|
64
|
+
error=ValueError("boom"),
|
|
65
|
+
run_id="chain-1",
|
|
66
|
+
parent_run_id=None,
|
|
67
|
+
)
|
|
68
|
+
args = mock_run_session.client._request.call_args
|
|
69
|
+
obs = args[0][2]["observations"][0]
|
|
70
|
+
assert obs["kind"] == "chain_step"
|
|
71
|
+
assert obs["status"] == "failed"
|
|
72
|
+
assert obs["error_code"] == "ValueError"
|
|
73
|
+
assert "boom" in obs["error_summary"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class TestLLMCallbacks:
|
|
77
|
+
def test_on_llm_start_posts_observation(self, callback, mock_run_session) -> None:
|
|
78
|
+
callback.on_llm_start(
|
|
79
|
+
serialized={"name": "OpenAI"},
|
|
80
|
+
prompts=["hello"],
|
|
81
|
+
run_id="llm-1",
|
|
82
|
+
parent_run_id="chain-1",
|
|
83
|
+
)
|
|
84
|
+
args = mock_run_session.client._request.call_args
|
|
85
|
+
obs = args[0][2]["observations"][0]
|
|
86
|
+
assert obs["kind"] == "llm_call"
|
|
87
|
+
assert obs["status"] == "running"
|
|
88
|
+
assert obs["step_id"] == "llm-1"
|
|
89
|
+
assert obs["parent_step_id"] == "chain-1"
|
|
90
|
+
|
|
91
|
+
def test_on_llm_end_posts_observation(self, callback, mock_run_session) -> None:
|
|
92
|
+
callback._starts["llm-1"] = 0.0
|
|
93
|
+
# Mock LLMResult with OpenAI-style token usage
|
|
94
|
+
result = MagicMock()
|
|
95
|
+
result.llm_output = {"token_usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}}
|
|
96
|
+
callback.on_llm_end(
|
|
97
|
+
response=result,
|
|
98
|
+
run_id="llm-1",
|
|
99
|
+
parent_run_id="chain-1",
|
|
100
|
+
)
|
|
101
|
+
args = mock_run_session.client._request.call_args
|
|
102
|
+
obs = args[0][2]["observations"][0]
|
|
103
|
+
assert obs["kind"] == "llm_call"
|
|
104
|
+
assert obs["status"] == "succeeded"
|
|
105
|
+
assert obs["metadata"]["prompt_tokens"] == 10
|
|
106
|
+
assert obs["metadata"]["completion_tokens"] == 5
|
|
107
|
+
|
|
108
|
+
def test_on_llm_error_posts_failed(self, callback, mock_run_session) -> None:
|
|
109
|
+
callback._starts["llm-1"] = 0.0
|
|
110
|
+
callback.on_llm_error(
|
|
111
|
+
error=RuntimeError("timeout"),
|
|
112
|
+
run_id="llm-1",
|
|
113
|
+
parent_run_id="chain-1",
|
|
114
|
+
)
|
|
115
|
+
args = mock_run_session.client._request.call_args
|
|
116
|
+
obs = args[0][2]["observations"][0]
|
|
117
|
+
assert obs["kind"] == "llm_call"
|
|
118
|
+
assert obs["status"] == "failed"
|
|
119
|
+
assert obs["error_code"] == "RuntimeError"
|
|
120
|
+
|
|
121
|
+
def test_extract_token_usage_openai(self, callback) -> None:
|
|
122
|
+
result = MagicMock()
|
|
123
|
+
result.llm_output = {"token_usage": {"prompt_tokens": 3, "completion_tokens": 2}}
|
|
124
|
+
usage = callback._extract_token_usage(result)
|
|
125
|
+
assert usage["prompt_tokens"] == 3
|
|
126
|
+
assert usage["completion_tokens"] == 2
|
|
127
|
+
|
|
128
|
+
def test_extract_token_usage_anthropic(self, callback) -> None:
|
|
129
|
+
result = MagicMock()
|
|
130
|
+
result.llm_output = {"token_usage": {"input_tokens": 4, "output_tokens": 1}}
|
|
131
|
+
usage = callback._extract_token_usage(result)
|
|
132
|
+
assert usage["prompt_tokens"] == 4
|
|
133
|
+
assert usage["completion_tokens"] == 1
|
|
134
|
+
|
|
135
|
+
def test_extract_token_usage_empty(self, callback) -> None:
|
|
136
|
+
assert callback._extract_token_usage(None) == {}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class TestToolCallbacks:
|
|
140
|
+
def test_on_tool_start_posts_observation(self, callback, mock_run_session) -> None:
|
|
141
|
+
callback.on_tool_start(
|
|
142
|
+
serialized={"name": "search"},
|
|
143
|
+
input_str='{"q": "hello"}',
|
|
144
|
+
run_id="tool-1",
|
|
145
|
+
parent_run_id="llm-1",
|
|
146
|
+
)
|
|
147
|
+
args = mock_run_session.client._request.call_args
|
|
148
|
+
obs = args[0][2]["observations"][0]
|
|
149
|
+
assert obs["kind"] == "tool_call"
|
|
150
|
+
assert obs["status"] == "running"
|
|
151
|
+
assert obs["tool_name"] == "search"
|
|
152
|
+
assert obs["step_id"] == "tool-1"
|
|
153
|
+
assert obs["parent_step_id"] == "llm-1"
|
|
154
|
+
|
|
155
|
+
def test_on_tool_end_posts_result_and_observation(self, callback, mock_run_session) -> None:
|
|
156
|
+
callback._starts["tool-1"] = 0.0
|
|
157
|
+
callback.on_tool_end(
|
|
158
|
+
output="result here",
|
|
159
|
+
run_id="tool-1",
|
|
160
|
+
parent_run_id="llm-1",
|
|
161
|
+
metadata={"name": "search"},
|
|
162
|
+
)
|
|
163
|
+
assert mock_run_session.client._request.call_count == 2
|
|
164
|
+
# First call is tool-results
|
|
165
|
+
args0 = mock_run_session.client._request.call_args_list[0]
|
|
166
|
+
assert "/tool-results" in args0[0][1]
|
|
167
|
+
result = args0[0][2]["results"][0]
|
|
168
|
+
assert result["status"] == "succeeded"
|
|
169
|
+
assert result["name"] == "search"
|
|
170
|
+
# Second call is observation
|
|
171
|
+
args1 = mock_run_session.client._request.call_args_list[1]
|
|
172
|
+
obs = args1[0][2]["observations"][0]
|
|
173
|
+
assert obs["kind"] == "tool_call"
|
|
174
|
+
assert obs["status"] == "succeeded"
|
|
175
|
+
|
|
176
|
+
def test_on_tool_error_posts_failed(self, callback, mock_run_session) -> None:
|
|
177
|
+
callback._starts["tool-1"] = 0.0
|
|
178
|
+
callback.on_tool_error(
|
|
179
|
+
error=Exception("fail"),
|
|
180
|
+
run_id="tool-1",
|
|
181
|
+
parent_run_id="llm-1",
|
|
182
|
+
metadata={"name": "search"},
|
|
183
|
+
)
|
|
184
|
+
assert mock_run_session.client._request.call_count == 2
|
|
185
|
+
args0 = mock_run_session.client._request.call_args_list[0]
|
|
186
|
+
result = args0[0][2]["results"][0]
|
|
187
|
+
assert result["status"] == "failed"
|
|
188
|
+
assert result["error_code"] == "Exception"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class TestRetrieverCallbacks:
|
|
192
|
+
def test_on_retriever_start(self, callback, mock_run_session) -> None:
|
|
193
|
+
callback.on_retriever_start(
|
|
194
|
+
serialized={"name": "VectorStore"},
|
|
195
|
+
query="AI agents",
|
|
196
|
+
run_id="ret-1",
|
|
197
|
+
parent_run_id="chain-1",
|
|
198
|
+
)
|
|
199
|
+
args = mock_run_session.client._request.call_args
|
|
200
|
+
obs = args[0][2]["observations"][0]
|
|
201
|
+
assert obs["kind"] == "retriever_query"
|
|
202
|
+
assert obs["status"] == "running"
|
|
203
|
+
assert "AI agents" in obs["input_summary"]
|
|
204
|
+
|
|
205
|
+
def test_on_retriever_end(self, callback, mock_run_session) -> None:
|
|
206
|
+
callback._starts["ret-1"] = 0.0
|
|
207
|
+
callback.on_retriever_end(
|
|
208
|
+
documents=[{"page_content": "doc1"}, {"page_content": "doc2"}],
|
|
209
|
+
run_id="ret-1",
|
|
210
|
+
parent_run_id="chain-1",
|
|
211
|
+
)
|
|
212
|
+
args = mock_run_session.client._request.call_args
|
|
213
|
+
obs = args[0][2]["observations"][0]
|
|
214
|
+
assert obs["kind"] == "retriever_query"
|
|
215
|
+
assert obs["status"] == "succeeded"
|
|
216
|
+
assert obs["metadata"]["document_count"] == 2
|
|
217
|
+
|
|
218
|
+
def test_retriever_events_disabled(self, mock_run_session) -> None:
|
|
219
|
+
cb = TraceAgentLangChainCallback(mock_run_session, record_retriever_events=False)
|
|
220
|
+
cb.on_retriever_start(
|
|
221
|
+
serialized={"name": "VectorStore"},
|
|
222
|
+
query="AI",
|
|
223
|
+
run_id="ret-1",
|
|
224
|
+
)
|
|
225
|
+
mock_run_session.client._request.assert_not_called()
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class TestAgentCallbacks:
|
|
229
|
+
def test_on_agent_action(self, callback, mock_run_session) -> None:
|
|
230
|
+
action = MagicMock()
|
|
231
|
+
action.tool = "search"
|
|
232
|
+
action.tool_input = {"q": "hello"}
|
|
233
|
+
callback.on_agent_action(
|
|
234
|
+
action=action,
|
|
235
|
+
run_id="agent-1",
|
|
236
|
+
parent_run_id="chain-1",
|
|
237
|
+
)
|
|
238
|
+
args = mock_run_session.client._request.call_args
|
|
239
|
+
obs = args[0][2]["observations"][0]
|
|
240
|
+
assert obs["kind"] == "agent_action"
|
|
241
|
+
assert obs["status"] == "running"
|
|
242
|
+
assert obs["tool_name"] == "search"
|
|
243
|
+
assert obs["metadata"]["tool_input"] == {"q": "hello"}
|
|
244
|
+
|
|
245
|
+
def test_on_agent_finish(self, callback, mock_run_session) -> None:
|
|
246
|
+
finish = MagicMock()
|
|
247
|
+
finish.return_values = {"output": "done"}
|
|
248
|
+
callback.on_agent_finish(
|
|
249
|
+
finish=finish,
|
|
250
|
+
run_id="agent-1",
|
|
251
|
+
parent_run_id="chain-1",
|
|
252
|
+
)
|
|
253
|
+
args = mock_run_session.client._request.call_args
|
|
254
|
+
obs = args[0][2]["observations"][0]
|
|
255
|
+
assert obs["kind"] == "agent_action"
|
|
256
|
+
assert obs["status"] == "succeeded"
|
|
257
|
+
assert obs["input_summary"] == "Agent finished"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class TestResilience:
|
|
261
|
+
def test_backend_error_is_silenced(self, mock_run_session) -> None:
|
|
262
|
+
mock_run_session.client._request.side_effect = Exception("connection refused")
|
|
263
|
+
callback = TraceAgentLangChainCallback(mock_run_session)
|
|
264
|
+
# Should not raise
|
|
265
|
+
callback.on_chain_start(
|
|
266
|
+
serialized={"name": "Chain"},
|
|
267
|
+
inputs={},
|
|
268
|
+
run_id="chain-1",
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class TestSDKNotInstalled:
|
|
273
|
+
def test_raises_when_langchain_missing(self) -> None:
|
|
274
|
+
with patch("trace_agent_langchain.callback.BaseCallbackHandler", None):
|
|
275
|
+
mock_session = MagicMock()
|
|
276
|
+
with pytest.raises(RuntimeError, match="langchain-core is not installed"):
|
|
277
|
+
TraceAgentLangChainCallback(mock_session)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Integration tests for LangChain callback with the TraceAgent backend.
|
|
2
|
+
|
|
3
|
+
These tests require langchain to be installed (via `uv pip install -e ".[langchain]"`).
|
|
4
|
+
They spin up the FastAPI app with an in-memory DB, create a run, invoke a LangChain
|
|
5
|
+
chain with the TraceAgent callback, and verify that events are persisted.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
from fastapi.testclient import TestClient
|
|
12
|
+
|
|
13
|
+
from trace_agent_server.config import Settings
|
|
14
|
+
from trace_agent_server.main import create_app
|
|
15
|
+
from trace_agent_server.models import Event, Run
|
|
16
|
+
|
|
17
|
+
# Skip entire module if langchain is not installed
|
|
18
|
+
langchain = pytest.importorskip("langchain")
|
|
19
|
+
langchain_core = pytest.importorskip("langchain_core")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def test_client(tmp_path) -> TestClient:
|
|
24
|
+
settings = Settings(
|
|
25
|
+
database_url=f"sqlite:///{tmp_path / 'test.db'}",
|
|
26
|
+
cors_origins=["http://localhost:5173"],
|
|
27
|
+
)
|
|
28
|
+
app = create_app(settings)
|
|
29
|
+
return TestClient(app)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestLangChainCallbackIntegration:
|
|
33
|
+
def test_chain_events_are_persisted(self, test_client) -> None:
|
|
34
|
+
client = test_client
|
|
35
|
+
|
|
36
|
+
# 1. Create a run via the API
|
|
37
|
+
resp = client.post(
|
|
38
|
+
"/api/runs",
|
|
39
|
+
json={"agent_name": "test-agent", "goal": "test chain observability"},
|
|
40
|
+
)
|
|
41
|
+
assert resp.status_code == 200
|
|
42
|
+
run_id = resp.json()["run_id"]
|
|
43
|
+
|
|
44
|
+
# 2. Build a simple LangChain chain and attach the callback
|
|
45
|
+
from langchain_core.prompts import ChatPromptTemplate
|
|
46
|
+
from langchain_core.runnables import RunnableLambda
|
|
47
|
+
|
|
48
|
+
from trace_agent_langchain import TraceAgentLangChainCallback
|
|
49
|
+
from trace_agent_sdk import RunSession, TraceAgentClient
|
|
50
|
+
|
|
51
|
+
TRACE_AGENT_client = TraceAgentClient(base_url="", http_client=client)
|
|
52
|
+
# Manually construct a RunSession since we bypass start_run()
|
|
53
|
+
run_session = RunSession(
|
|
54
|
+
client=TRACE_AGENT_client,
|
|
55
|
+
run_id=run_id,
|
|
56
|
+
agent_name="test-agent",
|
|
57
|
+
goal="test",
|
|
58
|
+
)
|
|
59
|
+
callback = TraceAgentLangChainCallback(run_session)
|
|
60
|
+
|
|
61
|
+
# 3. Invoke a simple Runnable sequence
|
|
62
|
+
chain = RunnableLambda(lambda x: x.upper())
|
|
63
|
+
result = chain.invoke("hello", config={"callbacks": [callback]})
|
|
64
|
+
assert result == "HELLO"
|
|
65
|
+
|
|
66
|
+
# 4. Verify events were persisted
|
|
67
|
+
with client.app.state.session_factory() as session:
|
|
68
|
+
run = session.query(Run).filter_by(id=run_id).first()
|
|
69
|
+
assert run is not None
|
|
70
|
+
events = session.query(Event).filter_by(run_id=run_id).order_by(Event.seq).all()
|
|
71
|
+
# Should have at least chain.started + chain.completed
|
|
72
|
+
assert len(events) >= 2
|
|
73
|
+
types = [e.type for e in events]
|
|
74
|
+
assert "chain.started" in types
|
|
75
|
+
assert "chain.completed" in types
|
|
76
|
+
|
|
77
|
+
def test_llm_events_with_mocked_provider(self, test_client) -> None:
|
|
78
|
+
"""Test that LLM calls produce the correct event types.
|
|
79
|
+
|
|
80
|
+
We mock the LLM so no real API key is needed.
|
|
81
|
+
"""
|
|
82
|
+
client = test_client
|
|
83
|
+
|
|
84
|
+
resp = client.post(
|
|
85
|
+
"/api/runs",
|
|
86
|
+
json={"agent_name": "test-llm-agent", "goal": "test llm events"},
|
|
87
|
+
)
|
|
88
|
+
run_id = resp.json()["run_id"]
|
|
89
|
+
|
|
90
|
+
from unittest.mock import MagicMock, patch
|
|
91
|
+
|
|
92
|
+
from langchain_core.outputs import Generation, LLMResult
|
|
93
|
+
|
|
94
|
+
from trace_agent_langchain import TraceAgentLangChainCallback
|
|
95
|
+
from trace_agent_sdk import RunSession, TraceAgentClient
|
|
96
|
+
|
|
97
|
+
TRACE_AGENT_client = TraceAgentClient(base_url="", http_client=client)
|
|
98
|
+
run_session = RunSession(
|
|
99
|
+
client=TRACE_AGENT_client,
|
|
100
|
+
run_id=run_id,
|
|
101
|
+
agent_name="test-llm-agent",
|
|
102
|
+
goal="test",
|
|
103
|
+
)
|
|
104
|
+
callback = TraceAgentLangChainCallback(run_session)
|
|
105
|
+
|
|
106
|
+
# Simulate an LLM call with token usage
|
|
107
|
+
mock_result = LLMResult(
|
|
108
|
+
generations=[[Generation(text="hello")]],
|
|
109
|
+
llm_output={"token_usage": {"prompt_tokens": 5, "completion_tokens": 2, "total_tokens": 7}},
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
callback.on_llm_start(
|
|
113
|
+
serialized={"name": "FakeLLM"},
|
|
114
|
+
prompts=["hi"],
|
|
115
|
+
run_id="llm-1",
|
|
116
|
+
)
|
|
117
|
+
callback.on_llm_end(
|
|
118
|
+
response=mock_result,
|
|
119
|
+
run_id="llm-1",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
with client.app.state.session_factory() as session:
|
|
123
|
+
events = session.query(Event).filter_by(run_id=run_id).order_by(Event.seq).all()
|
|
124
|
+
types = [e.type for e in events]
|
|
125
|
+
assert "llm.started" in types
|
|
126
|
+
assert "llm.completed" in types
|
|
127
|
+
llm_completed = next(e for e in events if e.type == "llm.completed")
|
|
128
|
+
assert llm_completed.event_metadata.get("prompt_tokens") == 5
|
|
129
|
+
assert llm_completed.event_metadata.get("completion_tokens") == 2
|
|
130
|
+
|
|
131
|
+
def test_tool_events_are_persisted(self, test_client) -> None:
|
|
132
|
+
client = test_client
|
|
133
|
+
|
|
134
|
+
resp = client.post(
|
|
135
|
+
"/api/runs",
|
|
136
|
+
json={"agent_name": "test-tool-agent", "goal": "test tool events"},
|
|
137
|
+
)
|
|
138
|
+
run_id = resp.json()["run_id"]
|
|
139
|
+
|
|
140
|
+
from trace_agent_langchain import TraceAgentLangChainCallback
|
|
141
|
+
from trace_agent_sdk import RunSession, TraceAgentClient
|
|
142
|
+
|
|
143
|
+
TRACE_AGENT_client = TraceAgentClient(base_url="", http_client=client)
|
|
144
|
+
run_session = RunSession(
|
|
145
|
+
client=TRACE_AGENT_client,
|
|
146
|
+
run_id=run_id,
|
|
147
|
+
agent_name="test-tool-agent",
|
|
148
|
+
goal="test",
|
|
149
|
+
)
|
|
150
|
+
callback = TraceAgentLangChainCallback(run_session)
|
|
151
|
+
|
|
152
|
+
callback.on_tool_start(
|
|
153
|
+
serialized={"name": "search"},
|
|
154
|
+
input_str='{"q": "hello"}',
|
|
155
|
+
run_id="tool-1",
|
|
156
|
+
)
|
|
157
|
+
callback.on_tool_end(
|
|
158
|
+
output="found it",
|
|
159
|
+
run_id="tool-1",
|
|
160
|
+
metadata={"name": "search"},
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
with client.app.state.session_factory() as session:
|
|
164
|
+
events = session.query(Event).filter_by(run_id=run_id).order_by(Event.seq).all()
|
|
165
|
+
types = [e.type for e in events]
|
|
166
|
+
assert "tool.started" in types
|
|
167
|
+
assert "tool.succeeded" in types
|