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
agenthandler/openclaw.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""OpenClaw integration — bridge agenthandler-supervised tools to OpenClaw.
|
|
2
|
+
|
|
3
|
+
Converts agenthandler Skill/ToolDefinition to OpenClaw's JSON Schema format
|
|
4
|
+
and serves them over HTTP for the OpenClaw TypeScript plugin.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from agenthandler import OpenClawBridge, Policy, ToolDefinition, ToolParameter
|
|
8
|
+
|
|
9
|
+
bridge = OpenClawBridge(policy=Policy(tool_timeout=10))
|
|
10
|
+
bridge.register_tool(tool_def, my_async_fn)
|
|
11
|
+
bridge.serve() # http://127.0.0.1:9229
|
|
12
|
+
|
|
13
|
+
Or convert schemas without running a server:
|
|
14
|
+
from agenthandler import to_openclaw_tool_schema
|
|
15
|
+
schema = to_openclaw_tool_schema(tool_def)
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import threading
|
|
24
|
+
import urllib.parse
|
|
25
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
26
|
+
from typing import Any, Callable, Coroutine, Dict, List, Optional, Tuple
|
|
27
|
+
|
|
28
|
+
from .audit import AuditLog
|
|
29
|
+
from .policy import Policy
|
|
30
|
+
from .skill import Skill, ToolDefinition
|
|
31
|
+
from .supervisor import SupervisedResult, Supervisor
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _slugify(name: str) -> str:
|
|
35
|
+
"""Convert a name to a safe identifier."""
|
|
36
|
+
s = name.lower().strip()
|
|
37
|
+
s = re.sub(r"[^a-z0-9]+", "-", s)
|
|
38
|
+
return s.strip("-")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def to_openclaw_tool_schema(tool: ToolDefinition) -> dict[str, Any]:
|
|
42
|
+
"""Convert a agenthandler ToolDefinition to OpenClaw's registerTool format.
|
|
43
|
+
|
|
44
|
+
Returns a dict with name, description, and JSON Schema parameters
|
|
45
|
+
compatible with OpenClaw's api.registerTool().
|
|
46
|
+
"""
|
|
47
|
+
properties: Dict[str, Any] = {}
|
|
48
|
+
required: List[str] = []
|
|
49
|
+
|
|
50
|
+
for param in tool.parameters:
|
|
51
|
+
prop: Dict[str, Any] = {"type": param.type}
|
|
52
|
+
if param.description:
|
|
53
|
+
prop["description"] = param.description
|
|
54
|
+
if param.default is not None:
|
|
55
|
+
prop["default"] = param.default
|
|
56
|
+
properties[param.name] = prop
|
|
57
|
+
if param.required:
|
|
58
|
+
required.append(param.name)
|
|
59
|
+
|
|
60
|
+
schema: Dict[str, Any] = {
|
|
61
|
+
"name": tool.name,
|
|
62
|
+
"description": tool.description,
|
|
63
|
+
"parameters": {
|
|
64
|
+
"type": "object",
|
|
65
|
+
"properties": properties,
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
if required:
|
|
69
|
+
schema["parameters"]["required"] = required
|
|
70
|
+
|
|
71
|
+
return schema
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def to_openclaw_manifest(
|
|
75
|
+
skill: Skill,
|
|
76
|
+
plugin_id: Optional[str] = None,
|
|
77
|
+
bridge_port: int = 9229,
|
|
78
|
+
) -> dict[str, Any]:
|
|
79
|
+
"""Generate an openclaw.plugin.json manifest for a skill.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
skill: The skill to generate a manifest for.
|
|
83
|
+
plugin_id: Plugin identifier. Defaults to "agenthandler-{skill.name}".
|
|
84
|
+
bridge_port: Port the bridge server will run on.
|
|
85
|
+
"""
|
|
86
|
+
pid = plugin_id or f"agenthandler-{_slugify(skill.name)}"
|
|
87
|
+
return {
|
|
88
|
+
"id": pid,
|
|
89
|
+
"name": f"AgentHandler: {skill.name}",
|
|
90
|
+
"version": skill.version,
|
|
91
|
+
"description": skill.description,
|
|
92
|
+
"entry": "dist/index.js",
|
|
93
|
+
"configSchema": {
|
|
94
|
+
"type": "object",
|
|
95
|
+
"properties": {
|
|
96
|
+
"bridgeUrl": {
|
|
97
|
+
"type": "string",
|
|
98
|
+
"default": f"http://localhost:{bridge_port}",
|
|
99
|
+
"description": "URL of the agenthandler bridge server",
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
"tools": [to_openclaw_tool_schema(t) for t in skill.tools],
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _supervised_result_to_dict(result: SupervisedResult) -> dict[str, Any]:
|
|
108
|
+
"""Convert a SupervisedResult to a JSON-serializable response dict."""
|
|
109
|
+
response: Dict[str, Any] = {
|
|
110
|
+
"success": result.succeeded,
|
|
111
|
+
"result": None,
|
|
112
|
+
"error": None,
|
|
113
|
+
"metadata": {
|
|
114
|
+
"duration_ms": result.duration_ms,
|
|
115
|
+
"tool_name": result.tool_name,
|
|
116
|
+
},
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if result.succeeded and result.output is not None:
|
|
120
|
+
try:
|
|
121
|
+
json.dumps(result.output)
|
|
122
|
+
response["result"] = result.output
|
|
123
|
+
except (TypeError, ValueError):
|
|
124
|
+
response["result"] = str(result.output)
|
|
125
|
+
|
|
126
|
+
if result.error:
|
|
127
|
+
response["error"] = {
|
|
128
|
+
"kind": result.error.kind,
|
|
129
|
+
"message": str(result.error),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if result.budget:
|
|
133
|
+
response["metadata"]["budget"] = {
|
|
134
|
+
"tokens_used": result.budget.tokens_used,
|
|
135
|
+
"token_limit": result.budget.token_limit,
|
|
136
|
+
"iterations": result.budget.iterations,
|
|
137
|
+
"iteration_limit": result.budget.iteration_limit,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return response
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class OpenClawBridge:
|
|
144
|
+
"""HTTP bridge that exposes agenthandler-supervised tools to OpenClaw.
|
|
145
|
+
|
|
146
|
+
Registers tool definitions with their async callables, then serves
|
|
147
|
+
them over HTTP for the OpenClaw TypeScript plugin to discover and invoke.
|
|
148
|
+
|
|
149
|
+
Usage:
|
|
150
|
+
bridge = OpenClawBridge(policy=Policy(tool_timeout=10))
|
|
151
|
+
bridge.register_tool(tool_def, my_search_fn)
|
|
152
|
+
bridge.serve() # blocking, Ctrl+C to stop
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
def __init__(
|
|
156
|
+
self,
|
|
157
|
+
policy: Optional[Policy] = None,
|
|
158
|
+
audit: Optional[AuditLog] = None,
|
|
159
|
+
host: str = "127.0.0.1",
|
|
160
|
+
port: int = 9229,
|
|
161
|
+
):
|
|
162
|
+
self._policy = policy or Policy()
|
|
163
|
+
self._audit = audit
|
|
164
|
+
self._host = host
|
|
165
|
+
self._port = port
|
|
166
|
+
self._registry: Dict[
|
|
167
|
+
str, Tuple[ToolDefinition, Callable[..., Coroutine[Any, Any, Any]]]
|
|
168
|
+
] = {}
|
|
169
|
+
self._lock = threading.Lock()
|
|
170
|
+
self._server: Optional[ThreadingHTTPServer] = None
|
|
171
|
+
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
|
172
|
+
self._loop_thread: Optional[threading.Thread] = None
|
|
173
|
+
|
|
174
|
+
def register_tool(
|
|
175
|
+
self,
|
|
176
|
+
tool: ToolDefinition,
|
|
177
|
+
fn: Callable[..., Coroutine[Any, Any, Any]],
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Register a tool with its async implementation."""
|
|
180
|
+
with self._lock:
|
|
181
|
+
self._registry[tool.name] = (tool, fn)
|
|
182
|
+
|
|
183
|
+
def register_skill(
|
|
184
|
+
self,
|
|
185
|
+
skill: Skill,
|
|
186
|
+
tool_impls: Dict[str, Callable[..., Coroutine[Any, Any, Any]]],
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Register all tools from a skill.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
skill: Skill containing tool definitions.
|
|
192
|
+
tool_impls: Dict mapping tool name to its async callable.
|
|
193
|
+
"""
|
|
194
|
+
if skill.supervision:
|
|
195
|
+
self._policy = Policy.from_dict(skill.supervision)
|
|
196
|
+
|
|
197
|
+
for tool in skill.tools:
|
|
198
|
+
if tool.name not in tool_impls:
|
|
199
|
+
raise ValueError(
|
|
200
|
+
f"Missing implementation for tool '{tool.name}'. Provide it in tool_impls."
|
|
201
|
+
)
|
|
202
|
+
self.register_tool(tool, tool_impls[tool.name])
|
|
203
|
+
|
|
204
|
+
def _start_event_loop(self) -> None:
|
|
205
|
+
"""Start a background event loop for async tool execution."""
|
|
206
|
+
self._loop = asyncio.new_event_loop()
|
|
207
|
+
self._loop_thread = threading.Thread(
|
|
208
|
+
target=self._loop.run_forever,
|
|
209
|
+
daemon=True,
|
|
210
|
+
name="agenthandler-bridge-loop",
|
|
211
|
+
)
|
|
212
|
+
self._loop_thread.start()
|
|
213
|
+
|
|
214
|
+
def _stop_event_loop(self) -> None:
|
|
215
|
+
"""Stop the background event loop."""
|
|
216
|
+
if self._loop and self._loop.is_running():
|
|
217
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
218
|
+
if self._loop_thread:
|
|
219
|
+
self._loop_thread.join(timeout=5)
|
|
220
|
+
|
|
221
|
+
def _execute_tool(self, tool_name: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
222
|
+
"""Execute a tool through the Supervisor. Called from HTTP handler thread."""
|
|
223
|
+
if not self._loop:
|
|
224
|
+
return {
|
|
225
|
+
"success": False,
|
|
226
|
+
"error": {"kind": "internal", "message": "Event loop not running"},
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
supervisor = Supervisor(self._policy, audit=self._audit or AuditLog.noop())
|
|
230
|
+
|
|
231
|
+
tool_def, fn = self._registry[tool_name]
|
|
232
|
+
|
|
233
|
+
future = asyncio.run_coroutine_threadsafe(
|
|
234
|
+
supervisor.call(tool_name, fn, **params),
|
|
235
|
+
self._loop,
|
|
236
|
+
)
|
|
237
|
+
result = future.result(timeout=self._policy.request_timeout)
|
|
238
|
+
return _supervised_result_to_dict(result)
|
|
239
|
+
|
|
240
|
+
def _make_handler(self) -> type[BaseHTTPRequestHandler]:
|
|
241
|
+
"""Create an HTTP request handler class bound to this bridge."""
|
|
242
|
+
bridge = self
|
|
243
|
+
|
|
244
|
+
class Handler(BaseHTTPRequestHandler):
|
|
245
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
246
|
+
pass # suppress default stderr logging
|
|
247
|
+
|
|
248
|
+
def _send_json(self, status: int, data: dict[str, Any]) -> None:
|
|
249
|
+
body = json.dumps(data).encode("utf-8")
|
|
250
|
+
self.send_response(status)
|
|
251
|
+
self.send_header("Content-Type", "application/json")
|
|
252
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
253
|
+
self.send_header("Content-Length", str(len(body)))
|
|
254
|
+
self.end_headers()
|
|
255
|
+
self.wfile.write(body)
|
|
256
|
+
|
|
257
|
+
def do_OPTIONS(self) -> None:
|
|
258
|
+
self.send_response(204)
|
|
259
|
+
self.send_header("Access-Control-Allow-Origin", "*")
|
|
260
|
+
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
|
|
261
|
+
self.send_header("Access-Control-Allow-Headers", "Content-Type")
|
|
262
|
+
self.end_headers()
|
|
263
|
+
|
|
264
|
+
def do_GET(self) -> None:
|
|
265
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
266
|
+
path = parsed.path.strip("/")
|
|
267
|
+
|
|
268
|
+
if path == "health":
|
|
269
|
+
self._send_json(
|
|
270
|
+
200,
|
|
271
|
+
{
|
|
272
|
+
"status": "ok",
|
|
273
|
+
"tools": len(bridge._registry),
|
|
274
|
+
},
|
|
275
|
+
)
|
|
276
|
+
elif path == "tools":
|
|
277
|
+
tools = []
|
|
278
|
+
with bridge._lock:
|
|
279
|
+
for tool_def, _ in bridge._registry.values():
|
|
280
|
+
tools.append(to_openclaw_tool_schema(tool_def))
|
|
281
|
+
self._send_json(200, {"tools": tools})
|
|
282
|
+
else:
|
|
283
|
+
self._send_json(404, {"error": "Not found"})
|
|
284
|
+
|
|
285
|
+
def do_POST(self) -> None:
|
|
286
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
287
|
+
segments = parsed.path.strip("/").split("/")
|
|
288
|
+
|
|
289
|
+
if len(segments) != 2 or segments[0] != "execute":
|
|
290
|
+
self._send_json(404, {"error": "Not found"})
|
|
291
|
+
return
|
|
292
|
+
|
|
293
|
+
tool_name = segments[1]
|
|
294
|
+
|
|
295
|
+
with bridge._lock:
|
|
296
|
+
if tool_name not in bridge._registry:
|
|
297
|
+
self._send_json(
|
|
298
|
+
404,
|
|
299
|
+
{
|
|
300
|
+
"error": f"Tool '{tool_name}' not registered",
|
|
301
|
+
},
|
|
302
|
+
)
|
|
303
|
+
return
|
|
304
|
+
|
|
305
|
+
# Parse request body
|
|
306
|
+
content_length = int(self.headers.get("Content-Length", 0))
|
|
307
|
+
if content_length == 0:
|
|
308
|
+
self._send_json(400, {"error": "Request body required"})
|
|
309
|
+
return
|
|
310
|
+
|
|
311
|
+
try:
|
|
312
|
+
body = json.loads(self.rfile.read(content_length))
|
|
313
|
+
except (json.JSONDecodeError, ValueError):
|
|
314
|
+
self._send_json(400, {"error": "Invalid JSON in request body"})
|
|
315
|
+
return
|
|
316
|
+
|
|
317
|
+
if not isinstance(body.get("params"), dict):
|
|
318
|
+
self._send_json(
|
|
319
|
+
400,
|
|
320
|
+
{
|
|
321
|
+
"error": "Request body must contain 'params' object",
|
|
322
|
+
},
|
|
323
|
+
)
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
# Execute through supervisor
|
|
327
|
+
try:
|
|
328
|
+
response = bridge._execute_tool(tool_name, body["params"])
|
|
329
|
+
self._send_json(200, response)
|
|
330
|
+
except Exception as e:
|
|
331
|
+
self._send_json(
|
|
332
|
+
500,
|
|
333
|
+
{
|
|
334
|
+
"error": f"Internal server error: {e}",
|
|
335
|
+
},
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
return Handler
|
|
339
|
+
|
|
340
|
+
def serve(self) -> None:
|
|
341
|
+
"""Start the bridge server (blocking). Ctrl+C to stop."""
|
|
342
|
+
self._start_event_loop()
|
|
343
|
+
handler = self._make_handler()
|
|
344
|
+
self._server = ThreadingHTTPServer((self._host, self._port), handler)
|
|
345
|
+
try:
|
|
346
|
+
self._server.serve_forever()
|
|
347
|
+
except KeyboardInterrupt:
|
|
348
|
+
pass
|
|
349
|
+
finally:
|
|
350
|
+
self.shutdown()
|
|
351
|
+
|
|
352
|
+
def serve_background(self) -> threading.Thread:
|
|
353
|
+
"""Start the bridge in a background thread. Returns the thread."""
|
|
354
|
+
self._start_event_loop()
|
|
355
|
+
handler = self._make_handler()
|
|
356
|
+
self._server = ThreadingHTTPServer((self._host, self._port), handler)
|
|
357
|
+
thread = threading.Thread(
|
|
358
|
+
target=self._server.serve_forever,
|
|
359
|
+
daemon=True,
|
|
360
|
+
name="agenthandler-bridge-http",
|
|
361
|
+
)
|
|
362
|
+
thread.start()
|
|
363
|
+
return thread
|
|
364
|
+
|
|
365
|
+
def shutdown(self) -> None:
|
|
366
|
+
"""Stop the server and clean up."""
|
|
367
|
+
if self._server:
|
|
368
|
+
self._server.shutdown()
|
|
369
|
+
self._server = None
|
|
370
|
+
self._stop_event_loop()
|