RouteKitAI 0.1.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.
- routekitai/__init__.py +53 -0
- routekitai/cli/__init__.py +18 -0
- routekitai/cli/main.py +40 -0
- routekitai/cli/replay.py +80 -0
- routekitai/cli/run.py +95 -0
- routekitai/cli/serve.py +966 -0
- routekitai/cli/test_agent.py +178 -0
- routekitai/cli/trace.py +209 -0
- routekitai/cli/trace_analyze.py +120 -0
- routekitai/cli/trace_search.py +126 -0
- routekitai/core/__init__.py +58 -0
- routekitai/core/agent.py +325 -0
- routekitai/core/errors.py +49 -0
- routekitai/core/hooks.py +174 -0
- routekitai/core/memory.py +54 -0
- routekitai/core/message.py +132 -0
- routekitai/core/model.py +91 -0
- routekitai/core/policies.py +373 -0
- routekitai/core/policy.py +85 -0
- routekitai/core/policy_adapter.py +133 -0
- routekitai/core/runtime.py +1403 -0
- routekitai/core/tool.py +148 -0
- routekitai/core/tools.py +180 -0
- routekitai/evals/__init__.py +13 -0
- routekitai/evals/dataset.py +75 -0
- routekitai/evals/metrics.py +101 -0
- routekitai/evals/runner.py +184 -0
- routekitai/graphs/__init__.py +12 -0
- routekitai/graphs/executors.py +457 -0
- routekitai/graphs/graph.py +164 -0
- routekitai/memory/__init__.py +13 -0
- routekitai/memory/episodic.py +242 -0
- routekitai/memory/kv.py +34 -0
- routekitai/memory/retrieval.py +192 -0
- routekitai/memory/vector.py +700 -0
- routekitai/memory/working.py +66 -0
- routekitai/message.py +29 -0
- routekitai/model.py +48 -0
- routekitai/observability/__init__.py +21 -0
- routekitai/observability/analyzer.py +314 -0
- routekitai/observability/exporters/__init__.py +10 -0
- routekitai/observability/exporters/base.py +30 -0
- routekitai/observability/exporters/jsonl.py +81 -0
- routekitai/observability/exporters/otel.py +119 -0
- routekitai/observability/spans.py +111 -0
- routekitai/observability/streaming.py +117 -0
- routekitai/observability/trace.py +144 -0
- routekitai/providers/__init__.py +9 -0
- routekitai/providers/anthropic.py +227 -0
- routekitai/providers/azure_openai.py +243 -0
- routekitai/providers/local.py +196 -0
- routekitai/providers/openai.py +321 -0
- routekitai/py.typed +0 -0
- routekitai/sandbox/__init__.py +12 -0
- routekitai/sandbox/filesystem.py +131 -0
- routekitai/sandbox/network.py +142 -0
- routekitai/sandbox/permissions.py +70 -0
- routekitai/tool.py +33 -0
- routekitai-0.1.0.dist-info/METADATA +328 -0
- routekitai-0.1.0.dist-info/RECORD +64 -0
- routekitai-0.1.0.dist-info/WHEEL +5 -0
- routekitai-0.1.0.dist-info/entry_points.txt +2 -0
- routekitai-0.1.0.dist-info/licenses/LICENSE +21 -0
- routekitai-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Working memory for current run."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from routekitai.core.memory import Memory
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WorkingMemory(Memory):
|
|
9
|
+
"""In-memory dict for current run.
|
|
10
|
+
|
|
11
|
+
Ephemeral memory that exists only for the duration of a single agent run.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
def __init__(self) -> None:
|
|
15
|
+
"""Initialize working memory."""
|
|
16
|
+
self._store: dict[str, Any] = {}
|
|
17
|
+
self._events: list[dict[str, Any]] = []
|
|
18
|
+
|
|
19
|
+
async def get(self, key: str) -> Any:
|
|
20
|
+
"""Get value by key.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
key: Key to retrieve
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Stored value or None if not found
|
|
27
|
+
"""
|
|
28
|
+
return self._store.get(key)
|
|
29
|
+
|
|
30
|
+
async def set(self, key: str, value: Any) -> None:
|
|
31
|
+
"""Set value by key.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
key: Key to set
|
|
35
|
+
value: Value to store
|
|
36
|
+
"""
|
|
37
|
+
self._store[key] = value
|
|
38
|
+
|
|
39
|
+
async def append(self, event: dict[str, Any]) -> None:
|
|
40
|
+
"""Append an event to memory.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
event: Event dictionary to append
|
|
44
|
+
"""
|
|
45
|
+
self._events.append(event)
|
|
46
|
+
|
|
47
|
+
def clear(self) -> None:
|
|
48
|
+
"""Clear all memory (useful for testing)."""
|
|
49
|
+
self._store.clear()
|
|
50
|
+
self._events.clear()
|
|
51
|
+
|
|
52
|
+
def get_all(self) -> dict[str, Any]:
|
|
53
|
+
"""Get all stored key-value pairs.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Dictionary of all stored values
|
|
57
|
+
"""
|
|
58
|
+
return self._store.copy()
|
|
59
|
+
|
|
60
|
+
def get_events(self) -> list[dict[str, Any]]:
|
|
61
|
+
"""Get all events.
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
List of all events
|
|
65
|
+
"""
|
|
66
|
+
return self._events.copy()
|
routekitai/message.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Message primitive for RouteKit."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class MessageRole(str, Enum):
|
|
10
|
+
"""Message role types."""
|
|
11
|
+
|
|
12
|
+
USER = "user"
|
|
13
|
+
ASSISTANT = "assistant"
|
|
14
|
+
SYSTEM = "system"
|
|
15
|
+
TOOL = "tool"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Message(BaseModel):
|
|
19
|
+
"""Represents a message in a conversation."""
|
|
20
|
+
|
|
21
|
+
role: MessageRole = Field(..., description="Message role")
|
|
22
|
+
content: str = Field(..., description="Message content")
|
|
23
|
+
metadata: dict[str, Any] = Field(default_factory=dict, description="Additional metadata")
|
|
24
|
+
tool_calls: list[dict[str, Any]] | None = Field(
|
|
25
|
+
default=None, description="Tool calls associated with this message"
|
|
26
|
+
)
|
|
27
|
+
tool_call_id: str | None = Field(
|
|
28
|
+
default=None, description="ID of the tool call this message responds to"
|
|
29
|
+
)
|
routekitai/model.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Model primitive for RouteKit."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator, Iterator
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, Field
|
|
7
|
+
|
|
8
|
+
from routekitai.core.message import Message
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Model(BaseModel):
|
|
12
|
+
"""Represents an LLM model interface."""
|
|
13
|
+
|
|
14
|
+
name: str = Field(..., description="Model identifier")
|
|
15
|
+
provider: str = Field(..., description="Model provider")
|
|
16
|
+
config: dict[str, Any] = Field(default_factory=dict, description="Model configuration")
|
|
17
|
+
|
|
18
|
+
async def generate(
|
|
19
|
+
self,
|
|
20
|
+
messages: list["Message"],
|
|
21
|
+
**kwargs: Any,
|
|
22
|
+
) -> AsyncIterator["Message"]:
|
|
23
|
+
"""Generate a response stream from messages.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
messages: List of input messages
|
|
27
|
+
**kwargs: Additional generation parameters
|
|
28
|
+
|
|
29
|
+
Yields:
|
|
30
|
+
Message chunks from the model
|
|
31
|
+
"""
|
|
32
|
+
raise NotImplementedError("Subclasses must implement generate")
|
|
33
|
+
|
|
34
|
+
def generate_sync(
|
|
35
|
+
self,
|
|
36
|
+
messages: list["Message"],
|
|
37
|
+
**kwargs: Any,
|
|
38
|
+
) -> Iterator["Message"]:
|
|
39
|
+
"""Synchronous wrapper for generate.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
messages: List of input messages
|
|
43
|
+
**kwargs: Additional generation parameters
|
|
44
|
+
|
|
45
|
+
Yields:
|
|
46
|
+
Message chunks from the model
|
|
47
|
+
"""
|
|
48
|
+
raise NotImplementedError("Subclasses must implement generate_sync")
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Observability and tracing for RouteKit."""
|
|
2
|
+
|
|
3
|
+
from routekitai.observability.analyzer import TraceAnalyzer, TraceMetrics
|
|
4
|
+
from routekitai.observability.exporters.jsonl import JSONLExporter
|
|
5
|
+
from routekitai.observability.exporters.otel import OTELExporter
|
|
6
|
+
from routekitai.observability.spans import Span, SpanContext
|
|
7
|
+
from routekitai.observability.streaming import TraceEventBroadcaster, get_broadcaster
|
|
8
|
+
from routekitai.observability.trace import Trace, TraceCollector
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Trace",
|
|
12
|
+
"TraceCollector",
|
|
13
|
+
"Span",
|
|
14
|
+
"SpanContext",
|
|
15
|
+
"JSONLExporter",
|
|
16
|
+
"OTELExporter",
|
|
17
|
+
"TraceAnalyzer",
|
|
18
|
+
"TraceMetrics",
|
|
19
|
+
"TraceEventBroadcaster",
|
|
20
|
+
"get_broadcaster",
|
|
21
|
+
]
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
"""Trace analysis and metrics calculation."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, Field
|
|
6
|
+
|
|
7
|
+
from routekitai.observability.trace import Trace, TraceEvent
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TraceMetrics(BaseModel):
|
|
11
|
+
"""Metrics calculated from a trace."""
|
|
12
|
+
|
|
13
|
+
model_config = {"protected_namespaces": ()}
|
|
14
|
+
|
|
15
|
+
total_events: int = Field(..., description="Total number of events")
|
|
16
|
+
total_duration_ms: float = Field(..., description="Total execution duration in milliseconds")
|
|
17
|
+
model_calls: int = Field(..., description="Number of model calls")
|
|
18
|
+
tool_calls: int = Field(..., description="Number of tool calls")
|
|
19
|
+
errors: int = Field(..., description="Number of errors")
|
|
20
|
+
total_tokens: int = Field(default=0, description="Total tokens used (prompt + completion)")
|
|
21
|
+
prompt_tokens: int = Field(default=0, description="Prompt tokens")
|
|
22
|
+
completion_tokens: int = Field(default=0, description="Completion tokens")
|
|
23
|
+
avg_model_latency_ms: float = Field(default=0.0, description="Average model call latency")
|
|
24
|
+
avg_tool_latency_ms: float = Field(default=0.0, description="Average tool call latency")
|
|
25
|
+
error_rate: float = Field(default=0.0, description="Error rate (0.0 to 1.0)")
|
|
26
|
+
steps: int = Field(default=0, description="Number of execution steps")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TraceAnalyzer:
|
|
30
|
+
"""Analyzes traces and calculates metrics."""
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def analyze(trace: Trace) -> TraceMetrics:
|
|
34
|
+
"""Analyze a trace and calculate metrics.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
trace: Trace to analyze
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
Calculated metrics
|
|
41
|
+
"""
|
|
42
|
+
if not trace.events:
|
|
43
|
+
return TraceMetrics(
|
|
44
|
+
total_events=0,
|
|
45
|
+
total_duration_ms=0.0,
|
|
46
|
+
model_calls=0,
|
|
47
|
+
tool_calls=0,
|
|
48
|
+
errors=0,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# Find start and end times
|
|
52
|
+
start_time = trace.events[0].timestamp
|
|
53
|
+
end_time = trace.events[-1].timestamp
|
|
54
|
+
total_duration_ms = (end_time - start_time) * 1000
|
|
55
|
+
|
|
56
|
+
# Count events
|
|
57
|
+
model_calls = 0
|
|
58
|
+
tool_calls = 0
|
|
59
|
+
errors = 0
|
|
60
|
+
steps = 0
|
|
61
|
+
|
|
62
|
+
# Token usage
|
|
63
|
+
total_tokens = 0
|
|
64
|
+
prompt_tokens = 0
|
|
65
|
+
completion_tokens = 0
|
|
66
|
+
|
|
67
|
+
# Latency tracking
|
|
68
|
+
model_latencies: list[float] = []
|
|
69
|
+
tool_latencies: list[float] = []
|
|
70
|
+
|
|
71
|
+
# Track step start times
|
|
72
|
+
step_start_times: dict[str, float] = {}
|
|
73
|
+
|
|
74
|
+
for event in trace.events:
|
|
75
|
+
if event.type == "model_called":
|
|
76
|
+
model_calls += 1
|
|
77
|
+
# Extract token usage if available
|
|
78
|
+
if "usage" in event.data:
|
|
79
|
+
usage = event.data["usage"]
|
|
80
|
+
if isinstance(usage, dict):
|
|
81
|
+
total_tokens += usage.get("total_tokens", 0)
|
|
82
|
+
prompt_tokens += usage.get("prompt_tokens", 0)
|
|
83
|
+
completion_tokens += usage.get("completion_tokens", 0)
|
|
84
|
+
elif event.type == "tool_called":
|
|
85
|
+
tool_calls += 1
|
|
86
|
+
# Track tool call start time
|
|
87
|
+
step_id = event.data.get("step_id", "")
|
|
88
|
+
if step_id:
|
|
89
|
+
step_start_times[step_id] = event.timestamp
|
|
90
|
+
elif event.type == "tool_result":
|
|
91
|
+
# Calculate tool latency
|
|
92
|
+
step_id = event.data.get("step_id", "")
|
|
93
|
+
if step_id and step_id in step_start_times:
|
|
94
|
+
latency_ms = (event.timestamp - step_start_times[step_id]) * 1000
|
|
95
|
+
tool_latencies.append(latency_ms)
|
|
96
|
+
del step_start_times[step_id]
|
|
97
|
+
elif event.type == "error":
|
|
98
|
+
errors += 1
|
|
99
|
+
elif event.type == "step_started":
|
|
100
|
+
steps += 1
|
|
101
|
+
step_id = event.data.get("step_id", "")
|
|
102
|
+
if step_id:
|
|
103
|
+
step_start_times[step_id] = event.timestamp
|
|
104
|
+
elif event.type == "step_completed":
|
|
105
|
+
# Calculate step latency (could be model or tool)
|
|
106
|
+
step_id = event.data.get("step_id", "")
|
|
107
|
+
if step_id and step_id in step_start_times:
|
|
108
|
+
latency_ms = (event.timestamp - step_start_times[step_id]) * 1000
|
|
109
|
+
# Check if this was a model call step
|
|
110
|
+
step_type = event.data.get("step_type", "")
|
|
111
|
+
if step_type == "model_call":
|
|
112
|
+
model_latencies.append(latency_ms)
|
|
113
|
+
elif step_type == "tool_call":
|
|
114
|
+
if latency_ms not in tool_latencies: # Avoid double counting
|
|
115
|
+
tool_latencies.append(latency_ms)
|
|
116
|
+
del step_start_times[step_id]
|
|
117
|
+
|
|
118
|
+
# Calculate averages
|
|
119
|
+
avg_model_latency_ms = (
|
|
120
|
+
sum(model_latencies) / len(model_latencies) if model_latencies else 0.0
|
|
121
|
+
)
|
|
122
|
+
avg_tool_latency_ms = sum(tool_latencies) / len(tool_latencies) if tool_latencies else 0.0
|
|
123
|
+
|
|
124
|
+
# Calculate error rate
|
|
125
|
+
total_operations = model_calls + tool_calls
|
|
126
|
+
error_rate = errors / total_operations if total_operations > 0 else 0.0
|
|
127
|
+
|
|
128
|
+
return TraceMetrics(
|
|
129
|
+
total_events=len(trace.events),
|
|
130
|
+
total_duration_ms=total_duration_ms,
|
|
131
|
+
model_calls=model_calls,
|
|
132
|
+
tool_calls=tool_calls,
|
|
133
|
+
errors=errors,
|
|
134
|
+
total_tokens=total_tokens,
|
|
135
|
+
prompt_tokens=prompt_tokens,
|
|
136
|
+
completion_tokens=completion_tokens,
|
|
137
|
+
avg_model_latency_ms=avg_model_latency_ms,
|
|
138
|
+
avg_tool_latency_ms=avg_tool_latency_ms,
|
|
139
|
+
error_rate=error_rate,
|
|
140
|
+
steps=steps,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
@staticmethod
|
|
144
|
+
def query(
|
|
145
|
+
trace: Trace,
|
|
146
|
+
event_type: str | None = None,
|
|
147
|
+
filter_func: Any | None = None,
|
|
148
|
+
) -> list[TraceEvent]:
|
|
149
|
+
"""Query trace events.
|
|
150
|
+
|
|
151
|
+
Args:
|
|
152
|
+
trace: Trace to query
|
|
153
|
+
event_type: Optional event type filter
|
|
154
|
+
filter_func: Optional function to filter events (takes TraceEvent, returns bool)
|
|
155
|
+
|
|
156
|
+
Returns:
|
|
157
|
+
List of matching events
|
|
158
|
+
"""
|
|
159
|
+
events = trace.events
|
|
160
|
+
|
|
161
|
+
if event_type:
|
|
162
|
+
events = [e for e in events if e.type == event_type]
|
|
163
|
+
|
|
164
|
+
if filter_func:
|
|
165
|
+
events = [e for e in events if filter_func(e)]
|
|
166
|
+
|
|
167
|
+
return events
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def search(
|
|
171
|
+
trace: Trace,
|
|
172
|
+
query: str,
|
|
173
|
+
search_in_data: bool = True,
|
|
174
|
+
) -> list[TraceEvent]:
|
|
175
|
+
"""Search trace events by text.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
trace: Trace to search
|
|
179
|
+
query: Search query (case-insensitive)
|
|
180
|
+
search_in_data: Whether to search in event data
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
List of matching events
|
|
184
|
+
"""
|
|
185
|
+
query_lower = query.lower()
|
|
186
|
+
results: list[TraceEvent] = []
|
|
187
|
+
|
|
188
|
+
for event in trace.events:
|
|
189
|
+
# Search in event type
|
|
190
|
+
if query_lower in event.type.lower():
|
|
191
|
+
results.append(event)
|
|
192
|
+
continue
|
|
193
|
+
|
|
194
|
+
# Search in event data
|
|
195
|
+
if search_in_data:
|
|
196
|
+
if _search_in_dict(event.data, query_lower):
|
|
197
|
+
results.append(event)
|
|
198
|
+
|
|
199
|
+
return results
|
|
200
|
+
|
|
201
|
+
@staticmethod
|
|
202
|
+
def get_timeline(trace: Trace) -> list[dict[str, Any]]:
|
|
203
|
+
"""Get timeline of events with relative timestamps.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
trace: Trace to analyze
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
List of timeline entries with relative time and duration
|
|
210
|
+
"""
|
|
211
|
+
if not trace.events:
|
|
212
|
+
return []
|
|
213
|
+
|
|
214
|
+
start_time = trace.events[0].timestamp
|
|
215
|
+
timeline: list[dict[str, Any]] = []
|
|
216
|
+
|
|
217
|
+
for i, event in enumerate(trace.events):
|
|
218
|
+
relative_time_ms = (event.timestamp - start_time) * 1000
|
|
219
|
+
|
|
220
|
+
# Calculate duration if this is a completion event
|
|
221
|
+
duration_ms = 0.0
|
|
222
|
+
if event.type in ("step_completed", "tool_result", "model_called"):
|
|
223
|
+
# Look for corresponding start event
|
|
224
|
+
step_id = event.data.get("step_id", "")
|
|
225
|
+
if step_id:
|
|
226
|
+
# Find the start event
|
|
227
|
+
for prev_event in reversed(trace.events[:i]):
|
|
228
|
+
if (
|
|
229
|
+
prev_event.type in ("step_started", "tool_called", "model_called")
|
|
230
|
+
and prev_event.data.get("step_id") == step_id
|
|
231
|
+
):
|
|
232
|
+
duration_ms = (event.timestamp - prev_event.timestamp) * 1000
|
|
233
|
+
break
|
|
234
|
+
|
|
235
|
+
timeline.append(
|
|
236
|
+
{
|
|
237
|
+
"event": event,
|
|
238
|
+
"relative_time_ms": relative_time_ms,
|
|
239
|
+
"duration_ms": duration_ms,
|
|
240
|
+
"index": i,
|
|
241
|
+
}
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
return timeline
|
|
245
|
+
|
|
246
|
+
@staticmethod
|
|
247
|
+
def get_step_sequence(trace: Trace) -> list[dict[str, Any]]:
|
|
248
|
+
"""Get step-by-step execution sequence.
|
|
249
|
+
|
|
250
|
+
Args:
|
|
251
|
+
trace: Trace to analyze
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
List of steps with their events
|
|
255
|
+
"""
|
|
256
|
+
steps: dict[str, dict[str, Any]] = {}
|
|
257
|
+
step_order: list[str] = []
|
|
258
|
+
|
|
259
|
+
for event in trace.events:
|
|
260
|
+
step_id = event.data.get("step_id", "")
|
|
261
|
+
if not step_id:
|
|
262
|
+
continue
|
|
263
|
+
|
|
264
|
+
if step_id not in steps:
|
|
265
|
+
steps[step_id] = {
|
|
266
|
+
"step_id": step_id,
|
|
267
|
+
"step_type": event.data.get("step_type", ""),
|
|
268
|
+
"events": [],
|
|
269
|
+
"start_time": event.timestamp,
|
|
270
|
+
"end_time": None,
|
|
271
|
+
"duration_ms": 0.0,
|
|
272
|
+
"error": None,
|
|
273
|
+
}
|
|
274
|
+
step_order.append(step_id)
|
|
275
|
+
|
|
276
|
+
steps[step_id]["events"].append(event)
|
|
277
|
+
|
|
278
|
+
if event.type == "step_completed":
|
|
279
|
+
steps[step_id]["end_time"] = event.timestamp
|
|
280
|
+
steps[step_id]["duration_ms"] = (
|
|
281
|
+
event.timestamp - steps[step_id]["start_time"]
|
|
282
|
+
) * 1000
|
|
283
|
+
if "error" in event.data:
|
|
284
|
+
steps[step_id]["error"] = event.data["error"]
|
|
285
|
+
elif event.type == "error":
|
|
286
|
+
steps[step_id]["error"] = event.data.get("message", "Unknown error")
|
|
287
|
+
|
|
288
|
+
return [steps[step_id] for step_id in step_order]
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _search_in_dict(data: dict[str, Any], query: str) -> bool:
|
|
292
|
+
"""Recursively search for query in dictionary values.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
data: Dictionary to search
|
|
296
|
+
query: Search query (lowercase)
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
True if query found
|
|
300
|
+
"""
|
|
301
|
+
for key, value in data.items():
|
|
302
|
+
if query in key.lower():
|
|
303
|
+
return True
|
|
304
|
+
if isinstance(value, str) and query in value.lower():
|
|
305
|
+
return True
|
|
306
|
+
if isinstance(value, dict) and _search_in_dict(value, query):
|
|
307
|
+
return True
|
|
308
|
+
if isinstance(value, list):
|
|
309
|
+
for item in value:
|
|
310
|
+
if isinstance(item, str) and query in item.lower():
|
|
311
|
+
return True
|
|
312
|
+
if isinstance(item, dict) and _search_in_dict(item, query):
|
|
313
|
+
return True
|
|
314
|
+
return False
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Trace exporters for RouteKit."""
|
|
2
|
+
|
|
3
|
+
# TODO: Implement trace exporters
|
|
4
|
+
from routekitai.observability.exporters.jsonl import JSONLExporter
|
|
5
|
+
from routekitai.observability.exporters.otel import OTELExporter
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"JSONLExporter",
|
|
9
|
+
"OTELExporter",
|
|
10
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Base interface for trace exporters."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
|
|
5
|
+
from routekitai.observability.trace import Trace
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TraceExporter(ABC):
|
|
9
|
+
"""Abstract base class for trace exporters."""
|
|
10
|
+
|
|
11
|
+
@abstractmethod
|
|
12
|
+
async def export(self, trace: Trace) -> None:
|
|
13
|
+
"""Export a trace.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
trace: Trace to export
|
|
17
|
+
"""
|
|
18
|
+
raise NotImplementedError
|
|
19
|
+
|
|
20
|
+
@abstractmethod
|
|
21
|
+
async def load(self, trace_id: str) -> Trace | None:
|
|
22
|
+
"""Load a trace by ID.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
trace_id: Trace ID to load
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Trace if found, None otherwise
|
|
29
|
+
"""
|
|
30
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""JSONL trace exporter."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from routekitai.observability.exporters.base import TraceExporter
|
|
10
|
+
from routekitai.observability.trace import Trace, TraceEvent
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class JSONLExporter(TraceExporter, BaseModel):
|
|
14
|
+
"""Exports traces to JSONL format.
|
|
15
|
+
|
|
16
|
+
Writes one event per line to .routekit/traces/<trace_id>.jsonl
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
output_dir: Path = Field(..., description="Output directory for JSONL files")
|
|
20
|
+
|
|
21
|
+
def __init__(self, output_dir: Path | str | None = None, **kwargs: Any) -> None:
|
|
22
|
+
"""Initialize JSONL exporter.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
output_dir: Output directory (defaults to .routekit/traces)
|
|
26
|
+
**kwargs: Additional fields
|
|
27
|
+
"""
|
|
28
|
+
if output_dir is None:
|
|
29
|
+
output_dir = Path(".routekit") / "traces"
|
|
30
|
+
if isinstance(output_dir, str):
|
|
31
|
+
output_dir = Path(output_dir)
|
|
32
|
+
super().__init__(output_dir=output_dir, **kwargs)
|
|
33
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
|
|
35
|
+
async def export(self, trace: Trace) -> None:
|
|
36
|
+
"""Export trace to JSONL file.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
trace: Trace to export
|
|
40
|
+
"""
|
|
41
|
+
# Ensure directory exists
|
|
42
|
+
self.output_dir.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
trace_file = self.output_dir / f"{trace.trace_id}.jsonl"
|
|
44
|
+
with trace_file.open("w") as f:
|
|
45
|
+
for event in trace.events:
|
|
46
|
+
# Use mode='json' to ensure all data is JSON-serializable
|
|
47
|
+
f.write(json.dumps(event.model_dump(mode="json")) + "\n")
|
|
48
|
+
|
|
49
|
+
async def load(self, trace_id: str) -> Trace | None:
|
|
50
|
+
"""Load trace from JSONL file.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
trace_id: Trace ID to load
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Trace if found, None otherwise
|
|
57
|
+
"""
|
|
58
|
+
trace_file = self.output_dir / f"{trace_id}.jsonl"
|
|
59
|
+
if not trace_file.exists():
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
events: list[TraceEvent] = []
|
|
63
|
+
with trace_file.open("r") as f:
|
|
64
|
+
for line in f:
|
|
65
|
+
line = line.strip()
|
|
66
|
+
if not line:
|
|
67
|
+
continue
|
|
68
|
+
event_data = json.loads(line)
|
|
69
|
+
events.append(TraceEvent(**event_data))
|
|
70
|
+
|
|
71
|
+
if not events:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
# Extract metadata from run_started event if present
|
|
75
|
+
metadata = {}
|
|
76
|
+
for event in events:
|
|
77
|
+
if event.type == "run_started" and "metadata" in event.data:
|
|
78
|
+
metadata = event.data.get("metadata", {})
|
|
79
|
+
break
|
|
80
|
+
|
|
81
|
+
return Trace(trace_id=trace_id, events=events, metadata=metadata)
|