zyndai-agent 0.1.5__py3-none-any.whl → 0.2.2__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.
zyndai_agent/search.py CHANGED
@@ -1,6 +1,7 @@
1
1
  # Agent Discovery and Search Protocol Module for ZyndAI
2
2
  import logging
3
3
  import requests
4
+ from urllib.parse import urlencode
4
5
 
5
6
  from typing import List, Optional, TypedDict
6
7
 
@@ -11,53 +12,174 @@ logging.basicConfig(
11
12
  )
12
13
  logger = logging.getLogger("SearchAndDiscovery")
13
14
 
15
+
14
16
  class AgentSearchResponse(TypedDict):
15
17
  id: str
16
18
  name: str
17
19
  description: str
18
- mqttUri: Optional[str]
20
+ mqttUri: Optional[str] # Deprecated, kept for backward compatibility
21
+ httpWebhookUrl: Optional[str] # Field for webhook communication
19
22
  inboxTopic: Optional[str]
20
- matchScore: int
23
+ capabilities: Optional[dict]
24
+ status: Optional[str]
21
25
  didIdentifier: str
22
- did: dict
26
+ did: str # JSON string of DID credential
27
+
23
28
 
24
29
  class SearchAndDiscoveryManager:
25
30
  """
26
31
  This class implements the search and discovery protocol for ZyndAI agents.
27
32
  It allows agents to discover each other and share information about their capabilities.
28
- """
29
33
 
30
- def __init__(self, registry_url: str = "http://localhost:3002/sdk/search"):
34
+ The search uses semantic matching via the keyword parameter, allowing for
35
+ fuzzy/vague searches across agent names, descriptions, and capabilities.
36
+ """
31
37
 
38
+ def __init__(self, registry_url: str = "http://localhost:3002"):
32
39
  self.agents = []
33
40
  self.registry_url = registry_url
34
41
 
42
+ def search_agents(
43
+ self,
44
+ keyword: Optional[str] = None,
45
+ name: Optional[str] = None,
46
+ capabilities: Optional[List[str]] = None,
47
+ status: Optional[str] = None,
48
+ did: Optional[str] = None,
49
+ limit: int = 10,
50
+ offset: int = 0
51
+ ) -> List[AgentSearchResponse]:
52
+ """
53
+ Search for agents in the registry using various filters.
54
+
55
+ The keyword parameter supports semantic search across name, description,
56
+ capabilities, and metadata fields.
57
+
58
+ Args:
59
+ keyword: Semantic search term (searches across name, description, capabilities, metadata)
60
+ name: Filter by agent name (case-insensitive, partial match)
61
+ capabilities: List of capabilities to filter by
62
+ status: Filter by agent status (e.g., "ACTIVE")
63
+ did: Filter by exact DID match
64
+ limit: Maximum number of results to return (default: 10, max: 100)
65
+ offset: Number of results to skip for pagination (default: 0)
66
+
67
+ Returns:
68
+ List of matching agents
69
+ """
70
+ logger.info(f"Searching agents with keyword='{keyword}', capabilities={capabilities}")
71
+
72
+ # Build query parameters
73
+ params = {}
74
+
75
+ if keyword:
76
+ params["keyword"] = keyword
77
+ if name:
78
+ params["name"] = name
79
+ if capabilities:
80
+ params["capabilities"] = ",".join(capabilities)
81
+ if status:
82
+ params["status"] = status
83
+ if did:
84
+ params["did"] = did
85
+
86
+ params["limit"] = limit
87
+ params["offset"] = offset
88
+
89
+ try:
90
+ url = f"{self.registry_url}/agents"
91
+ logger.info(f"GET {url}?{urlencode(params)}")
92
+
93
+ resp = requests.get(url, params=params)
94
+
95
+ if resp.status_code == 200:
96
+ response_data = resp.json()
97
+ # API returns { data: [...], count: N, total: N }
98
+ agents = response_data.get("data", [])
99
+ total = response_data.get("total", len(agents))
100
+ logger.info(f"Found {len(agents)} agents (total: {total}).")
101
+ return agents
102
+ else:
103
+ logger.error(f"Failed to search agents: {resp.status_code} - {resp.text}")
104
+ return []
105
+
106
+ except requests.RequestException as e:
107
+ logger.error(f"Request failed: {e}")
108
+ return []
109
+
110
+ def search_agents_by_capabilities(
111
+ self,
112
+ capabilities: List[str] = [],
113
+ top_k: Optional[int] = None
114
+ ) -> List[AgentSearchResponse]:
115
+ """
116
+ Discover agents based on capabilities using semantic keyword search.
117
+
118
+ This method converts capabilities into a keyword search query for
119
+ semantic matching across the registry.
35
120
 
