Skip to content

Commit 0f88bd5

Browse files
Improve Langfuse trace handoff
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent a3b665e commit 0f88bd5

9 files changed

Lines changed: 329 additions & 97 deletions

File tree

README.md

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,16 +46,24 @@ Related work:
4646

4747
## Install
4848

49+
For agent/CLI use, prefer an isolated executable install:
50+
4951
```bash
50-
pip install context-profiler
52+
pipx install context-profiler
53+
# or
54+
uv tool install context-profiler
55+
context-profiler --version
56+
which -a context-profiler # ensure a stale executable is not shadowing pipx/uv
5157
```
5258

5359
Or install from source:
5460

5561
```bash
5662
git clone https://github.com/Turdot/context-profiler.git
5763
cd context-profiler
58-
pip install -e .
64+
uv tool install -e .
65+
# for local development in this repo:
66+
PYTHONPATH=src uv run context-profiler --version
5967
```
6068

6169
## Quick Start
@@ -82,6 +90,14 @@ context-profiler diagnose trace.json --format langfuse --json
8290
context-profiler analyze trace.json --format langfuse --html report.html
8391
```
8492

93+
Analyze a Langfuse trace fetched by Langfuse CLI:
94+
95+
```bash
96+
npx langfuse-cli api traces get <trace-id> --fields core,io,observations --json \
97+
| context-profiler diagnose - --format langfuse --json
98+
```
99+
100+
85101
Generate an interactive report:
86102

87103
```bash
@@ -105,8 +121,10 @@ context-profiler schema diagnosis --json
105121
context-profiler validate trace.json --format auto --json
106122
context-profiler normalize trace.json --from auto --json
107123

108-
# Diagnose for agent consumption
124+
# Diagnose for agent consumption; '-' reads JSON/JSONL from stdin
109125
context-profiler diagnose trace.json --format auto --json
126+
npx langfuse-cli api traces get <trace-id> --fields core,io,observations --json \
127+
| context-profiler diagnose - --format langfuse --json
110128
```
111129

112130
If validation fails, the JSON response includes `errors[].agent_action` and `next_steps` so the agent can convert the trace into `ContextTrace`.
@@ -115,7 +133,7 @@ If validation fails, the JSON response includes `errors[].agent_action` and `nex
115133

116134
This repository ships an `analyze-agent-context` skill for Cursor, Claude Code, and other Agent Skills / Open Plugins compatible tools.
117135

118-
The skill does not fetch traces. It teaches agents to use `context-profiler` whenever the user asks to analyze a trace, loop, transcript, agent run, context growth, stale context, or tool bloat.
136+
The skill does not make `context-profiler` fetch traces itself. It teaches agents to use Langfuse tooling to fetch Langfuse trace ids, then route the fetched JSON into `context-profiler` for diagnosis whenever the user asks to analyze a trace, loop, transcript, agent run, context growth, stale context, or tool bloat.
119137

120138
Canonical skill:
121139

