devduck 1.2.0__py3-none-any.whl → 1.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.
- devduck/__init__.py +83 -6
- devduck/_version.py +2 -2
- devduck/agentcore_handler.py +1 -0
- devduck/tools/__init__.py +2 -2
- devduck/tools/zenoh_peer.py +1163 -0
- {devduck-1.2.0.dist-info → devduck-1.3.0.dist-info}/METADATA +2 -1
- {devduck-1.2.0.dist-info → devduck-1.3.0.dist-info}/RECORD +11 -10
- {devduck-1.2.0.dist-info → devduck-1.3.0.dist-info}/WHEEL +1 -1
- {devduck-1.2.0.dist-info → devduck-1.3.0.dist-info}/entry_points.txt +0 -0
- {devduck-1.2.0.dist-info → devduck-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {devduck-1.2.0.dist-info → devduck-1.3.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1163 @@
|
|
|
1
|
+
"""Zenoh tool for DevDuck agents with automatic peer discovery.
|
|
2
|
+
|
|
3
|
+
This module provides Zenoh-based peer-to-peer communication for DevDuck agents,
|
|
4
|
+
allowing multiple DevDuck instances to automatically discover and communicate
|
|
5
|
+
with each other using Zenoh's multicast scouting.
|
|
6
|
+
|
|
7
|
+
Key Features:
|
|
8
|
+
1. Auto-Discovery: DevDuck instances find each other automatically via multicast
|
|
9
|
+
2. Peer-to-Peer: Direct communication without central server
|
|
10
|
+
3. Broadcast: Send commands to ALL connected DevDuck instances at once
|
|
11
|
+
4. Direct Message: Send to specific peer by instance ID
|
|
12
|
+
5. Real-time Streaming: Responses stream as they're generated
|
|
13
|
+
|
|
14
|
+
How It Works:
|
|
15
|
+
------------
|
|
16
|
+
When a DevDuck instance starts with Zenoh enabled:
|
|
17
|
+
1. Joins the Zenoh peer network via multicast scouting (224.0.0.224:7446)
|
|
18
|
+
2. Subscribes to "devduck/**" key expressions for messages
|
|
19
|
+
3. Publishes its presence to "devduck/presence/{instance_id}"
|
|
20
|
+
4. Listens for commands on "devduck/cmd/{instance_id}" and "devduck/broadcast"
|
|
21
|
+
5. Responds on "devduck/response/{requester_id}/{turn_id}"
|
|
22
|
+
|
|
23
|
+
Key Expressions:
|
|
24
|
+
---------------
|
|
25
|
+
- devduck/presence/{id} - Peer announcements (heartbeat)
|
|
26
|
+
- devduck/broadcast - Messages to all peers
|
|
27
|
+
- devduck/cmd/{id} - Direct messages to specific peer
|
|
28
|
+
- devduck/response/{requester}/{turn_id} - Responses
|
|
29
|
+
|
|
30
|
+
Usage:
|
|
31
|
+
------
|
|
32
|
+
```python
|
|
33
|
+
# Terminal 1
|
|
34
|
+
devduck "start zenoh" # or auto-starts if DEVDUCK_ENABLE_ZENOH=true
|
|
35
|
+
|
|
36
|
+
# Terminal 2
|
|
37
|
+
devduck "start zenoh" # Auto-discovers Terminal 1
|
|
38
|
+
|
|
39
|
+
# Terminal 1: Broadcast to all
|
|
40
|
+
devduck "zenoh broadcast 'list all files'"
|
|
41
|
+
|
|
42
|
+
# Terminal 2: Send to specific peer
|
|
43
|
+
devduck "zenoh send peer-abc123 'what is 2+2?'"
|
|
44
|
+
|
|
45
|
+
# Check discovered peers
|
|
46
|
+
devduck "zenoh list peers"
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Note: This file is named zenoh_peer.py to avoid shadowing the eclipse-zenoh package.
|
|
50
|
+
The tool is exported as 'zenoh' for backward compatibility.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
import logging
|
|
54
|
+
import importlib
|
|
55
|
+
import threading
|
|
56
|
+
import time
|
|
57
|
+
import os
|
|
58
|
+
import json
|
|
59
|
+
import uuid
|
|
60
|
+
import socket
|
|
61
|
+
from typing import Any
|
|
62
|
+
from datetime import datetime
|
|
63
|
+
|
|
64
|
+
from strands import tool
|
|
65
|
+
|
|
66
|
+
logger = logging.getLogger(__name__)
|
|
67
|
+
|
|
68
|
+
# Global state for Zenoh
|
|
69
|
+
ZENOH_STATE: dict[str, Any] = {
|
|
70
|
+
"running": False,
|
|
71
|
+
"session": None,
|
|
72
|
+
"instance_id": None,
|
|
73
|
+
"peers": {}, # {peer_id: {last_seen, hostname, ...}}
|
|
74
|
+
"subscribers": [],
|
|
75
|
+
"publisher": None,
|
|
76
|
+
"agent": None,
|
|
77
|
+
"pending_responses": {}, # {turn_id: asyncio.Future or threading.Event}
|
|
78
|
+
"collected_responses": {}, # {turn_id: [responses]}
|
|
79
|
+
"streamed_content": {}, # {turn_id: {responder_id: "accumulated text"}}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# Heartbeat interval in seconds
|
|
83
|
+
HEARTBEAT_INTERVAL = 5.0
|
|
84
|
+
PEER_TIMEOUT = 15.0 # Consider peer dead after this many seconds
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def get_instance_id() -> str:
|
|
88
|
+
"""Generate or retrieve unique instance ID for this DevDuck."""
|
|
89
|
+
if ZENOH_STATE["instance_id"]:
|
|
90
|
+
return ZENOH_STATE["instance_id"]
|
|
91
|
+
|
|
92
|
+
# Generate a unique ID based on hostname + random suffix
|
|
93
|
+
hostname = socket.gethostname()[:8]
|
|
94
|
+
suffix = uuid.uuid4().hex[:6]
|
|
95
|
+
instance_id = f"{hostname}-{suffix}"
|
|
96
|
+
ZENOH_STATE["instance_id"] = instance_id
|
|
97
|
+
return instance_id
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def handle_presence(sample) -> None:
|
|
101
|
+
"""Handle peer presence announcements.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
sample: Zenoh sample containing peer info
|
|
105
|
+
"""
|
|
106
|
+
try:
|
|
107
|
+
key = str(sample.key_expr)
|
|
108
|
+
payload = sample.payload.to_bytes().decode()
|
|
109
|
+
data = json.loads(payload)
|
|
110
|
+
|
|
111
|
+
peer_id = data.get("instance_id")
|
|
112
|
+
if peer_id and peer_id != get_instance_id():
|
|
113
|
+
# Update peer info
|
|
114
|
+
ZENOH_STATE["peers"][peer_id] = {
|
|
115
|
+
"last_seen": time.time(),
|
|
116
|
+
"hostname": data.get("hostname", "unknown"),
|
|
117
|
+
"started": data.get("started"),
|
|
118
|
+
"model": data.get("model", "unknown"),
|
|
119
|
+
}
|
|
120
|
+
logger.debug(f"Zenoh: Peer discovered/updated: {peer_id}")
|
|
121
|
+
except Exception as e:
|
|
122
|
+
logger.error(f"Zenoh: Error handling presence: {e}")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ZenohStreamingCallbackHandler:
|
|
126
|
+
"""Callback handler that streams agent responses over Zenoh.
|
|
127
|
+
|
|
128
|
+
This handler implements real-time streaming of:
|
|
129
|
+
- Assistant responses (text chunks as they're generated)
|
|
130
|
+
- Tool invocations (names and status)
|
|
131
|
+
- Reasoning text (if enabled)
|
|
132
|
+
- Tool results (success/error status)
|
|
133
|
+
|
|
134
|
+
All data is published immediately to Zenoh for the requester to receive.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
def __init__(self, response_key: str, turn_id: str, responder_id: str):
|
|
138
|
+
"""Initialize the streaming handler.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
response_key: Zenoh key expression to publish responses to
|
|
142
|
+
turn_id: Unique turn ID for this conversation
|
|
143
|
+
responder_id: This instance's ID
|
|
144
|
+
"""
|
|
145
|
+
self.response_key = response_key
|
|
146
|
+
self.turn_id = turn_id
|
|
147
|
+
self.responder_id = responder_id
|
|
148
|
+
self.tool_count = 0
|
|
149
|
+
self.previous_tool_use = None
|
|
150
|
+
self.chunk_count = 0
|
|
151
|
+
|
|
152
|
+
def _publish(self, data: str, chunk_type: str = "text") -> None:
|
|
153
|
+
"""Publish a streaming chunk over Zenoh.
|
|
154
|
+
|
|
155
|
+
Args:
|
|
156
|
+
data: String data to publish
|
|
157
|
+
chunk_type: Type of chunk (text, tool, reasoning)
|
|
158
|
+
"""
|
|
159
|
+
try:
|
|
160
|
+
self.chunk_count += 1
|
|
161
|
+
chunk_msg = {
|
|
162
|
+
"type": "stream",
|
|
163
|
+
"chunk_type": chunk_type,
|
|
164
|
+
"responder_id": self.responder_id,
|
|
165
|
+
"turn_id": self.turn_id,
|
|
166
|
+
"chunk_num": self.chunk_count,
|
|
167
|
+
"data": data,
|
|
168
|
+
"timestamp": time.time(),
|
|
169
|
+
}
|
|
170
|
+
publish_message(self.response_key, chunk_msg)
|
|
171
|
+
except Exception as e:
|
|
172
|
+
logger.warning(f"Zenoh: Failed to publish stream chunk: {e}")
|
|
173
|
+
|
|
174
|
+
def __call__(self, **kwargs) -> None:
|
|
175
|
+
"""Stream events to Zenoh in real-time.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
**kwargs: Callback event data including:
|
|
179
|
+
- reasoningText (Optional[str]): Reasoning text to stream
|
|
180
|
+
- data (str): Text content to stream
|
|
181
|
+
- complete (bool): Whether this is the final chunk
|
|
182
|
+
- current_tool_use (dict): Current tool being invoked
|
|
183
|
+
- message (dict): Full message objects (for tool results)
|
|
184
|
+
"""
|
|
185
|
+
reasoningText = kwargs.get("reasoningText", False)
|
|
186
|
+
data = kwargs.get("data", "")
|
|
187
|
+
complete = kwargs.get("complete", False)
|
|
188
|
+
current_tool_use = kwargs.get("current_tool_use", {})
|
|
189
|
+
message = kwargs.get("message", {})
|
|
190
|
+
|
|
191
|
+
# Stream reasoning text
|
|
192
|
+
if reasoningText:
|
|
193
|
+
self._publish(reasoningText, "reasoning")
|
|
194
|
+
|
|
195
|
+
# Stream response text chunks
|
|
196
|
+
if data:
|
|
197
|
+
self._publish(data, "text")
|
|
198
|
+
if complete:
|
|
199
|
+
self._publish("\n", "text")
|
|
200
|
+
|
|
201
|
+
# Stream tool invocation notifications
|
|
202
|
+
if current_tool_use and current_tool_use.get("name"):
|
|
203
|
+
tool_name = current_tool_use.get("name", "Unknown tool")
|
|
204
|
+
if self.previous_tool_use != current_tool_use:
|
|
205
|
+
self.previous_tool_use = current_tool_use
|
|
206
|
+
self.tool_count += 1
|
|
207
|
+
self._publish(f"\n🛠️ Tool #{self.tool_count}: {tool_name}\n", "tool")
|
|
208
|
+
|
|
209
|
+
# Stream tool results
|
|
210
|
+
if isinstance(message, dict) and message.get("role") == "user":
|
|
211
|
+
for content in message.get("content", []):
|
|
212
|
+
if isinstance(content, dict):
|
|
213
|
+
tool_result = content.get("toolResult")
|
|
214
|
+
if tool_result:
|
|
215
|
+
status = tool_result.get("status", "unknown")
|
|
216
|
+
if status == "success":
|
|
217
|
+
self._publish("✅ Tool completed successfully\n", "tool")
|
|
218
|
+
else:
|
|
219
|
+
self._publish("❌ Tool failed\n", "tool")
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def handle_command(sample) -> None:
|
|
223
|
+
"""Handle incoming commands (broadcast or direct).
|
|
224
|
+
|
|
225
|
+
Creates a NEW DevDuck instance for each command to avoid concurrent
|
|
226
|
+
invocation errors (Strands Agent doesn't support concurrent requests).
|
|
227
|
+
|
|
228
|
+
Uses ZenohStreamingCallbackHandler to stream responses in real-time,
|
|
229
|
+
just like the TCP implementation.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
sample: Zenoh sample containing command
|
|
233
|
+
"""
|
|
234
|
+
try:
|
|
235
|
+
key = str(sample.key_expr)
|
|
236
|
+
payload = sample.payload.to_bytes().decode()
|
|
237
|
+
data = json.loads(payload)
|
|
238
|
+
|
|
239
|
+
sender_id = data.get("sender_id")
|
|
240
|
+
turn_id = data.get("turn_id")
|
|
241
|
+
command = data.get("command", "")
|
|
242
|
+
|
|
243
|
+
# Don't process our own messages
|
|
244
|
+
if sender_id == get_instance_id():
|
|
245
|
+
return
|
|
246
|
+
|
|
247
|
+
logger.info(f"Zenoh: Received command from {sender_id}: {command[:50]}...")
|
|
248
|
+
|
|
249
|
+
# Process the command with a NEW DevDuck instance
|
|
250
|
+
# This avoids concurrent invocation errors on the main agent
|
|
251
|
+
try:
|
|
252
|
+
# Create response topic
|
|
253
|
+
response_key = f"devduck/response/{sender_id}/{turn_id}"
|
|
254
|
+
instance_id = get_instance_id()
|
|
255
|
+
|
|
256
|
+
# Send acknowledgment
|
|
257
|
+
ack = {
|
|
258
|
+
"type": "ack",
|
|
259
|
+
"responder_id": instance_id,
|
|
260
|
+
"turn_id": turn_id,
|
|
261
|
+
"timestamp": time.time(),
|
|
262
|
+
}
|
|
263
|
+
publish_message(response_key, ack)
|
|
264
|
+
|
|
265
|
+
# Create a NEW DevDuck instance for this command
|
|
266
|
+
# auto_start_servers=False prevents recursion
|
|
267
|
+
from devduck import DevDuck
|
|
268
|
+
|
|
269
|
+
command_devduck = DevDuck(auto_start_servers=False)
|
|
270
|
+
|
|
271
|
+
if command_devduck.agent:
|
|
272
|
+
# Create streaming callback handler for real-time response streaming
|
|
273
|
+
streaming_handler = ZenohStreamingCallbackHandler(
|
|
274
|
+
response_key=response_key,
|
|
275
|
+
turn_id=turn_id,
|
|
276
|
+
responder_id=instance_id,
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
# Attach streaming handler to the agent
|
|
280
|
+
command_devduck.agent.callback_handler = streaming_handler
|
|
281
|
+
|
|
282
|
+
# Process with the new agent instance
|
|
283
|
+
# Responses stream automatically via callback_handler
|
|
284
|
+
result = command_devduck.agent(command)
|
|
285
|
+
|
|
286
|
+
# Send turn_end AFTER agent completes and all chunks are sent
|
|
287
|
+
# This is the definitive signal that streaming is complete
|
|
288
|
+
turn_end = {
|
|
289
|
+
"type": "turn_end",
|
|
290
|
+
"responder_id": instance_id,
|
|
291
|
+
"turn_id": turn_id,
|
|
292
|
+
"result": str(result),
|
|
293
|
+
"chunks_sent": streaming_handler.chunk_count,
|
|
294
|
+
"timestamp": time.time(),
|
|
295
|
+
}
|
|
296
|
+
publish_message(response_key, turn_end)
|
|
297
|
+
|
|
298
|
+
logger.info(
|
|
299
|
+
f"Zenoh: Sent turn_end to {sender_id} for turn {turn_id} ({streaming_handler.chunk_count} chunks)"
|
|
300
|
+
)
|
|
301
|
+
else:
|
|
302
|
+
raise Exception("Failed to create DevDuck instance")
|
|
303
|
+
|
|
304
|
+
except Exception as e:
|
|
305
|
+
# Send error response
|
|
306
|
+
error_response = {
|
|
307
|
+
"type": "error",
|
|
308
|
+
"responder_id": get_instance_id(),
|
|
309
|
+
"turn_id": turn_id,
|
|
310
|
+
"error": str(e),
|
|
311
|
+
"timestamp": time.time(),
|
|
312
|
+
}
|
|
313
|
+
publish_message(f"devduck/response/{sender_id}/{turn_id}", error_response)
|
|
314
|
+
logger.error(f"Zenoh: Error processing command: {e}")
|
|
315
|
+
|
|
316
|
+
except Exception as e:
|
|
317
|
+
logger.error(f"Zenoh: Error handling command: {e}")
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def handle_response(sample) -> None:
|
|
321
|
+
"""Handle responses to our commands.
|
|
322
|
+
|
|
323
|
+
Streams chunks to terminal in real-time and collects final response.
|
|
324
|
+
Waits for explicit 'turn_end' message which indicates all streaming is complete.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
sample: Zenoh sample containing response
|
|
328
|
+
"""
|
|
329
|
+
try:
|
|
330
|
+
key = str(sample.key_expr)
|
|
331
|
+
payload = sample.payload.to_bytes().decode()
|
|
332
|
+
data = json.loads(payload)
|
|
333
|
+
|
|
334
|
+
turn_id = data.get("turn_id")
|
|
335
|
+
responder_id = data.get("responder_id")
|
|
336
|
+
msg_type = data.get("type")
|
|
337
|
+
|
|
338
|
+
if turn_id in ZENOH_STATE["pending_responses"]:
|
|
339
|
+
# Handle streaming chunks - print to terminal AND collect for return
|
|
340
|
+
if msg_type == "stream":
|
|
341
|
+
chunk_data = data.get("data", "")
|
|
342
|
+
chunk_type = data.get("chunk_type", "text")
|
|
343
|
+
|
|
344
|
+
# Print streaming content directly to terminal
|
|
345
|
+
# This gives the same experience as TCP streaming
|
|
346
|
+
import sys
|
|
347
|
+
|
|
348
|
+
if chunk_data:
|
|
349
|
+
sys.stdout.write(chunk_data)
|
|
350
|
+
sys.stdout.flush()
|
|
351
|
+
|
|
352
|
+
# Also collect streamed content for tool return value
|
|
353
|
+
if turn_id not in ZENOH_STATE["streamed_content"]:
|
|
354
|
+
ZENOH_STATE["streamed_content"][turn_id] = {}
|
|
355
|
+
if responder_id not in ZENOH_STATE["streamed_content"][turn_id]:
|
|
356
|
+
ZENOH_STATE["streamed_content"][turn_id][responder_id] = ""
|
|
357
|
+
ZENOH_STATE["streamed_content"][turn_id][responder_id] += chunk_data
|
|
358
|
+
|
|
359
|
+
logger.debug(f"Zenoh: Stream chunk from {responder_id}: {chunk_type}")
|
|
360
|
+
return # Continue to next chunk
|
|
361
|
+
|
|
362
|
+
# Handle ACK - show peer is processing
|
|
363
|
+
if msg_type == "ack":
|
|
364
|
+
import sys
|
|
365
|
+
|
|
366
|
+
sys.stdout.write(f"\n🦆 [{responder_id}] Processing...\n")
|
|
367
|
+
sys.stdout.flush()
|
|
368
|
+
logger.debug(f"Zenoh: ACK from {responder_id} for turn {turn_id}")
|
|
369
|
+
return
|
|
370
|
+
|
|
371
|
+
# Handle turn_end - THIS is the real completion signal
|
|
372
|
+
# Sent AFTER all stream chunks have been published
|
|
373
|
+
if msg_type == "turn_end":
|
|
374
|
+
import sys
|
|
375
|
+
|
|
376
|
+
chunks_sent = data.get("chunks_sent", 0)
|
|
377
|
+
sys.stdout.write(
|
|
378
|
+
f"\n\n✅ [{responder_id}] Complete ({chunks_sent} chunks)\n"
|
|
379
|
+
)
|
|
380
|
+
sys.stdout.flush()
|
|
381
|
+
|
|
382
|
+
# Store final result if present
|
|
383
|
+
if turn_id not in ZENOH_STATE["collected_responses"]:
|
|
384
|
+
ZENOH_STATE["collected_responses"][turn_id] = []
|
|
385
|
+
|
|
386
|
+
ZENOH_STATE["collected_responses"][turn_id].append(
|
|
387
|
+
{
|
|
388
|
+
"responder": responder_id,
|
|
389
|
+
"type": "complete",
|
|
390
|
+
"result": data.get("result"),
|
|
391
|
+
"chunks_sent": chunks_sent,
|
|
392
|
+
"timestamp": data.get("timestamp"),
|
|
393
|
+
}
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
# Signal completion - all chunks have been sent
|
|
397
|
+
pending = ZENOH_STATE["pending_responses"].get(turn_id)
|
|
398
|
+
if isinstance(pending, threading.Event):
|
|
399
|
+
pending.set()
|
|
400
|
+
|
|
401
|
+
logger.debug(
|
|
402
|
+
f"Zenoh: Turn ended from {responder_id} for turn {turn_id}"
|
|
403
|
+
)
|
|
404
|
+
return
|
|
405
|
+
|
|
406
|
+
# Handle errors
|
|
407
|
+
if msg_type == "error":
|
|
408
|
+
import sys
|
|
409
|
+
|
|
410
|
+
sys.stdout.write(
|
|
411
|
+
f"\n\n❌ [{responder_id}] Error: {data.get('error', 'unknown')}\n"
|
|
412
|
+
)
|
|
413
|
+
sys.stdout.flush()
|
|
414
|
+
|
|
415
|
+
if turn_id not in ZENOH_STATE["collected_responses"]:
|
|
416
|
+
ZENOH_STATE["collected_responses"][turn_id] = []
|
|
417
|
+
|
|
418
|
+
ZENOH_STATE["collected_responses"][turn_id].append(
|
|
419
|
+
{
|
|
420
|
+
"responder": responder_id,
|
|
421
|
+
"type": "error",
|
|
422
|
+
"error": data.get("error"),
|
|
423
|
+
"timestamp": data.get("timestamp"),
|
|
424
|
+
}
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
# Signal completion on error too
|
|
428
|
+
pending = ZENOH_STATE["pending_responses"].get(turn_id)
|
|
429
|
+
if isinstance(pending, threading.Event):
|
|
430
|
+
pending.set()
|
|
431
|
+
|
|
432
|
+
logger.debug(f"Zenoh: Error from {responder_id} for turn {turn_id}")
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
# Legacy "response" type - treat as turn_end for backward compatibility
|
|
436
|
+
if msg_type == "response":
|
|
437
|
+
# Old-style response, treat as completion
|
|
438
|
+
if turn_id not in ZENOH_STATE["collected_responses"]:
|
|
439
|
+
ZENOH_STATE["collected_responses"][turn_id] = []
|
|
440
|
+
|
|
441
|
+
ZENOH_STATE["collected_responses"][turn_id].append(
|
|
442
|
+
{
|
|
443
|
+
"responder": responder_id,
|
|
444
|
+
"type": msg_type,
|
|
445
|
+
"result": data.get("result"),
|
|
446
|
+
"chunks_sent": data.get("chunks_sent", 0),
|
|
447
|
+
"timestamp": data.get("timestamp"),
|
|
448
|
+
}
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
# Don't signal completion here - wait for turn_end
|
|
452
|
+
logger.debug(
|
|
453
|
+
f"Zenoh: Legacy response from {responder_id} for turn {turn_id}"
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
except Exception as e:
|
|
457
|
+
logger.error(f"Zenoh: Error handling response: {e}")
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def publish_message(key_expr: str, data: dict) -> None:
|
|
461
|
+
"""Publish a message to a Zenoh key expression.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
key_expr: The key expression to publish to
|
|
465
|
+
data: Dictionary to publish as JSON
|
|
466
|
+
"""
|
|
467
|
+
if ZENOH_STATE["session"]:
|
|
468
|
+
try:
|
|
469
|
+
payload = json.dumps(data).encode()
|
|
470
|
+
ZENOH_STATE["session"].put(key_expr, payload)
|
|
471
|
+
except Exception as e:
|
|
472
|
+
logger.error(f"Zenoh: Error publishing to {key_expr}: {e}")
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def heartbeat_thread() -> None:
|
|
476
|
+
"""Background thread that sends periodic presence announcements."""
|
|
477
|
+
instance_id = get_instance_id()
|
|
478
|
+
|
|
479
|
+
while ZENOH_STATE["running"]:
|
|
480
|
+
try:
|
|
481
|
+
# Publish presence
|
|
482
|
+
presence_data = {
|
|
483
|
+
"instance_id": instance_id,
|
|
484
|
+
"hostname": socket.gethostname(),
|
|
485
|
+
"started": ZENOH_STATE.get("start_time"),
|
|
486
|
+
"model": ZENOH_STATE.get("model", "unknown"),
|
|
487
|
+
"timestamp": time.time(),
|
|
488
|
+
}
|
|
489
|
+
publish_message(f"devduck/presence/{instance_id}", presence_data)
|
|
490
|
+
|
|
491
|
+
# Clean up stale peers
|
|
492
|
+
current_time = time.time()
|
|
493
|
+
stale_peers = [
|
|
494
|
+
peer_id
|
|
495
|
+
for peer_id, info in ZENOH_STATE["peers"].items()
|
|
496
|
+
if current_time - info["last_seen"] > PEER_TIMEOUT
|
|
497
|
+
]
|
|
498
|
+
for peer_id in stale_peers:
|
|
499
|
+
del ZENOH_STATE["peers"][peer_id]
|
|
500
|
+
logger.info(f"Zenoh: Peer {peer_id} timed out")
|
|
501
|
+
|
|
502
|
+
except Exception as e:
|
|
503
|
+
logger.error(f"Zenoh: Heartbeat error: {e}")
|
|
504
|
+
|
|
505
|
+
time.sleep(HEARTBEAT_INTERVAL)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def start_zenoh(
|
|
509
|
+
agent=None,
|
|
510
|
+
model: str = "unknown",
|
|
511
|
+
connect: str = None,
|
|
512
|
+
listen: str = None,
|
|
513
|
+
) -> dict:
|
|
514
|
+
"""Start Zenoh peer networking for DevDuck.
|
|
515
|
+
|
|
516
|
+
Args:
|
|
517
|
+
agent: The DevDuck agent instance to use for processing commands
|
|
518
|
+
model: Model name for peer info
|
|
519
|
+
connect: Remote endpoint(s) to connect to (e.g., "tcp/1.2.3.4:7447" or comma-separated)
|
|
520
|
+
listen: Endpoint(s) to listen on (e.g., "tcp/0.0.0.0:7447" for public access)
|
|
521
|
+
|
|
522
|
+
Returns:
|
|
523
|
+
Status dictionary
|
|
524
|
+
"""
|
|
525
|
+
if ZENOH_STATE["running"]:
|
|
526
|
+
return {
|
|
527
|
+
"status": "error",
|
|
528
|
+
"content": [{"text": "❌ Zenoh already running"}],
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
try:
|
|
532
|
+
# Use importlib to avoid shadowing by this file's name
|
|
533
|
+
zenoh_pkg = importlib.import_module("zenoh")
|
|
534
|
+
except ImportError:
|
|
535
|
+
return {
|
|
536
|
+
"status": "error",
|
|
537
|
+
"content": [
|
|
538
|
+
{"text": "❌ Zenoh not installed. Run: pip install eclipse-zenoh"}
|
|
539
|
+
],
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
try:
|
|
543
|
+
instance_id = get_instance_id()
|
|
544
|
+
logger.info(f"Zenoh: Starting with instance ID: {instance_id}")
|
|
545
|
+
|
|
546
|
+
# Check for env vars for remote connection
|
|
547
|
+
connect = connect or os.getenv("ZENOH_CONNECT")
|
|
548
|
+
listen = listen or os.getenv("ZENOH_LISTEN")
|
|
549
|
+
|
|
550
|
+
# Configure Zenoh for peer mode with multicast scouting
|
|
551
|
+
# API changed in zenoh 1.x - handle both versions
|
|
552
|
+
try:
|
|
553
|
+
# New API (zenoh >= 1.0)
|
|
554
|
+
config = zenoh_pkg.Config.default()
|
|
555
|
+
except AttributeError:
|
|
556
|
+
try:
|
|
557
|
+
# Old API (zenoh < 1.0)
|
|
558
|
+
config = zenoh_pkg.Config()
|
|
559
|
+
except AttributeError:
|
|
560
|
+
# Fallback - open without config
|
|
561
|
+
config = None
|
|
562
|
+
|
|
563
|
+
# Configure remote endpoints if provided
|
|
564
|
+
endpoints_info = []
|
|
565
|
+
if config is not None:
|
|
566
|
+
# Add connect endpoints (for connecting to remote peers/routers)
|
|
567
|
+
if connect:
|
|
568
|
+
connect_endpoints = [e.strip() for e in connect.split(",")]
|
|
569
|
+
try:
|
|
570
|
+
config.insert_json5(
|
|
571
|
+
"connect/endpoints", json.dumps(connect_endpoints)
|
|
572
|
+
)
|
|
573
|
+
endpoints_info.append(
|
|
574
|
+
f"🔗 Connecting to: {', '.join(connect_endpoints)}"
|
|
575
|
+
)
|
|
576
|
+
logger.info(
|
|
577
|
+
f"Zenoh: Configured connect endpoints: {connect_endpoints}"
|
|
578
|
+
)
|
|
579
|
+
except Exception as e:
|
|
580
|
+
logger.warning(f"Zenoh: Failed to set connect endpoints: {e}")
|
|
581
|
+
|
|
582
|
+
# Add listen endpoints (for accepting remote connections)
|
|
583
|
+
if listen:
|
|
584
|
+
listen_endpoints = [e.strip() for e in listen.split(",")]
|
|
585
|
+
try:
|
|
586
|
+
config.insert_json5(
|
|
587
|
+
"listen/endpoints", json.dumps(listen_endpoints)
|
|
588
|
+
)
|
|
589
|
+
endpoints_info.append(
|
|
590
|
+
f"👂 Listening on: {', '.join(listen_endpoints)}"
|
|
591
|
+
)
|
|
592
|
+
logger.info(
|
|
593
|
+
f"Zenoh: Configured listen endpoints: {listen_endpoints}"
|
|
594
|
+
)
|
|
595
|
+
except Exception as e:
|
|
596
|
+
logger.warning(f"Zenoh: Failed to set listen endpoints: {e}")
|
|
597
|
+
|
|
598
|
+
# Open Zenoh session
|
|
599
|
+
if config is not None:
|
|
600
|
+
session = zenoh_pkg.open(config)
|
|
601
|
+
else:
|
|
602
|
+
session = zenoh_pkg.open()
|
|
603
|
+
ZENOH_STATE["session"] = session
|
|
604
|
+
ZENOH_STATE["running"] = True
|
|
605
|
+
ZENOH_STATE["start_time"] = datetime.now().isoformat()
|
|
606
|
+
ZENOH_STATE["model"] = model
|
|
607
|
+
ZENOH_STATE["agent"] = agent
|
|
608
|
+
|
|
609
|
+
# Subscribe to presence announcements
|
|
610
|
+
presence_sub = session.declare_subscriber("devduck/presence/*", handle_presence)
|
|
611
|
+
ZENOH_STATE["subscribers"].append(presence_sub)
|
|
612
|
+
|
|
613
|
+
# Subscribe to broadcast commands
|
|
614
|
+
broadcast_sub = session.declare_subscriber("devduck/broadcast", handle_command)
|
|
615
|
+
ZENOH_STATE["subscribers"].append(broadcast_sub)
|
|
616
|
+
|
|
617
|
+
# Subscribe to direct commands for this instance
|
|
618
|
+
direct_sub = session.declare_subscriber(
|
|
619
|
+
f"devduck/cmd/{instance_id}", handle_command
|
|
620
|
+
)
|
|
621
|
+
ZENOH_STATE["subscribers"].append(direct_sub)
|
|
622
|
+
|
|
623
|
+
# Subscribe to responses for this instance
|
|
624
|
+
response_sub = session.declare_subscriber(
|
|
625
|
+
f"devduck/response/{instance_id}/*", handle_response
|
|
626
|
+
)
|
|
627
|
+
ZENOH_STATE["subscribers"].append(response_sub)
|
|
628
|
+
|
|
629
|
+
# Start heartbeat thread
|
|
630
|
+
heartbeat = threading.Thread(target=heartbeat_thread, daemon=True)
|
|
631
|
+
heartbeat.start()
|
|
632
|
+
ZENOH_STATE["heartbeat_thread"] = heartbeat
|
|
633
|
+
|
|
634
|
+
logger.info(f"Zenoh: Started successfully as {instance_id}")
|
|
635
|
+
|
|
636
|
+
# Build response content
|
|
637
|
+
content = [
|
|
638
|
+
{"text": f"✅ Zenoh started successfully"},
|
|
639
|
+
{"text": f"🆔 Instance ID: {instance_id}"},
|
|
640
|
+
]
|
|
641
|
+
|
|
642
|
+
# Add endpoint info if remote connections configured
|
|
643
|
+
if endpoints_info:
|
|
644
|
+
for info in endpoints_info:
|
|
645
|
+
content.append({"text": info})
|
|
646
|
+
else:
|
|
647
|
+
content.append({"text": "🔍 Multicast scouting enabled (224.0.0.224:7446)"})
|
|
648
|
+
|
|
649
|
+
content.extend(
|
|
650
|
+
[
|
|
651
|
+
{"text": "📡 Listening for peers..."},
|
|
652
|
+
{"text": ""},
|
|
653
|
+
{"text": "Commands:"},
|
|
654
|
+
{"text": " • zenoh_peer(action='list_peers') - See discovered peers"},
|
|
655
|
+
{
|
|
656
|
+
"text": " • zenoh_peer(action='broadcast', message='...') - Send to all"
|
|
657
|
+
},
|
|
658
|
+
{
|
|
659
|
+
"text": " • zenoh_peer(action='send', peer_id='...', message='...') - Send to one"
|
|
660
|
+
},
|
|
661
|
+
]
|
|
662
|
+
)
|
|
663
|
+
|
|
664
|
+
return {
|
|
665
|
+
"status": "success",
|
|
666
|
+
"content": content,
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
except Exception as e:
|
|
670
|
+
logger.error(f"Zenoh: Failed to start: {e}")
|
|
671
|
+
ZENOH_STATE["running"] = False
|
|
672
|
+
return {
|
|
673
|
+
"status": "error",
|
|
674
|
+
"content": [{"text": f"❌ Failed to start Zenoh: {e}"}],
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def stop_zenoh() -> dict:
|
|
679
|
+
"""Stop Zenoh peer networking.
|
|
680
|
+
|
|
681
|
+
Returns:
|
|
682
|
+
Status dictionary
|
|
683
|
+
"""
|
|
684
|
+
if not ZENOH_STATE["running"]:
|
|
685
|
+
return {
|
|
686
|
+
"status": "error",
|
|
687
|
+
"content": [{"text": "❌ Zenoh not running"}],
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
try:
|
|
691
|
+
ZENOH_STATE["running"] = False
|
|
692
|
+
|
|
693
|
+
# Unsubscribe all
|
|
694
|
+
for sub in ZENOH_STATE["subscribers"]:
|
|
695
|
+
try:
|
|
696
|
+
sub.undeclare()
|
|
697
|
+
except:
|
|
698
|
+
pass
|
|
699
|
+
ZENOH_STATE["subscribers"] = []
|
|
700
|
+
|
|
701
|
+
# Close session
|
|
702
|
+
if ZENOH_STATE["session"]:
|
|
703
|
+
ZENOH_STATE["session"].close()
|
|
704
|
+
ZENOH_STATE["session"] = None
|
|
705
|
+
|
|
706
|
+
# Clear state
|
|
707
|
+
peer_count = len(ZENOH_STATE["peers"])
|
|
708
|
+
ZENOH_STATE["peers"] = {}
|
|
709
|
+
ZENOH_STATE["agent"] = None
|
|
710
|
+
|
|
711
|
+
instance_id = ZENOH_STATE["instance_id"]
|
|
712
|
+
ZENOH_STATE["instance_id"] = None
|
|
713
|
+
|
|
714
|
+
logger.info("Zenoh: Stopped")
|
|
715
|
+
|
|
716
|
+
return {
|
|
717
|
+
"status": "success",
|
|
718
|
+
"content": [
|
|
719
|
+
{"text": f"✅ Zenoh stopped"},
|
|
720
|
+
{"text": f"🆔 Was: {instance_id}"},
|
|
721
|
+
{"text": f"👥 Had {peer_count} connected peers"},
|
|
722
|
+
],
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
except Exception as e:
|
|
726
|
+
logger.error(f"Zenoh: Error stopping: {e}")
|
|
727
|
+
return {
|
|
728
|
+
"status": "error",
|
|
729
|
+
"content": [{"text": f"❌ Error stopping Zenoh: {e}"}],
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def get_zenoh_status() -> dict:
|
|
734
|
+
"""Get current Zenoh status.
|
|
735
|
+
|
|
736
|
+
Returns:
|
|
737
|
+
Status dictionary
|
|
738
|
+
"""
|
|
739
|
+
if not ZENOH_STATE["running"]:
|
|
740
|
+
return {
|
|
741
|
+
"status": "success",
|
|
742
|
+
"content": [{"text": "Zenoh not running"}],
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
instance_id = get_instance_id()
|
|
746
|
+
peer_count = len(ZENOH_STATE["peers"])
|
|
747
|
+
start_time = ZENOH_STATE.get("start_time", "unknown")
|
|
748
|
+
|
|
749
|
+
peer_list = []
|
|
750
|
+
for peer_id, info in ZENOH_STATE["peers"].items():
|
|
751
|
+
age = time.time() - info["last_seen"]
|
|
752
|
+
peer_list.append(f" • {peer_id} ({info['hostname']}) - seen {age:.1f}s ago")
|
|
753
|
+
|
|
754
|
+
content = [
|
|
755
|
+
{"text": "🦆 Zenoh Status"},
|
|
756
|
+
{"text": f"🆔 Instance: {instance_id}"},
|
|
757
|
+
{"text": f"⏱️ Started: {start_time}"},
|
|
758
|
+
{"text": f"👥 Peers: {peer_count}"},
|
|
759
|
+
]
|
|
760
|
+
|
|
761
|
+
if peer_list:
|
|
762
|
+
content.append({"text": "\nDiscovered Peers:"})
|
|
763
|
+
content.append({"text": "\n".join(peer_list)})
|
|
764
|
+
else:
|
|
765
|
+
content.append({"text": "\nNo peers discovered yet"})
|
|
766
|
+
|
|
767
|
+
return {
|
|
768
|
+
"status": "success",
|
|
769
|
+
"content": content,
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
def list_peers() -> dict:
|
|
774
|
+
"""List all discovered Zenoh peers.
|
|
775
|
+
|
|
776
|
+
Returns:
|
|
777
|
+
Status dictionary with peer list
|
|
778
|
+
"""
|
|
779
|
+
if not ZENOH_STATE["running"]:
|
|
780
|
+
return {
|
|
781
|
+
"status": "error",
|
|
782
|
+
"content": [{"text": "❌ Zenoh not running"}],
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
peers = ZENOH_STATE["peers"]
|
|
786
|
+
if not peers:
|
|
787
|
+
return {
|
|
788
|
+
"status": "success",
|
|
789
|
+
"content": [
|
|
790
|
+
{"text": "No peers discovered yet"},
|
|
791
|
+
{"text": "💡 Start another DevDuck instance with Zenoh to see it here"},
|
|
792
|
+
],
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
peer_info = []
|
|
796
|
+
for peer_id, info in peers.items():
|
|
797
|
+
age = time.time() - info["last_seen"]
|
|
798
|
+
peer_info.append(
|
|
799
|
+
{
|
|
800
|
+
"id": peer_id,
|
|
801
|
+
"hostname": info.get("hostname", "unknown"),
|
|
802
|
+
"model": info.get("model", "unknown"),
|
|
803
|
+
"last_seen": f"{age:.1f}s ago",
|
|
804
|
+
}
|
|
805
|
+
)
|
|
806
|
+
|
|
807
|
+
content = [{"text": f"👥 Discovered Peers ({len(peers)}):"}]
|
|
808
|
+
for p in peer_info:
|
|
809
|
+
content.append(
|
|
810
|
+
{
|
|
811
|
+
"text": f"\n 🦆 {p['id']}\n Host: {p['hostname']}\n Model: {p['model']}\n Seen: {p['last_seen']}"
|
|
812
|
+
}
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
return {
|
|
816
|
+
"status": "success",
|
|
817
|
+
"content": content,
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def broadcast_message(message: str, wait_time: float = 60.0) -> dict:
|
|
822
|
+
"""Broadcast a command to ALL connected DevDuck peers.
|
|
823
|
+
|
|
824
|
+
Args:
|
|
825
|
+
message: The command/message to send
|
|
826
|
+
wait_time: Maximum time to wait for responses (seconds, default: 60)
|
|
827
|
+
|
|
828
|
+
Returns:
|
|
829
|
+
Status dictionary with collected responses
|
|
830
|
+
"""
|
|
831
|
+
if not ZENOH_STATE["running"]:
|
|
832
|
+
return {
|
|
833
|
+
"status": "error",
|
|
834
|
+
"content": [{"text": "❌ Zenoh not running"}],
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if not ZENOH_STATE["peers"]:
|
|
838
|
+
return {
|
|
839
|
+
"status": "error",
|
|
840
|
+
"content": [
|
|
841
|
+
{
|
|
842
|
+
"text": "❌ No peers discovered. Start another DevDuck instance first."
|
|
843
|
+
}
|
|
844
|
+
],
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
turn_id = uuid.uuid4().hex[:8]
|
|
848
|
+
instance_id = get_instance_id()
|
|
849
|
+
peer_count = len(ZENOH_STATE["peers"])
|
|
850
|
+
|
|
851
|
+
# Prepare for responses - use threading.Event for completion signal
|
|
852
|
+
completion_event = threading.Event()
|
|
853
|
+
ZENOH_STATE["pending_responses"][turn_id] = completion_event
|
|
854
|
+
ZENOH_STATE["collected_responses"][turn_id] = []
|
|
855
|
+
|
|
856
|
+
# Broadcast the command
|
|
857
|
+
command_data = {
|
|
858
|
+
"sender_id": instance_id,
|
|
859
|
+
"turn_id": turn_id,
|
|
860
|
+
"command": message,
|
|
861
|
+
"timestamp": time.time(),
|
|
862
|
+
}
|
|
863
|
+
publish_message("devduck/broadcast", command_data)
|
|
864
|
+
|
|
865
|
+
logger.info(
|
|
866
|
+
f"Zenoh: Broadcast '{message[:50]}...' to {peer_count} peers (turn: {turn_id})"
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
# Wait for responses - could be multiple, so wait for timeout or all peers
|
|
870
|
+
# For broadcast, wait for at least one response or timeout
|
|
871
|
+
completed = completion_event.wait(timeout=wait_time)
|
|
872
|
+
|
|
873
|
+
if not completed:
|
|
874
|
+
logger.warning(
|
|
875
|
+
f"Zenoh: Broadcast timeout after {wait_time}s for turn {turn_id}"
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
# Collect responses
|
|
879
|
+
responses = ZENOH_STATE["collected_responses"].get(turn_id, [])
|
|
880
|
+
|
|
881
|
+
# Get streamed content
|
|
882
|
+
streamed = ZENOH_STATE["streamed_content"].get(turn_id, {})
|
|
883
|
+
|
|
884
|
+
# Cleanup
|
|
885
|
+
del ZENOH_STATE["pending_responses"][turn_id]
|
|
886
|
+
if turn_id in ZENOH_STATE["collected_responses"]:
|
|
887
|
+
del ZENOH_STATE["collected_responses"][turn_id]
|
|
888
|
+
if turn_id in ZENOH_STATE["streamed_content"]:
|
|
889
|
+
del ZENOH_STATE["streamed_content"][turn_id]
|
|
890
|
+
|
|
891
|
+
content = [
|
|
892
|
+
{"text": f"📢 Broadcast sent to {peer_count} peers"},
|
|
893
|
+
{"text": f"💬 Message: {message}"},
|
|
894
|
+
{"text": f"⏱️ Waited: {wait_time}s"},
|
|
895
|
+
{"text": f"📥 Responses: {len(responses)}, Streamed: {len(streamed)}"},
|
|
896
|
+
]
|
|
897
|
+
|
|
898
|
+
# Include streamed content first (real-time responses)
|
|
899
|
+
if streamed:
|
|
900
|
+
for responder, text in streamed.items():
|
|
901
|
+
content.append({"text": f"\n🦆 {responder} (streamed):\n{text}"})
|
|
902
|
+
|
|
903
|
+
# Then include formal responses
|
|
904
|
+
for resp in responses:
|
|
905
|
+
resp_type = resp.get("type", "unknown")
|
|
906
|
+
responder = resp.get("responder", "unknown")
|
|
907
|
+
|
|
908
|
+
if resp_type == "response":
|
|
909
|
+
result = resp.get("result", "")[:500] # Truncate long responses
|
|
910
|
+
content.append({"text": f"\n🦆 {responder}:\n{result}"})
|
|
911
|
+
elif resp_type == "error":
|
|
912
|
+
error = resp.get("error", "unknown error")
|
|
913
|
+
content.append({"text": f"\n❌ {responder}: {error}"})
|
|
914
|
+
elif resp_type == "ack":
|
|
915
|
+
content.append({"text": f"\n✓ {responder}: acknowledged"})
|
|
916
|
+
|
|
917
|
+
return {
|
|
918
|
+
"status": "success",
|
|
919
|
+
"content": content,
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def send_to_peer(peer_id: str, message: str, wait_time: float = 120.0) -> dict:
|
|
924
|
+
"""Send a command to a specific DevDuck peer.
|
|
925
|
+
|
|
926
|
+
Args:
|
|
927
|
+
peer_id: The target peer's instance ID
|
|
928
|
+
message: The command/message to send
|
|
929
|
+
wait_time: Maximum time to wait for response (seconds, default: 120)
|
|
930
|
+
|
|
931
|
+
Returns:
|
|
932
|
+
Status dictionary with response
|
|
933
|
+
"""
|
|
934
|
+
if not ZENOH_STATE["running"]:
|
|
935
|
+
return {
|
|
936
|
+
"status": "error",
|
|
937
|
+
"content": [{"text": "❌ Zenoh not running"}],
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
if peer_id not in ZENOH_STATE["peers"]:
|
|
941
|
+
available = list(ZENOH_STATE["peers"].keys())
|
|
942
|
+
return {
|
|
943
|
+
"status": "error",
|
|
944
|
+
"content": [
|
|
945
|
+
{"text": f"❌ Peer '{peer_id}' not found"},
|
|
946
|
+
{"text": f"Available peers: {available}"},
|
|
947
|
+
],
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
turn_id = uuid.uuid4().hex[:8]
|
|
951
|
+
instance_id = get_instance_id()
|
|
952
|
+
|
|
953
|
+
# Prepare for response - use threading.Event for completion signal
|
|
954
|
+
completion_event = threading.Event()
|
|
955
|
+
ZENOH_STATE["pending_responses"][turn_id] = completion_event
|
|
956
|
+
ZENOH_STATE["collected_responses"][turn_id] = []
|
|
957
|
+
|
|
958
|
+
# Send direct command
|
|
959
|
+
command_data = {
|
|
960
|
+
"sender_id": instance_id,
|
|
961
|
+
"turn_id": turn_id,
|
|
962
|
+
"command": message,
|
|
963
|
+
"timestamp": time.time(),
|
|
964
|
+
}
|
|
965
|
+
publish_message(f"devduck/cmd/{peer_id}", command_data)
|
|
966
|
+
|
|
967
|
+
logger.info(f"Zenoh: Sent '{message[:50]}...' to {peer_id} (turn: {turn_id})")
|
|
968
|
+
|
|
969
|
+
# Wait for completion signal OR timeout
|
|
970
|
+
# This waits until handle_response sets the event (on "turn_end" or "error")
|
|
971
|
+
completed = completion_event.wait(timeout=wait_time)
|
|
972
|
+
|
|
973
|
+
if not completed:
|
|
974
|
+
logger.warning(f"Zenoh: Response timeout after {wait_time}s for turn {turn_id}")
|
|
975
|
+
|
|
976
|
+
# Get response
|
|
977
|
+
responses = ZENOH_STATE["collected_responses"].get(turn_id, [])
|
|
978
|
+
|
|
979
|
+
# Get streamed content
|
|
980
|
+
streamed = ZENOH_STATE["streamed_content"].get(turn_id, {})
|
|
981
|
+
|
|
982
|
+
# Cleanup
|
|
983
|
+
del ZENOH_STATE["pending_responses"][turn_id]
|
|
984
|
+
if turn_id in ZENOH_STATE["collected_responses"]:
|
|
985
|
+
del ZENOH_STATE["collected_responses"][turn_id]
|
|
986
|
+
if turn_id in ZENOH_STATE["streamed_content"]:
|
|
987
|
+
del ZENOH_STATE["streamed_content"][turn_id]
|
|
988
|
+
|
|
989
|
+
content = [
|
|
990
|
+
{"text": f"📨 Sent to: {peer_id}"},
|
|
991
|
+
{"text": f"💬 Message: {message}"},
|
|
992
|
+
]
|
|
993
|
+
|
|
994
|
+
# Include streamed content in response
|
|
995
|
+
if streamed:
|
|
996
|
+
for responder, text in streamed.items():
|
|
997
|
+
content.append({"text": f"\n📥 Streamed from {responder}:\n{text}"})
|
|
998
|
+
elif responses:
|
|
999
|
+
for resp in responses:
|
|
1000
|
+
resp_type = resp.get("type", "unknown")
|
|
1001
|
+
if resp_type == "response":
|
|
1002
|
+
result = resp.get("result", "")
|
|
1003
|
+
content.append({"text": f"\n📥 Response:\n{result}"})
|
|
1004
|
+
elif resp_type == "error":
|
|
1005
|
+
error = resp.get("error", "unknown error")
|
|
1006
|
+
content.append({"text": f"\n❌ Error: {error}"})
|
|
1007
|
+
else:
|
|
1008
|
+
content.append(
|
|
1009
|
+
{"text": "\n⏱️ No response received (peer may be busy or timed out)"}
|
|
1010
|
+
)
|
|
1011
|
+
|
|
1012
|
+
return {
|
|
1013
|
+
"status": "success",
|
|
1014
|
+
"content": content,
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
@tool
|
|
1019
|
+
def zenoh_peer(
|
|
1020
|
+
action: str,
|
|
1021
|
+
message: str = "",
|
|
1022
|
+
peer_id: str = "",
|
|
1023
|
+
wait_time: float = 120.0,
|
|
1024
|
+
connect: str = "",
|
|
1025
|
+
listen: str = "",
|
|
1026
|
+
agent=None,
|
|
1027
|
+
) -> dict:
|
|
1028
|
+
"""Zenoh peer-to-peer networking for DevDuck auto-discovery and communication.
|
|
1029
|
+
|
|
1030
|
+
This tool enables multiple DevDuck instances to automatically discover each other
|
|
1031
|
+
and communicate using Zenoh's multicast scouting. No manual configuration needed -
|
|
1032
|
+
just start Zenoh on multiple terminals and they find each other!
|
|
1033
|
+
|
|
1034
|
+
How It Works:
|
|
1035
|
+
------------
|
|
1036
|
+
1. Each DevDuck instance joins a Zenoh peer network
|
|
1037
|
+
2. Multicast scouting (224.0.0.224:7446) auto-discovers peers on local network
|
|
1038
|
+
3. Peers exchange heartbeats to maintain presence awareness
|
|
1039
|
+
4. Commands can be broadcast to ALL peers or sent to specific peers
|
|
1040
|
+
5. Responses stream back from all responding peers
|
|
1041
|
+
|
|
1042
|
+
Remote Connections:
|
|
1043
|
+
------------------
|
|
1044
|
+
To connect DevDuck instances across different networks:
|
|
1045
|
+
- Use 'connect' to specify remote peer/router endpoints
|
|
1046
|
+
- Use 'listen' to accept incoming remote connections
|
|
1047
|
+
- Or set ZENOH_CONNECT / ZENOH_LISTEN environment variables
|
|
1048
|
+
|
|
1049
|
+
Use Cases:
|
|
1050
|
+
---------
|
|
1051
|
+
- Multi-terminal coordination: "zenoh broadcast 'git pull && npm install'"
|
|
1052
|
+
- Distributed task execution: One command triggers all instances
|
|
1053
|
+
- Peer monitoring: See all active DevDuck instances
|
|
1054
|
+
- Direct messaging: Send specific tasks to specific instances
|
|
1055
|
+
- Cross-network collaboration: Connect home and office DevDucks
|
|
1056
|
+
|
|
1057
|
+
Args:
|
|
1058
|
+
action: Action to perform:
|
|
1059
|
+
- "start": Start Zenoh networking (auto-joins peer mesh)
|
|
1060
|
+
- "stop": Stop Zenoh networking
|
|
1061
|
+
- "status": Show current status and peer count
|
|
1062
|
+
- "list_peers": List all discovered peers
|
|
1063
|
+
- "broadcast": Send command to ALL peers
|
|
1064
|
+
- "send": Send command to specific peer
|
|
1065
|
+
message: Command/message to send (for broadcast/send actions)
|
|
1066
|
+
peer_id: Target peer ID (for send action)
|
|
1067
|
+
wait_time: Seconds to wait for responses (default: 5.0)
|
|
1068
|
+
connect: Remote endpoint(s) to connect to (e.g., "tcp/1.2.3.4:7447")
|
|
1069
|
+
listen: Endpoint(s) to listen on for remote connections (e.g., "tcp/0.0.0.0:7447")
|
|
1070
|
+
agent: DevDuck agent instance (passed automatically on start)
|
|
1071
|
+
|
|
1072
|
+
Returns:
|
|
1073
|
+
Dictionary containing status and response content
|
|
1074
|
+
|
|
1075
|
+
Examples:
|
|
1076
|
+
# Terminal 1: Start Zenoh (local network only)
|
|
1077
|
+
zenoh_peer(action="start")
|
|
1078
|
+
|
|
1079
|
+
# Terminal 2: Start Zenoh (auto-discovers Terminal 1)
|
|
1080
|
+
zenoh_peer(action="start")
|
|
1081
|
+
|
|
1082
|
+
# Start with remote connection (connect to peer at home)
|
|
1083
|
+
zenoh_peer(action="start", connect="tcp/home.example.com:7447")
|
|
1084
|
+
|
|
1085
|
+
# Start listening for remote connections
|
|
1086
|
+
zenoh_peer(action="start", listen="tcp/0.0.0.0:7447")
|
|
1087
|
+
|
|
1088
|
+
# Terminal 1: See peers
|
|
1089
|
+
zenoh_peer(action="list_peers")
|
|
1090
|
+
# Shows: Terminal 2's instance
|
|
1091
|
+
|
|
1092
|
+
# Terminal 1: Broadcast to all
|
|
1093
|
+
zenoh_peer(action="broadcast", message="echo 'Hello from all DevDucks!'")
|
|
1094
|
+
# Terminal 2 executes the command and responds
|
|
1095
|
+
|
|
1096
|
+
# Send to specific peer
|
|
1097
|
+
zenoh_peer(action="send", peer_id="hostname-abc123", message="what files are here?")
|
|
1098
|
+
|
|
1099
|
+
Environment:
|
|
1100
|
+
DEVDUCK_ENABLE_ZENOH=true - Auto-start Zenoh on DevDuck launch
|
|
1101
|
+
ZENOH_CONNECT=tcp/1.2.3.4:7447 - Auto-connect to remote endpoint
|
|
1102
|
+
ZENOH_LISTEN=tcp/0.0.0.0:7447 - Auto-listen for remote connections
|
|
1103
|
+
"""
|
|
1104
|
+
if action == "start":
|
|
1105
|
+
# Get model info if agent provided
|
|
1106
|
+
model = "unknown"
|
|
1107
|
+
if agent and hasattr(agent, "model"):
|
|
1108
|
+
agent_model = getattr(agent, "model", None)
|
|
1109
|
+
if agent_model:
|
|
1110
|
+
# Try to get model_id attribute (most model providers have this)
|
|
1111
|
+
model = (
|
|
1112
|
+
getattr(agent_model, "model_id", None)
|
|
1113
|
+
or getattr(agent_model, "model_name", None)
|
|
1114
|
+
or getattr(agent_model, "name", None)
|
|
1115
|
+
or type(agent_model).__name__
|
|
1116
|
+
)
|
|
1117
|
+
return start_zenoh(
|
|
1118
|
+
agent=agent,
|
|
1119
|
+
model=model,
|
|
1120
|
+
connect=connect if connect else None,
|
|
1121
|
+
listen=listen if listen else None,
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
elif action == "stop":
|
|
1125
|
+
return stop_zenoh()
|
|
1126
|
+
|
|
1127
|
+
elif action == "status":
|
|
1128
|
+
return get_zenoh_status()
|
|
1129
|
+
|
|
1130
|
+
elif action == "list_peers":
|
|
1131
|
+
return list_peers()
|
|
1132
|
+
|
|
1133
|
+
elif action == "broadcast":
|
|
1134
|
+
if not message:
|
|
1135
|
+
return {
|
|
1136
|
+
"status": "error",
|
|
1137
|
+
"content": [{"text": "❌ message parameter required for broadcast"}],
|
|
1138
|
+
}
|
|
1139
|
+
return broadcast_message(message, wait_time)
|
|
1140
|
+
|
|
1141
|
+
elif action == "send":
|
|
1142
|
+
if not peer_id:
|
|
1143
|
+
return {
|
|
1144
|
+
"status": "error",
|
|
1145
|
+
"content": [{"text": "❌ peer_id parameter required for send"}],
|
|
1146
|
+
}
|
|
1147
|
+
if not message:
|
|
1148
|
+
return {
|
|
1149
|
+
"status": "error",
|
|
1150
|
+
"content": [{"text": "❌ message parameter required for send"}],
|
|
1151
|
+
}
|
|
1152
|
+
return send_to_peer(peer_id, message, wait_time)
|
|
1153
|
+
|
|
1154
|
+
else:
|
|
1155
|
+
return {
|
|
1156
|
+
"status": "error",
|
|
1157
|
+
"content": [
|
|
1158
|
+
{"text": f"❌ Unknown action: {action}"},
|
|
1159
|
+
{
|
|
1160
|
+
"text": "Valid actions: start, stop, status, list_peers, broadcast, send"
|
|
1161
|
+
},
|
|
1162
|
+
],
|
|
1163
|
+
}
|