36
- def search_agents_by_capabilities(self, capabilities: List[str] = [], match_score_gte: float = 0.5, top_k: Optional[int] = None) -> List[AgentSearchResponse]:
121
+ Args:
122
+ capabilities: List of capability terms to search for
123
+ top_k: Maximum number of results to return
124
+
125
+ Returns:
126
+ List of matching agents
37
127
  """
38
- Discover all registered agents in the system based on their capabilities.
128
+ logger.info(f"Discovering agents by capabilities: {capabilities}")
129
+
130
+ # Convert capabilities list into a semantic search keyword
131
+ # Join capabilities into a search phrase for semantic matching
132
+ keyword = " ".join(capabilities) if capabilities else None
133
+
134
+ limit = top_k if top_k is not None else 10
135
+
136
+ return self.search_agents(
137
+ keyword=keyword,
138
+ limit=limit
139
+ )
39
140
 
40
- match_score_gte: Minimum match score for agents to be included in the results.
41
- top_k: Optional parameter to limit the number of results returned or return all.
141
+ def search_agents_by_keyword(
142
+ self,
143
+ keyword: str,
144
+ limit: int = 10,
145
+ offset: int = 0
146
+ ) -> List[AgentSearchResponse]:
42
147
  """
148
+ Search for agents using a semantic keyword search.
43
149
 
44
- logger.info("Discovering agents...")
150
+ The keyword is matched against agent name, description, capabilities,
151
+ and metadata using semantic search.
45
152
 
153
+ Args:
154
+ keyword: Search term for semantic matching
155
+ limit: Maximum number of results (default: 10)
156
+ offset: Pagination offset (default: 0)
46
157
 
47
- resp = requests.post(f"{self.registry_url}/sdk/search", json={"userProvidedCapabilities": capabilities})
48
- if resp.status_code == 201:
49
- agents = resp.json()
50
- logger.info(f"Discovered {len(agents)} agents.")
158
+ Returns:
159
+ List of matching agents
160
+ """
161
+ return self.search_agents(keyword=keyword, limit=limit, offset=offset)
51
162
 
52
- filtered_agents = [
53
- agent for agent in agents
54
- if agent.get("matchScore", 0) >= match_score_gte
55
- ]
163
+ def get_agent_by_id(self, agent_id: str) -> Optional[AgentSearchResponse]:
164
+ """
165
+ Get a specific agent by its ID.
56
166
 
57
- if top_k is not None:
58
- filtered_agents = filtered_agents[:top_k]
167
+ Args:
168
+ agent_id: The unique identifier of the agent
59
169
 
