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.
@@ -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
+ }