soorma-core 0.3.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.
soorma/agents/tool.py ADDED
@@ -0,0 +1,373 @@
1
+ """
2
+ Tool Service - Atomic, stateless capability.
3
+
4
+ Tools are specialized micro-services that perform atomic, stateless operations.
5
+ They:
6
+ - Expose specific capabilities (e.g., calculator, API search, file parser)
7
+ - Handle synchronous request/response operations
8
+ - Are stateless - no memory of previous calls
9
+ - Often wrap external APIs or perform deterministic computations
10
+
11
+ Unlike Workers (which are cognitive), Tools are rules-based and deterministic.
12
+
13
+ Usage:
14
+ from soorma.agents import Tool
15
+
16
+ tool = Tool(
17
+ name="calculator-tool",
18
+ description="Performs mathematical calculations",
19
+ capabilities=["arithmetic", "unit_conversion"],
20
+ )
21
+
22
+ @tool.on_invoke("calculate")
23
+ async def calculate(request, context):
24
+ expression = request.data["expression"]
25
+ result = eval(expression) # (use safe_eval in production!)
26
+ return {"result": result, "expression": expression}
27
+
28
+ @tool.on_invoke("convert_units")
29
+ async def convert_units(request, context):
30
+ value = request.data["value"]
31
+ from_unit = request.data["from"]
32
+ to_unit = request.data["to"]
33
+ converted = perform_conversion(value, from_unit, to_unit)
34
+ return {"result": converted, "from": from_unit, "to": to_unit}
35
+
36
+ tool.run()
37
+ """
38
+ import logging
39
+ from dataclasses import dataclass, field
40
+ from typing import Any, Awaitable, Callable, Dict, List, Optional
41
+ from uuid import uuid4
42
+
43
+ from .base import Agent
44
+ from ..context import PlatformContext
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ @dataclass
50
+ class ToolRequest:
51
+ """
52
+ A request to invoke a tool operation.
53
+
54
+ Attributes:
55
+ operation: The operation to perform
56
+ data: Input parameters
57
+ request_id: Unique request identifier
58
+ correlation_id: Tracking ID
59
+ timeout: Request timeout in seconds
60
+ """
61
+ operation: str
62
+ data: Dict[str, Any]
63
+ request_id: str = field(default_factory=lambda: str(uuid4()))
64
+ correlation_id: Optional[str] = None
65
+ session_id: Optional[str] = None
66
+ tenant_id: Optional[str] = None
67
+ timeout: Optional[float] = None
68
+
69
+
70
+ @dataclass
71
+ class ToolResponse:
72
+ """
73
+ Response from a tool invocation.
74
+
75
+ Attributes:
76
+ request_id: Original request ID
77
+ success: Whether the operation succeeded
78
+ data: Result data (if successful)
79
+ error: Error message (if failed)
80
+ """
81
+ request_id: str
82
+ success: bool
83
+ data: Optional[Dict[str, Any]] = None
84
+ error: Optional[str] = None
85
+
86
+
87
+ # Type alias for tool handlers
88
+ ToolHandler = Callable[[ToolRequest, PlatformContext], Awaitable[Dict[str, Any]]]
89
+
90
+
91
+ class Tool(Agent):
92
+ """
93
+ Atomic, stateless capability micro-service.
94
+
95
+ Tools are the "utilities" of the DisCo architecture. They:
96
+ 1. Expose deterministic, stateless operations
97
+ 2. Handle both event-driven and synchronous requests
98
+ 3. Wrap external APIs or perform computations
99
+ 4. Are highly reusable across different workflows
100
+
101
+ Key differences from Workers:
102
+ - Tools are stateless (no memory between calls)
103
+ - Tools are deterministic (same input = same output)
104
+ - Tools are typically rules-based, not cognitive
105
+ - Tools can also expose REST endpoints for sync calls
106
+
107
+ Attributes:
108
+ All Agent attributes, plus:
109
+ on_invoke: Decorator for registering operation handlers
110
+
111
+ Usage:
112
+ tool = Tool(
113
+ name="weather-api",
114
+ description="Fetches weather data",
115
+ capabilities=["current_weather", "forecast"],
116
+ )
117
+
118
+ @tool.on_invoke("get_weather")
119
+ async def get_weather(request: ToolRequest, context: PlatformContext) -> Dict:
120
+ location = request.data["location"]
121
+ weather = await fetch_weather_api(location)
122
+ return {"temperature": weather.temp, "conditions": weather.conditions}
123
+
124
+ tool.run()
125
+ """
126
+
127
+ def __init__(
128
+ self,
129
+ name: str,
130
+ description: str = "",
131
+ version: str = "0.1.0",
132
+ capabilities: Optional[List[str]] = None,
133
+ **kwargs,
134
+ ):
135
+ """
136
+ Initialize the Tool.
137
+
138
+ Args:
139
+ name: Tool name
140
+ description: What this tool does
141
+ version: Version string
142
+ capabilities: Operations this tool provides
143
+ **kwargs: Additional Agent arguments
144
+ """
145
+ # Tools consume tool.request and produce tool.response
146
+ events_consumed = kwargs.pop("events_consumed", [])
147
+ if "tool.request" not in events_consumed:
148
+ events_consumed.append("tool.request")
149
+
150
+ events_produced = kwargs.pop("events_produced", [])
151
+ if "tool.response" not in events_produced:
152
+ events_produced.append("tool.response")
153
+
154
+ super().__init__(
155
+ name=name,
156
+ description=description,
157
+ version=version,
158
+ agent_type="tool",
159
+ capabilities=capabilities or [],
160
+ events_consumed=events_consumed,
161
+ events_produced=events_produced,
162
+ **kwargs,
163
+ )
164
+
165
+ # Operation handlers: operation_name -> handler
166
+ self._operation_handlers: Dict[str, ToolHandler] = {}
167
+
168
+ # Register the main tool.request handler
169
+ self._register_tool_request_handler()
170
+
171
+ def _register_tool_request_handler(self) -> None:
172
+ """Register the main tool.request event handler."""
173
+ @self.on_event("tool.request")
174
+ async def handle_tool_request(event: Dict[str, Any], context: PlatformContext) -> None:
175
+ await self._handle_tool_request(event, context)
176
+
177
+ def on_invoke(self, operation: str) -> Callable[[ToolHandler], ToolHandler]:
178
+ """
179
+ Decorator to register an operation handler.
180
+
181
+ Operation handlers receive a ToolRequest and PlatformContext,
182
+ and return a result dictionary.
183
+
184
+ Usage:
185
+ @tool.on_invoke("calculate")
186
+ async def calculate(request: ToolRequest, context: PlatformContext) -> Dict:
187
+ result = compute(request.data["expression"])
188
+ return {"result": result}
189
+
190
+ Args:
191
+ operation: The operation name to handle
192
+
193
+ Returns:
194
+ Decorator function
195
+ """
196
+ def decorator(func: ToolHandler) -> ToolHandler:
197
+ self._operation_handlers[operation] = func
198
+
199
+ # Add to capabilities if not already there
200
+ if operation not in self.config.capabilities:
201
+ self.config.capabilities.append(operation)
202
+
203
+ logger.debug(f"Registered operation handler: {operation}")
204
+ return func
205
+ return decorator
206
+
207
+ async def _handle_tool_request(
208
+ self,
209
+ event: Dict[str, Any],
210
+ context: PlatformContext,
211
+ ) -> None:
212
+ """Handle an incoming tool.request event."""
213
+ data = event.get("data", {})
214
+
215
+ operation = data.get("operation")
216
+ target_tool = data.get("tool")
217
+
218
+ # Check if this request is for us
219
+ if not self._should_handle_request(target_tool):
220
+ return
221
+
222
+ handler = self._operation_handlers.get(operation)
223
+ if not handler:
224
+ logger.debug(f"No handler for operation: {operation}")
225
+ # Emit error response
226
+ await self._emit_error_response(
227
+ request_id=data.get("request_id", str(uuid4())),
228
+ error=f"Unknown operation: {operation}",
229
+ correlation_id=event.get("correlation_id"),
230
+ context=context,
231
+ )
232
+ return
233
+
234
+ # Create ToolRequest
235
+ request = ToolRequest(
236
+ operation=operation,
237
+ data=data.get("data", {}),
238
+ request_id=data.get("request_id", str(uuid4())),
239
+ correlation_id=event.get("correlation_id"),
240
+ session_id=event.get("session_id"),
241
+ tenant_id=event.get("tenant_id"),
242
+ timeout=data.get("timeout"),
243
+ )
244
+
245
+ try:
246
+ # Execute operation
247
+ logger.info(f"Executing operation: {operation} ({request.request_id})")
248
+ result = await handler(request, context)
249
+
250
+ # Emit tool.response
251
+ await context.bus.publish(
252
+ event_type="tool.response",
253
+ data={
254
+ "request_id": request.request_id,
255
+ "operation": operation,
256
+ "success": True,
257
+ "result": result,
258
+ },
259
+ topic="action-results", # Tools use action-results topic
260
+ correlation_id=request.correlation_id,
261
+ )
262
+
263
+ logger.info(f"Completed operation: {operation} ({request.request_id})")
264
+
265
+ except Exception as e:
266
+ logger.error(f"Operation failed: {operation} - {e}")
267
+ await self._emit_error_response(
268
+ request_id=request.request_id,
269
+ error=str(e),
270
+ correlation_id=request.correlation_id,
271
+ context=context,
272
+ )
273
+
274
+ async def _emit_error_response(
275
+ self,
276
+ request_id: str,
277
+ error: str,
278
+ correlation_id: Optional[str],
279
+ context: PlatformContext,
280
+ ) -> None:
281
+ """Emit an error response."""
282
+ await context.bus.publish(
283
+ event_type="tool.response",
284
+ data={
285
+ "request_id": request_id,
286
+ "success": False,
287
+ "error": error,
288
+ },
289
+ topic="action-results",
290
+ correlation_id=correlation_id,
291
+ )
292
+
293
+ def _should_handle_request(self, target_tool: Optional[str]) -> bool:
294
+ """Check if this tool should handle a request."""
295
+ if not target_tool:
296
+ return False
297
+
298
+ # Match by name
299
+ if target_tool == self.name:
300
+ return True
301
+
302
+ # Match by agent_id
303
+ if target_tool == self.agent_id:
304
+ return True
305
+
306
+ # Match by capability (tool name might be a capability)
307
+ if target_tool in self.config.capabilities:
308
+ return True
309
+
310
+ return False
311
+
312
+ async def invoke(
313
+ self,
314
+ operation: str,
315
+ data: Dict[str, Any],
316
+ ) -> Dict[str, Any]:
317
+ """
318
+ Programmatically invoke a tool operation.
319
+
320
+ This method allows invoking operations without going through the event bus,
321
+ useful for testing or direct integration.
322
+
323
+ Args:
324
+ operation: Name of the operation
325
+ data: Input parameters
326
+
327
+ Returns:
328
+ Operation result dictionary
329
+
330
+ Raises:
331
+ ValueError: If no handler for operation
332
+ """
333
+ handler = self._operation_handlers.get(operation)
334
+ if not handler:
335
+ raise ValueError(f"No handler for operation: {operation}")
336
+
337
+ request = ToolRequest(
338
+ operation=operation,
339
+ data=data,
340
+ )
341
+
342
+ return await handler(request, self.context)
343
+
344
+ async def invoke_remote(
345
+ self,
346
+ tool_name: str,
347
+ operation: str,
348
+ data: Dict[str, Any],
349
+ timeout: float = 30.0,
350
+ ) -> Optional[Dict[str, Any]]:
351
+ """
352
+ Invoke an operation on a remote tool.
353
+
354
+ This sends a tool.request event and waits for tool.response.
355
+
356
+ Args:
357
+ tool_name: Name of the target tool
358
+ operation: Operation to invoke
359
+ data: Input parameters
360
+ timeout: Timeout in seconds
361
+
362
+ Returns:
363
+ Result dictionary if successful, None on timeout
364
+ """
365
+ return await self.context.bus.request(
366
+ event_type="tool.request",
367
+ data={
368
+ "tool": tool_name,
369
+ "operation": operation,
370
+ "data": data,
371
+ },
372
+ timeout=timeout,
373
+ )