@@ -181,6 +199,7 @@ The HTML report is self-contained and keeps the existing profiler style:
181199
}
182200
```
183201

202+
184203
## Examples
185204

186205
See [`examples/README.md`](examples/README.md) for runnable fixtures and conversion patterns.

skills/analyze-agent-context/SKILL.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,29 @@ Use `context-profiler` as the trace-source agnostic context analysis harness.
99

1010
## Core Rule
1111

12-
Do not fetch traces unless the user asks you to. If another tool already fetched trace data, use that local file or recent JSON output. The source can be Langfuse CLI, Claude Code, Cursor, OpenTelemetry, raw OpenAI/Anthropic requests, or an academic trajectory dataset.
12+
Do not fetch traces unless the user asks you to. If the user provides a Langfuse trace id and asks to inspect, debug, or analyze it, use the Langfuse skill/CLI to fetch that trace, then hand the fetched JSON to `context-profiler`. Do not manually summarize the raw trace before running `context-profiler`.
13+
14+
If another tool already fetched trace data, use that local file or recent JSON output. The source can be Langfuse CLI, Claude Code, Cursor, OpenTelemetry, raw OpenAI/Anthropic requests, or an academic trajectory dataset.
15+
16+
Before analysis, verify the CLI is callable:
17+
18+
```bash
19+
context-profiler --version
20+
```
21+
22+
If the command is missing or its entry point is broken, ask the user to install it with `pipx install context-profiler` or `uv tool install context-profiler`.
23+
If `which -a context-profiler` shows a stale broken executable before the `pipx`/`uv tool` executable, use the working executable path or fix `PATH` before continuing.
1324

1425
## Workflow
1526

1627
1. Identify the available trace or loop data:
1728
- File path supplied by user.
1829
- Recent JSON output from another CLI, such as `langfuse-cli`.
30+
- Langfuse trace id supplied by user. Fetch it with Langfuse tooling first:
31+
```bash
32+
npx langfuse-cli api traces get <trace-id> --fields core,io,observations --json \
33+
| context-profiler diagnose - --format langfuse --json
34+
```
1935
- Current Cursor transcript under `~/.cursor/projects/**/agent-transcripts/**/*.jsonl`.
2036
- Current Claude Code transcript under `~/.claude/projects/**/*.jsonl`.
2137

src/context_profiler/adapters/langfuse_adapter.py

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
"""Adapter for Langfuse trace export format.
22
3-
Parses a Langfuse trace JSON (exported from UI or API) and extracts all
3+
Parses a Langfuse trace JSON (exported from UI or API) and extracts analyzable
44
GENERATION observations as a Session of APIRequests.
5-
6-
Each GENERATION observation contains an 'input' dict with {tools, messages}
7-
in OpenAI format, which we delegate to the OpenAI adapter for parsing.
85
"""
96

107
from __future__ import annotations
@@ -29,39 +26,91 @@ def is_langfuse_trace(data: dict[str, Any]) -> bool:
2926
)
3027

3128

29+
def _json_text(value: Any) -> str:
30+
if isinstance(value, str):
31+
return value
32+
return json.dumps(value, ensure_ascii=False)
33+
34+
35+
def _normalize_generation_input(inp: Any) -> dict[str, Any] | None:
36+
"""Convert common Langfuse generation input shapes to OpenAI messages."""
37+
if isinstance(inp, dict):
38+
messages = inp.get("messages")
39+
if isinstance(messages, list) and messages:
40+
return dict(inp)
41+
42+
role = inp.get("role")
43+
content = inp.get("content")
44+
if isinstance(role, str) and role and content is not None:
45+
normalized = {
46+
"messages": [
47+
{
48+
"role": role,
49+
"content": _json_text(content),
50+
}
51+
]
52+
}
53+
if isinstance(inp.get("tools"), list):
54+
normalized["tools"] = inp["tools"]
55+
if isinstance(inp.get("model"), str):
56+
normalized["model"] = inp["model"]
57+
return normalized
58+
59+
if isinstance(inp, str) and inp:
60+
return {"messages": [{"role": "user", "content": inp}]}
61+
62+
return None
63+
64+
3265
def parse_langfuse_trace(data: dict[str, Any]) -> Session:
3366
"""Extract all GENERATION observations and build a Session.
3467
3568
Returns a Session with one APIRequest per GENERATION, sorted by startTime.
3669
"""
3770
observations = data.get("observations", [])
3871

39-
generations = [
72+
generation_observations = [
4073
obs for obs in observations
4174
if obs.get("type") == "GENERATION"
42-
and isinstance(obs.get("input"), dict)
43-
and "messages" in obs["input"]
4475
]
45-
46-
generations.sort(key=lambda x: x.get("startTime", ""))
76+
generation_observations.sort(key=lambda x: x.get("startTime", ""))
4777

4878
requests: list[APIRequest] = []
49-
for idx, gen in enumerate(generations):
50-
inp = gen["input"]
51-
req = _openai.parse(inp)
52-
req.request_index = idx
53-
req.model = gen.get("model", req.model)
79+
unsupported_generation_count = 0
80+
for gen in generation_observations:
81+
normalized_input = _normalize_generation_input(gen.get("input"))
82+
if normalized_input is None:
83+
unsupported_generation_count += 1
84+
continue
85+
86+
req = _openai.parse(normalized_input)
87+
req.request_index = len(requests)
88+
req.source_format = "langfuse"
89+
req.model = gen.get("model") or req.model
5490
requests.append(req)
5591

92+
warnings: list[str] = []
93+
if unsupported_generation_count:
94+
warnings.append(
95+
f"Skipped {unsupported_generation_count} Langfuse GENERATION observations without analyzable input messages."
96+
)
97+
if generation_observations and not requests:
98+
warnings.append("Langfuse trace contains GENERATION observations, but none include analyzable input messages.")
99+
if not generation_observations:
100+
warnings.append("Langfuse trace contains no GENERATION observations.")
101+
56102
metadata = {
57103
"trace_id": data.get("id"),
58104
"trace_name": data.get("name"),
59105
"project_id": data.get("projectId"),
60106
"session_id": data.get("sessionId"),
61107
"timestamp": data.get("timestamp"),
62-
"total_generations": len(generations),
108+
"total_generations": len(requests),
109+
"total_generation_observations": len(generation_observations),
110+
"unsupported_generation_observations": unsupported_generation_count,
63111
"total_observations": len(observations),
64112
"source_format": "langfuse",
113+
"warnings": warnings,
65114
}
66115

67116
return Session(requests=requests, metadata=metadata)

0 commit comments

Comments
 (0)