|
9 | 9 | Public API: |
10 | 10 |
|
11 | 11 | * :func:`from_chat_messages` — a list of ``{role, content, tool_calls?}`` dicts. |
12 | | -* :func:`from_otel_spans` — a list of OpenTelemetry span dicts (best-effort, |
13 | | - reads ``gen_ai.*`` attributes). |
| 12 | +* :func:`from_otel_spans` — a list of OpenTelemetry / OpenInference span dicts |
| 13 | + (best-effort; reads ``gen_ai.*`` and OpenInference ``llm.*`` attributes — |
| 14 | + also covers Arize Phoenix exports). |
| 15 | +* :func:`from_langsmith_runs` — a LangSmith run (tree) or list of runs. |
14 | 16 | """ |
15 | 17 |
|
16 | 18 | from __future__ import annotations |
17 | 19 |
|
| 20 | +import json |
18 | 21 | from datetime import datetime, timedelta, timezone |
19 | | -from typing import Any |
| 22 | +from typing import Any, Literal |
20 | 23 |
|
21 | 24 | from vstack.aar import AgentTrace, TraceStep |
22 | 25 |
|
23 | | -__all__ = ["from_chat_messages", "from_otel_spans"] |
| 26 | +__all__ = ["from_chat_messages", "from_langsmith_runs", "from_otel_spans"] |
| 27 | + |
| 28 | +StepType = Literal["tool_call", "message", "decision", "observation", "thought"] |
24 | 29 |
|
25 | 30 | _BASE_TS = datetime(2026, 1, 1, tzinfo=timezone.utc) |
26 | 31 |
|
@@ -133,25 +138,37 @@ def from_otel_spans( |
133 | 138 | """Build an ``AgentTrace`` from OpenTelemetry spans (best-effort). |
134 | 139 |
|
135 | 140 | Spans are ordered by start time. Each span becomes a step: GenAI/LLM spans |
136 | | - (a ``gen_ai.*`` attribute or an ``llm``/``chat`` name) → ``tool_call``; |
137 | | - others → ``observation``. Reads ``attributes`` either as a dict or as a |
138 | | - list of ``{key, value}`` (OTLP-JSON form). |
| 141 | + (a ``gen_ai.*`` attribute, an OpenInference ``llm.*`` attribute or |
| 142 | + ``openinference.span.kind == LLM``, or an ``llm``/``chat`` name) → |
| 143 | + ``tool_call``; others → ``observation``. Reads ``attributes`` either as a |
| 144 | + dict or as a list of ``{key, value}`` (OTLP-JSON form). Also handles |
| 145 | + OpenInference spans (the format Arize Phoenix exports). |
139 | 146 | """ |
140 | 147 | ordered = sorted(spans, key=_span_start) |
141 | 148 | steps: list[TraceStep] = [] |
142 | 149 |
|
143 | 150 | for i, span in enumerate(ordered): |
144 | 151 | attrs = _otel_attrs(span) |
145 | 152 | name = str(span.get("name", "span")) |
146 | | - is_genai = name.lower().startswith(("llm", "chat", "gen_ai")) or any( |
147 | | - k.startswith("gen_ai") for k in attrs |
| 153 | + kind = str(attrs.get("openinference.span.kind", "")).upper() |
| 154 | + is_genai = ( |
| 155 | + name.lower().startswith(("llm", "chat", "gen_ai")) |
| 156 | + or kind in ("LLM", "CHAIN", "AGENT") |
| 157 | + or any(k.startswith(("gen_ai", "llm.")) for k in attrs) |
148 | 158 | ) |
149 | 159 | completion = ( |
150 | 160 | attrs.get("gen_ai.completion") |
151 | 161 | or attrs.get("gen_ai.response.content") |
152 | 162 | or attrs.get("llm.output") |
| 163 | + or attrs.get("llm.output_messages") |
| 164 | + or attrs.get("output.value") # OpenInference / Phoenix |
| 165 | + ) |
| 166 | + prompt = ( |
| 167 | + attrs.get("gen_ai.prompt") |
| 168 | + or attrs.get("llm.input") |
| 169 | + or attrs.get("llm.input_messages") |
| 170 | + or attrs.get("input.value") # OpenInference / Phoenix |
153 | 171 | ) |
154 | | - prompt = attrs.get("gen_ai.prompt") or attrs.get("llm.input") |
155 | 172 | detail = str(completion or prompt or "") |
156 | 173 | content = f"{name}: {detail}" if detail else name |
157 | 174 | steps.append( |
@@ -187,3 +204,95 @@ def _otel_attrs(span: dict[str, Any]) -> dict[str, Any]: |
187 | 204 | val = next(iter(val.values()), val) |
188 | 205 | out[str(item["key"])] = val |
189 | 206 | return out |
| 207 | + |
| 208 | + |
| 209 | +# LangSmith run_type → trace step type. |
| 210 | +_LANGSMITH_STEP_TYPE: dict[str, StepType] = { |
| 211 | + "llm": "message", |
| 212 | + "chat": "message", |
| 213 | + "tool": "tool_call", |
| 214 | + "retriever": "observation", |
| 215 | + "chain": "thought", |
| 216 | + "agent": "decision", |
| 217 | + "prompt": "thought", |
| 218 | + "parser": "observation", |
| 219 | +} |
| 220 | + |
| 221 | + |
| 222 | +def _short(obj: Any, limit: int = 300) -> str: |
| 223 | + """Compact one-line rendering of a run's inputs/outputs.""" |
| 224 | + if obj is None: |
| 225 | + return "" |
| 226 | + text = obj if isinstance(obj, str) else json.dumps(obj, default=str, ensure_ascii=False) |
| 227 | + text = " ".join(text.split()) |
| 228 | + return text if len(text) <= limit else text[: limit - 1] + "…" |
| 229 | + |
| 230 | + |
| 231 | +def _flatten_runs(runs: list[dict[str, Any]]) -> list[dict[str, Any]]: |
| 232 | + """Depth-first flatten of LangSmith run trees, then order by start time.""" |
| 233 | + out: list[dict[str, Any]] = [] |
| 234 | + |
| 235 | + def visit(run: dict[str, Any]) -> None: |
| 236 | + out.append(run) |
| 237 | + for child in run.get("child_runs") or []: |
| 238 | + if isinstance(child, dict): |
| 239 | + visit(child) |
| 240 | + |
| 241 | + for run in runs: |
| 242 | + visit(run) |
| 243 | + return sorted(out, key=lambda r: str(r.get("start_time") or "")) |
| 244 | + |
| 245 | + |
| 246 | +def _ls_content(run: dict[str, Any]) -> str: |
| 247 | + name = str(run.get("name", "run")) |
| 248 | + parts: list[str] = [] |
| 249 | + if run.get("inputs"): |
| 250 | + parts.append(f"in: {_short(run.get('inputs'))}") |
| 251 | + if run.get("outputs"): |
| 252 | + parts.append(f"out: {_short(run.get('outputs'))}") |
| 253 | + if run.get("error"): |
| 254 | + parts.append(f"ERROR: {_short(run.get('error'))}") |
| 255 | + body = " | ".join(parts) |
| 256 | + return f"{name} — {body}" if body else name |
| 257 | + |
| 258 | + |
| 259 | +def from_langsmith_runs( |
| 260 | + runs: list[dict[str, Any]] | dict[str, Any], |
| 261 | + *, |
| 262 | + goal: str = "", |
| 263 | + outcome: str = "", |
| 264 | + success: bool = False, |
| 265 | + agent_id: str | None = None, |
| 266 | + agent_framework: str = "langsmith", |
| 267 | + metadata: dict[str, Any] | None = None, |
| 268 | +) -> AgentTrace: |
| 269 | + """Build an ``AgentTrace`` from LangSmith runs. |
| 270 | +
|
| 271 | + Accepts a single run (with nested ``child_runs`` — a run tree) or a flat |
| 272 | + list of runs. ``run_type`` maps to the step type (``llm``→message, |
| 273 | + ``tool``→tool_call, ``chain``→thought, ``retriever``→observation, …); |
| 274 | + ``inputs``/``outputs``/``error`` become the step content. ``goal`` defaults |
| 275 | + to the root run's inputs and ``outcome`` to its outputs. |
| 276 | + """ |
| 277 | + run_list = [runs] if isinstance(runs, dict) else list(runs) |
| 278 | + flat = _flatten_runs(run_list) |
| 279 | + steps = [ |
| 280 | + TraceStep( |
| 281 | + timestamp=_ts(i), |
| 282 | + type=_LANGSMITH_STEP_TYPE.get(str(run.get("run_type", "")).lower(), "observation"), |
| 283 | + content=_ls_content(run), |
| 284 | + metadata={"run_type": str(run.get("run_type", "")), "name": str(run.get("name", ""))}, |
| 285 | + ) |
| 286 | + for i, run in enumerate(flat) |
| 287 | + ] |
| 288 | + |
| 289 | + root = run_list[0] if run_list else {} |
| 290 | + return AgentTrace( |
| 291 | + agent_id=agent_id, |
| 292 | + agent_framework=agent_framework, |
| 293 | + goal=goal or _short(root.get("inputs")) or "(goal not provided)", |
| 294 | + steps=steps, |
| 295 | + outcome=outcome or _short(root.get("outputs")) or "(outcome not provided)", |
| 296 | + success=success, |
| 297 | + metadata=metadata or {}, |
| 298 | + ) |
0 commit comments