evalshift-sdk 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
evalshift/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ """EvalShift capture SDK.
2
+
3
+ Import name ``evalshift`` (distribution ``evalshift-sdk``). Records agent behavior in-process
4
+ and writes CLI-valid traces to disk. Capture is off unless ``EVALSHIFT_CAPTURE=1``.
5
+
6
+ Public surface: the ``capture`` decorator, the ``record_model_call`` helper, the programmatic
7
+ ``configure`` entry point, the opt-in ``default_redactor``, and the built-in
8
+ ``FileSink`` / ``MemorySink``.
9
+
10
+ Read side (tooling): ``load_capture`` / ``load_envelope`` read and upgrade a written capture to
11
+ the current schema version; ``register_migration`` plugs in a step for a future version;
12
+ ``MigrationError`` is the base of the typed read errors.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from evalshift.capture.api import capture, record_model_call
18
+ from evalshift.config import configure
19
+ from evalshift.redaction import Redactor, default_redactor
20
+ from evalshift.sinks.file import FileSink
21
+ from evalshift.sinks.memory import MemorySink
22
+ from evalshift.trace.migrate import (
23
+ MigrationError,
24
+ load_capture,
25
+ load_envelope,
26
+ register_migration,
27
+ )
28
+ from evalshift.trace.schema import SCHEMA_VERSION
29
+
30
+ __version__ = "0.2.0"
31
+
32
+ __all__ = [
33
+ "SCHEMA_VERSION",
34
+ "FileSink",
35
+ "MemorySink",
36
+ "MigrationError",
37
+ "Redactor",
38
+ "__version__",
39
+ "capture",
40
+ "configure",
41
+ "default_redactor",
42
+ "load_capture",
43
+ "load_envelope",
44
+ "record_model_call",
45
+ "register_migration",
46
+ ]
@@ -0,0 +1,554 @@
1
+ """LangChain adapter: a callback handler that captures a chain/agent run with zero hand-wiring.
2
+
3
+ Drop :class:`EvalShiftCallbackHandler` into any LangChain ``callbacks=[...]`` list and a run is
4
+ recorded into the SDK's span tree and written exactly like manual ``@capture.agent``
5
+ instrumentation — no decorators on the user's own code.
6
+
7
+ **Why this can't reuse the contextvar machinery.** The manual API (``evalshift.capture.state``)
8
+ infers parentage from the Python call stack: a tool span opened *inside* another tool nests via a
9
+ contextvar. LangChain callbacks fire **flat** — every callback carries a ``run_id`` and a
10
+ ``parent_run_id`` (UUIDs) instead of nesting on the stack. So this handler keeps its own
11
+ ``run_id -> span`` maps, resolves ``parent_call_id`` by walking the ``parent_run_id`` chain to the
12
+ nearest enclosing *tool*, owns the :class:`~evalshift.capture.span.SpanTree` for each root run, and
13
+ finalizes the capture when that root run ends. It deliberately does **not** bind
14
+ ``state.use_tree`` — so mixing this handler with ``@capture.tool``-decorated code won't
15
+ double-record (use one or the other).
16
+
17
+ **Fail-open is sacred.** Every callback body runs under :func:`evalshift.safety.fail_open`, so a
18
+ handler fault can never propagate into — and break — the user's chain. Framework payloads
19
+ (messages, documents, tool args) are coerced to JSON-able primitives before they reach a span, so
20
+ a non-serializable object can't silently drop the capture at sink-write time.
21
+
22
+ Runtime-optional: ``langchain_core`` is import-guarded, so importing this module without the
23
+ ``[langchain]`` extra installed does not fail — the SDK stays stdlib-only at runtime (D-deps).
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import json
29
+ import threading
30
+ import uuid
31
+ from dataclasses import dataclass, field
32
+ from typing import TYPE_CHECKING, Any
33
+ from uuid import UUID
34
+
35
+ from evalshift import config, safety
36
+ from evalshift.capture import api
37
+ from evalshift.capture.span import Span, SpanTree
38
+ from evalshift.redaction import Redactor
39
+
40
+ if TYPE_CHECKING:
41
+ from langchain_core.callbacks import BaseCallbackHandler
42
+ else: # runtime: usable without the langchain extra installed
43
+ try:
44
+ from langchain_core.callbacks import BaseCallbackHandler
45
+ except ImportError: # pragma: no cover - exercised only when the extra is absent
46
+ BaseCallbackHandler = object
47
+
48
+ #: Sentinel distinguishing "no final output for this finish" from a real ``None`` output.
49
+ _UNSET: Any = object()
50
+
51
+ #: Provenance stamp written into each event's (freeform) ``metadata`` dict.
52
+ _ADAPTER = "langchain"
53
+
54
+
55
+ def _jsonable(value: Any) -> Any:
56
+ """Coerce an arbitrary framework value into JSON-able primitives (objects -> ``str``).
57
+
58
+ The FileSink serializes with a plain ``json.dumps`` (no ``default=``), so a stray
59
+ non-serializable object would raise at write time and silently drop the capture. Coercing here
60
+ keeps captures intact; this mirrors the lossy ``default=str`` already used for input hashing.
61
+ """
62
+ if value is None or isinstance(value, (str, int, float, bool)):
63
+ return value
64
+ if isinstance(value, dict):
65
+ return {str(key): _jsonable(item) for key, item in value.items()}
66
+ if isinstance(value, (list, tuple)):
67
+ return [_jsonable(item) for item in value]
68
+ return str(value)
69
+
70
+
71
+ def _coerce_text(value: Any) -> str:
72
+ """Best-effort human-readable string for a chain's final output."""
73
+ if isinstance(value, str):
74
+ return value
75
+ if isinstance(value, dict):
76
+ for key in ("output", "text", "result", "answer", "content"):
77
+ found = value.get(key)
78
+ if isinstance(found, str):
79
+ return found
80
+ return json.dumps(_jsonable(value), ensure_ascii=False)
81
+
82
+
83
+ def _tool_name(serialized: Any) -> str:
84
+ if isinstance(serialized, dict):
85
+ name = serialized.get("name")
86
+ if isinstance(name, str) and name:
87
+ return name
88
+ return "tool"
89
+
90
+
91
+ def _retriever_source(serialized: Any) -> str:
92
+ if isinstance(serialized, dict):
93
+ name = serialized.get("name")
94
+ if isinstance(name, str) and name:
95
+ return name
96
+ return "retriever"
97
+
98
+
99
+ def _model_id(serialized: Any, kwargs: dict[str, Any]) -> str:
100
+ invocation = kwargs.get("invocation_params")
101
+ if isinstance(invocation, dict):
102
+ for key in ("model", "model_name", "model_id", "deployment_name"):
103
+ value = invocation.get(key)
104
+ if isinstance(value, str) and value:
105
+ return value
106
+ if isinstance(serialized, dict):
107
+ name = serialized.get("name")
108
+ if isinstance(name, str) and name:
109
+ return name
110
+ ident = serialized.get("id")
111
+ if isinstance(ident, list) and ident:
112
+ return str(ident[-1])
113
+ return "unknown"
114
+
115
+
116
+ def _messages_to_input(messages: Any) -> list[dict[str, Any]]:
117
+ """Flatten LangChain ``list[list[BaseMessage]]`` into JSON-able ``{role, content}`` dicts."""
118
+ out: list[dict[str, Any]] = []
119
+ for batch in messages or []:
120
+ for message in batch or []:
121
+ out.append(
122
+ {
123
+ "role": str(getattr(message, "type", "message")),
124
+ "content": _jsonable(getattr(message, "content", str(message))),
125
+ }
126
+ )
127
+ return out
128
+
129
+
130
+ def _documents_to_list(documents: Any) -> list[dict[str, Any]]:
131
+ out: list[dict[str, Any]] = []
132
+ for doc in documents or []:
133
+ out.append(
134
+ {
135
+ "page_content": str(getattr(doc, "page_content", doc)),
136
+ "metadata": _jsonable(getattr(doc, "metadata", {})),
137
+ }
138
+ )
139
+ return out
140
+
141
+
142
+ def _llm_usage(response: Any) -> tuple[str, int, int]:
143
+ """Extract ``(output_text, input_tokens, output_tokens)`` from an ``LLMResult``, defensively.
144
+
145
+ Prefers the modern per-message ``usage_metadata``; falls back to the older
146
+ ``llm_output["token_usage"]`` (OpenAI-style). Any missing piece degrades to ``0`` / ``""``.
147
+ """
148
+ output_text = ""
149
+ input_tokens = 0
150
+ output_tokens = 0
151
+ generations = getattr(response, "generations", None) or []
152
+ if generations and generations[0]:
153
+ first = generations[0][0]
154
+ output_text = str(getattr(first, "text", "") or "")
155
+ message = getattr(first, "message", None)
156
+ usage = getattr(message, "usage_metadata", None)
157
+ if isinstance(usage, dict):
158
+ input_tokens = int(usage.get("input_tokens", 0) or 0)
159
+ output_tokens = int(usage.get("output_tokens", 0) or 0)
160
+ if input_tokens == 0 and output_tokens == 0:
161
+ llm_output = getattr(response, "llm_output", None)
162
+ if isinstance(llm_output, dict):
163
+ token_usage = llm_output.get("token_usage")
164
+ if isinstance(token_usage, dict):
165
+ input_tokens = int(
166
+ token_usage.get("prompt_tokens", token_usage.get("input_tokens", 0)) or 0
167
+ )
168
+ output_tokens = int(
169
+ token_usage.get("completion_tokens", token_usage.get("output_tokens", 0)) or 0
170
+ )
171
+ return output_text, input_tokens, output_tokens
172
+
173
+
174
+ @dataclass
175
+ class _Session:
176
+ """Per-root-run capture state. ``inert`` sessions (gate off / sampled out) record nothing."""
177
+
178
+ tree: SpanTree | None
179
+ capture_id: str
180
+ agent_input: Any
181
+ inert: bool
182
+ open_spans: dict[UUID, Span] = field(default_factory=dict)
183
+ tool_callid: dict[UUID, str] = field(default_factory=dict)
184
+
185
+
186
+ class EvalShiftCallbackHandler(BaseCallbackHandler):
187
+ """LangChain ``BaseCallbackHandler`` that records a run as an EvalShift capture.
188
+
189
+ One handler instance may be reused across many invocations and across threads: per-root state
190
+ is keyed by the root ``run_id`` and guarded by a lock (the underlying ``SpanTree`` is itself
191
+ thread-safe). ``redact`` overrides any process-wide ``configure(redact=...)``; if it raises the
192
+ capture is dropped rather than written unredacted (fail-closed), and the chain is unaffected.
193
+ """
194
+
195
+ def __init__(
196
+ self,
197
+ *,
198
+ suite: str,
199
+ code_version: str = "",
200
+ redact: Redactor | None = None,
201
+ ) -> None:
202
+ super().__init__()
203
+ self._suite = suite
204
+ self._code_version = code_version
205
+ self._redact = redact
206
+ self._lock = threading.Lock()
207
+ self._run_to_root: dict[UUID, UUID] = {}
208
+ self._run_to_parent: dict[UUID, UUID | None] = {}
209
+ self._sessions: dict[UUID, _Session] = {}
210
+
211
+ # --- internal session bookkeeping (all map mutations hold self._lock) -------------------
212
+
213
+ def _register_run_locked(self, run_id: UUID, parent_run_id: UUID | None) -> UUID:
214
+ root = (
215
+ run_id if parent_run_id is None else self._run_to_root.get(parent_run_id, parent_run_id)
216
+ )
217
+ self._run_to_root[run_id] = root
218
+ self._run_to_parent[run_id] = parent_run_id
219
+ return root
220
+
221
+ def _begin_session_locked(self, root: UUID, agent_input: Any) -> None:
222
+ enabled = safety.guard(
223
+ "langchain gate check",
224
+ lambda: config.is_capture_enabled() and config.should_capture_now(),
225
+ )
226
+ tree = safety.guard("langchain open session", SpanTree) if enabled else None
227
+ if tree is None:
228
+ self._sessions[root] = _Session(tree=None, capture_id="", agent_input=None, inert=True)
229
+ return
230
+ self._sessions[root] = _Session(
231
+ tree=tree, capture_id=api._new_capture_id(), agent_input=agent_input, inert=False
232
+ )
233
+
234
+ def _ensure_session(
235
+ self, run_id: UUID, parent_run_id: UUID | None, agent_input: Any
236
+ ) -> _Session | None:
237
+ with self._lock:
238
+ root = self._register_run_locked(run_id, parent_run_id)
239
+ if parent_run_id is None and root not in self._sessions:
240
+ self._begin_session_locked(root, agent_input)
241
+ return self._sessions.get(root)
242
+
243
+ def _parent_call_id_locked(self, session: _Session, parent_run_id: UUID | None) -> str | None:
244
+ rid = parent_run_id
245
+ while rid is not None:
246
+ call_id = session.tool_callid.get(rid)
247
+ if call_id is not None:
248
+ return call_id
249
+ rid = self._run_to_parent.get(rid)
250
+ return None
251
+
252
+ def _start_span(
253
+ self,
254
+ session: _Session,
255
+ run_id: UUID,
256
+ parent_run_id: UUID | None,
257
+ kind: Any,
258
+ span_id: str,
259
+ data: dict[str, Any],
260
+ *,
261
+ is_tool: bool = False,
262
+ ) -> None:
263
+ with self._lock:
264
+ if session.tree is None:
265
+ return
266
+ parent_call_id = self._parent_call_id_locked(session, parent_run_id)
267
+ span = session.tree.open_span(
268
+ kind,
269
+ span_id=span_id,
270
+ start_ts=api._now(),
271
+ parent_call_id=parent_call_id,
272
+ data=data,
273
+ metadata={"adapter": _ADAPTER, "lc_run_id": str(run_id)},
274
+ )
275
+ session.open_spans[run_id] = span
276
+ if is_tool:
277
+ session.tool_callid[run_id] = span_id
278
+
279
+ def _close_span(self, run_id: UUID, **data: Any) -> None:
280
+ with self._lock:
281
+ root = self._run_to_root.get(run_id)
282
+ session = self._sessions.get(root) if root is not None else None
283
+ if session is None or session.tree is None:
284
+ return
285
+ span = session.open_spans.pop(run_id, None)
286
+ if span is None:
287
+ return
288
+ session.tree.close_span(span, end_ts=api._now(), **data)
289
+
290
+ def _add_error_event(self, run_id: UUID, exc: BaseException) -> None:
291
+ with self._lock:
292
+ root = self._run_to_root.get(run_id)
293
+ session = self._sessions.get(root) if root is not None else None
294
+ if session is None or session.tree is None:
295
+ return
296
+ ts = api._now()
297
+ span = session.tree.open_span(
298
+ "error",
299
+ span_id=f"err_{uuid.uuid4().hex}",
300
+ start_ts=ts,
301
+ parent_call_id=None,
302
+ data={"message": str(exc), "category": type(exc).__name__},
303
+ metadata={"adapter": _ADAPTER, "lc_run_id": str(run_id)},
304
+ )
305
+ session.tree.close_span(span, end_ts=ts)
306
+
307
+ def _record_final_output(self, tree: SpanTree, outputs: Any) -> None:
308
+ ts = api._now()
309
+ span = tree.open_span(
310
+ "final_output",
311
+ span_id=f"fo_{uuid.uuid4().hex}",
312
+ start_ts=ts,
313
+ parent_call_id=None,
314
+ data={"text": _coerce_text(outputs)},
315
+ metadata={"adapter": _ADAPTER},
316
+ )
317
+ tree.close_span(span, end_ts=ts)
318
+
319
+ def _finish_root(self, run_id: UUID, *, final_output: Any = _UNSET) -> None:
320
+ """Finalize + write the capture iff ``run_id`` is a tracked root run; else a no-op."""
321
+ with self._lock:
322
+ if self._run_to_root.get(run_id) != run_id:
323
+ return # not the root of its tree -> nothing to finalize yet
324
+ root = run_id
325
+ session = self._sessions.pop(root, None)
326
+ self._run_to_root = {r: rt for r, rt in self._run_to_root.items() if rt != root}
327
+ self._run_to_parent = {
328
+ r: p for r, p in self._run_to_parent.items() if r in self._run_to_root
329
+ }
330
+ if session is None or session.inert or session.tree is None:
331
+ return
332
+ if final_output is not _UNSET:
333
+ self._record_final_output(session.tree, final_output)
334
+ api._finalize(
335
+ session.tree,
336
+ suite=self._suite,
337
+ agent_input=session.agent_input,
338
+ capture_id=session.capture_id,
339
+ code_version=self._code_version,
340
+ redact=self._redact,
341
+ )
342
+
343
+ # --- LangChain callback surface (each body is fail-open) --------------------------------
344
+
345
+ def on_chain_start(
346
+ self,
347
+ serialized: Any,
348
+ inputs: Any,
349
+ *,
350
+ run_id: UUID,
351
+ parent_run_id: UUID | None = None,
352
+ **kwargs: Any,
353
+ ) -> None:
354
+ with safety.fail_open("langchain on_chain_start"):
355
+ # Chains are not a CLI event kind: a root chain opens the session; nested chains are
356
+ # registered only so descendants can resolve their nearest-tool parent.
357
+ self._ensure_session(run_id, parent_run_id, _jsonable(inputs))
358
+
359
+ def on_chain_end(
360
+ self,
361
+ outputs: Any,
362
+ *,
363
+ run_id: UUID,
364
+ parent_run_id: UUID | None = None,
365
+ **kwargs: Any,
366
+ ) -> None:
367
+ with safety.fail_open("langchain on_chain_end"):
368
+ self._finish_root(run_id, final_output=outputs)
369
+
370
+ def on_chain_error(
371
+ self,
372
+ error: BaseException,
373
+ *,
374
+ run_id: UUID,
375
+ parent_run_id: UUID | None = None,
376
+ **kwargs: Any,
377
+ ) -> None:
378
+ with safety.fail_open("langchain on_chain_error"):
379
+ self._add_error_event(run_id, error)
380
+ self._finish_root(run_id)
381
+
382
+ def on_llm_start(
383
+ self,
384
+ serialized: Any,
385
+ prompts: Any,
386
+ *,
387
+ run_id: UUID,
388
+ parent_run_id: UUID | None = None,
389
+ **kwargs: Any,
390
+ ) -> None:
391
+ with safety.fail_open("langchain on_llm_start"):
392
+ payload = _jsonable(prompts)
393
+ session = self._ensure_session(run_id, parent_run_id, payload)
394
+ if session is None:
395
+ return
396
+ self._start_span(
397
+ session,
398
+ run_id,
399
+ parent_run_id,
400
+ "model_call",
401
+ f"mc_{uuid.uuid4().hex}",
402
+ {"model_id": _model_id(serialized, kwargs), "input": payload},
403
+ )
404
+
405
+ def on_chat_model_start(
406
+ self,
407
+ serialized: Any,
408
+ messages: Any,
409
+ *,
410
+ run_id: UUID,
411
+ parent_run_id: UUID | None = None,
412
+ **kwargs: Any,
413
+ ) -> None:
414
+ with safety.fail_open("langchain on_chat_model_start"):
415
+ payload = _messages_to_input(messages)
416
+ session = self._ensure_session(run_id, parent_run_id, payload)
417
+ if session is None:
418
+ return
419
+ self._start_span(
420
+ session,
421
+ run_id,
422
+ parent_run_id,
423
+ "model_call",
424
+ f"mc_{uuid.uuid4().hex}",
425
+ {"model_id": _model_id(serialized, kwargs), "input": payload},
426
+ )
427
+
428
+ def on_llm_end(
429
+ self,
430
+ response: Any,
431
+ *,
432
+ run_id: UUID,
433
+ parent_run_id: UUID | None = None,
434
+ **kwargs: Any,
435
+ ) -> None:
436
+ with safety.fail_open("langchain on_llm_end"):
437
+ output, input_tokens, output_tokens = _llm_usage(response)
438
+ self._close_span(
439
+ run_id, output=output, input_tokens=input_tokens, output_tokens=output_tokens
440
+ )
441
+ self._finish_root(run_id, final_output=output)
442
+
443
+ def on_llm_error(
444
+ self,
445
+ error: BaseException,
446
+ *,
447
+ run_id: UUID,
448
+ parent_run_id: UUID | None = None,
449
+ **kwargs: Any,
450
+ ) -> None:
451
+ with safety.fail_open("langchain on_llm_error"):
452
+ self._close_span(run_id, output="")
453
+ self._add_error_event(run_id, error)
454
+ self._finish_root(run_id)
455
+
456
+ def on_tool_start(
457
+ self,
458
+ serialized: Any,
459
+ input_str: Any,
460
+ *,
461
+ run_id: UUID,
462
+ parent_run_id: UUID | None = None,
463
+ inputs: Any = None,
464
+ **kwargs: Any,
465
+ ) -> None:
466
+ with safety.fail_open("langchain on_tool_start"):
467
+ arguments = _jsonable(inputs) if inputs is not None else {"input": _jsonable(input_str)}
468
+ session = self._ensure_session(run_id, parent_run_id, arguments)
469
+ if session is None:
470
+ return
471
+ self._start_span(
472
+ session,
473
+ run_id,
474
+ parent_run_id,
475
+ "tool",
476
+ api._new_call_id(),
477
+ {"name": _tool_name(serialized), "arguments": arguments},
478
+ is_tool=True,
479
+ )
480
+
481
+ def on_tool_end(
482
+ self,
483
+ output: Any,
484
+ *,
485
+ run_id: UUID,
486
+ parent_run_id: UUID | None = None,
487
+ **kwargs: Any,
488
+ ) -> None:
489
+ with safety.fail_open("langchain on_tool_end"):
490
+ self._close_span(run_id, result=_jsonable(output))
491
+ self._finish_root(run_id)
492
+
493
+ def on_tool_error(
494
+ self,
495
+ error: BaseException,
496
+ *,
497
+ run_id: UUID,
498
+ parent_run_id: UUID | None = None,
499
+ **kwargs: Any,
500
+ ) -> None:
501
+ with safety.fail_open("langchain on_tool_error"):
502
+ # The tool_result already carries the error; no separate error event needed.
503
+ self._close_span(run_id, result=None, error=str(error))
504
+ self._finish_root(run_id)
505
+
506
+ def on_retriever_start(
507
+ self,
508
+ serialized: Any,
509
+ query: Any,
510
+ *,
511
+ run_id: UUID,
512
+ parent_run_id: UUID | None = None,
513
+ **kwargs: Any,
514
+ ) -> None:
515
+ with safety.fail_open("langchain on_retriever_start"):
516
+ session = self._ensure_session(run_id, parent_run_id, _jsonable(query))
517
+ if session is None:
518
+ return
519
+ self._start_span(
520
+ session,
521
+ run_id,
522
+ parent_run_id,
523
+ "retrieval",
524
+ f"ret_{uuid.uuid4().hex}",
525
+ {"source": _retriever_source(serialized), "query": str(query)},
526
+ )
527
+
528
+ def on_retriever_end(
529
+ self,
530
+ documents: Any,
531
+ *,
532
+ run_id: UUID,
533
+ parent_run_id: UUID | None = None,
534
+ **kwargs: Any,
535
+ ) -> None:
536
+ with safety.fail_open("langchain on_retriever_end"):
537
+ self._close_span(run_id, documents=_documents_to_list(documents))
538
+ self._finish_root(run_id)
539
+
540
+ def on_retriever_error(
541
+ self,
542
+ error: BaseException,
543
+ *,
544
+ run_id: UUID,
545
+ parent_run_id: UUID | None = None,
546
+ **kwargs: Any,
547
+ ) -> None:
548
+ with safety.fail_open("langchain on_retriever_error"):
549
+ self._close_span(run_id)
550
+ self._add_error_event(run_id, error)
551
+ self._finish_root(run_id)
552
+
553
+
554
+ __all__ = ["EvalShiftCallbackHandler"]
@@ -0,0 +1,11 @@
1
+ """Capture-time machinery: the live recording structures.
2
+
3
+ Phase 1 ships only the span tree (``span.py``). The public ``capture`` decorator, contextvars
4
+ state, and helpers (``api.py`` / ``state.py``) land in Phase 2.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from evalshift.capture.span import Span, SpanTree
10
+
11
+ __all__ = ["Span", "SpanTree"]