Skip to content

Commit 21a0e3c

Browse files
Add agent-trace format support
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0f88bd5 commit 21a0e3c

8 files changed

Lines changed: 653 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
- `normalize`
1313
- `diagnose`
1414
- Cursor and Claude Code transcript JSONL ingestion.
15+
- `pagarsky/agent-trace` sample ingestion via `--format agent-trace`.
1516
- Agent-readable diagnosis reports with input scope, confidence, limitations, issue codes, and recommendations.
1617
- Context diff evidence:
1718
- turn-to-turn added/removed/retained token summary

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,12 @@ npx langfuse-cli api traces get <trace-id> --fields core,io,observations --json
9797
| context-profiler diagnose - --format langfuse --json
9898
```
9999

100+
Analyze a public academic agent trajectory:
101+
102+
```bash
103+
context-profiler diagnose examples/agent-trace/sample.json --format agent-trace --json
104+
context-profiler analyze examples/agent-trace/sample.json --format agent-trace --html report.html
105+
```
100106

101107
Generate an interactive report:
102108

@@ -199,6 +205,16 @@ The HTML report is self-contained and keeps the existing profiler style:
199205
}
200206
```
201207

208+
Academic trajectory sample:
209+
210+
```text
211+
context-profiler analyze examples/agent-trace/sample.json --format agent-trace --html report.html
212+
213+
Total Input: 11.7K
214+
Messages (assistant): 10.7K
215+
Tool: python_interpreter 2.9K
216+
Warnings: Content duplication 2.3K redundant tokens
217+
```
202218

203219
## Examples
204220

examples/README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,28 @@ context-profiler analyze trace.json --format langfuse --html /tmp/context-profil
7575

7676
`context-profiler` does not fetch Langfuse data. Use Langfuse's own CLI/API or any other tool to obtain the trace first.
7777

