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.
Files changed (64) hide show
  1. routekitai/__init__.py +53 -0
  2. routekitai/cli/__init__.py +18 -0
  3. routekitai/cli/main.py +40 -0
  4. routekitai/cli/replay.py +80 -0
  5. routekitai/cli/run.py +95 -0
  6. routekitai/cli/serve.py +966 -0
  7. routekitai/cli/test_agent.py +178 -0
  8. routekitai/cli/trace.py +209 -0
  9. routekitai/cli/trace_analyze.py +120 -0
  10. routekitai/cli/trace_search.py +126 -0
  11. routekitai/core/__init__.py +58 -0
  12. routekitai/core/agent.py +325 -0
  13. routekitai/core/errors.py +49 -0
  14. routekitai/core/hooks.py +174 -0
  15. routekitai/core/memory.py +54 -0
  16. routekitai/core/message.py +132 -0
  17. routekitai/core/model.py +91 -0
  18. routekitai/core/policies.py +373 -0
  19. routekitai/core/policy.py +85 -0
  20. routekitai/core/policy_adapter.py +133 -0
  21. routekitai/core/runtime.py +1403 -0
  22. routekitai/core/tool.py +148 -0
  23. routekitai/core/tools.py +180 -0
  24. routekitai/evals/__init__.py +13 -0
  25. routekitai/evals/dataset.py +75 -0
  26. routekitai/evals/metrics.py +101 -0
  27. routekitai/evals/runner.py +184 -0
  28. routekitai/graphs/__init__.py +12 -0
  29. routekitai/graphs/executors.py +457 -0
  30. routekitai/graphs/graph.py +164 -0
  31. routekitai/memory/__init__.py +13 -0
  32. routekitai/memory/episodic.py +242 -0
  33. routekitai/memory/kv.py +34 -0
  34. routekitai/memory/retrieval.py +192 -0
  35. routekitai/memory/vector.py +700 -0
  36. routekitai/memory/working.py +66 -0
  37. routekitai/message.py +29 -0
  38. routekitai/model.py +48 -0
  39. routekitai/observability/__init__.py +21 -0
  40. routekitai/observability/analyzer.py +314 -0
  41. routekitai/observability/exporters/__init__.py +10 -0
  42. routekitai/observability/exporters/base.py +30 -0
  43. routekitai/observability/exporters/jsonl.py +81 -0
  44. routekitai/observability/exporters/otel.py +119 -0
  45. routekitai/observability/spans.py +111 -0
  46. routekitai/observability/streaming.py +117 -0
  47. routekitai/observability/trace.py +144 -0
  48. routekitai/providers/__init__.py +9 -0
  49. routekitai/providers/anthropic.py +227 -0
  50. routekitai/providers/azure_openai.py +243 -0
  51. routekitai/providers/local.py +196 -0
  52. routekitai/providers/openai.py +321 -0
  53. routekitai/py.typed +0 -0
  54. routekitai/sandbox/__init__.py +12 -0
  55. routekitai/sandbox/filesystem.py +131 -0
  56. routekitai/sandbox/network.py +142 -0
  57. routekitai/sandbox/permissions.py +70 -0
  58. routekitai/tool.py +33 -0
  59. routekitai-0.1.0.dist-info/METADATA +328 -0
  60. routekitai-0.1.0.dist-info/RECORD +64 -0
  61. routekitai-0.1.0.dist-info/WHEEL +5 -0
  62. routekitai-0.1.0.dist-info/entry_points.txt +2 -0
  63. routekitai-0.1.0.dist-info/licenses/LICENSE +21 -0
  64. 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)