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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ __all__ = ["__version__", "TraceAgentLangChainCallback"]
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from trace_agent_langchain.callback import TraceAgentLangChainCallback
@@ -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,8 @@
1
+ trace-agent-sdk
2
+ langchain-core<0.4.0,>=0.3.0
3
+
4
+ [dev]
5
+ pytest<9.0.0,>=8.3.2
6
+ langchain<0.4.0,>=0.3.0
7
+ langchain-openai<0.3.0,>=0.2.0
8
+ trace-agent-server
@@ -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