78+
## Academic AgentTrace
79+
80+
This repository includes a small sample from [`pagarsky/agent-trace`](https://huggingface.co/datasets/pagarsky/agent-trace):
81+
82+
```bash
83+
PYTHONPATH=src uv run context-profiler diagnose examples/agent-trace/sample.json --format agent-trace --json
84+
PYTHONPATH=src uv run context-profiler analyze examples/agent-trace/sample.json --format agent-trace --html /tmp/context-profiler-agent-trace-demo.html
85+
```
86+
87+
The sample is a multi-step MBPP trajectory with:
88+
89+
- 11 LLM steps
90+
- 10 `python_interpreter` tool spans
91+
- repeated code passed into tool calls
92+
- turn-to-turn growth suitable for recording the timeline
93+
94+
Expected findings:
95+
96+
- `REPEATED_CONTENT_BLOCK`
97+
- `REPEATED_TOOL_INPUT` on `python_interpreter.code`
98+
- `large_addition` diff hints
99+
78100
## Adapting Other Formats
79101

80102
Agents should prefer the built-in format registry and schema:

examples/agent-trace/sample.json

Lines changed: 396 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
"""Adapter for pagarsky/agent-trace style academic trajectories."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from pathlib import Path
7+
from typing import Any
8+
9+
from context_profiler.models import APIRequest, BlockType, ContentBlock, Message, Role, Session
10+
from context_profiler.token_utils import count_tokens
11+
12+
13+
def is_agent_trace(data: dict[str, Any]) -> bool:
14+
return (
15+
("llm_steps" in data or "llm_steps_json" in data)
16+
and ("spans" in data or "spans_json" in data)
17+
)
18+
19+
20+
def load_agent_trace_session(path: Path) -> Session:
21+
with open(path, encoding="utf-8") as f:
22+
data = json.load(f)
23+
return parse_agent_trace(data, source=str(path))
24+
25+
26+
def parse_agent_trace(data: dict[str, Any], source: str = "") -> Session:
27+
llm_steps = _json_field(data, "llm_steps")
28+
spans = _json_field(data, "spans")
29+
metadata = _json_field(data, "metadata") if ("metadata" in data or "metadata_json" in data) else {}
30+
31+
messages: list[Message] = []
32+
requests: list[APIRequest] = []
33+
34+
prompt = data.get("prompt")
35+
if prompt:
36+
messages.append(Message(
37+
role=Role.USER,
38+
blocks=[_text_block(prompt)],
39+
index=len(messages),
40+
))
41+
42+
span_idx = 0
43+
for step in llm_steps:
44+
assistant_blocks = _assistant_blocks(step)
45+
if assistant_blocks:
46+
messages.append(Message(role=Role.ASSISTANT, blocks=assistant_blocks, index=len(messages)))
47+
48+
tool_calls = step.get("tool_calls") or []
49+
for call in tool_calls:
50+
span = spans[span_idx] if span_idx < len(spans) else None
51+
if span is not None:
52+
span_idx += 1
53+
messages.append(_tool_result_message(span, len(messages)))
54+
55+
requests.append(_snapshot(messages, len(requests), data))
56+
57+
return Session(
58+
requests=requests,
59+
metadata={
60+
"source": source,
61+
"source_format": "agent-trace",
62+
"trace_id": data.get("trace_id"),
63+
"dataset_name": data.get("dataset_name") or metadata.get("dataset_name"),
64+
"task_id": data.get("task_id") or metadata.get("task_id"),
65+
"model": data.get("model") or metadata.get("model_family"),
66+
"llm_step_count": len(llm_steps),
67+
"tool_span_count": len(spans),
68+
},
69+
)
70+
71+
72+
def _json_field(data: dict[str, Any], name: str) -> Any:
73+
if name in data:
74+
return data[name]
75+
raw = data.get(f"{name}_json")
76+
if raw is None:
77+
return [] if name in {"llm_steps", "spans"} else {}
78+
return json.loads(raw)
79+
80+
81+
def _assistant_blocks(step: dict[str, Any]) -> list[ContentBlock]:
82+
blocks = []
83+
reasoning = step.get("reasoning_content")
84+
model_output = step.get("model_output")
85+
text_parts = [part for part in (reasoning, model_output) if part]
86+
if text_parts:
87+
blocks.append(_text_block("\n\n".join(text_parts)))
88+
89+
for idx, call in enumerate(step.get("tool_calls") or []):
90+
name = call.get("name") or "unknown"
91+
arguments = call.get("arguments") or {}
92+
text = json.dumps(arguments, ensure_ascii=False)
93+
blocks.append(ContentBlock(
94+
block_type=BlockType.TOOL_USE,
95+
text=text,
96+
token_count=count_tokens(text),
97+
tool_name=name,
98+
tool_call_id=f"{step.get('step_id', 'step')}:tool:{idx}",
99+
tool_input=arguments if isinstance(arguments, dict) else {"value": arguments},
100+
))
101+
return blocks
102+
103+
104+
def _tool_result_message(span: dict[str, Any], index: int) -> Message:
105+
output = span.get("tool_output")
106+
if not isinstance(output, str):
107+
output = json.dumps(output, ensure_ascii=False)
108+
block = ContentBlock(
109+
block_type=BlockType.TOOL_RESULT,
110+
text=output or "",
111+
token_count=count_tokens(output or ""),
112+
tool_name=span.get("tool_name"),
113+
tool_call_id=span.get("span_id"),
114+
)
115+
return Message(role=Role.TOOL, blocks=[block], index=index)
116+
117+
118+
def _text_block(text: str) -> ContentBlock:
119+
return ContentBlock(
120+
block_type=BlockType.TEXT,
121+
text=text,
122+
token_count=count_tokens(text),
123+
)
124+
125+
126+
def _snapshot(messages: list[Message], request_index: int, data: dict[str, Any]) -> APIRequest:
127+
return APIRequest(
128+
messages=list(messages),
129+
tools=[],
130+
model=data.get("model", "unknown"),
131+
request_index=request_index,
132+
source_format="agent-trace",
133+
raw_input={
134+
"model": data.get("model", "unknown"),
135+
"messages": [_message_to_raw(message) for message in messages],
136+
"tools": [],
137+
"source_format": "agent-trace",
138+
},
139+
)
140+
141+
142+
def _message_to_raw(message: Message) -> dict[str, Any]:
143+
content = []
144+
tool_calls = []
145+
for block in message.blocks:
146+
if block.block_type == BlockType.TEXT:
147+
content.append({"type": "text", "text": block.text})
148+
elif block.block_type == BlockType.TOOL_USE:
149+
tool_calls.append({
150+
"id": block.tool_call_id,
151+
"type": "function",
152+
"function": {
153+
"name": block.tool_name or "unknown",
154+
"arguments": block.text,
155+
},
156+
})
157+
elif block.block_type == BlockType.TOOL_RESULT:
158+
content.append({
159+
"type": "tool_result",
160+
"tool_use_id": block.tool_call_id,
161+
"content": block.text,
162+
})
163+
164+
raw: dict[str, Any] = {"role": message.role.value, "content": content}
165+
if len(content) == 1 and content[0].get("type") == "text":
166+
raw["content"] = content[0]["text"]
167+
if tool_calls:
168+
raw["tool_calls"] = tool_calls
169+
return raw

src/context_profiler/profiler.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from typing import Any
99

1010
from context_profiler.adapters.auto_detect import detect_adapter
11+
from context_profiler.adapters.agent_trace_adapter import is_agent_trace, load_agent_trace_session
1112
from context_profiler.adapters.langfuse_adapter import is_langfuse_trace, parse_langfuse_trace
1213
from context_profiler.adapters.transcript_adapter import (
1314
TRANSCRIPT_FORMATS,
@@ -95,6 +96,20 @@ def try_load_langfuse(path: Path) -> Session | None:
9596
return None
9697

9798

99+
def try_load_agent_trace(path: Path) -> Session | None:
100+
"""Try to load as an AgentTrace trajectory file."""
101+
if not path.is_file() or path.suffix not in (".json",):
102+
return None
103+
try:
104+
with open(path) as f:
105+
data = json.load(f)
106+
if is_agent_trace(data):
107+
return load_agent_trace_session(path)
108+
except (json.JSONDecodeError, KeyError):
109+
pass
110+
return None
111+
112+
98113
def load_multi_trace_session(paths: list[Path], format_hint: str | None = None) -> Session:
99114
"""Load multiple Langfuse trace files and merge into a single Session.
100115
@@ -144,13 +159,20 @@ def load_session(path: Path, format_hint: str | None = None) -> Session:
144159
if format_hint == "langfuse":
145160
return load_langfuse_trace(path)
146161

162+
if format_hint == "agent-trace":
163+
return load_agent_trace_session(path)
164+
147165
if format_hint in TRANSCRIPT_FORMATS:
148166
return load_transcript_session(path, source_format=format_hint)
149167

150168
langfuse_session = try_load_langfuse(path)
151169
if langfuse_session is not None:
152170
return langfuse_session
153171

172+
agent_trace_session = try_load_agent_trace(path)
173+
if agent_trace_session is not None:
174+
return agent_trace_session
175+
154176
requests: list[APIRequest] = []
155177

156178
if path.is_dir():

src/context_profiler/validation.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Any
88

99
from context_profiler.adapters.auto_detect import detect_adapter
10+
from context_profiler.adapters.agent_trace_adapter import is_agent_trace
1011
from context_profiler.adapters.langfuse_adapter import is_langfuse_trace, parse_langfuse_trace
1112
from context_profiler.adapters.transcript_adapter import TRANSCRIPT_FORMATS
1213
from context_profiler.models import APIRequest, Session
@@ -58,6 +59,8 @@ def validate_input(path: str, format_hint: str | None = None) -> dict[str, Any]:
5859
if not session.requests:
5960
return _invalid_langfuse_result(session)
6061
return _valid_result("langfuse")
62+
if isinstance(data, dict) and is_agent_trace(data):
63+
return _valid_result("agent-trace")
6164
if isinstance(data, dict):
6265
adapter = detect_adapter(data) if format_hint is None else None
6366
return _valid_result(format_hint or _adapter_format_name(adapter))
@@ -142,6 +145,8 @@ def normalize_input(path: str, format_hint: str | None = None) -> dict[str, Any]
142145

143146
if isinstance(data, dict) and is_langfuse_trace(data):
144147
session = parse_langfuse_trace(data)
148+
elif isinstance(data, dict) and is_agent_trace(data):
149+
session = load_session(Path(path), format_hint="agent-trace")
145150
elif path != "-":
146151
session = load_session(Path(path), format_hint=format_hint)
147152
elif isinstance(data, dict):

tests/test_smoke.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,3 +286,25 @@ def test_skill_distribution_manifests():
286286
marketplace_data = json.loads(claude_marketplace.read_text())
287287
assert marketplace_data["plugins"][0]["name"] == "context-profiler"
288288
assert marketplace_data["plugins"][0]["skills"] == ["./skills/analyze-agent-context"]
289+
290+
291+
def test_diagnose_agent_trace_sample_json():
292+
sample = Path(__file__).parents[1] / "examples" / "agent-trace" / "sample.json"
293+
runner = CliRunner()
294+
result = runner.invoke(main, ["diagnose", str(sample), "--format", "agent-trace", "--json"])
295+
assert result.exit_code == 0
296+
data = json.loads(result.output)
297+
assert data["analysis_scope"]["format"] == "agent-trace"
298+
assert data["mode"] == "session"
299+
assert data["diff_summary"]["transition_count"] > 0
300+
assert data["diff_hints"]
301+
302+
303+
def test_analyze_agent_trace_sample_html(tmp_path):
304+
sample = Path(__file__).parents[1] / "examples" / "agent-trace" / "sample.json"
305+
out = tmp_path / "agent-trace-report.html"
306+
runner = CliRunner()
307+
result = runner.invoke(main, ["analyze", str(sample), "--format", "agent-trace", "--html", str(out)])
308+
assert result.exit_code == 0
309+
assert out.exists()
310+
assert "<html" in out.read_text().lower()

0 commit comments

Comments
 (0)