60
- return filtered_agents
61
- else:
62
- logger.error(f"Failed to discover agents: {resp.status_code} - {resp.text}")
63
- return []
170
+ Returns:
171
+ Agent details or None if not found
172
+ """
173
+ try:
174
+ url = f"{self.registry_url}/agents/{agent_id}"
175
+ resp = requests.get(url)
176
+
177
+ if resp.status_code == 200:
178
+ return resp.json()
179
+ else:
180
+ logger.error(f"Failed to get agent {agent_id}: {resp.status_code}")
181
+ return None
182
+
183
+ except requests.RequestException as e:
184
+ logger.error(f"Request failed: {e}")
185
+ return None
@@ -0,0 +1,470 @@
1
+ import time
2
+ import logging
3
+ import threading
4
+ import requests
5
+ from flask import Flask, request, jsonify
6
+ from typing import List, Callable, Optional, Dict, Any
7
+ from zyndai_agent.message import AgentMessage
8
+ from zyndai_agent.search import AgentSearchResponse
9
+ from x402.flask.middleware import PaymentMiddleware
10
+
11
+ # Configure logging with a more descriptive format
12
+ logging.basicConfig(
13
+ level=logging.INFO,
14
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
15
+ )
16
+ logger = logging.getLogger("WebhookAgentCommunication")
17
+
18
+
19
+ class WebhookCommunicationManager:
20
+ """
21
+ HTTP Webhook-based communication manager for LangChain agents.
22
+
23
+ This class provides tools for LangChain agents to communicate via HTTP webhooks,
24
+ enabling multi-agent collaboration through a request-response pattern.
25
+ Each agent runs an embedded Flask server to receive messages.
26
+ """
27
+
28
+ identity_credential: dict = None
29
+
30
+ def __init__(
31
+ self,
32
+ agent_id: str,
33
+ webhook_host: str = "0.0.0.0",
34
+ webhook_port: int = 5000,
35
+ webhook_url: Optional[str] = None,
36
+ auto_restart: bool = True,
37
+ message_history_limit: int = 100,
38
+ identity_credential: dict = None,
39
+ price: Optional[str] = "$0.01",
40
+ pay_to_address: Optional[str] = None
41
+ ):
42
+ """
43
+ Initialize the webhook agent communication manager.
44
+
45
+ Args:
46
+ agent_id: Unique identifier for this agent
47
+ webhook_host: Host address to bind the webhook server (default: 0.0.0.0)
48
+ webhook_port: Port number for the webhook server (default: 5000)
49
+ webhook_url: Public webhook URL (auto-generated if None)
50
+ auto_restart: Whether to attempt restart on failure
51
+ message_history_limit: Maximum number of messages to keep in history
52
+ identity_credential: DID credential for this agent
53
+ """
54
+
55
+ self.agent_id = agent_id
56
+ self.webhook_host = webhook_host
57
+ self.webhook_port = webhook_port
58
+ self.webhook_url = webhook_url
59
+ self.auto_restart = auto_restart
60
+ self.message_history_limit = message_history_limit
61
+
62
+ self.identity_credential = identity_credential
63
+
64
+ self.is_running = False
65
+ self.is_agent_connected = False
66
+ self.received_messages = []
67
+ self.message_history = []
68
+ self.message_handlers = []
69
+ self.target_webhook_url = None
70
+ self.pending_responses = {} # Store responses by message_id
71
+
72
+ # Thread safety
73
+ self._lock = threading.Lock()
74
+
75
+ # Create Flask app
76
+ self.flask_app = Flask(f"agent_{agent_id}")
77
+ self.flask_app.logger.setLevel(logging.ERROR) # Suppress Flask logging
78
+
79
+ if price != None and pay_to_address != None:
80
+ self.payment_middleware = PaymentMiddleware(self.flask_app)
81
+ self.payment_middleware.add(
82
+ price,
83
+ pay_to_address,
84
+ path="/webhook"
85
+ )
86
+ else:
87
+ print("Disabling x402, X402 payment config not provided")
88
+
89
+ self._setup_routes()
90
+
91
+ # Start webhook server
92
+ self.start_webhook_server()
93
+
94
+ print("Agent webhook server started")
95
+ print(f"Listening on {self.webhook_url}")
96
+
97
+ def _setup_routes(self):
98
+ """Setup Flask routes for webhook endpoints."""
99
+
100
+ @self.flask_app.route('/webhook', methods=['POST'])
101
+ def webhook_handler():
102
+ return self._handle_webhook_request(sync=False)
103
+
104
+ @self.flask_app.route('/webhook/sync', methods=['POST'])
105
+ def webhook_sync_handler():
106
+ return self._handle_webhook_request(sync=True)
107
+
108
+ @self.flask_app.route('/health', methods=['GET'])
109
+ def health_check():
110
+ return jsonify({
111
+ "status": "ok",
112
+ "agent_id": self.agent_id,
113
+ "timestamp": time.time()
114
+ }), 200
115
+
116
+ def _handle_webhook_request(self, sync=False):
117
+ """Handle incoming webhook POST requests."""
118
+ try:
119
+ # Verify request is JSON
120
+ if not request.is_json:
121
+ logger.error("Received non-JSON request")
122
+ return jsonify({"error": "Content-Type must be application/json"}), 400
123
+
124
+ payload = request.get_json()
125
+
126
+ # Parse message from dict (request.get_json() returns a dict, not a string)
127
+ message = AgentMessage.from_dict(payload)
128
+
129
+ logger.info(f"[{self.agent_id}] Received message from {message.sender_id}")
130
+
131
+ # Auto-connect to sender if not connected
132
+ if not self.is_agent_connected:
133
+ self.is_agent_connected = True
134
+
135
+ # Store in history
136
+ message_with_metadata = {
137
+ "message": message,
138
+ "received_at": time.time(),
139
+ "structured": True,
140
+ "source_ip": request.remote_addr
141
+ }
142
+
143
+ print("\nIncoming Message: ", message.content, "\n")
144
+
145
+ with self._lock:
146
+ self.received_messages.append(message_with_metadata)
147
+ self.message_history.append(message_with_metadata)
148
+
149
+ # Maintain history limit
150
+ if len(self.message_history) > self.message_history_limit:
151
+ self.message_history = self.message_history[-self.message_history_limit:]
152
+
153
+ # Check if synchronous response is requested
154
+ if sync:
155
+ # Wait for handler to process and store response
156
+ # Invoke message handlers synchronously
157
+ for handler in self.message_handlers:
158
+ try:
159
+ handler(message, None) # No topic in webhook context
160
+ except Exception as e:
161
+ logger.error(f"Error in message handler: {e}")
162
+
163
+ # Wait for response (with timeout)
164
+ timeout = 30 # 30 seconds
165
+ start_time = time.time()
166
+ while time.time() - start_time < timeout:
167
+ with self._lock:
168
+ if message.message_id in self.pending_responses:
169
+ response = self.pending_responses.pop(message.message_id)
170
+ return jsonify({
171
+ "status": "success",
172
+ "message_id": message.message_id,
173
+ "response": response,
174
+ "timestamp": time.time()
175
+ }), 200
176
+ time.sleep(0.1) # Small delay to avoid busy waiting
177
+
178
+ # Timeout - no response received
179
+ return jsonify({
180
+ "status": "timeout",
181
+ "message_id": message.message_id,
182
+ "error": "Agent did not respond within timeout period",
183
+ "timestamp": time.time()
184
+ }), 408
185
+ else:
186
+ # Async mode - invoke handlers and return immediately
187
+ for handler in self.message_handlers:
188
+ try:
189
+ handler(message, None) # No topic in webhook context
190
+ except Exception as e:
191
+ logger.error(f"Error in message handler: {e}")
192
+
193
+ # Return success
194
+ return jsonify({
195
+ "status": "received",
196
+ "message_id": message.message_id,
197
+ "timestamp": time.time()
198
+ }), 200
199
+
200
+ except Exception as e:
201
+ logger.error(f"Error handling webhook request: {e}")
202
+ return jsonify({"error": str(e)}), 500
203
+
204
+ def start_webhook_server(self):
205
+ """Start Flask webhook server in background thread."""
206
+ if self.is_running:
207
+ logger.warning("Webhook server already running")
208
+ return
209
+
210
+ # Try to bind to configured port, retry with different ports if needed
211
+ max_retries = 10
212
+ server_started = False
213
+
214
+ for attempt in range(max_retries):
215
+ try:
216
+ port = self.webhook_port + attempt
217
+
218
+ def run_flask():
219
+ self.flask_app.run(
220
+ host=self.webhook_host,
221
+ port=port,
222
+ debug=False,
223
+ use_reloader=False,
224
+ threaded=True
225
+ )
226
+
227
+ self.flask_thread = threading.Thread(
228
+ target=run_flask,
229
+ daemon=True,
230
+ name=f"WebhookServer-{self.agent_id}"
231
+ )
232
+ self.flask_thread.start()
233
+
234
+ # Update actual port used
235
+ self.webhook_port = port
236
+
237
+ # Auto-form webhook URL from host and port
238
+ if self.webhook_url is None:
239
+ host = "localhost" if self.webhook_host == "0.0.0.0" else self.webhook_host
240
+ scheme = "https" if port == 443 else "http"
241
+ self.webhook_url = f"{scheme}://{host}:{port}/webhook"
242
+
243
+ self.is_running = True
244
+ server_started = True
245
+
246
+ # Wait for server to start
247
+ time.sleep(1.5)
248
+
249
+ logger.info(f"Webhook server started on {self.webhook_host}:{port}")
250
+ break
251
+
252
+ except OSError as e:
253
+ if "Address already in use" in str(e) and attempt < max_retries - 1:
254
+ logger.warning(f"Port {port} already in use, trying next port...")
255
+ continue
256
+ else:
257
+ logger.error(f"Failed to start webhook server: {e}")
258
+ raise
259
+
260
+ if not server_started:
261
+ raise RuntimeError("Failed to start webhook server after multiple attempts")
262
+
263
+ def stop_webhook_server(self):
264
+ """Stop the webhook server and cleanup resources."""
265
+ if not self.is_running:
266
+ logger.warning("Webhook server not running")
267
+ return
268
+
269
+ self.is_running = False
270
+ logger.info(f"[{self.agent_id}] Webhook server stopped")
271
+
272
+ def send_message(
273
+ self,
274
+ message_content: str,
275
+ message_type: str = "query",
276
+ receiver_id: Optional[str] = None
277
+ ) -> str:
278
+ """
279
+ Send a message to another agent via HTTP POST.
280
+
281
+ Args:
282
+ message_content: The main content of the message
283
+ message_type: The type of message being sent
284
+ receiver_id: Specific recipient ID
285
+
286
+ Returns:
287
+ Status message or error
288
+ """
289
+ if not self.is_running:
290
+ return "Webhook server not running. Cannot send messages."
291
+
292
+ if not self.target_webhook_url:
293
+ return "No target agent connected. Use connect_agent() first."
294
+
295
+ try:
296
+ # Create structured message
297
+ message = AgentMessage(
298
+ content=message_content,
299
+ sender_id=self.agent_id,
300
+ receiver_id=receiver_id,
301
+ message_type=message_type,
302
+ sender_did=self.identity_credential
303
+ )
304
+
305
+ # Convert to dict for JSON serialization
306
+ # Note: use to_dict() not to_json() - json= parameter expects a dict
307
+ json_payload = message.to_dict()
308
+
309
+ # Send HTTP POST request with JSON body
310
+ response = requests.post(
311
+ self.target_webhook_url,
312
+ json=json_payload,
313
+ headers={"Content-Type": "application/json"},
314
+ timeout=30 # 30 second timeout
315
+ )
316
+
317
+ # Check response
318
+ if response.status_code == 200:
319
+ logger.info(f"Message sent successfully to {self.target_webhook_url}")
320
+
321
+ # Add to history
322
+ with self._lock:
323
+ self.message_history.append({
324
+ "message": message,
325
+ "sent_at": time.time(),
326
+ "direction": "outgoing",
327
+ "target_url": self.target_webhook_url
328
+ })
329
+
330
+ if len(self.message_history) > self.message_history_limit:
331
+ self.message_history = self.message_history[-self.message_history_limit:]
332
+
333
+ return f"Message sent successfully to topic '{self.target_webhook_url}'"
334
+ else:
335
+ error_msg = f"Failed to send message. HTTP {response.status_code}: {response.text}"
336
+ logger.error(error_msg)
337
+ return error_msg
338
+
339
+ except requests.exceptions.Timeout:
340
+ error_msg = "Error: Request timed out. Target agent may be offline."
341
+ logger.error(error_msg)
342
+ return error_msg
343
+ except requests.exceptions.ConnectionError:
344
+ error_msg = "Error: Could not connect to target agent. Agent may be offline."
345
+ logger.error(error_msg)
346
+ return error_msg
347
+ except Exception as e:
348
+ error_msg = f"Error sending message: {str(e)}"
349
+ logger.error(f"[{self.agent_id}] {error_msg}")
350
+ return error_msg
351
+
352
+ def read_messages(self) -> str:
353
+ """
354
+ Read and clear the current message queue.
355
+
356
+ Returns:
357
+ Formatted string of received messages
358
+ """
359
+ if not self.is_running:
360
+ return "Webhook server not running."
361
+
362
+ if not self.received_messages:
363
+ return "No new messages in the queue."
364
+
365
+ # Format messages for output
366
+ formatted_messages = []
367
+ for item in self.received_messages:
368
+ message = item["message"]
369
+
370
+ formatted_msg = (
371
+ f"From: {message.sender_id}\n"
372
+ f"Type: {message.message_type}\n"
373
+ f"Content: {message.content}\n"
374
+ )
375
+ formatted_messages.append(formatted_msg)
376
+
377
+ # Create a combined output
378
+ output = "Messages received:\n\n" + "\n---\n".join(formatted_messages)
379
+
380
+ # Clear the received messages queue but keep them in history
381
+ with self._lock:
382
+ self.received_messages.clear()
383
+
384
+ return output
385
+
386
+ def add_message_handler(self, handler_function: Callable) -> None:
387
+ """
388
+ Add a custom message handler function.
389
+
390
+ Args:
391
+ handler_function: Function to call when messages arrive
392
+ Should accept (message, topic) parameters
393
+ """
394
+ with self._lock:
395
+ self.message_handlers.append(handler_function)
396
+ logger.info(f"[{self.agent_id}] Added custom message handler")
397
+
398
+ def register_handler(self, handler_fn: Callable[[AgentMessage, str], None]):
399
+ """Alias for add_message_handler for backward compatibility."""
400
+ self.add_message_handler(handler_fn)
401
+
402
+ def set_response(self, message_id: str, response: str):
403
+ """
404
+ Set a response for a specific message ID (for synchronous responses).
405
+
406
+ Args:
407
+ message_id: The ID of the message being responded to
408
+ response: The response content
409
+ """
410
+ with self._lock:
411
+ self.pending_responses[message_id] = response
412
+ logger.info(f"[{self.agent_id}] Set response for message {message_id}")
413
+
414
+ def get_connection_status(self) -> Dict[str, Any]:
415
+ """
416
+ Get the current webhook server status and statistics.
417
+
418
+ Returns:
419
+ Dictionary with connection information
420
+ """
421
+ return {
422
+ "agent_id": self.agent_id,
423
+ "is_running": self.is_running,
424
+ "webhook_url": self.webhook_url,
425
+ "webhook_port": self.webhook_port,
426
+ "target_webhook_url": self.target_webhook_url,
427
+ "pending_messages": len(self.received_messages),
428
+ "message_history_count": len(self.message_history)
429
+ }
430
+
431
+ def get_message_history(
432
+ self,
433
+ limit: int = None,
434
+ filter_by_topic: str = None
435
+ ) -> List[Dict[str, Any]]:
436
+ """
437
+ Get the message history with optional filtering.
438
+
439
+ Args:
440
+ limit: Maximum number of messages to return (None for all)
441
+ filter_by_topic: Only return messages from this topic (not applicable for webhooks)
442
+
443
+ Returns:
444
+ List of message history entries
445
+ """
446
+ with self._lock:
447
+ history = self.message_history.copy()
448
+
449
+ # Apply limit if specified
450
+ if limit is not None:
451
+ history = history[-limit:]
452
+
453
+ return history
454
+
455
+ def connect_agent(self, agent: AgentSearchResponse):
456
+ """
457
+ Connect to another agent using their webhook URL.
458
+
459
+ Args:
460
+ agent: Agent search response containing httpWebhookUrl
461
+ """
462
+ # Support both old 'webhookUrl' and new 'httpWebhookUrl' field names
463
+ webhook_url = agent.get("httpWebhookUrl") or agent.get("webhookUrl")
464
+ if not webhook_url:
465
+ raise ValueError("Agent does not have httpWebhookUrl. Cannot connect via webhook.")
466
+
467
+ self.target_webhook_url = webhook_url
468
+ self.is_agent_connected = True
469
+
470
+ logger.info(f"Connected to agent {agent.get('didIdentifier', 'unknown')} at {self.target_webhook_url}")