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.

@@ -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()