devduck 0.4.1__py3-none-any.whl → 0.5.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.

Potentially problematic release.


This version of devduck might be problematic. Click here for more details.

devduck/tools/ipc.py ADDED
@@ -0,0 +1,543 @@
1
+ """IPC tool for DevDuck agents with real-time streaming support.
2
+
3
+ This module provides Unix socket IPC server functionality for DevDuck agents,
4
+ allowing local processes (tray, ambient, etc.) to communicate with real-time streaming.
5
+ Similar to websocket.py but uses Unix sockets for inter-process communication.
6
+
7
+ Key Features:
8
+ 1. IPC Server: Listen on Unix socket for local process connections
9
+ 2. Real-time Streaming: Responses stream to clients as they're generated
10
+ 3. Concurrent Processing: Handle multiple connections simultaneously
11
+ 4. Background Processing: Server runs in a background thread
12
+ 5. Per-Connection DevDuck: Creates a fresh DevDuck instance for each connection
13
+ 6. Callback Handler: Uses Strands callback system for efficient streaming
14
+ 7. Bidirectional: Clients can send commands AND receive streaming responses
15
+
16
+ Message Format:
17
+ ```json
18
+ {
19
+ "type": "turn_start" | "chunk" | "tool_start" | "tool_end" | "turn_end" | "command",
20
+ "turn_id": "uuid",
21
+ "data": "text content",
22
+ "timestamp": 1234567890.123,
23
+ "command": "optional_command_name",
24
+ "params": {"optional": "parameters"}
25
+ }
26
+ ```
27
+
28
+ Usage with DevDuck Agent:
29
+
30
+ ```python
31
+ from devduck import devduck
32
+
33
+ # Start IPC server
34
+ result = devduck.agent.tool.ipc(
35
+ action="start_server",
36
+ socket_path="/tmp/devduck_main.sock",
37
+ system_prompt="You are a helpful IPC server assistant.",
38
+ )
39
+
40
+ # Stop IPC server
41
+ result = devduck.agent.tool.ipc(
42
+ action="stop_server",
43
+ socket_path="/tmp/devduck_main.sock"
44
+ )
45
+ ```
46
+
47
+ Client Example (Python):
48
+ ```python
49
+ import socket
50
+ import json
51
+
52
+ # Connect to IPC server
53
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
54
+ sock.connect("/tmp/devduck_main.sock")
55
+
56
+ # Send message
57
+ message = json.dumps({"message": "Hello DevDuck!", "turn_id": "123"})
58
+ sock.sendall(message.encode() + b'\n')
59
+
60
+ # Receive streaming response
61
+ buffer = b''
62
+ while True:
63
+ chunk = sock.recv(4096)
64
+ if not chunk:
65
+ break
66
+ buffer += chunk
67
+ # Process complete JSON messages (newline delimited)
68
+ while b'\n' in buffer:
69
+ line, buffer = buffer.split(b'\n', 1)
70
+ msg = json.loads(line.decode())
71
+ print(f"[{msg['type']}] {msg.get('data', '')}")
72
+ ```
73
+ """
74
+
75
+ import logging
76
+ import threading
77
+ import time
78
+ import os
79
+ import asyncio
80
+ import json
81
+ import uuid
82
+ import tempfile
83
+ from typing import Any
84
+ from concurrent.futures import ThreadPoolExecutor
85
+ from pathlib import Path
86
+
87
+ from strands import Agent, tool
88
+
89
+ logger = logging.getLogger(__name__)
90
+
91
+ # Global registry to store server threads
92
+ IPC_SERVER_THREADS: dict[str, dict[str, Any]] = {}
93
+
94
+
95
+ class IPCStreamingCallbackHandler:
96
+ """Callback handler that streams agent responses directly over Unix socket with turn tracking."""
97
+
98
+ def __init__(self, client_socket, turn_id: str):
99
+ """Initialize the streaming handler.
100
+
101
+ Args:
102
+ client_socket: The Unix socket connection to stream data to
103
+ turn_id: Unique identifier for this conversation turn
104
+ """
105
+ self.socket = client_socket
106
+ self.turn_id = turn_id
107
+ self.tool_count = 0
108
+ self.previous_tool_use = None
109
+
110
+ def _send_message(
111
+ self, msg_type: str, data: str = "", metadata: dict = None
112
+ ) -> None:
113
+ """Send a structured message over Unix socket.
114
+
115
+ Args:
116
+ msg_type: Message type (turn_start, chunk, tool_start, tool_end, turn_end)
117
+ data: Text content
118
+ metadata: Additional metadata
119
+ """
120
+ try:
121
+ message = {
122
+ "type": msg_type,
123
+ "turn_id": self.turn_id,
124
+ "data": data,
125
+ "timestamp": time.time(),
126
+ }
127
+ if metadata:
128
+ message.update(metadata)
129
+
130
+ # Send as newline-delimited JSON for easy parsing
131
+ self.socket.sendall(json.dumps(message).encode() + b"\n")
132
+ except (BrokenPipeError, ConnectionResetError, OSError) as e:
133
+ logger.warning(f"Failed to send message over IPC: {e}")
134
+
135
+ def __call__(self, **kwargs: Any) -> None:
136
+ """Stream events to Unix socket in real-time with turn tracking."""
137
+ reasoningText = kwargs.get("reasoningText", False)
138
+ data = kwargs.get("data", "")
139
+ complete = kwargs.get("complete", False)
140
+ current_tool_use = kwargs.get("current_tool_use", {})
141
+ message = kwargs.get("message", {})
142
+
143
+ # Stream reasoning text
144
+ if reasoningText:
145
+ self._send_message("chunk", reasoningText, {"reasoning": True})
146
+
147
+ # Stream response text chunks
148
+ if data:
149
+ self._send_message("chunk", data)
150
+
151
+ # Stream tool invocation notifications
152
+ if current_tool_use and current_tool_use.get("name"):
153
+ tool_name = current_tool_use.get("name", "Unknown tool")
154
+ if self.previous_tool_use != current_tool_use:
155
+ self.previous_tool_use = current_tool_use
156
+ self.tool_count += 1
157
+ self._send_message(
158
+ "tool_start", tool_name, {"tool_number": self.tool_count}
159
+ )
160
+
161
+ # Stream tool results
162
+ if isinstance(message, dict) and message.get("role") == "user":
163
+ for content in message.get("content", []):
164
+ if isinstance(content, dict):
165
+ tool_result = content.get("toolResult")
166
+ if tool_result:
167
+ status = tool_result.get("status", "unknown")
168
+ self._send_message(
169
+ "tool_end", status, {"success": status == "success"}
170
+ )
171
+
172
+
173
+ def process_ipc_message(connection_agent, message_data, client_socket, turn_id):
174
+ """Process an IPC message and stream response.
175
+
176
+ Args:
177
+ connection_agent: The agent instance to process the message
178
+ message_data: Parsed message data
179
+ client_socket: Unix socket connection
180
+ turn_id: Unique turn ID
181
+ """
182
+ try:
183
+ message = message_data.get("message", "")
184
+
185
+ # Send turn start notification
186
+ turn_start = {
187
+ "type": "turn_start",
188
+ "turn_id": turn_id,
189
+ "data": message,
190
+ "timestamp": time.time(),
191
+ }
192
+ client_socket.sendall(json.dumps(turn_start).encode() + b"\n")
193
+
194
+ # Create callback handler for this turn
195
+ streaming_handler = IPCStreamingCallbackHandler(client_socket, turn_id)
196
+ connection_agent.callback_handler = streaming_handler
197
+
198
+ # Process message (synchronous, runs in thread pool)
199
+ connection_agent(message)
200
+
201
+ # Send turn end notification
202
+ turn_end = {"type": "turn_end", "turn_id": turn_id, "timestamp": time.time()}
203
+ client_socket.sendall(json.dumps(turn_end).encode() + b"\n")
204
+
205
+ except Exception as e:
206
+ logger.error(f"Error processing message in turn {turn_id}: {e}", exc_info=True)
207
+ error_msg = {
208
+ "type": "error",
209
+ "turn_id": turn_id,
210
+ "data": f"Error processing message: {e}",
211
+ "timestamp": time.time(),
212
+ }
213
+ try:
214
+ client_socket.sendall(json.dumps(error_msg).encode() + b"\n")
215
+ except:
216
+ pass
217
+
218
+
219
+ def handle_ipc_client(client_socket, client_id, system_prompt: str, socket_path: str):
220
+ """Handle an IPC client connection with streaming responses.
221
+
222
+ Args:
223
+ client_socket: Unix socket connection object
224
+ client_id: Unique client identifier
225
+ system_prompt: System prompt for the DevDuck agent
226
+ socket_path: Socket path (for logging)
227
+ """
228
+ logger.info(f"IPC connection established with client {client_id}")
229
+
230
+ # Import DevDuck and create a new instance for this connection
231
+ try:
232
+ from devduck import DevDuck
233
+
234
+ # Create a new DevDuck instance with auto_start_servers=False to avoid recursion
235
+ connection_devduck = DevDuck(auto_start_servers=False)
236
+
237
+ # Override system prompt if provided
238
+ if connection_devduck.agent and system_prompt:
239
+ connection_devduck.agent.system_prompt += (
240
+ "\nCustom system prompt: " + system_prompt
241
+ )
242
+
243
+ connection_agent = connection_devduck.agent
244
+
245
+ except Exception as e:
246
+ logger.error(f"Failed to create DevDuck instance: {e}", exc_info=True)
247
+ # Fallback to basic Agent if DevDuck fails
248
+ from strands import Agent
249
+ from strands.models.ollama import OllamaModel
250
+
251
+ agent_model = OllamaModel(
252
+ host=os.getenv("OLLAMA_HOST", "http://localhost:11434"),
253
+ model_id=os.getenv("OLLAMA_MODEL", "qwen3:1.7b"),
254
+ temperature=1,
255
+ keep_alive="5m",
256
+ )
257
+
258
+ connection_agent = Agent(
259
+ model=agent_model,
260
+ tools=[],
261
+ system_prompt=system_prompt or "You are a helpful IPC server assistant.",
262
+ )
263
+
264
+ try:
265
+ # Send welcome message
266
+ welcome = {
267
+ "type": "connected",
268
+ "data": "🦆 Welcome to DevDuck IPC!",
269
+ "timestamp": time.time(),
270
+ }
271
+ client_socket.sendall(json.dumps(welcome).encode() + b"\n")
272
+
273
+ # Track active tasks
274
+ with ThreadPoolExecutor(max_workers=5) as executor:
275
+ buffer = b""
276
+
277
+ while True:
278
+ # Receive data
279
+ chunk = client_socket.recv(4096)
280
+ if not chunk:
281
+ break
282
+
283
+ buffer += chunk
284
+
285
+ # Process complete messages (newline delimited)
286
+ while b"\n" in buffer:
287
+ line, buffer = buffer.split(b"\n", 1)
288
+
289
+ try:
290
+ message_data = json.loads(line.decode())
291
+
292
+ # Check for exit command
293
+ if message_data.get("message", "").lower() == "exit":
294
+ bye = {
295
+ "type": "disconnected",
296
+ "data": "Connection closed by client request.",
297
+ "timestamp": time.time(),
298
+ }
299
+ client_socket.sendall(json.dumps(bye).encode() + b"\n")
300
+ logger.info(f"Client {client_id} requested to exit")
301
+ return
302
+
303
+ # Generate unique turn ID
304
+ turn_id = message_data.get("turn_id") or str(uuid.uuid4())
305
+
306
+ logger.info(
307
+ f"Received from client {client_id}: {message_data.get('message', '')[:100]}"
308
+ )
309
+
310
+ # Process message in thread pool (concurrent)
311
+ executor.submit(
312
+ process_ipc_message,
313
+ connection_agent,
314
+ message_data,
315
+ client_socket,
316
+ turn_id,
317
+ )
318
+
319
+ except json.JSONDecodeError:
320
+ logger.warning(
321
+ f"Invalid JSON from client {client_id}: {line[:100]}"
322
+ )
323
+ continue
324
+
325
+ except Exception as e:
326
+ logger.error(f"Error handling IPC client {client_id}: {e}", exc_info=True)
327
+ finally:
328
+ try:
329
+ client_socket.close()
330
+ except:
331
+ pass
332
+ logger.info(f"IPC connection with client {client_id} closed")
333
+
334
+
335
+ def run_ipc_server(socket_path: str, system_prompt: str) -> None:
336
+ """Run an IPC server that processes client requests with DevDuck instances.
337
+
338
+ Args:
339
+ socket_path: Unix socket path to bind
340
+ system_prompt: System prompt for DevDuck agents
341
+ """
342
+ IPC_SERVER_THREADS[socket_path]["running"] = True
343
+ IPC_SERVER_THREADS[socket_path]["connections"] = 0
344
+ IPC_SERVER_THREADS[socket_path]["start_time"] = time.time()
345
+
346
+ # Remove existing socket if it exists
347
+ if os.path.exists(socket_path):
348
+ os.unlink(socket_path)
349
+
350
+ import socket as unix_socket
351
+
352
+ server_socket = unix_socket.socket(unix_socket.AF_UNIX, unix_socket.SOCK_STREAM)
353
+
354
+ try:
355
+ server_socket.bind(socket_path)
356
+ server_socket.listen(10)
357
+ logger.info(f"IPC Server listening on {socket_path}")
358
+
359
+ IPC_SERVER_THREADS[socket_path]["socket"] = server_socket
360
+
361
+ client_counter = 0
362
+
363
+ while IPC_SERVER_THREADS[socket_path]["running"]:
364
+ # Set timeout to check periodically if server should stop
365
+ server_socket.settimeout(1.0)
366
+
367
+ try:
368
+ client_socket, _ = server_socket.accept()
369
+ IPC_SERVER_THREADS[socket_path]["connections"] += 1
370
+ client_counter += 1
371
+
372
+ client_id = f"client_{client_counter}"
373
+
374
+ # Handle client in a new thread
375
+ client_thread = threading.Thread(
376
+ target=handle_ipc_client,
377
+ args=(client_socket, client_id, system_prompt, socket_path),
378
+ daemon=True,
379
+ )
380
+ client_thread.start()
381
+
382
+ except TimeoutError:
383
+ # Expected timeout for checking stop condition
384
+ pass
385
+ except Exception as e:
386
+ if IPC_SERVER_THREADS[socket_path]["running"]:
387
+ logger.error(f"Error accepting connection: {e}")
388
+
389
+ except Exception as e:
390
+ logger.error(f"IPC server error on {socket_path}: {e}", exc_info=True)
391
+ finally:
392
+ try:
393
+ server_socket.close()
394
+ except:
395
+ pass
396
+
397
+ # Clean up socket file
398
+ if os.path.exists(socket_path):
399
+ os.unlink(socket_path)
400
+
401
+ logger.info(f"IPC Server on {socket_path} stopped")
402
+ IPC_SERVER_THREADS[socket_path]["running"] = False
403
+
404
+
405
+ @tool
406
+ def ipc(
407
+ action: str,
408
+ socket_path: str = None,
409
+ system_prompt: str = "You are a helpful IPC server assistant.",
410
+ ) -> dict:
411
+ """Create and manage IPC servers with real-time streaming.
412
+
413
+ This tool creates a Unix socket server for inter-process communication,
414
+ similar to the WebSocket server but for local processes (tray, ambient, etc.).
415
+
416
+ Args:
417
+ action: Action to perform (start_server, stop_server, get_status)
418
+ socket_path: Unix socket path (default: /tmp/devduck_main.sock)
419
+ system_prompt: System prompt for the server DevDuck instances
420
+
421
+ Returns:
422
+ Dictionary containing status and response content
423
+ """
424
+ # Default socket path
425
+ if not socket_path:
426
+ socket_path = os.path.join(tempfile.gettempdir(), "devduck_main.sock")
427
+
428
+ if action == "start_server":
429
+ if socket_path in IPC_SERVER_THREADS and IPC_SERVER_THREADS[socket_path].get(
430
+ "running", False
431
+ ):
432
+ return {
433
+ "status": "error",
434
+ "content": [
435
+ {"text": f"❌ Error: IPC Server already running on {socket_path}"}
436
+ ],
437
+ }
438
+
439
+ IPC_SERVER_THREADS[socket_path] = {"running": False}
440
+ server_thread = threading.Thread(
441
+ target=run_ipc_server, args=(socket_path, system_prompt), daemon=True
442
+ )
443
+ server_thread.start()
444
+
445
+ time.sleep(0.5)
446
+
447
+ if not IPC_SERVER_THREADS[socket_path].get("running", False):
448
+ return {
449
+ "status": "error",
450
+ "content": [
451
+ {"text": f"❌ Error: Failed to start IPC Server on {socket_path}"}
452
+ ],
453
+ }
454
+
455
+ return {
456
+ "status": "success",
457
+ "content": [
458
+ {"text": f"✅ IPC Server started successfully on {socket_path}"},
459
+ {"text": f"System prompt: {system_prompt}"},
460
+ {"text": "🌊 Real-time streaming with concurrent message processing"},
461
+ {"text": "📦 Newline-delimited JSON messages with turn_id"},
462
+ {
463
+ "text": "🦆 Server creates a new DevDuck instance for each connection"
464
+ },
465
+ {"text": "⚡ Send multiple messages without waiting!"},
466
+ {"text": f"📝 Connect from local processes to: {socket_path}"},
467
+ ],
468
+ }
469
+
470
+ elif action == "stop_server":
471
+ if socket_path not in IPC_SERVER_THREADS or not IPC_SERVER_THREADS[
472
+ socket_path
473
+ ].get("running", False):
474
+ return {
475
+ "status": "error",
476
+ "content": [
477
+ {"text": f"❌ Error: No IPC Server running on {socket_path}"}
478
+ ],
479
+ }
480
+
481
+ IPC_SERVER_THREADS[socket_path]["running"] = False
482
+
483
+ # Close socket if exists
484
+ if "socket" in IPC_SERVER_THREADS[socket_path]:
485
+ try:
486
+ IPC_SERVER_THREADS[socket_path]["socket"].close()
487
+ except:
488
+ pass
489
+
490
+ time.sleep(1.0)
491
+
492
+ connections = IPC_SERVER_THREADS[socket_path].get("connections", 0)
493
+ uptime = time.time() - IPC_SERVER_THREADS[socket_path].get(
494
+ "start_time", time.time()
495
+ )
496
+
497
+ del IPC_SERVER_THREADS[socket_path]
498
+
499
+ return {
500
+ "status": "success",
501
+ "content": [
502
+ {"text": f"✅ IPC Server on {socket_path} stopped successfully"},
503
+ {
504
+ "text": f"Statistics: {connections} connections handled, uptime {uptime:.2f} seconds"
505
+ },
506
+ ],
507
+ }
508
+
509
+ elif action == "get_status":
510
+ if not IPC_SERVER_THREADS:
511
+ return {
512
+ "status": "success",
513
+ "content": [{"text": "No IPC Servers running"}],
514
+ }
515
+
516
+ status_info = []
517
+ for path, data in IPC_SERVER_THREADS.items():
518
+ if data.get("running", False):
519
+ uptime = time.time() - data.get("start_time", time.time())
520
+ connections = data.get("connections", 0)
521
+ status_info.append(
522
+ f"Socket {path}: Running - {connections} connections, uptime {uptime:.2f}s"
523
+ )
524
+ else:
525
+ status_info.append(f"Socket {path}: Stopped")
526
+
527
+ return {
528
+ "status": "success",
529
+ "content": [
530
+ {"text": "IPC Server Status:"},
531
+ {"text": "\n".join(status_info)},
532
+ ],
533
+ }
534
+
535
+ else:
536
+ return {
537
+ "status": "error",
538
+ "content": [
539
+ {
540
+ "text": f"Error: Unknown action '{action}'. Supported: start_server, stop_server, get_status"
541
+ }
542
+ ],
543
+ }
devduck/tools/tcp.py CHANGED
@@ -216,10 +216,6 @@ def handle_client(
216
216
  )
217
217
 
218
218
  try:
219
- # Send welcome message
220
- welcome_msg = "🦆 Welcome to DevDuck TCP Server!\n"
221
- welcome_msg += "Send a message or 'exit' to close the connection.\n\n"
222
- streaming_handler._send(welcome_msg)
223
219
 
224
220
  while True:
225
221
  # Receive data from the client