pyagent-trace 0.1.0__tar.gz
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.
- pyagent_trace-0.1.0/.gitignore +18 -0
- pyagent_trace-0.1.0/PKG-INFO +62 -0
- pyagent_trace-0.1.0/README.md +35 -0
- pyagent_trace-0.1.0/pyproject.toml +34 -0
- pyagent_trace-0.1.0/src/pyagent_trace/__init__.py +28 -0
- pyagent_trace-0.1.0/src/pyagent_trace/attributes.py +46 -0
- pyagent_trace-0.1.0/src/pyagent_trace/cost.py +90 -0
- pyagent_trace-0.1.0/src/pyagent_trace/decorators.py +87 -0
- pyagent_trace-0.1.0/src/pyagent_trace/py.typed +0 -0
- pyagent_trace-0.1.0/src/pyagent_trace/recorder.py +111 -0
- pyagent_trace-0.1.0/src/pyagent_trace/spans.py +115 -0
- pyagent_trace-0.1.0/tests/__init__.py +0 -0
- pyagent_trace-0.1.0/tests/test_trace.py +181 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyagent-trace
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pattern-aware OpenTelemetry tracing for multi-agent LLM systems
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: LLM,OpenTelemetry,agents,observability,tracing
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Requires-Dist: opentelemetry-api>=1.25
|
|
17
|
+
Requires-Dist: opentelemetry-sdk>=1.25
|
|
18
|
+
Requires-Dist: pyagent-patterns>=0.1.0
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
24
|
+
Provides-Extra: langfuse
|
|
25
|
+
Requires-Dist: langfuse>=2.0; extra == 'langfuse'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# pyagent-trace
|
|
29
|
+
|
|
30
|
+
**Pattern-aware OpenTelemetry tracing** for multi-agent LLM systems. Track costs, record interactions, debug with replay.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install pyagent-trace # Core (CostTracker, Recorder work without OTel)
|
|
36
|
+
pip install pyagent-trace[otel] # With OpenTelemetry spans
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Components
|
|
40
|
+
|
|
41
|
+
- **CostTracker** — Accumulate costs by pattern, agent, and model (no OTel required)
|
|
42
|
+
- **Recorder** — Record all messages + LLM responses to JSONL for replay/debug
|
|
43
|
+
- **PatternSpanEmitter** — Create OTel spans for pattern executions (requires OTel)
|
|
44
|
+
- **traced_pattern / traced_agent** — Decorators for automatic tracing (requires OTel)
|
|
45
|
+
- **PyAgentAttributes** — Custom `pyagent.*` attribute constants
|
|
46
|
+
|
|
47
|
+
## Quick Example (no OTel required)
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from pyagent_trace import CostTracker, Recorder
|
|
51
|
+
|
|
52
|
+
tracker = CostTracker()
|
|
53
|
+
tracker.record("debate", "bull_agent", "gpt-4o", 500, 200, 0.003)
|
|
54
|
+
print(f"Total: ${tracker.total_cost:.4f}")
|
|
55
|
+
print(f"By model: {tracker.by_model()}")
|
|
56
|
+
|
|
57
|
+
recorder = Recorder()
|
|
58
|
+
recorder.start("debate")
|
|
59
|
+
recorder.record_llm_call("bull", messages, "Bull case: ...")
|
|
60
|
+
recorder.end("Final decision")
|
|
61
|
+
recorder.save("traces/debug.jsonl")
|
|
62
|
+
```
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# pyagent-trace
|
|
2
|
+
|
|
3
|
+
**Pattern-aware OpenTelemetry tracing** for multi-agent LLM systems. Track costs, record interactions, debug with replay.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install pyagent-trace # Core (CostTracker, Recorder work without OTel)
|
|
9
|
+
pip install pyagent-trace[otel] # With OpenTelemetry spans
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Components
|
|
13
|
+
|
|
14
|
+
- **CostTracker** — Accumulate costs by pattern, agent, and model (no OTel required)
|
|
15
|
+
- **Recorder** — Record all messages + LLM responses to JSONL for replay/debug
|
|
16
|
+
- **PatternSpanEmitter** — Create OTel spans for pattern executions (requires OTel)
|
|
17
|
+
- **traced_pattern / traced_agent** — Decorators for automatic tracing (requires OTel)
|
|
18
|
+
- **PyAgentAttributes** — Custom `pyagent.*` attribute constants
|
|
19
|
+
|
|
20
|
+
## Quick Example (no OTel required)
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from pyagent_trace import CostTracker, Recorder
|
|
24
|
+
|
|
25
|
+
tracker = CostTracker()
|
|
26
|
+
tracker.record("debate", "bull_agent", "gpt-4o", 500, 200, 0.003)
|
|
27
|
+
print(f"Total: ${tracker.total_cost:.4f}")
|
|
28
|
+
print(f"By model: {tracker.by_model()}")
|
|
29
|
+
|
|
30
|
+
recorder = Recorder()
|
|
31
|
+
recorder.start("debate")
|
|
32
|
+
recorder.record_llm_call("bull", messages, "Bull case: ...")
|
|
33
|
+
recorder.end("Final decision")
|
|
34
|
+
recorder.save("traces/debug.jsonl")
|
|
35
|
+
```
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyagent-trace"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Pattern-aware OpenTelemetry tracing for multi-agent LLM systems"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
keywords = ["agents", "tracing", "OpenTelemetry", "observability", "LLM"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3.11",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Programming Language :: Python :: 3.13",
|
|
20
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"pyagent-patterns>=0.1.0",
|
|
25
|
+
"opentelemetry-api>=1.25",
|
|
26
|
+
"opentelemetry-sdk>=1.25",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.optional-dependencies]
|
|
30
|
+
langfuse = ["langfuse>=2.0"]
|
|
31
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "ruff>=0.5", "mypy>=1.10"]
|
|
32
|
+
|
|
33
|
+
[tool.hatch.build.targets.wheel]
|
|
34
|
+
packages = ["src/pyagent_trace"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""PyAgent Trace — pattern-aware OpenTelemetry tracing for multi-agent LLM systems."""
|
|
2
|
+
|
|
3
|
+
from pyagent_trace.attributes import PyAgentAttributes
|
|
4
|
+
from pyagent_trace.cost import CostTracker
|
|
5
|
+
from pyagent_trace.recorder import Recorder
|
|
6
|
+
|
|
7
|
+
# OTel-dependent imports are lazy to avoid hard dependency
|
|
8
|
+
def __getattr__(name: str):
|
|
9
|
+
if name == "traced_agent":
|
|
10
|
+
from pyagent_trace.decorators import traced_agent
|
|
11
|
+
return traced_agent
|
|
12
|
+
if name == "traced_pattern":
|
|
13
|
+
from pyagent_trace.decorators import traced_pattern
|
|
14
|
+
return traced_pattern
|
|
15
|
+
if name == "PatternSpanEmitter":
|
|
16
|
+
from pyagent_trace.spans import PatternSpanEmitter
|
|
17
|
+
return PatternSpanEmitter
|
|
18
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"traced_pattern",
|
|
22
|
+
"traced_agent",
|
|
23
|
+
"PatternSpanEmitter",
|
|
24
|
+
"PyAgentAttributes",
|
|
25
|
+
"CostTracker",
|
|
26
|
+
"Recorder",
|
|
27
|
+
]
|
|
28
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Custom OTel attributes for pyagent spans.
|
|
2
|
+
|
|
3
|
+
All attributes are namespaced under `pyagent.*` to avoid collisions.
|
|
4
|
+
Follows OpenTelemetry GenAI semantic conventions where applicable.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PyAgentAttributes:
|
|
11
|
+
"""Attribute key constants for pyagent OTel spans."""
|
|
12
|
+
|
|
13
|
+
# Pattern attributes
|
|
14
|
+
PATTERN_TYPE = "pyagent.pattern.type"
|
|
15
|
+
PATTERN_ROUNDS = "pyagent.pattern.rounds"
|
|
16
|
+
PATTERN_CONSENSUS = "pyagent.pattern.consensus"
|
|
17
|
+
PATTERN_ESCALATED = "pyagent.pattern.escalated"
|
|
18
|
+
PATTERN_ESCALATION_LEVEL = "pyagent.pattern.escalation_level"
|
|
19
|
+
|
|
20
|
+
# Router attributes
|
|
21
|
+
ROUTER_DIFFICULTY = "pyagent.router.difficulty"
|
|
22
|
+
ROUTER_DIFFICULTY_CATEGORY = "pyagent.router.difficulty_category"
|
|
23
|
+
ROUTER_SELECTED_MODEL = "pyagent.router.selected_model"
|
|
24
|
+
ROUTER_COST_ESTIMATE = "pyagent.router.cost_estimate"
|
|
25
|
+
ROUTER_ALTERNATIVES = "pyagent.router.alternatives"
|
|
26
|
+
|
|
27
|
+
# Compression attributes
|
|
28
|
+
COMPRESS_INPUT_TOKENS = "pyagent.compress.input_tokens"
|
|
29
|
+
COMPRESS_OUTPUT_TOKENS = "pyagent.compress.output_tokens"
|
|
30
|
+
COMPRESS_SAVINGS_PCT = "pyagent.compress.savings_pct"
|
|
31
|
+
|
|
32
|
+
# Agent attributes
|
|
33
|
+
AGENT_NAME = "pyagent.agent.name"
|
|
34
|
+
AGENT_ROLE = "pyagent.agent.role"
|
|
35
|
+
AGENT_CONTRIBUTION = "pyagent.agent.contribution"
|
|
36
|
+
|
|
37
|
+
# Cost attributes
|
|
38
|
+
COST_TOTAL_USD = "pyagent.cost.total_usd"
|
|
39
|
+
COST_INPUT_TOKENS = "pyagent.cost.input_tokens"
|
|
40
|
+
COST_OUTPUT_TOKENS = "pyagent.cost.output_tokens"
|
|
41
|
+
COST_MODEL = "pyagent.cost.model"
|
|
42
|
+
|
|
43
|
+
# Execution attributes
|
|
44
|
+
EXEC_DURATION_MS = "pyagent.exec.duration_ms"
|
|
45
|
+
EXEC_TOKEN_ESTIMATE = "pyagent.exec.token_estimate"
|
|
46
|
+
EXEC_LLM_CALLS = "pyagent.exec.llm_calls"
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""CostTracker: accumulate and attribute costs by pattern, agent, and model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class CostEntry:
|
|
10
|
+
"""A single cost record."""
|
|
11
|
+
|
|
12
|
+
pattern_type: str
|
|
13
|
+
agent_name: str
|
|
14
|
+
model: str
|
|
15
|
+
input_tokens: int
|
|
16
|
+
output_tokens: int
|
|
17
|
+
cost_usd: float
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class CostTracker:
|
|
21
|
+
"""Track costs across an entire workflow.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
tracker = CostTracker()
|
|
25
|
+
tracker.record("debate", "bull_agent", "gpt-4o", 500, 200, 0.003)
|
|
26
|
+
tracker.record("debate", "bear_agent", "gpt-4o-mini", 500, 200, 0.0004)
|
|
27
|
+
print(tracker.summary())
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._entries: list[CostEntry] = []
|
|
32
|
+
|
|
33
|
+
def record(
|
|
34
|
+
self,
|
|
35
|
+
pattern_type: str,
|
|
36
|
+
agent_name: str,
|
|
37
|
+
model: str,
|
|
38
|
+
input_tokens: int,
|
|
39
|
+
output_tokens: int,
|
|
40
|
+
cost_usd: float,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Record a cost entry."""
|
|
43
|
+
self._entries.append(CostEntry(
|
|
44
|
+
pattern_type=pattern_type,
|
|
45
|
+
agent_name=agent_name,
|
|
46
|
+
model=model,
|
|
47
|
+
input_tokens=input_tokens,
|
|
48
|
+
output_tokens=output_tokens,
|
|
49
|
+
cost_usd=cost_usd,
|
|
50
|
+
))
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def total_cost(self) -> float:
|
|
54
|
+
return sum(e.cost_usd for e in self._entries)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def total_tokens(self) -> int:
|
|
58
|
+
return sum(e.input_tokens + e.output_tokens for e in self._entries)
|
|
59
|
+
|
|
60
|
+
def by_pattern(self) -> dict[str, float]:
|
|
61
|
+
"""Cost breakdown by pattern type."""
|
|
62
|
+
result: dict[str, float] = {}
|
|
63
|
+
for e in self._entries:
|
|
64
|
+
result[e.pattern_type] = result.get(e.pattern_type, 0.0) + e.cost_usd
|
|
65
|
+
return result
|
|
66
|
+
|
|
67
|
+
def by_agent(self) -> dict[str, float]:
|
|
68
|
+
"""Cost breakdown by agent name."""
|
|
69
|
+
result: dict[str, float] = {}
|
|
70
|
+
for e in self._entries:
|
|
71
|
+
result[e.agent_name] = result.get(e.agent_name, 0.0) + e.cost_usd
|
|
72
|
+
return result
|
|
73
|
+
|
|
74
|
+
def by_model(self) -> dict[str, float]:
|
|
75
|
+
"""Cost breakdown by model."""
|
|
76
|
+
result: dict[str, float] = {}
|
|
77
|
+
for e in self._entries:
|
|
78
|
+
result[e.model] = result.get(e.model, 0.0) + e.cost_usd
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
def summary(self) -> dict[str, object]:
|
|
82
|
+
"""Full cost summary."""
|
|
83
|
+
return {
|
|
84
|
+
"total_cost_usd": self.total_cost,
|
|
85
|
+
"total_tokens": self.total_tokens,
|
|
86
|
+
"entries": len(self._entries),
|
|
87
|
+
"by_pattern": self.by_pattern(),
|
|
88
|
+
"by_agent": self.by_agent(),
|
|
89
|
+
"by_model": self.by_model(),
|
|
90
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Decorators for automatic tracing of patterns and agents.
|
|
2
|
+
|
|
3
|
+
@traced_pattern: wraps a Pattern subclass to emit spans on every run().
|
|
4
|
+
@traced_agent: wraps an Agent to emit spans on every run().
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import functools
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from opentelemetry import trace
|
|
14
|
+
|
|
15
|
+
from pyagent_patterns.base import Agent, Context, Message, Pattern, Result
|
|
16
|
+
from pyagent_trace.attributes import PyAgentAttributes
|
|
17
|
+
|
|
18
|
+
_tracer = trace.get_tracer("pyagent", "0.1.0")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def traced_pattern(cls: type[Pattern]) -> type[Pattern]:
|
|
22
|
+
"""Class decorator: auto-emit OTel spans for every pattern.run() call.
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
@traced_pattern
|
|
26
|
+
class MyPattern(Pattern):
|
|
27
|
+
...
|
|
28
|
+
"""
|
|
29
|
+
original_run = cls.run
|
|
30
|
+
|
|
31
|
+
@functools.wraps(original_run)
|
|
32
|
+
async def traced_run(self: Pattern, task: str, context: Context | None = None) -> Result:
|
|
33
|
+
with _tracer.start_as_current_span(
|
|
34
|
+
f"pyagent.pattern.{self.pattern_type}",
|
|
35
|
+
attributes={PyAgentAttributes.PATTERN_TYPE: self.pattern_type},
|
|
36
|
+
) as span:
|
|
37
|
+
start = time.perf_counter()
|
|
38
|
+
try:
|
|
39
|
+
result = await original_run(self, task, context)
|
|
40
|
+
|
|
41
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
42
|
+
span.set_attribute(PyAgentAttributes.EXEC_DURATION_MS, duration_ms)
|
|
43
|
+
span.set_attribute(PyAgentAttributes.EXEC_TOKEN_ESTIMATE, result.token_estimate)
|
|
44
|
+
span.set_attribute(PyAgentAttributes.COST_TOTAL_USD, result.cost_estimate)
|
|
45
|
+
|
|
46
|
+
if "rounds" in result.metadata:
|
|
47
|
+
span.set_attribute(PyAgentAttributes.PATTERN_ROUNDS, result.metadata["rounds"])
|
|
48
|
+
|
|
49
|
+
span.set_status(trace.StatusCode.OK)
|
|
50
|
+
return result
|
|
51
|
+
except Exception as e:
|
|
52
|
+
span.set_status(trace.StatusCode.ERROR, str(e))
|
|
53
|
+
span.record_exception(e)
|
|
54
|
+
raise
|
|
55
|
+
|
|
56
|
+
cls.run = traced_run # type: ignore[assignment]
|
|
57
|
+
return cls
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def traced_agent(agent: Agent) -> Agent:
|
|
61
|
+
"""Wrap an Agent instance to emit OTel spans on every run() call.
|
|
62
|
+
|
|
63
|
+
Usage:
|
|
64
|
+
agent = traced_agent(Agent("my_agent", llm))
|
|
65
|
+
"""
|
|
66
|
+
original_run = agent.run
|
|
67
|
+
|
|
68
|
+
@functools.wraps(original_run)
|
|
69
|
+
async def traced_run(messages: list[Message]) -> Message:
|
|
70
|
+
with _tracer.start_as_current_span(
|
|
71
|
+
f"pyagent.agent.{agent.name}",
|
|
72
|
+
attributes={PyAgentAttributes.AGENT_NAME: agent.name},
|
|
73
|
+
) as span:
|
|
74
|
+
start = time.perf_counter()
|
|
75
|
+
try:
|
|
76
|
+
result = await original_run(messages)
|
|
77
|
+
duration_ms = (time.perf_counter() - start) * 1000
|
|
78
|
+
span.set_attribute(PyAgentAttributes.EXEC_DURATION_MS, duration_ms)
|
|
79
|
+
span.set_status(trace.StatusCode.OK)
|
|
80
|
+
return result
|
|
81
|
+
except Exception as e:
|
|
82
|
+
span.set_status(trace.StatusCode.ERROR, str(e))
|
|
83
|
+
span.record_exception(e)
|
|
84
|
+
raise
|
|
85
|
+
|
|
86
|
+
agent.run = traced_run # type: ignore[assignment]
|
|
87
|
+
return agent
|
|
File without changes
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Recorder: serialize all pattern messages and LLM responses for replay/debug.
|
|
2
|
+
|
|
3
|
+
Record mode: saves all messages + LLM responses to a JSONL file.
|
|
4
|
+
Replay mode: re-runs pattern with recorded LLM responses (deterministic).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import asdict, dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from pyagent_patterns.base import Message, Role
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class RecordEntry:
|
|
20
|
+
"""A single recorded event."""
|
|
21
|
+
|
|
22
|
+
timestamp: float
|
|
23
|
+
event_type: str # "llm_call", "message", "pattern_start", "pattern_end"
|
|
24
|
+
agent_name: str
|
|
25
|
+
messages_in: list[dict[str, Any]]
|
|
26
|
+
response: str
|
|
27
|
+
metadata: dict[str, Any]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Recorder:
|
|
31
|
+
"""Record pattern executions for debugging and replay.
|
|
32
|
+
|
|
33
|
+
Usage:
|
|
34
|
+
recorder = Recorder()
|
|
35
|
+
recorder.start("debate")
|
|
36
|
+
recorder.record_llm_call("bull", messages, response)
|
|
37
|
+
recorder.save("debug_trace.jsonl")
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
self._entries: list[RecordEntry] = []
|
|
42
|
+
self._start_time: float = 0.0
|
|
43
|
+
|
|
44
|
+
def start(self, pattern_type: str) -> None:
|
|
45
|
+
"""Mark the start of a pattern execution."""
|
|
46
|
+
self._start_time = time.time()
|
|
47
|
+
self._entries.append(RecordEntry(
|
|
48
|
+
timestamp=self._start_time,
|
|
49
|
+
event_type="pattern_start",
|
|
50
|
+
agent_name="",
|
|
51
|
+
messages_in=[],
|
|
52
|
+
response="",
|
|
53
|
+
metadata={"pattern_type": pattern_type},
|
|
54
|
+
))
|
|
55
|
+
|
|
56
|
+
def record_llm_call(
|
|
57
|
+
self,
|
|
58
|
+
agent_name: str,
|
|
59
|
+
messages: list[Message],
|
|
60
|
+
response: str,
|
|
61
|
+
metadata: dict[str, Any] | None = None,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Record an LLM call and its response."""
|
|
64
|
+
self._entries.append(RecordEntry(
|
|
65
|
+
timestamp=time.time(),
|
|
66
|
+
event_type="llm_call",
|
|
67
|
+
agent_name=agent_name,
|
|
68
|
+
messages_in=[
|
|
69
|
+
{"role": m.role.value, "content": m.content, "name": m.name}
|
|
70
|
+
for m in messages
|
|
71
|
+
],
|
|
72
|
+
response=response,
|
|
73
|
+
metadata=metadata or {},
|
|
74
|
+
))
|
|
75
|
+
|
|
76
|
+
def end(self, result_output: str) -> None:
|
|
77
|
+
"""Mark the end of a pattern execution."""
|
|
78
|
+
self._entries.append(RecordEntry(
|
|
79
|
+
timestamp=time.time(),
|
|
80
|
+
event_type="pattern_end",
|
|
81
|
+
agent_name="",
|
|
82
|
+
messages_in=[],
|
|
83
|
+
response=result_output,
|
|
84
|
+
metadata={"duration_seconds": time.time() - self._start_time},
|
|
85
|
+
))
|
|
86
|
+
|
|
87
|
+
def save(self, path: str | Path) -> None:
|
|
88
|
+
"""Save recorded entries to a JSONL file."""
|
|
89
|
+
p = Path(path)
|
|
90
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
with p.open("w") as f:
|
|
92
|
+
for entry in self._entries:
|
|
93
|
+
f.write(json.dumps(asdict(entry), default=str) + "\n")
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def load(cls, path: str | Path) -> list[RecordEntry]:
|
|
97
|
+
"""Load recorded entries from a JSONL file."""
|
|
98
|
+
entries: list[RecordEntry] = []
|
|
99
|
+
with Path(path).open() as f:
|
|
100
|
+
for line in f:
|
|
101
|
+
data = json.loads(line.strip())
|
|
102
|
+
entries.append(RecordEntry(**data))
|
|
103
|
+
return entries
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def entries(self) -> list[RecordEntry]:
|
|
107
|
+
return list(self._entries)
|
|
108
|
+
|
|
109
|
+
@property
|
|
110
|
+
def llm_calls(self) -> list[RecordEntry]:
|
|
111
|
+
return [e for e in self._entries if e.event_type == "llm_call"]
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""PatternSpanEmitter: create OTel spans for pattern executions.
|
|
2
|
+
|
|
3
|
+
Emits structured spans with pyagent.* attributes for every pattern run,
|
|
4
|
+
agent call, and routing/compression decision.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from opentelemetry import trace
|
|
12
|
+
from opentelemetry.trace import Span, StatusCode
|
|
13
|
+
|
|
14
|
+
from pyagent_trace.attributes import PyAgentAttributes
|
|
15
|
+
|
|
16
|
+
_tracer = trace.get_tracer("pyagent", "0.1.0")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PatternSpanEmitter:
|
|
20
|
+
"""Emit OTel spans for pattern executions.
|
|
21
|
+
|
|
22
|
+
Usage:
|
|
23
|
+
emitter = PatternSpanEmitter()
|
|
24
|
+
with emitter.pattern_span("debate", {"rounds": 3}) as span:
|
|
25
|
+
# ... pattern logic ...
|
|
26
|
+
emitter.set_result(span, result)
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def pattern_span(
|
|
30
|
+
self,
|
|
31
|
+
pattern_type: str,
|
|
32
|
+
attributes: dict[str, Any] | None = None,
|
|
33
|
+
) -> Span:
|
|
34
|
+
"""Start a span for a pattern execution."""
|
|
35
|
+
span = _tracer.start_span(
|
|
36
|
+
f"pyagent.pattern.{pattern_type}",
|
|
37
|
+
attributes={
|
|
38
|
+
PyAgentAttributes.PATTERN_TYPE: pattern_type,
|
|
39
|
+
**(attributes or {}),
|
|
40
|
+
},
|
|
41
|
+
)
|
|
42
|
+
return span
|
|
43
|
+
|
|
44
|
+
def agent_span(
|
|
45
|
+
self,
|
|
46
|
+
agent_name: str,
|
|
47
|
+
parent_span: Span | None = None,
|
|
48
|
+
attributes: dict[str, Any] | None = None,
|
|
49
|
+
) -> Span:
|
|
50
|
+
"""Start a span for an individual agent call."""
|
|
51
|
+
ctx = trace.set_span_in_context(parent_span) if parent_span else None
|
|
52
|
+
span = _tracer.start_span(
|
|
53
|
+
f"pyagent.agent.{agent_name}",
|
|
54
|
+
context=ctx,
|
|
55
|
+
attributes={
|
|
56
|
+
PyAgentAttributes.AGENT_NAME: agent_name,
|
|
57
|
+
**(attributes or {}),
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
return span
|
|
61
|
+
|
|
62
|
+
@staticmethod
|
|
63
|
+
def set_pattern_result(
|
|
64
|
+
span: Span,
|
|
65
|
+
output_length: int,
|
|
66
|
+
rounds: int | None = None,
|
|
67
|
+
consensus: float | None = None,
|
|
68
|
+
escalated: bool = False,
|
|
69
|
+
duration_ms: float = 0.0,
|
|
70
|
+
token_estimate: int = 0,
|
|
71
|
+
cost_estimate: float = 0.0,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Set result attributes on a pattern span."""
|
|
74
|
+
if rounds is not None:
|
|
75
|
+
span.set_attribute(PyAgentAttributes.PATTERN_ROUNDS, rounds)
|
|
76
|
+
if consensus is not None:
|
|
77
|
+
span.set_attribute(PyAgentAttributes.PATTERN_CONSENSUS, consensus)
|
|
78
|
+
span.set_attribute(PyAgentAttributes.PATTERN_ESCALATED, escalated)
|
|
79
|
+
span.set_attribute(PyAgentAttributes.EXEC_DURATION_MS, duration_ms)
|
|
80
|
+
span.set_attribute(PyAgentAttributes.EXEC_TOKEN_ESTIMATE, token_estimate)
|
|
81
|
+
span.set_attribute(PyAgentAttributes.COST_TOTAL_USD, cost_estimate)
|
|
82
|
+
span.set_status(StatusCode.OK)
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def set_routing_info(
|
|
86
|
+
span: Span,
|
|
87
|
+
difficulty: int,
|
|
88
|
+
selected_model: str,
|
|
89
|
+
cost_estimate: float,
|
|
90
|
+
category: str = "",
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Set routing attributes on a span."""
|
|
93
|
+
span.set_attribute(PyAgentAttributes.ROUTER_DIFFICULTY, difficulty)
|
|
94
|
+
span.set_attribute(PyAgentAttributes.ROUTER_SELECTED_MODEL, selected_model)
|
|
95
|
+
span.set_attribute(PyAgentAttributes.ROUTER_COST_ESTIMATE, cost_estimate)
|
|
96
|
+
if category:
|
|
97
|
+
span.set_attribute(PyAgentAttributes.ROUTER_DIFFICULTY_CATEGORY, category)
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def set_compression_info(
|
|
101
|
+
span: Span,
|
|
102
|
+
input_tokens: int,
|
|
103
|
+
output_tokens: int,
|
|
104
|
+
savings_pct: float,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Set compression attributes on a span."""
|
|
107
|
+
span.set_attribute(PyAgentAttributes.COMPRESS_INPUT_TOKENS, input_tokens)
|
|
108
|
+
span.set_attribute(PyAgentAttributes.COMPRESS_OUTPUT_TOKENS, output_tokens)
|
|
109
|
+
span.set_attribute(PyAgentAttributes.COMPRESS_SAVINGS_PCT, savings_pct)
|
|
110
|
+
|
|
111
|
+
@staticmethod
|
|
112
|
+
def set_error(span: Span, error: Exception) -> None:
|
|
113
|
+
"""Record an error on a span."""
|
|
114
|
+
span.set_status(StatusCode.ERROR, str(error))
|
|
115
|
+
span.record_exception(error)
|
|
File without changes
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Tests for pyagent-trace: CostTracker, Recorder, PyAgentAttributes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import tempfile
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from pyagent_patterns.base import Message, Role
|
|
12
|
+
from pyagent_trace.attributes import PyAgentAttributes
|
|
13
|
+
from pyagent_trace.cost import CostTracker
|
|
14
|
+
from pyagent_trace.recorder import Recorder
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# --- PyAgentAttributes ---
|
|
18
|
+
|
|
19
|
+
def test_attributes_pattern_keys():
|
|
20
|
+
assert PyAgentAttributes.PATTERN_TYPE == "pyagent.pattern.type"
|
|
21
|
+
assert PyAgentAttributes.PATTERN_ROUNDS == "pyagent.pattern.rounds"
|
|
22
|
+
assert PyAgentAttributes.PATTERN_ESCALATED == "pyagent.pattern.escalated"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_attributes_router_keys():
|
|
26
|
+
assert PyAgentAttributes.ROUTER_DIFFICULTY == "pyagent.router.difficulty"
|
|
27
|
+
assert PyAgentAttributes.ROUTER_SELECTED_MODEL == "pyagent.router.selected_model"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_attributes_compress_keys():
|
|
31
|
+
assert PyAgentAttributes.COMPRESS_SAVINGS_PCT == "pyagent.compress.savings_pct"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_attributes_cost_keys():
|
|
35
|
+
assert PyAgentAttributes.COST_TOTAL_USD == "pyagent.cost.total_usd"
|
|
36
|
+
assert PyAgentAttributes.COST_MODEL == "pyagent.cost.model"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_attributes_exec_keys():
|
|
40
|
+
assert PyAgentAttributes.EXEC_DURATION_MS == "pyagent.exec.duration_ms"
|
|
41
|
+
assert PyAgentAttributes.EXEC_LLM_CALLS == "pyagent.exec.llm_calls"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# --- CostTracker ---
|
|
45
|
+
|
|
46
|
+
def test_cost_tracker_empty():
|
|
47
|
+
tracker = CostTracker()
|
|
48
|
+
assert tracker.total_cost == 0.0
|
|
49
|
+
assert tracker.total_tokens == 0
|
|
50
|
+
assert tracker.by_pattern() == {}
|
|
51
|
+
assert tracker.by_agent() == {}
|
|
52
|
+
assert tracker.by_model() == {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_cost_tracker_record_and_totals():
|
|
56
|
+
tracker = CostTracker()
|
|
57
|
+
tracker.record("debate", "bull", "gpt-4o", 500, 200, 0.003)
|
|
58
|
+
tracker.record("debate", "bear", "gpt-4o-mini", 500, 200, 0.0004)
|
|
59
|
+
tracker.record("debate", "judge", "gpt-4o", 1000, 300, 0.0055)
|
|
60
|
+
|
|
61
|
+
assert tracker.total_cost == pytest.approx(0.0089, abs=1e-6)
|
|
62
|
+
assert tracker.total_tokens == 2700
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_cost_tracker_by_pattern():
|
|
66
|
+
tracker = CostTracker()
|
|
67
|
+
tracker.record("debate", "bull", "gpt-4o", 100, 50, 0.002)
|
|
68
|
+
tracker.record("pipeline", "stage1", "gpt-4o-mini", 100, 50, 0.0005)
|
|
69
|
+
|
|
70
|
+
by_p = tracker.by_pattern()
|
|
71
|
+
assert "debate" in by_p
|
|
72
|
+
assert "pipeline" in by_p
|
|
73
|
+
assert by_p["debate"] == pytest.approx(0.002)
|
|
74
|
+
assert by_p["pipeline"] == pytest.approx(0.0005)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_cost_tracker_by_agent():
|
|
78
|
+
tracker = CostTracker()
|
|
79
|
+
tracker.record("debate", "bull", "gpt-4o", 100, 50, 0.002)
|
|
80
|
+
tracker.record("debate", "bull", "gpt-4o", 100, 50, 0.002)
|
|
81
|
+
tracker.record("debate", "bear", "gpt-4o", 100, 50, 0.001)
|
|
82
|
+
|
|
83
|
+
by_a = tracker.by_agent()
|
|
84
|
+
assert by_a["bull"] == pytest.approx(0.004)
|
|
85
|
+
assert by_a["bear"] == pytest.approx(0.001)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_cost_tracker_by_model():
|
|
89
|
+
tracker = CostTracker()
|
|
90
|
+
tracker.record("p", "a", "gpt-4o", 100, 50, 0.003)
|
|
91
|
+
tracker.record("p", "b", "gpt-4o-mini", 100, 50, 0.0003)
|
|
92
|
+
|
|
93
|
+
by_m = tracker.by_model()
|
|
94
|
+
assert "gpt-4o" in by_m
|
|
95
|
+
assert "gpt-4o-mini" in by_m
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_cost_tracker_summary():
|
|
99
|
+
tracker = CostTracker()
|
|
100
|
+
tracker.record("debate", "bull", "gpt-4o", 500, 200, 0.003)
|
|
101
|
+
s = tracker.summary()
|
|
102
|
+
assert s["total_cost_usd"] == pytest.approx(0.003)
|
|
103
|
+
assert s["total_tokens"] == 700
|
|
104
|
+
assert s["entries"] == 1
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --- Recorder ---
|
|
108
|
+
|
|
109
|
+
def test_recorder_start_end():
|
|
110
|
+
rec = Recorder()
|
|
111
|
+
rec.start("debate")
|
|
112
|
+
rec.end("Final output")
|
|
113
|
+
assert len(rec.entries) == 2
|
|
114
|
+
assert rec.entries[0].event_type == "pattern_start"
|
|
115
|
+
assert rec.entries[1].event_type == "pattern_end"
|
|
116
|
+
assert rec.entries[1].response == "Final output"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_recorder_record_llm_call():
|
|
120
|
+
rec = Recorder()
|
|
121
|
+
rec.start("pipeline")
|
|
122
|
+
messages = [Message(role=Role.USER, content="Hello")]
|
|
123
|
+
rec.record_llm_call("agent1", messages, "Response text")
|
|
124
|
+
rec.end("done")
|
|
125
|
+
|
|
126
|
+
assert len(rec.llm_calls) == 1
|
|
127
|
+
call = rec.llm_calls[0]
|
|
128
|
+
assert call.agent_name == "agent1"
|
|
129
|
+
assert call.response == "Response text"
|
|
130
|
+
assert call.messages_in[0]["role"] == "user"
|
|
131
|
+
assert call.messages_in[0]["content"] == "Hello"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_recorder_save_and_load():
|
|
135
|
+
rec = Recorder()
|
|
136
|
+
rec.start("voting")
|
|
137
|
+
messages = [Message(role=Role.USER, content="Vote please")]
|
|
138
|
+
rec.record_llm_call("voter1", messages, "YES")
|
|
139
|
+
rec.record_llm_call("voter2", messages, "NO")
|
|
140
|
+
rec.end("YES wins")
|
|
141
|
+
|
|
142
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
143
|
+
path = Path(tmpdir) / "trace.jsonl"
|
|
144
|
+
rec.save(path)
|
|
145
|
+
|
|
146
|
+
# Verify file exists and is valid JSONL
|
|
147
|
+
lines = path.read_text().strip().split("\n")
|
|
148
|
+
assert len(lines) == 4 # start + 2 calls + end
|
|
149
|
+
|
|
150
|
+
for line in lines:
|
|
151
|
+
data = json.loads(line)
|
|
152
|
+
assert "event_type" in data
|
|
153
|
+
assert "timestamp" in data
|
|
154
|
+
|
|
155
|
+
# Load and verify
|
|
156
|
+
loaded = Recorder.load(path)
|
|
157
|
+
assert len(loaded) == 4
|
|
158
|
+
assert loaded[0].event_type == "pattern_start"
|
|
159
|
+
assert loaded[1].event_type == "llm_call"
|
|
160
|
+
assert loaded[1].agent_name == "voter1"
|
|
161
|
+
assert loaded[2].agent_name == "voter2"
|
|
162
|
+
assert loaded[3].event_type == "pattern_end"
|
|
163
|
+
assert loaded[3].response == "YES wins"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_recorder_metadata():
|
|
167
|
+
rec = Recorder()
|
|
168
|
+
rec.start("debate")
|
|
169
|
+
messages = [Message(role=Role.USER, content="test")]
|
|
170
|
+
rec.record_llm_call("bull", messages, "Bull case", metadata={"round": 1, "position": "BUY"})
|
|
171
|
+
rec.end("done")
|
|
172
|
+
|
|
173
|
+
call = rec.llm_calls[0]
|
|
174
|
+
assert call.metadata["round"] == 1
|
|
175
|
+
assert call.metadata["position"] == "BUY"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def test_recorder_empty():
|
|
179
|
+
rec = Recorder()
|
|
180
|
+
assert rec.entries == []
|
|
181
|
+
assert rec.llm_calls == []
|