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
routekitai/core/agent.py
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Agent primitive for RouteKit."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import uuid
|
|
7
|
+
from collections.abc import AsyncIterator
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from pydantic import BaseModel, Field
|
|
12
|
+
|
|
13
|
+
from routekitai.core.hooks import ToolFilter
|
|
14
|
+
from routekitai.core.memory import Memory
|
|
15
|
+
from routekitai.core.message import Message
|
|
16
|
+
from routekitai.core.model import Model
|
|
17
|
+
from routekitai.core.policy import Policy
|
|
18
|
+
from routekitai.core.policy_adapter import PolicyAdapter
|
|
19
|
+
from routekitai.core.runtime import Runtime
|
|
20
|
+
from routekitai.core.tool import Tool
|
|
21
|
+
from routekitai.graphs.graph import Graph
|
|
22
|
+
from routekitai.observability.trace import Trace, TraceEvent
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RunResult(BaseModel):
|
|
26
|
+
"""Result of an agent run."""
|
|
27
|
+
|
|
28
|
+
output: Message = Field(..., description="Final output message")
|
|
29
|
+
trace_id: str = Field(..., description="Trace ID for this run")
|
|
30
|
+
final_state: dict[str, Any] = Field(default_factory=dict, description="Final agent state")
|
|
31
|
+
messages: list[Message] = Field(default_factory=list, description="All messages in the run")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Agent(BaseModel):
|
|
35
|
+
"""Agent with model, tools, policy, and memory.
|
|
36
|
+
|
|
37
|
+
Provides a clean API for creating and running agents.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
model_config = {"arbitrary_types_allowed": True}
|
|
41
|
+
|
|
42
|
+
name: str = Field(..., description="Agent name")
|
|
43
|
+
model: Model = Field(..., description="Model used by the agent")
|
|
44
|
+
tools: list[Tool] = Field(default_factory=list, description="Tools available to the agent")
|
|
45
|
+
policy: Policy | dict[str, Any] | None = Field(
|
|
46
|
+
default=None, description="Agent policy or policy configuration"
|
|
47
|
+
)
|
|
48
|
+
memory: Memory | None = Field(default=None, description="Agent memory system")
|
|
49
|
+
tool_filter: ToolFilter | None = Field(
|
|
50
|
+
default=None, description="Agent-level tool allow/deny list"
|
|
51
|
+
)
|
|
52
|
+
trace_dir: Path | None = Field(default=None, description="Directory for trace files")
|
|
53
|
+
|
|
54
|
+
def __init__(self, **data: Any) -> None:
|
|
55
|
+
"""Initialize agent with optional runtime."""
|
|
56
|
+
super().__init__(**data)
|
|
57
|
+
# Create internal runtime if not provided
|
|
58
|
+
if not hasattr(self, "_runtime"):
|
|
59
|
+
trace_dir = data.get("trace_dir")
|
|
60
|
+
if trace_dir is None:
|
|
61
|
+
trace_dir = Path(".routekit") / "traces"
|
|
62
|
+
self._runtime = Runtime(trace_dir=trace_dir)
|
|
63
|
+
self._runtime.register_agent(self)
|
|
64
|
+
|
|
65
|
+
async def run(self, prompt: str, **kwargs: Any) -> RunResult:
|
|
66
|
+
"""Run the agent with a prompt.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
prompt: User prompt
|
|
70
|
+
**kwargs: Additional run parameters
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
RunResult with output, trace_id, final_state, and messages
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
PolicyError: If agent policy fails
|
|
77
|
+
"""
|
|
78
|
+
# Convert policy to PolicyAdapter if needed
|
|
79
|
+
policy_adapter = None
|
|
80
|
+
if self.policy:
|
|
81
|
+
if isinstance(self.policy, Policy):
|
|
82
|
+
policy_adapter = PolicyAdapter(self.policy)
|
|
83
|
+
elif isinstance(self.policy, dict):
|
|
84
|
+
# Legacy dict-based policy config - convert to appropriate policy
|
|
85
|
+
from routekitai.core.policies import ReActPolicy
|
|
86
|
+
|
|
87
|
+
policy_adapter = PolicyAdapter(ReActPolicy(**self.policy))
|
|
88
|
+
|
|
89
|
+
# Run via runtime
|
|
90
|
+
return await self._runtime.run(self.name, prompt, policy=policy_adapter, **kwargs)
|
|
91
|
+
|
|
92
|
+
async def run_stream(self, prompt: str, **kwargs: Any) -> AsyncIterator[dict[str, Any]]:
|
|
93
|
+
"""Run the agent with streaming updates.
|
|
94
|
+
|
|
95
|
+
Yields trace events and progress updates in real-time.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
prompt: User prompt
|
|
99
|
+
**kwargs: Additional run parameters
|
|
100
|
+
|
|
101
|
+
Yields:
|
|
102
|
+
Dicts with event information:
|
|
103
|
+
- type: Event type (trace_event, progress_update, result)
|
|
104
|
+
- data: Event data
|
|
105
|
+
- result: Final RunResult (only in last event)
|
|
106
|
+
|
|
107
|
+
Examples:
|
|
108
|
+
>>> async for event in agent.run_stream("Hello"):
|
|
109
|
+
... if event["type"] == "trace_event":
|
|
110
|
+
... print(f"Event: {event['data']['type']}")
|
|
111
|
+
... elif event["type"] == "progress_update":
|
|
112
|
+
... print(f"Progress: {event['data']['progress_percent']}%")
|
|
113
|
+
"""
|
|
114
|
+
# Use queues to collect events and progress updates
|
|
115
|
+
trace_queue: asyncio.Queue[TraceEvent] = asyncio.Queue()
|
|
116
|
+
progress_queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
|
117
|
+
result_queue: asyncio.Queue[RunResult] = asyncio.Queue()
|
|
118
|
+
|
|
119
|
+
def trace_callback(event: TraceEvent) -> None:
|
|
120
|
+
trace_queue.put_nowait(event)
|
|
121
|
+
|
|
122
|
+
def progress_callback(progress: dict[str, Any]) -> None:
|
|
123
|
+
progress_queue.put_nowait(progress)
|
|
124
|
+
|
|
125
|
+
# Convert policy to PolicyAdapter if needed
|
|
126
|
+
policy_adapter = None
|
|
127
|
+
if self.policy:
|
|
128
|
+
if isinstance(self.policy, Policy):
|
|
129
|
+
policy_adapter = PolicyAdapter(self.policy)
|
|
130
|
+
elif isinstance(self.policy, dict):
|
|
131
|
+
from routekitai.core.policies import ReActPolicy
|
|
132
|
+
|
|
133
|
+
policy_adapter = PolicyAdapter(ReActPolicy(**self.policy))
|
|
134
|
+
|
|
135
|
+
# Add callbacks to runtime
|
|
136
|
+
self._runtime.add_progress_callback(progress_callback)
|
|
137
|
+
|
|
138
|
+
# Create a trace to capture events
|
|
139
|
+
trace_id = str(uuid.uuid4())
|
|
140
|
+
trace = Trace(trace_id=trace_id, metadata={"agent": self.name, "prompt": prompt})
|
|
141
|
+
trace.add_event_callback(trace_callback)
|
|
142
|
+
|
|
143
|
+
# Run agent in background task
|
|
144
|
+
async def run_agent() -> None:
|
|
145
|
+
try:
|
|
146
|
+
result = await self._runtime.run(self.name, prompt, policy=policy_adapter, **kwargs)
|
|
147
|
+
result_queue.put_nowait(result)
|
|
148
|
+
except Exception as e:
|
|
149
|
+
# Put error in result queue
|
|
150
|
+
result_queue.put_nowait(None) # type: ignore[arg-type]
|
|
151
|
+
trace_queue.put_nowait(
|
|
152
|
+
TraceEvent(
|
|
153
|
+
type="error",
|
|
154
|
+
timestamp=asyncio.get_event_loop().time(),
|
|
155
|
+
data={"error": str(e), "error_type": type(e).__name__},
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
run_task = asyncio.create_task(run_agent())
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
# Stream events as they arrive
|
|
163
|
+
while True:
|
|
164
|
+
# Check all queues with timeout
|
|
165
|
+
done, pending = await asyncio.wait(
|
|
166
|
+
[
|
|
167
|
+
asyncio.create_task(trace_queue.get()),
|
|
168
|
+
asyncio.create_task(progress_queue.get()),
|
|
169
|
+
asyncio.create_task(result_queue.get()),
|
|
170
|
+
run_task,
|
|
171
|
+
],
|
|
172
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
173
|
+
timeout=0.1,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if not done:
|
|
177
|
+
continue
|
|
178
|
+
|
|
179
|
+
for task in done:
|
|
180
|
+
try:
|
|
181
|
+
result = await task
|
|
182
|
+
if task == run_task:
|
|
183
|
+
# Agent run completed
|
|
184
|
+
if result is not None:
|
|
185
|
+
# Type guard: result should be RunResult when task is run_task
|
|
186
|
+
from routekitai.core.agent import RunResult
|
|
187
|
+
|
|
188
|
+
assert isinstance(result, RunResult), (
|
|
189
|
+
"Expected RunResult from run_task"
|
|
190
|
+
)
|
|
191
|
+
yield {
|
|
192
|
+
"type": "result",
|
|
193
|
+
"result": {
|
|
194
|
+
"output": result.output.model_dump(mode="json"),
|
|
195
|
+
"trace_id": result.trace_id,
|
|
196
|
+
"final_state": result.final_state,
|
|
197
|
+
"messages": [
|
|
198
|
+
msg.model_dump(mode="json") for msg in result.messages
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
return
|
|
203
|
+
elif isinstance(result, TraceEvent):
|
|
204
|
+
yield {
|
|
205
|
+
"type": "trace_event",
|
|
206
|
+
"data": {
|
|
207
|
+
"type": result.type,
|
|
208
|
+
"timestamp": result.timestamp,
|
|
209
|
+
"data": result.data,
|
|
210
|
+
},
|
|
211
|
+
}
|
|
212
|
+
elif isinstance(result, dict):
|
|
213
|
+
yield {"type": "progress_update", "data": result}
|
|
214
|
+
except asyncio.CancelledError:
|
|
215
|
+
return
|
|
216
|
+
except Exception:
|
|
217
|
+
pass
|
|
218
|
+
|
|
219
|
+
# Cancel pending tasks
|
|
220
|
+
for task in pending:
|
|
221
|
+
task.cancel()
|
|
222
|
+
|
|
223
|
+
finally:
|
|
224
|
+
# Clean up callbacks
|
|
225
|
+
self._runtime.remove_progress_callback(progress_callback)
|
|
226
|
+
if not run_task.done():
|
|
227
|
+
run_task.cancel()
|
|
228
|
+
try:
|
|
229
|
+
await run_task
|
|
230
|
+
except asyncio.CancelledError:
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
def run_sync(self, prompt: str, **kwargs: Any) -> RunResult:
|
|
234
|
+
"""Synchronous wrapper for agent.run().
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
prompt: User prompt
|
|
238
|
+
**kwargs: Additional run parameters
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
RunResult with output, trace_id, final_state, and messages
|
|
242
|
+
|
|
243
|
+
Raises:
|
|
244
|
+
RuntimeError: If called from within an async context
|
|
245
|
+
"""
|
|
246
|
+
try:
|
|
247
|
+
asyncio.get_running_loop()
|
|
248
|
+
# We're in an async context - need to use a different approach
|
|
249
|
+
# Try to use nest_asyncio if available, otherwise raise error
|
|
250
|
+
try:
|
|
251
|
+
import nest_asyncio
|
|
252
|
+
|
|
253
|
+
nest_asyncio.apply()
|
|
254
|
+
return asyncio.run(self.run(prompt, **kwargs))
|
|
255
|
+
except ImportError:
|
|
256
|
+
raise RuntimeError(
|
|
257
|
+
"Cannot use run_sync() in an async context. "
|
|
258
|
+
"Either use 'await agent.run()' or install nest_asyncio: pip install nest-asyncio"
|
|
259
|
+
) from None
|
|
260
|
+
except RuntimeError:
|
|
261
|
+
# No event loop exists, safe to use asyncio.run
|
|
262
|
+
return asyncio.run(self.run(prompt, **kwargs))
|
|
263
|
+
|
|
264
|
+
@classmethod
|
|
265
|
+
def with_fake_model(
|
|
266
|
+
cls,
|
|
267
|
+
name: str,
|
|
268
|
+
responses: list[str | dict[str, Any]],
|
|
269
|
+
tools: list[Tool] | None = None,
|
|
270
|
+
) -> Agent:
|
|
271
|
+
"""Create agent with FakeModel for testing.
|
|
272
|
+
|
|
273
|
+
Args:
|
|
274
|
+
name: Agent name
|
|
275
|
+
responses: List of responses to queue in FakeModel
|
|
276
|
+
tools: Optional list of tools
|
|
277
|
+
|
|
278
|
+
Returns:
|
|
279
|
+
Agent instance with FakeModel
|
|
280
|
+
|
|
281
|
+
Examples:
|
|
282
|
+
>>> agent = Agent.with_fake_model("test", ["Hello!", "World!"])
|
|
283
|
+
>>> result = await agent.run("Hi")
|
|
284
|
+
"""
|
|
285
|
+
from routekitai.providers.local import FakeModel
|
|
286
|
+
|
|
287
|
+
model = FakeModel(name=f"{name}_model")
|
|
288
|
+
for response in responses:
|
|
289
|
+
model.add_response(response)
|
|
290
|
+
return cls(name=name, model=model, tools=tools or [])
|
|
291
|
+
|
|
292
|
+
@classmethod
|
|
293
|
+
def with_graph_policy(
|
|
294
|
+
cls,
|
|
295
|
+
name: str,
|
|
296
|
+
graph: Graph,
|
|
297
|
+
model: Model,
|
|
298
|
+
runtime: Runtime | None = None,
|
|
299
|
+
**kwargs: Any,
|
|
300
|
+
) -> Agent:
|
|
301
|
+
"""Create agent with graph policy.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
name: Agent name
|
|
305
|
+
graph: Graph to execute
|
|
306
|
+
model: Model instance
|
|
307
|
+
runtime: Optional runtime (will create one if not provided)
|
|
308
|
+
**kwargs: Additional agent parameters
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
Agent instance with GraphPolicy
|
|
312
|
+
|
|
313
|
+
Examples:
|
|
314
|
+
>>> from routekitai.graphs import Graph, GraphNode, NodeType
|
|
315
|
+
>>> graph = Graph(name="test", entry_node="start", nodes=[...])
|
|
316
|
+
>>> agent = Agent.with_graph_policy("graph_agent", graph, model)
|
|
317
|
+
"""
|
|
318
|
+
|
|
319
|
+
from routekitai.core.policies import GraphPolicy
|
|
320
|
+
|
|
321
|
+
if runtime is None:
|
|
322
|
+
runtime = Runtime(trace_dir=kwargs.get("trace_dir"))
|
|
323
|
+
|
|
324
|
+
policy = GraphPolicy(graph=graph, runtime=runtime)
|
|
325
|
+
return cls(name=name, model=model, policy=policy, **kwargs)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Well-typed exceptions for RouteKit."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RouteKitError(Exception):
|
|
7
|
+
"""Base exception for all routkitai errors."""
|
|
8
|
+
|
|
9
|
+
def __init__(self, message: str, context: dict[str, Any] | None = None) -> None:
|
|
10
|
+
"""Initialize error with message and optional context.
|
|
11
|
+
|
|
12
|
+
Args:
|
|
13
|
+
message: Error message
|
|
14
|
+
context: Optional context dictionary (trace_id, agent_name, step_id, etc.)
|
|
15
|
+
"""
|
|
16
|
+
super().__init__(message)
|
|
17
|
+
self.message = message
|
|
18
|
+
self.context = context or {}
|
|
19
|
+
|
|
20
|
+
def __str__(self) -> str:
|
|
21
|
+
"""Return error message with context if available."""
|
|
22
|
+
if self.context:
|
|
23
|
+
context_str = ", ".join(f"{k}={v}" for k, v in self.context.items())
|
|
24
|
+
return f"{self.message} (context: {context_str})"
|
|
25
|
+
return self.message
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ModelError(RouteKitError):
|
|
29
|
+
"""Error raised when model operations fail."""
|
|
30
|
+
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ToolError(RouteKitError):
|
|
35
|
+
"""Error raised when tool execution fails."""
|
|
36
|
+
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class PolicyError(RouteKitError):
|
|
41
|
+
"""Error raised when agent policy fails."""
|
|
42
|
+
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class RuntimeError(RouteKitError):
|
|
47
|
+
"""Error raised when runtime operations fail."""
|
|
48
|
+
|
|
49
|
+
pass
|
routekitai/core/hooks.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Policy hooks for routkitai runtime."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PIIRedactionHook(BaseModel):
|
|
11
|
+
"""PII redaction hook for redacting sensitive information.
|
|
12
|
+
|
|
13
|
+
Uses regex patterns to identify and redact PII like emails and phone numbers.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
redact_emails: bool = Field(default=True, description="Redact email addresses")
|
|
17
|
+
redact_phones: bool = Field(default=True, description="Redact phone numbers")
|
|
18
|
+
redact_patterns: list[tuple[str, str]] = Field(
|
|
19
|
+
default_factory=list, description="Custom (pattern, replacement) tuples"
|
|
20
|
+
)
|
|
21
|
+
replacement: str = Field(
|
|
22
|
+
default="[REDACTED]", description="Replacement string for redacted content"
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Compiled regex patterns
|
|
26
|
+
_email_pattern: re.Pattern[str] | None = None
|
|
27
|
+
_phone_pattern: re.Pattern[str] | None = None
|
|
28
|
+
|
|
29
|
+
def __init__(self, **kwargs: Any) -> None:
|
|
30
|
+
"""Initialize PII redaction hook."""
|
|
31
|
+
super().__init__(**kwargs)
|
|
32
|
+
if self.redact_emails:
|
|
33
|
+
self._email_pattern = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b")
|
|
34
|
+
if self.redact_phones:
|
|
35
|
+
# Matches various phone formats
|
|
36
|
+
self._phone_pattern = re.compile(
|
|
37
|
+
r"(\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def redact(self, text: str) -> str:
|
|
41
|
+
"""Redact PII from text.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
text: Text to redact
|
|
45
|
+
|
|
46
|
+
Returns:
|
|
47
|
+
Text with PII redacted
|
|
48
|
+
"""
|
|
49
|
+
result = text
|
|
50
|
+
|
|
51
|
+
if self._email_pattern:
|
|
52
|
+
result = self._email_pattern.sub(self.replacement, result)
|
|
53
|
+
|
|
54
|
+
if self._phone_pattern:
|
|
55
|
+
result = self._phone_pattern.sub(self.replacement, result)
|
|
56
|
+
|
|
57
|
+
# Apply custom patterns
|
|
58
|
+
for pattern, replacement in self.redact_patterns:
|
|
59
|
+
result = re.sub(pattern, replacement, result)
|
|
60
|
+
|
|
61
|
+
return result
|
|
62
|
+
|
|
63
|
+
def redact_dict(self, data: dict[str, Any]) -> dict[str, Any]:
|
|
64
|
+
"""Recursively redact PII from a dictionary.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
data: Dictionary to redact
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Dictionary with PII redacted
|
|
71
|
+
"""
|
|
72
|
+
redacted: dict[str, Any] = {}
|
|
73
|
+
for key, value in data.items():
|
|
74
|
+
if isinstance(value, str):
|
|
75
|
+
redacted[key] = self.redact(value)
|
|
76
|
+
elif isinstance(value, dict):
|
|
77
|
+
redacted[key] = self.redact_dict(value)
|
|
78
|
+
elif isinstance(value, list):
|
|
79
|
+
redacted[key] = [
|
|
80
|
+
self.redact(item) if isinstance(item, str) else item for item in value
|
|
81
|
+
]
|
|
82
|
+
else:
|
|
83
|
+
redacted[key] = value
|
|
84
|
+
return redacted
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class ToolFilter(BaseModel):
|
|
88
|
+
"""Tool allow/deny list filter."""
|
|
89
|
+
|
|
90
|
+
allowed_tools: list[str] | None = Field(
|
|
91
|
+
default=None, description="List of allowed tool names (None = allow all)"
|
|
92
|
+
)
|
|
93
|
+
denied_tools: list[str] = Field(default_factory=list, description="List of denied tool names")
|
|
94
|
+
|
|
95
|
+
def is_allowed(self, tool_name: str) -> bool:
|
|
96
|
+
"""Check if a tool is allowed.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
tool_name: Name of the tool
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
True if tool is allowed, False otherwise
|
|
103
|
+
"""
|
|
104
|
+
# Deny list takes precedence
|
|
105
|
+
if tool_name in self.denied_tools:
|
|
106
|
+
return False
|
|
107
|
+
|
|
108
|
+
# If allow list is set, tool must be in it
|
|
109
|
+
if self.allowed_tools is not None:
|
|
110
|
+
return tool_name in self.allowed_tools
|
|
111
|
+
|
|
112
|
+
# Default: allow if not in deny list
|
|
113
|
+
return True
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ApprovalGate(BaseModel):
|
|
117
|
+
"""Approval gate for blocking tools until approved.
|
|
118
|
+
|
|
119
|
+
Uses a callback function to determine if a tool should be approved.
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
approval_callback: Callable[[str, dict[str, Any]], bool] | None = Field(
|
|
123
|
+
default=None, description="Callback(tool_name, tool_args) -> bool"
|
|
124
|
+
)
|
|
125
|
+
require_approval_for_permissions: list[str] = Field(
|
|
126
|
+
default_factory=list, description="Permissions that require approval"
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def requires_approval(
|
|
130
|
+
self, tool_name: str, tool_args: dict[str, Any], tool_permissions: list[str]
|
|
131
|
+
) -> bool:
|
|
132
|
+
"""Check if a tool requires approval.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
tool_name: Name of the tool
|
|
136
|
+
tool_args: Tool arguments
|
|
137
|
+
tool_permissions: Tool permissions
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
True if approval is required
|
|
141
|
+
"""
|
|
142
|
+
# Check if tool has permissions that require approval
|
|
143
|
+
if any(perm in self.require_approval_for_permissions for perm in tool_permissions):
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
# Check callback if provided
|
|
147
|
+
if self.approval_callback:
|
|
148
|
+
return not self.approval_callback(tool_name, tool_args)
|
|
149
|
+
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
def is_approved(self, tool_name: str, tool_args: dict[str, Any]) -> bool:
|
|
153
|
+
"""Check if a tool is approved.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
tool_name: Name of the tool
|
|
157
|
+
tool_args: Tool arguments
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
True if approved, False otherwise
|
|
161
|
+
"""
|
|
162
|
+
if self.approval_callback:
|
|
163
|
+
return self.approval_callback(tool_name, tool_args)
|
|
164
|
+
return True # Default: approved if no callback
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class PolicyHooks(BaseModel):
|
|
168
|
+
"""Collection of policy hooks for runtime."""
|
|
169
|
+
|
|
170
|
+
pii_redaction: PIIRedactionHook | None = Field(
|
|
171
|
+
default=None, description="PII redaction hook for messages and tool arguments"
|
|
172
|
+
)
|
|
173
|
+
tool_filter: ToolFilter | None = Field(default=None, description="Tool allow/deny list")
|
|
174
|
+
approval_gate: ApprovalGate | None = Field(default=None, description="Approval gate for tools")
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Memory interface for routkitai agents."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Memory(ABC):
|
|
8
|
+
"""Base interface for agent memory systems."""
|
|
9
|
+
|
|
10
|
+
@abstractmethod
|
|
11
|
+
async def get(self, key: str) -> Any:
|
|
12
|
+
"""Get value by key.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
key: Key to retrieve
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Stored value or None if not found
|
|
19
|
+
"""
|
|
20
|
+
raise NotImplementedError("Subclasses must implement get")
|
|
21
|
+
|
|
22
|
+
@abstractmethod
|
|
23
|
+
async def set(self, key: str, value: Any) -> None:
|
|
24
|
+
"""Set value by key.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
key: Key to set
|
|
28
|
+
value: Value to store
|
|
29
|
+
"""
|
|
30
|
+
raise NotImplementedError("Subclasses must implement set")
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
async def append(self, event: dict[str, Any]) -> None:
|
|
34
|
+
"""Append an event to memory.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
event: Event dictionary to append
|
|
38
|
+
"""
|
|
39
|
+
raise NotImplementedError("Subclasses must implement append")
|
|
40
|
+
|
|
41
|
+
async def search(self, query: str, k: int = 5) -> list[dict[str, Any]]:
|
|
42
|
+
"""Search memory (optional for retrieval memory).
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
query: Search query
|
|
46
|
+
k: Number of results to return
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
List of matching results
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
NotImplementedError: If search is not supported
|
|
53
|
+
"""
|
|
54
|
+
raise NotImplementedError("Search not supported by this memory type")
|