agenthandler 0.92.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.
Potentially problematic release.
This version of agenthandler might be problematic. Click here for more details.
- agenthandler/__init__.py +163 -0
- agenthandler/approval.py +135 -0
- agenthandler/audit.py +144 -0
- agenthandler/auth.py +188 -0
- agenthandler/budget.py +94 -0
- agenthandler/builder.py +178 -0
- agenthandler/circuit_breaker.py +88 -0
- agenthandler/connectors.py +425 -0
- agenthandler/context.py +410 -0
- agenthandler/errors.py +130 -0
- agenthandler/guardrails/__init__.py +79 -0
- agenthandler/guardrails/deterministic.py +472 -0
- agenthandler/guardrails/llm_based.py +549 -0
- agenthandler/guardrails/types.py +83 -0
- agenthandler/observe.py +525 -0
- agenthandler/openclaw.py +370 -0
- agenthandler/pipeline.py +532 -0
- agenthandler/pipeline_poml.py +593 -0
- agenthandler/policy.py +251 -0
- agenthandler/py.typed +0 -0
- agenthandler/redact.py +260 -0
- agenthandler/reflection.py +371 -0
- agenthandler/replay.py +225 -0
- agenthandler/server.py +752 -0
- agenthandler/session.py +391 -0
- agenthandler/skill.py +265 -0
- agenthandler/store.py +341 -0
- agenthandler/streams.py +339 -0
- agenthandler/supervisor.py +882 -0
- agenthandler/triggers.py +471 -0
- agenthandler-0.92.0.dist-info/METADATA +1355 -0
- agenthandler-0.92.0.dist-info/RECORD +34 -0
- agenthandler-0.92.0.dist-info/WHEEL +4 -0
- agenthandler-0.92.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
"""Database connectors — supervised query tools for SQL and NoSQL.
|
|
2
|
+
|
|
3
|
+
Give agents the ability to query databases as regular supervised tool calls.
|
|
4
|
+
Results flow through the full AgentHandler pipeline: PII redaction, audit logging,
|
|
5
|
+
tool scoping, budgets, and observability.
|
|
6
|
+
|
|
7
|
+
Connectors return async callables that work with ``supervisor.call()`` and
|
|
8
|
+
``Pipeline.add_step()``.
|
|
9
|
+
|
|
10
|
+
SQL (PostgreSQL, MySQL, SQLite) — uses DB-API 2.0 (stdlib sqlite3 or
|
|
11
|
+
any PEP 249 driver). No required dependencies beyond stdlib.
|
|
12
|
+
|
|
13
|
+
MongoDB — optional, requires ``pymongo``.
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
from agenthandler.connectors import SqlConnector
|
|
17
|
+
|
|
18
|
+
sql = SqlConnector("sqlite:///data.db")
|
|
19
|
+
result = await supervisor.call("query", sql.query, sql="SELECT * FROM users LIMIT 10")
|
|
20
|
+
|
|
21
|
+
# In a pipeline
|
|
22
|
+
pipeline.add_step("get_metrics", sql.query, sql="SELECT avg(price) FROM orders")
|
|
23
|
+
pipeline.add_step("analyze", my_analyze_fn)
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
import sqlite3
|
|
30
|
+
from typing import Any, Dict, List, Optional
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SqlConnector:
|
|
34
|
+
"""Supervised SQL query tool.
|
|
35
|
+
|
|
36
|
+
Connects to SQLite (stdlib), PostgreSQL, or MySQL via DB-API 2.0.
|
|
37
|
+
Returns an async callable for use with Supervisor.call().
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
connection_string: Database connection string.
|
|
41
|
+
- SQLite: "sqlite:///path/to/db.sqlite" or "sqlite://:memory:"
|
|
42
|
+
- PostgreSQL: "postgresql://user:pass@host/db" (requires psycopg2)
|
|
43
|
+
- MySQL: "mysql://user:pass@host/db" (requires mysql-connector-python)
|
|
44
|
+
allow_write: If False (default), only SELECT statements are allowed.
|
|
45
|
+
INSERT, UPDATE, DELETE, DROP, ALTER, etc. are blocked.
|
|
46
|
+
max_rows: Maximum rows to return per query (default: 1000).
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
connection_string: str,
|
|
52
|
+
allow_write: bool = False,
|
|
53
|
+
max_rows: int = 1000,
|
|
54
|
+
):
|
|
55
|
+
self._conn_str = connection_string
|
|
56
|
+
self._allow_write = allow_write
|
|
57
|
+
self._max_rows = max_rows
|
|
58
|
+
self._conn: Any = None
|
|
59
|
+
|
|
60
|
+
def _connect(self) -> Any:
|
|
61
|
+
"""Lazily connect to the database."""
|
|
62
|
+
if self._conn is not None:
|
|
63
|
+
return self._conn
|
|
64
|
+
|
|
65
|
+
if self._conn_str.startswith("sqlite:///"):
|
|
66
|
+
path = self._conn_str[len("sqlite:///") :]
|
|
67
|
+
self._conn = sqlite3.connect(path)
|
|
68
|
+
self._conn.row_factory = sqlite3.Row
|
|
69
|
+
elif self._conn_str == "sqlite://:memory:":
|
|
70
|
+
self._conn = sqlite3.connect(":memory:")
|
|
71
|
+
self._conn.row_factory = sqlite3.Row
|
|
72
|
+
elif self._conn_str.startswith("postgresql://"):
|
|
73
|
+
try:
|
|
74
|
+
import psycopg2 # type: ignore[import-untyped]
|
|
75
|
+
import psycopg2.extras # type: ignore[import-untyped]
|
|
76
|
+
|
|
77
|
+
self._conn = psycopg2.connect(self._conn_str)
|
|
78
|
+
except ImportError as e:
|
|
79
|
+
raise ImportError(
|
|
80
|
+
"psycopg2 required for PostgreSQL: pip install psycopg2-binary"
|
|
81
|
+
) from e
|
|
82
|
+
elif self._conn_str.startswith("mysql://"):
|
|
83
|
+
try:
|
|
84
|
+
import mysql.connector # type: ignore[import-not-found]
|
|
85
|
+
|
|
86
|
+
# Parse mysql://user:pass@host/db
|
|
87
|
+
parsed = re.match(r"mysql://([^:]+):([^@]+)@([^/]+)/(.+)", self._conn_str)
|
|
88
|
+
if not parsed:
|
|
89
|
+
raise ValueError(f"Invalid MySQL connection string: {self._conn_str}")
|
|
90
|
+
self._conn = mysql.connector.connect(
|
|
91
|
+
user=parsed.group(1),
|
|
92
|
+
password=parsed.group(2),
|
|
93
|
+
host=parsed.group(3),
|
|
94
|
+
database=parsed.group(4),
|
|
95
|
+
)
|
|
96
|
+
except ImportError as e:
|
|
97
|
+
raise ImportError(
|
|
98
|
+
"mysql-connector-python required for MySQL: pip install mysql-connector-python"
|
|
99
|
+
) from e
|
|
100
|
+
else:
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"Unsupported connection string: {self._conn_str}. "
|
|
103
|
+
"Use sqlite:///path, postgresql://..., or mysql://..."
|
|
104
|
+
)
|
|
105
|
+
return self._conn
|
|
106
|
+
|
|
107
|
+
def _check_write(self, sql: str) -> None:
|
|
108
|
+
"""Block write operations if allow_write is False."""
|
|
109
|
+
if self._allow_write:
|
|
110
|
+
return
|
|
111
|
+
normalized = sql.strip().upper()
|
|
112
|
+
write_ops = ("INSERT", "UPDATE", "DELETE", "DROP", "ALTER", "CREATE", "TRUNCATE", "REPLACE")
|
|
113
|
+
for op in write_ops:
|
|
114
|
+
if normalized.startswith(op):
|
|
115
|
+
raise PermissionError(
|
|
116
|
+
f"Write operation '{op}' blocked. Set allow_write=True to enable."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
async def query(
|
|
120
|
+
self,
|
|
121
|
+
sql: str = "",
|
|
122
|
+
params: Optional[Dict[str, Any]] = None,
|
|
123
|
+
input: str = "",
|
|
124
|
+
) -> Dict[str, Any]:
|
|
125
|
+
"""Execute a SQL query and return results.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
sql: The SQL query to execute. If empty, uses ``input`` (for pipeline chaining).
|
|
129
|
+
params: Query parameters (for parameterized queries).
|
|
130
|
+
input: Alternative to ``sql`` — used when chained from a previous pipeline step.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Dict with "rows" (list of dicts), "row_count", and "columns".
|
|
134
|
+
"""
|
|
135
|
+
query_str = sql or input
|
|
136
|
+
if not query_str:
|
|
137
|
+
return {"rows": [], "row_count": 0, "columns": []}
|
|
138
|
+
|
|
139
|
+
self._check_write(query_str)
|
|
140
|
+
conn = self._connect()
|
|
141
|
+
cursor = conn.cursor()
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
if params:
|
|
145
|
+
cursor.execute(query_str, params)
|
|
146
|
+
else:
|
|
147
|
+
cursor.execute(query_str)
|
|
148
|
+
|
|
149
|
+
# For SELECT queries, fetch results
|
|
150
|
+
if cursor.description:
|
|
151
|
+
columns = [desc[0] for desc in cursor.description]
|
|
152
|
+
rows_raw = cursor.fetchmany(self._max_rows)
|
|
153
|
+
rows = [dict(zip(columns, row)) for row in rows_raw]
|
|
154
|
+
return {
|
|
155
|
+
"rows": rows,
|
|
156
|
+
"row_count": len(rows),
|
|
157
|
+
"columns": columns,
|
|
158
|
+
}
|
|
159
|
+
else:
|
|
160
|
+
# Write operation (INSERT/UPDATE/DELETE)
|
|
161
|
+
conn.commit()
|
|
162
|
+
return {
|
|
163
|
+
"rows": [],
|
|
164
|
+
"row_count": cursor.rowcount,
|
|
165
|
+
"columns": [],
|
|
166
|
+
"affected_rows": cursor.rowcount,
|
|
167
|
+
}
|
|
168
|
+
finally:
|
|
169
|
+
cursor.close()
|
|
170
|
+
|
|
171
|
+
async def execute(
|
|
172
|
+
self,
|
|
173
|
+
sql: str = "",
|
|
174
|
+
params: Optional[Dict[str, Any]] = None,
|
|
175
|
+
input: str = "",
|
|
176
|
+
) -> Dict[str, Any]:
|
|
177
|
+
"""Execute a write SQL statement. Alias for query() with write intent.
|
|
178
|
+
|
|
179
|
+
Raises PermissionError if allow_write is False.
|
|
180
|
+
"""
|
|
181
|
+
return await self.query(sql=sql, params=params, input=input)
|
|
182
|
+
|
|
183
|
+
async def discover_schema(self) -> Dict[str, Any]:
|
|
184
|
+
"""Discover the database schema — tables, columns, types.
|
|
185
|
+
|
|
186
|
+
Returns a dict describing every table and its columns, suitable
|
|
187
|
+
for passing to an LLM so it can write correct SQL.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
{"tables": [{"name": "...", "columns": [{"name": "...", "type": "..."}]}]}
|
|
191
|
+
"""
|
|
192
|
+
conn = self._connect()
|
|
193
|
+
tables: List[Dict[str, Any]] = []
|
|
194
|
+
|
|
195
|
+
if self._conn_str.startswith("sqlite"):
|
|
196
|
+
cursor = conn.cursor()
|
|
197
|
+
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
|
198
|
+
table_names = [row[0] for row in cursor.fetchall()]
|
|
199
|
+
for tname in table_names:
|
|
200
|
+
if tname.startswith("sqlite_"):
|
|
201
|
+
continue
|
|
202
|
+
cursor.execute(f"PRAGMA table_info({tname})") # noqa: S608
|
|
203
|
+
cols = [
|
|
204
|
+
{"name": row[1], "type": row[2], "nullable": not row[3], "pk": bool(row[5])}
|
|
205
|
+
for row in cursor.fetchall()
|
|
206
|
+
]
|
|
207
|
+
# Sample a few rows for context
|
|
208
|
+
cursor.execute(f"SELECT COUNT(*) FROM {tname}") # noqa: S608
|
|
209
|
+
row_count = cursor.fetchone()[0]
|
|
210
|
+
tables.append(
|
|
211
|
+
{
|
|
212
|
+
"name": tname,
|
|
213
|
+
"columns": cols,
|
|
214
|
+
"row_count": row_count,
|
|
215
|
+
}
|
|
216
|
+
)
|
|
217
|
+
cursor.close()
|
|
218
|
+
|
|
219
|
+
elif self._conn_str.startswith("postgresql"):
|
|
220
|
+
cursor = conn.cursor()
|
|
221
|
+
cursor.execute(
|
|
222
|
+
"SELECT table_name FROM information_schema.tables "
|
|
223
|
+
"WHERE table_schema = 'public' ORDER BY table_name"
|
|
224
|
+
)
|
|
225
|
+
table_names = [row[0] for row in cursor.fetchall()]
|
|
226
|
+
for tname in table_names:
|
|
227
|
+
cursor.execute(
|
|
228
|
+
"SELECT column_name, data_type, is_nullable, "
|
|
229
|
+
"column_default FROM information_schema.columns "
|
|
230
|
+
"WHERE table_name = %s ORDER BY ordinal_position",
|
|
231
|
+
(tname,),
|
|
232
|
+
)
|
|
233
|
+
cols = [
|
|
234
|
+
{
|
|
235
|
+
"name": row[0],
|
|
236
|
+
"type": row[1],
|
|
237
|
+
"nullable": row[2] == "YES",
|
|
238
|
+
"default": row[3],
|
|
239
|
+
}
|
|
240
|
+
for row in cursor.fetchall()
|
|
241
|
+
]
|
|
242
|
+
tables.append({"name": tname, "columns": cols})
|
|
243
|
+
cursor.close()
|
|
244
|
+
|
|
245
|
+
elif self._conn_str.startswith("mysql"):
|
|
246
|
+
cursor = conn.cursor()
|
|
247
|
+
cursor.execute("SHOW TABLES")
|
|
248
|
+
table_names = [row[0] for row in cursor.fetchall()]
|
|
249
|
+
for tname in table_names:
|
|
250
|
+
cursor.execute(f"DESCRIBE {tname}") # noqa: S608
|
|
251
|
+
cols = [
|
|
252
|
+
{
|
|
253
|
+
"name": row[0],
|
|
254
|
+
"type": row[1],
|
|
255
|
+
"nullable": row[2] == "YES",
|
|
256
|
+
"pk": row[3] == "PRI",
|
|
257
|
+
}
|
|
258
|
+
for row in cursor.fetchall()
|
|
259
|
+
]
|
|
260
|
+
tables.append({"name": tname, "columns": cols})
|
|
261
|
+
cursor.close()
|
|
262
|
+
|
|
263
|
+
return {"tables": tables}
|
|
264
|
+
|
|
265
|
+
def schema_to_text(self, schema: Dict[str, Any]) -> str:
|
|
266
|
+
"""Format a schema dict as human-readable text for LLM prompts.
|
|
267
|
+
|
|
268
|
+
Example output:
|
|
269
|
+
Table: orders (1500 rows)
|
|
270
|
+
- id INTEGER (PK)
|
|
271
|
+
- product TEXT
|
|
272
|
+
- amount REAL
|
|
273
|
+
"""
|
|
274
|
+
lines = []
|
|
275
|
+
for table in schema.get("tables", []):
|
|
276
|
+
header = f"Table: {table['name']}"
|
|
277
|
+
if "row_count" in table:
|
|
278
|
+
header += f" ({table['row_count']} rows)"
|
|
279
|
+
lines.append(header)
|
|
280
|
+
for col in table.get("columns", []):
|
|
281
|
+
parts = [f" - {col['name']} {col.get('type', '')}"]
|
|
282
|
+
if col.get("pk"):
|
|
283
|
+
parts.append("(PK)")
|
|
284
|
+
if col.get("nullable") is False:
|
|
285
|
+
parts.append("NOT NULL")
|
|
286
|
+
lines.append(" ".join(parts))
|
|
287
|
+
lines.append("")
|
|
288
|
+
return "\n".join(lines)
|
|
289
|
+
|
|
290
|
+
def close(self) -> None:
|
|
291
|
+
"""Close the database connection."""
|
|
292
|
+
if self._conn is not None:
|
|
293
|
+
self._conn.close()
|
|
294
|
+
self._conn = None
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
class MongoConnector:
|
|
298
|
+
"""Supervised MongoDB query tool.
|
|
299
|
+
|
|
300
|
+
Requires ``pymongo``: pip install pymongo
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
uri: MongoDB connection URI (e.g., "mongodb://localhost:27017").
|
|
304
|
+
database: Database name.
|
|
305
|
+
allow_write: If False (default), only find operations are allowed.
|
|
306
|
+
max_docs: Maximum documents to return per query (default: 1000).
|
|
307
|
+
"""
|
|
308
|
+
|
|
309
|
+
def __init__(
|
|
310
|
+
self,
|
|
311
|
+
uri: str = "mongodb://localhost:27017",
|
|
312
|
+
database: str = "default",
|
|
313
|
+
allow_write: bool = False,
|
|
314
|
+
max_docs: int = 1000,
|
|
315
|
+
):
|
|
316
|
+
self._uri = uri
|
|
317
|
+
self._database = database
|
|
318
|
+
self._allow_write = allow_write
|
|
319
|
+
self._max_docs = max_docs
|
|
320
|
+
self._client: Any = None
|
|
321
|
+
self._db: Any = None
|
|
322
|
+
|
|
323
|
+
def _connect(self) -> Any:
|
|
324
|
+
"""Lazily connect to MongoDB."""
|
|
325
|
+
if self._db is not None:
|
|
326
|
+
return self._db
|
|
327
|
+
try:
|
|
328
|
+
import pymongo # type: ignore[import-not-found]
|
|
329
|
+
|
|
330
|
+
self._client = pymongo.MongoClient(self._uri)
|
|
331
|
+
self._db = self._client[self._database]
|
|
332
|
+
except ImportError as e:
|
|
333
|
+
raise ImportError("pymongo required: pip install pymongo") from e
|
|
334
|
+
return self._db
|
|
335
|
+
|
|
336
|
+
async def find(
|
|
337
|
+
self,
|
|
338
|
+
collection: str = "",
|
|
339
|
+
filter: Optional[Dict[str, Any]] = None,
|
|
340
|
+
projection: Optional[Dict[str, Any]] = None,
|
|
341
|
+
sort: Optional[List[Any]] = None,
|
|
342
|
+
limit: Optional[int] = None,
|
|
343
|
+
input: str = "",
|
|
344
|
+
) -> Dict[str, Any]:
|
|
345
|
+
"""Query a MongoDB collection.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
collection: Collection name.
|
|
349
|
+
filter: MongoDB query filter.
|
|
350
|
+
projection: Fields to include/exclude.
|
|
351
|
+
sort: Sort specification.
|
|
352
|
+
limit: Max documents (capped at max_docs).
|
|
353
|
+
input: Unused — for pipeline chaining compatibility.
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
Dict with "documents" (list of dicts) and "count".
|
|
357
|
+
"""
|
|
358
|
+
db = self._connect()
|
|
359
|
+
coll = db[collection]
|
|
360
|
+
cursor = coll.find(
|
|
361
|
+
filter=filter or {},
|
|
362
|
+
projection=projection,
|
|
363
|
+
)
|
|
364
|
+
if sort:
|
|
365
|
+
cursor = cursor.sort(sort)
|
|
366
|
+
cursor = cursor.limit(min(limit or self._max_docs, self._max_docs))
|
|
367
|
+
|
|
368
|
+
docs = []
|
|
369
|
+
for doc in cursor:
|
|
370
|
+
# Convert ObjectId to string for JSON serialization
|
|
371
|
+
if "_id" in doc:
|
|
372
|
+
doc["_id"] = str(doc["_id"])
|
|
373
|
+
docs.append(doc)
|
|
374
|
+
|
|
375
|
+
return {"documents": docs, "count": len(docs)}
|
|
376
|
+
|
|
377
|
+
async def insert(
|
|
378
|
+
self,
|
|
379
|
+
collection: str = "",
|
|
380
|
+
documents: Optional[List[Dict[str, Any]]] = None,
|
|
381
|
+
input: str = "",
|
|
382
|
+
) -> Dict[str, Any]:
|
|
383
|
+
"""Insert documents into a MongoDB collection.
|
|
384
|
+
|
|
385
|
+
Raises PermissionError if allow_write is False.
|
|
386
|
+
"""
|
|
387
|
+
if not self._allow_write:
|
|
388
|
+
raise PermissionError("Write operation blocked. Set allow_write=True to enable.")
|
|
389
|
+
db = self._connect()
|
|
390
|
+
coll = db[collection]
|
|
391
|
+
docs = documents or []
|
|
392
|
+
if not docs:
|
|
393
|
+
return {"inserted_count": 0}
|
|
394
|
+
result = coll.insert_many(docs)
|
|
395
|
+
return {"inserted_count": len(result.inserted_ids)}
|
|
396
|
+
|
|
397
|
+
async def discover_schema(self) -> Dict[str, Any]:
|
|
398
|
+
"""Discover MongoDB collections and sample field structures.
|
|
399
|
+
|
|
400
|
+
Samples a few documents from each collection to infer the schema.
|
|
401
|
+
"""
|
|
402
|
+
db = self._connect()
|
|
403
|
+
collections = []
|
|
404
|
+
for name in db.list_collection_names():
|
|
405
|
+
sample = list(db[name].find().limit(5))
|
|
406
|
+
fields: Dict[str, str] = {}
|
|
407
|
+
for doc in sample:
|
|
408
|
+
for k, v in doc.items():
|
|
409
|
+
if k not in fields:
|
|
410
|
+
fields[k] = type(v).__name__
|
|
411
|
+
collections.append(
|
|
412
|
+
{
|
|
413
|
+
"name": name,
|
|
414
|
+
"document_count": db[name].estimated_document_count(),
|
|
415
|
+
"fields": [{"name": k, "type": v} for k, v in fields.items()],
|
|
416
|
+
}
|
|
417
|
+
)
|
|
418
|
+
return {"collections": collections}
|
|
419
|
+
|
|
420
|
+
def close(self) -> None:
|
|
421
|
+
"""Close the MongoDB connection."""
|
|
422
|
+
if self._client is not None:
|
|
423
|
+
self._client.close()
|
|
424
|
+
self._client = None
|
|
425
|
+
self._db = None
|