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/__init__.py +22 -0
- zyndai_agent/agent.py +143 -34
- zyndai_agent/config_manager.py +153 -0
- zyndai_agent/message.py +112 -0
- zyndai_agent/search.py +146 -24
- zyndai_agent/webhook_communication.py +470 -0
- {zyndai_agent-0.1.5.dist-info → zyndai_agent-0.2.2.dist-info}/METADATA +458 -63
- zyndai_agent-0.2.2.dist-info/RECORD +14 -0
- {zyndai_agent-0.1.5.dist-info → zyndai_agent-0.2.2.dist-info}/WHEEL +1 -1
- zyndai_agent-0.1.5.dist-info/RECORD +0 -11
- {zyndai_agent-0.1.5.dist-info → zyndai_agent-0.2.2.dist-info}/top_level.txt +0 -0
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
|
-
|
|
23
|
+
capabilities: Optional[dict]
|
|
24
|
+
status: Optional[str]
|
|
21
25
|
didIdentifier: str
|
|
22
|
-
did:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
158
|
+
Returns:
|
|
159
|
+
List of matching agents
|
|
160
|
+
"""
|
|
161
|
+
return self.search_agents(keyword=keyword, limit=limit, offset=offset)
|
|
51
162
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
167
|
+
Args:
|
|
168
|
+
agent_id: The unique identifier of the agent
|
|
59
169
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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}")
|