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 +46 -0
- evalshift/adapters/langchain.py +554 -0
- evalshift/capture/__init__.py +11 -0
- evalshift/capture/api.py +660 -0
- evalshift/capture/span.py +117 -0
- evalshift/capture/state.py +60 -0
- evalshift/config.py +221 -0
- evalshift/hygiene/__init__.py +13 -0
- evalshift/hygiene/dedup.py +42 -0
- evalshift/hygiene/gc.py +72 -0
- evalshift/hygiene/sample.py +32 -0
- evalshift/py.typed +0 -0
- evalshift/redaction/__init__.py +12 -0
- evalshift/redaction/base.py +51 -0
- evalshift/redaction/defaults.py +45 -0
- evalshift/safety.py +49 -0
- evalshift/sinks/__init__.py +9 -0
- evalshift/sinks/base.py +27 -0
- evalshift/sinks/file.py +68 -0
- evalshift/sinks/hygiene.py +78 -0
- evalshift/sinks/memory.py +45 -0
- evalshift/trace/__init__.py +90 -0
- evalshift/trace/migrate.py +456 -0
- evalshift/trace/models.py +189 -0
- evalshift/trace/schema.py +109 -0
- evalshift/trace/serialize.py +328 -0
- evalshift_sdk-0.2.0.dist-info/METADATA +92 -0
- evalshift_sdk-0.2.0.dist-info/RECORD +30 -0
- evalshift_sdk-0.2.0.dist-info/WHEEL +4 -0
- evalshift_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
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"]
|