devduck 0.5.4__py3-none-any.whl → 0.7.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.

Potentially problematic release.


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

devduck/__init__.py CHANGED
@@ -1,15 +1,20 @@
1
1
  #!/usr/bin/env python3
2
- """🦆 devduck - self-adapting agent"""
2
+ """
3
+ 🦆 devduck - extreme minimalist self-adapting agent
4
+ one file. self-healing. runtime dependencies. adaptive.
5
+ """
3
6
  import sys
4
- import threading
7
+ import subprocess
5
8
  import os
6
9
  import platform
10
+ import socket
7
11
  import logging
8
12
  import tempfile
9
- import boto3
13
+ import time
14
+ import warnings
10
15
  from pathlib import Path
11
16
  from datetime import datetime
12
- import warnings
17
+ from typing import Dict, Any
13
18
  from logging.handlers import RotatingFileHandler
14
19
 
15
20
  warnings.filterwarnings("ignore", message=".*pkg_resources is deprecated.*")
@@ -21,31 +26,357 @@ os.environ["EDITOR_DISABLE_BACKUP"] = "true"
21
26
 
22
27
  LOG_DIR = Path(tempfile.gettempdir()) / "devduck" / "logs"
23
28
  LOG_DIR.mkdir(parents=True, exist_ok=True)
24
- LOG_FILE = LOG_DIR / "devduck.log"
25
29
 
30
+ LOG_FILE = LOG_DIR / "devduck.log"
26
31
  logger = logging.getLogger("devduck")
27
32
  logger.setLevel(logging.DEBUG)
28
- logger.addHandler(
29
- RotatingFileHandler(LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=3)
33
+
34
+ file_handler = RotatingFileHandler(
35
+ LOG_FILE, maxBytes=10 * 1024 * 1024, backupCount=3, encoding="utf-8"
36
+ )
37
+ file_handler.setLevel(logging.DEBUG)
38
+ file_formatter = logging.Formatter(
39
+ "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
30
40
  )
31
- logger.info("DevDuck initialized")
41
+ file_handler.setFormatter(file_formatter)
42
+
43
+ console_handler = logging.StreamHandler()
44
+ console_handler.setLevel(logging.WARNING)
45
+ console_formatter = logging.Formatter("🦆 %(levelname)s: %(message)s")
46
+ console_handler.setFormatter(console_formatter)
47
+
48
+ logger.addHandler(file_handler)
49
+ logger.addHandler(console_handler)
50
+
51
+ logger.info("DevDuck logging system initialized")
32
52
 
33
53
 
34
54
  def get_own_source_code():
35
55
  """Read own source code for self-awareness"""
36
56
  try:
37
57
  with open(__file__, "r", encoding="utf-8") as f:
38
- return f"# devduck/__init__.py\n```python\n{f.read()}\n```"
58
+ return f"# Source path: {__file__}\n\ndevduck/__init__.py\n```python\n{f.read()}\n```"
39
59
  except Exception as e:
40
60
  return f"Error reading source: {e}"
41
61
 
42
62
 
63
+ def view_logs_tool(
64
+ action: str = "view",
65
+ lines: int = 100,
66
+ pattern: str = None,
67
+ ) -> Dict[str, Any]:
68
+ """
69
+ View and manage DevDuck logs.
70
+
71
+ Args:
72
+ action: Action to perform - "view", "tail", "search", "clear", "stats"
73
+ lines: Number of lines to show (for view/tail)
74
+ pattern: Search pattern (for search action)
75
+
76
+ Returns:
77
+ Dict with status and content
78
+ """
79
+ try:
80
+ if action == "view":
81
+ if not LOG_FILE.exists():
82
+ return {"status": "success", "content": [{"text": "No logs yet"}]}
83
+
84
+ with open(LOG_FILE, "r", encoding="utf-8") as f:
85
+ all_lines = f.readlines()
86
+ recent_lines = (
87
+ all_lines[-lines:] if len(all_lines) > lines else all_lines
88
+ )
89
+ content = "".join(recent_lines)
90
+
91
+ return {
92
+ "status": "success",
93
+ "content": [
94
+ {"text": f"Last {len(recent_lines)} log lines:\n\n{content}"}
95
+ ],
96
+ }
97
+
98
+ elif action == "tail":
99
+ if not LOG_FILE.exists():
100
+ return {"status": "success", "content": [{"text": "No logs yet"}]}
101
+
102
+ with open(LOG_FILE, "r", encoding="utf-8") as f:
103
+ all_lines = f.readlines()
104
+ tail_lines = all_lines[-50:] if len(all_lines) > 50 else all_lines
105
+ content = "".join(tail_lines)
106
+
107
+ return {
108
+ "status": "success",
109
+ "content": [{"text": f"Tail (last 50 lines):\n\n{content}"}],
110
+ }
111
+
112
+ elif action == "search":
113
+ if not pattern:
114
+ return {
115
+ "status": "error",
116
+ "content": [{"text": "pattern parameter required for search"}],
117
+ }
118
+
119
+ if not LOG_FILE.exists():
120
+ return {"status": "success", "content": [{"text": "No logs yet"}]}
121
+
122
+ with open(LOG_FILE, "r", encoding="utf-8") as f:
123
+ matching_lines = [line for line in f if pattern.lower() in line.lower()]
124
+
125
+ if not matching_lines:
126
+ return {
127
+ "status": "success",
128
+ "content": [{"text": f"No matches found for pattern: {pattern}"}],
129
+ }
130
+
131
+ content = "".join(matching_lines[-100:]) # Last 100 matches
132
+ return {
133
+ "status": "success",
134
+ "content": [
135
+ {
136
+ "text": f"Found {len(matching_lines)} matches (showing last 100):\n\n{content}"
137
+ }
138
+ ],
139
+ }
140
+
141
+ elif action == "clear":
142
+ if LOG_FILE.exists():
143
+ LOG_FILE.unlink()
144
+ logger.info("Log file cleared by user")
145
+ return {
146
+ "status": "success",
147
+ "content": [{"text": "Logs cleared successfully"}],
148
+ }
149
+
150
+ elif action == "stats":
151
+ if not LOG_FILE.exists():
152
+ return {"status": "success", "content": [{"text": "No logs yet"}]}
153
+
154
+ stat = LOG_FILE.stat()
155
+ size_mb = stat.st_size / (1024 * 1024)
156
+ modified = datetime.fromtimestamp(stat.st_mtime).strftime(
157
+ "%Y-%m-%d %H:%M:%S"
158
+ )
159
+
160
+ with open(LOG_FILE, "r", encoding="utf-8") as f:
161
+ total_lines = sum(1 for _ in f)
162
+
163
+ stats_text = f"""Log File Statistics:
164
+ Path: {LOG_FILE}
165
+ Size: {size_mb:.2f} MB
166
+ Lines: {total_lines}
167
+ Last Modified: {modified}"""
168
+
169
+ return {"status": "success", "content": [{"text": stats_text}]}
170
+
171
+ else:
172
+ return {
173
+ "status": "error",
174
+ "content": [
175
+ {
176
+ "text": f"Unknown action: {action}. Valid: view, tail, search, clear, stats"
177
+ }
178
+ ],
179
+ }
180
+
181
+ except Exception as e:
182
+ logger.error(f"Error in view_logs_tool: {e}")
183
+ return {"status": "error", "content": [{"text": f"Error: {str(e)}"}]}
184
+
185
+
186
+ def manage_tools_func(
187
+ action: str,
188
+ package: str = None,
189
+ tool_names: str = None,
190
+ tool_path: str = None,
191
+ ) -> Dict[str, Any]:
192
+ """
193
+ Manage the agent's tool set at runtime using ToolRegistry.
194
+
195
+ Args:
196
+ action: Action to perform - "list", "add", "remove", "reload"
197
+ package: Package name to load tools from (e.g., "strands_tools", "strands_fun_tools")
198
+ tool_names: Comma-separated tool names (e.g., "shell,editor,calculator")
199
+ tool_path: Path to a .py file to load as a tool
200
+
201
+ Returns:
202
+ Dict with status and content
203
+ """
204
+ try:
205
+ if not hasattr(devduck, "agent") or not devduck.agent:
206
+ return {"status": "error", "content": [{"text": "Agent not initialized"}]}
207
+
208
+ registry = devduck.agent.tool_registry
209
+
210
+ if action == "list":
211
+ # List tools from registry
212
+ tool_list = list(registry.registry.keys())
213
+ dynamic_tools = list(registry.dynamic_tools.keys())
214
+
215
+ text = f"Currently loaded {len(tool_list)} tools:\n"
216
+ text += "\n".join(f" • {t}" for t in sorted(tool_list))
217
+ if dynamic_tools:
218
+ text += f"\n\nDynamic tools ({len(dynamic_tools)}):\n"
219
+ text += "\n".join(f" • {t}" for t in sorted(dynamic_tools))
220
+
221
+ return {"status": "success", "content": [{"text": text}]}
222
+
223
+ elif action == "add":
224
+ if not package and not tool_path:
225
+ return {
226
+ "status": "error",
227
+ "content": [
228
+ {
229
+ "text": "Either 'package' or 'tool_path' required for add action"
230
+ }
231
+ ],
232
+ }
233
+
234
+ added_tools = []
235
+
236
+ # Add from package using process_tools
237
+ if package:
238
+ if not tool_names:
239
+ return {
240
+ "status": "error",
241
+ "content": [
242
+ {"text": "'tool_names' required when adding from package"}
243
+ ],
244
+ }
245
+
246
+ tools_to_add = [t.strip() for t in tool_names.split(",")]
247
+
248
+ # Build tool specs: package.tool_name format
249
+ tool_specs = [f"{package}.{tool_name}" for tool_name in tools_to_add]
250
+
251
+ try:
252
+ added_tool_names = registry.process_tools(tool_specs)
253
+ added_tools.extend(added_tool_names)
254
+ logger.info(f"Added tools from {package}: {added_tool_names}")
255
+ except Exception as e:
256
+ logger.error(f"Failed to add tools from {package}: {e}")
257
+ return {
258
+ "status": "error",
259
+ "content": [{"text": f"Failed to add tools: {str(e)}"}],
260
+ }
261
+
262
+ # Add from file path using process_tools
263
+ if tool_path:
264
+ try:
265
+ added_tool_names = registry.process_tools([tool_path])
266
+ added_tools.extend(added_tool_names)
267
+ logger.info(f"Added tools from file: {added_tool_names}")
268
+ except Exception as e:
269
+ logger.error(f"Failed to add tool from {tool_path}: {e}")
270
+ return {
271
+ "status": "error",
272
+ "content": [{"text": f"Failed to add tool: {str(e)}"}],
273
+ }
274
+
275
+ if added_tools:
276
+ return {
277
+ "status": "success",
278
+ "content": [
279
+ {
280
+ "text": f"✅ Added {len(added_tools)} tools: {', '.join(added_tools)}\n"
281
+ + f"Total tools: {len(registry.registry)}"
282
+ }
283
+ ],
284
+ }
285
+ else:
286
+ return {"status": "error", "content": [{"text": "No tools were added"}]}
287
+
288
+ elif action == "remove":
289
+ if not tool_names:
290
+ return {
291
+ "status": "error",
292
+ "content": [{"text": "'tool_names' required for remove action"}],
293
+ }
294
+
295
+ tools_to_remove = [t.strip() for t in tool_names.split(",")]
296
+ removed_tools = []
297
+
298
+ # Remove from registry
299
+ for tool_name in tools_to_remove:
300
+ if tool_name in registry.registry:
301
+ del registry.registry[tool_name]
302
+ removed_tools.append(tool_name)
303
+ logger.info(f"Removed tool: {tool_name}")
304
+
305
+ if tool_name in registry.dynamic_tools:
306
+ del registry.dynamic_tools[tool_name]
307
+ logger.info(f"Removed dynamic tool: {tool_name}")
308
+
309
+ if removed_tools:
310
+ return {
311
+ "status": "success",
312
+ "content": [
313
+ {
314
+ "text": f"✅ Removed {len(removed_tools)} tools: {', '.join(removed_tools)}\n"
315
+ + f"Total tools: {len(registry.registry)}"
316
+ }
317
+ ],
318
+ }
319
+ else:
320
+ return {
321
+ "status": "success",
322
+ "content": [{"text": "No tools were removed (not found)"}],
323
+ }
324
+
325
+ elif action == "reload":
326
+ if tool_names:
327
+ # Reload specific tools
328
+ tools_to_reload = [t.strip() for t in tool_names.split(",")]
329
+ reloaded_tools = []
330
+ failed_tools = []
331
+
332
+ for tool_name in tools_to_reload:
333
+ try:
334
+ registry.reload_tool(tool_name)
335
+ reloaded_tools.append(tool_name)
336
+ logger.info(f"Reloaded tool: {tool_name}")
337
+ except Exception as e:
338
+ failed_tools.append((tool_name, str(e)))
339
+ logger.error(f"Failed to reload {tool_name}: {e}")
340
+
341
+ text = ""
342
+ if reloaded_tools:
343
+ text += f"✅ Reloaded {len(reloaded_tools)} tools: {', '.join(reloaded_tools)}\n"
344
+ if failed_tools:
345
+ text += f"❌ Failed to reload {len(failed_tools)} tools:\n"
346
+ for tool_name, error in failed_tools:
347
+ text += f" • {tool_name}: {error}\n"
348
+
349
+ return {"status": "success", "content": [{"text": text}]}
350
+ else:
351
+ # Reload all tools - restart agent
352
+ logger.info("Reloading all tools via restart")
353
+ devduck.restart()
354
+ return {
355
+ "status": "success",
356
+ "content": [{"text": "✅ All tools reloaded - agent restarted"}],
357
+ }
358
+
359
+ else:
360
+ return {
361
+ "status": "error",
362
+ "content": [
363
+ {
364
+ "text": f"Unknown action: {action}. Valid: list, add, remove, reload"
365
+ }
366
+ ],
367
+ }
368
+
369
+ except Exception as e:
370
+ logger.error(f"Error in manage_tools: {e}")
371
+ return {"status": "error", "content": [{"text": f"Error: {str(e)}"}]}
372
+
373
+
43
374
  def get_shell_history_file():
44
- """Get devduck history file"""
45
- history = Path.home() / ".devduck_history"
46
- if not history.exists():
47
- history.touch(mode=0o600)
48
- return str(history)
375
+ """Get the devduck-specific history file path."""
376
+ devduck_history = Path.home() / ".devduck_history"
377
+ if not devduck_history.exists():
378
+ devduck_history.touch(mode=0o600)
379
+ return str(devduck_history)
49
380
 
50
381
 
51
382
  def get_shell_history_files():
@@ -125,6 +456,32 @@ def parse_history_line(line, history_type):
125
456
  return None
126
457
 
127
458
 
459
+ def get_recent_logs():
460
+ """Get the last N lines from the log file for context."""
461
+ try:
462
+ log_line_count = int(os.getenv("DEVDUCK_LOG_LINE_COUNT", "50"))
463
+
464
+ if not LOG_FILE.exists():
465
+ return ""
466
+
467
+ with open(LOG_FILE, "r", encoding="utf-8", errors="ignore") as f:
468
+ all_lines = f.readlines()
469
+
470
+ recent_lines = (
471
+ all_lines[-log_line_count:]
472
+ if len(all_lines) > log_line_count
473
+ else all_lines
474
+ )
475
+
476
+ if not recent_lines:
477
+ return ""
478
+
479
+ log_content = "".join(recent_lines)
480
+ return f"\n\n## Recent Logs (last {len(recent_lines)} lines):\n```\n{log_content}```\n"
481
+ except Exception as e:
482
+ return f"\n\n## Recent Logs: Error reading logs - {e}\n"
483
+
484
+
128
485
  def get_last_messages():
129
486
  """Get the last N messages from multiple shell histories for context."""
130
487
  try:
@@ -184,225 +541,198 @@ def get_last_messages():
184
541
  return ""
185
542
 
186
543
 
187
- def get_recent_logs():
188
- """Get recent logs for context"""
189
- try:
190
- log_count = int(os.getenv("DEVDUCK_LOG_LINE_COUNT", "50"))
191
-
192
- if not LOG_FILE.exists():
193
- return ""
194
-
195
- with open(LOG_FILE, "r", encoding="utf-8", errors="ignore") as f:
196
- lines = f.readlines()
197
-
198
- recent = lines[-log_count:] if len(lines) > log_count else lines
199
-
200
- if recent:
201
- return f"\n\n## Recent Logs (last {len(recent)} lines):\n```\n{''.join(recent)}```\n"
202
- return ""
203
- except:
204
- return ""
205
-
206
-
207
544
  def append_to_shell_history(query, response):
208
- """Append interaction to history"""
209
- import time
210
-
545
+ """Append the interaction to devduck shell history."""
211
546
  try:
212
547
  history_file = get_shell_history_file()
213
548
  timestamp = str(int(time.time()))
214
- response_summary = (
215
- str(response).replace("\n", " ")[
216
- : int(os.getenv("DEVDUCK_RESPONSE_SUMMARY_LENGTH", "10000"))
217
- ]
218
- + "..."
219
- )
220
549
 
221
550
  with open(history_file, "a", encoding="utf-8") as f:
222
551
  f.write(f": {timestamp}:0;# devduck: {query}\n")
552
+ response_summary = (
553
+ str(response).replace("\n", " ")[
554
+ : int(os.getenv("DEVDUCK_RESPONSE_SUMMARY_LENGTH", "10000"))
555
+ ]
556
+ + "..."
557
+ )
223
558
  f.write(f": {timestamp}:0;# devduck_result: {response_summary}\n")
224
559
 
225
560
  os.chmod(history_file, 0o600)
226
- except:
561
+ except Exception:
227
562
  pass
228
563
 
229
564
 
565
+ # 🦆 The devduck agent
230
566
  class DevDuck:
231
- """Minimalist adaptive agent with flexible tool loading"""
232
-
233
567
  def __init__(
234
568
  self,
235
569
  auto_start_servers=True,
236
- tcp_port=9999,
237
- ws_port=8080,
238
- mcp_port=8000,
239
- ipc_socket=None,
240
- enable_tcp=True,
241
- enable_ws=True,
242
- enable_mcp=True,
243
- enable_ipc=True,
570
+ servers=None,
571
+ load_mcp_servers=True,
244
572
  ):
245
- """Initialize the minimalist adaptive agent"""
246
- logger.info("Initializing DevDuck...")
247
-
248
- # Environment detection
249
- self.os = platform.system()
250
- self.arch = platform.machine()
251
- self.model = "qwen3:1.7b" if self.os == "Darwin" else "qwen3:8b"
252
-
253
- # Hot-reload state
254
- self._agent_executing = False
255
- self._reload_pending = False
256
-
257
- # Server configuration
258
- self.tcp_port = tcp_port
259
- self.ws_port = ws_port
260
- self.mcp_port = mcp_port
261
- self.ipc_socket = ipc_socket or "/tmp/devduck_main.sock"
262
- self.enable_tcp = enable_tcp
263
- self.enable_ws = enable_ws
264
- self.enable_mcp = enable_mcp
265
- self.enable_ipc = enable_ipc
266
-
267
- # Import core dependencies
268
- from strands import Agent, tool
269
-
270
- # Load tools with flexible configuration
271
- tools = self._load_tools_flexible()
272
-
273
- # Add built-in view_logs tool
274
- @tool
275
- def view_logs(action: str = "view", lines: int = 100, pattern: str = None):
276
- """View and manage DevDuck logs"""
277
- return self._view_logs_impl(action, lines, pattern)
278
-
279
- tools.append(view_logs)
280
-
281
- # Create model
282
- model = self._create_model()
283
-
284
- # Create agent
285
- self.agent = Agent(
286
- model=model,
287
- tools=tools,
288
- system_prompt=self._build_prompt(),
289
- load_tools_from_directory=True,
290
- )
291
-
292
- # Auto-start servers
293
- if auto_start_servers and "--mcp" not in sys.argv:
294
- self._start_servers()
295
-
296
- # Start hot-reload watcher
297
- self._start_hot_reload()
298
-
299
- logger.info(f"DevDuck ready with {len(tools)} tools")
300
-
301
- def _load_tools_flexible(self):
302
- """
303
- Load tools with flexible configuration via DEVDUCK_TOOLS env var.
304
-
305
- Format: package:tool1,tool2:package2:tool3,tool4
306
- Example: strands_tools:shell,editor:strands_fun_tools:clipboard
307
-
308
- Static tools (always loaded):
309
- - DevDuck's own tools (tcp, websocket, ipc, etc.)
310
- - AgentCore tools (if AWS credentials available)
573
+ """Initialize the minimalist adaptive agent
574
+
575
+ Args:
576
+ auto_start_servers: Enable automatic server startup
577
+ servers: Dict of server configs with optional env var lookups
578
+ Example: {
579
+ "tcp": {"port": 9999},
580
+ "ws": {"port": 8080, "LOOKUP_KEY": "SLACK_API_KEY"},
581
+ "mcp": {"port": 8000},
582
+ "ipc": {"socket_path": "/tmp/devduck.sock"}
583
+ }
584
+ load_mcp_servers: Load MCP servers from MCP_SERVERS env var
311
585
  """
312
- tools = []
313
-
314
- # 1. STATIC: Core DevDuck tools (always load)
586
+ logger.info("Initializing DevDuck agent...")
315
587
  try:
316
- from devduck.tools import (
317
- tcp,
318
- websocket,
319
- ipc,
320
- mcp_server,
321
- install_tools,
322
- use_github,
323
- create_subagent,
324
- store_in_kb,
325
- system_prompt,
326
- tray,
327
- ambient,
328
- )
588
+ self.env_info = {
589
+ "os": platform.system(),
590
+ "arch": platform.machine(),
591
+ "python": sys.version_info,
592
+ "cwd": str(Path.cwd()),
593
+ "home": str(Path.home()),
594
+ "shell": os.environ.get("SHELL", "unknown"),
595
+ "hostname": socket.gethostname(),
596
+ }
597
+
598
+ # Execution state tracking for hot-reload
599
+ self._agent_executing = False
600
+ self._reload_pending = False
601
+
602
+ # Server configuration
603
+ if servers is None:
604
+ # Default server config from env vars
605
+ servers = {
606
+ "tcp": {
607
+ "port": int(os.getenv("DEVDUCK_TCP_PORT", "9999")),
608
+ "enabled": os.getenv("DEVDUCK_ENABLE_TCP", "true").lower()
609
+ == "true",
610
+ },
611
+ "ws": {
612
+ "port": int(os.getenv("DEVDUCK_WS_PORT", "8080")),
613
+ "enabled": os.getenv("DEVDUCK_ENABLE_WS", "true").lower()
614
+ == "true",
615
+ },
616
+ "mcp": {
617
+ "port": int(os.getenv("DEVDUCK_MCP_PORT", "8000")),
618
+ "enabled": os.getenv("DEVDUCK_ENABLE_MCP", "true").lower()
619
+ == "true",
620
+ },
621
+ "ipc": {
622
+ "socket_path": os.getenv(
623
+ "DEVDUCK_IPC_SOCKET", "/tmp/devduck_main.sock"
624
+ ),
625
+ "enabled": os.getenv("DEVDUCK_ENABLE_IPC", "true").lower()
626
+ == "true",
627
+ },
628
+ }
329
629
 
330
- tools.extend(
331
- [
332
- tcp,
333
- websocket,
334
- ipc,
335
- mcp_server,
336
- install_tools,
337
- use_github,
338
- create_subagent,
339
- store_in_kb,
340
- system_prompt,
341
- tray,
342
- ambient,
343
- ]
630
+ self.servers = servers
631
+
632
+ from strands import Agent, tool
633
+
634
+ # Load tools with flexible configuration
635
+ # Default tool config - user can override with DEVDUCK_TOOLS env var
636
+ default_tools = "devduck.tools:system_prompt,store_in_kb,ipc,tcp,websocket,mcp_server,state_manager,tray,ambient,agentcore_config,agentcore_invoke,agentcore_logs,agentcore_agents,install_tools,create_subagent,use_github:strands_tools:shell,editor,file_read,file_write,image_reader,load_tool,retrieve,calculator,use_agent,environment,mcp_client,speak,slack:strands_fun_tools:listen,cursor,clipboard,screen_reader,bluetooth,yolo_vision"
637
+
638
+ tools_config = os.getenv("DEVDUCK_TOOLS", default_tools)
639
+ logger.info(f"Loading tools from config: {tools_config}")
640
+ core_tools = self._load_tools_from_config(tools_config)
641
+
642
+ # Wrap view_logs_tool with @tool decorator
643
+ @tool
644
+ def view_logs(
645
+ action: str = "view",
646
+ lines: int = 100,
647
+ pattern: str = None,
648
+ ) -> Dict[str, Any]:
649
+ """View and manage DevDuck logs."""
650
+ return view_logs_tool(action, lines, pattern)
651
+
652
+ # Wrap manage_tools_func with @tool decorator
653
+ @tool
654
+ def manage_tools(
655
+ action: str,
656
+ package: str = None,
657
+ tool_names: str = None,
658
+ tool_path: str = None,
659
+ ) -> Dict[str, Any]:
660
+ """Manage the agent's tool set at runtime - add, remove, list, reload tools on the fly."""
661
+ return manage_tools_func(action, package, tool_names, tool_path)
662
+
663
+ # Add built-in tools to the toolset
664
+ core_tools.extend([view_logs, manage_tools])
665
+
666
+ # Assign tools
667
+ self.tools = core_tools
668
+
669
+ # 🔌 Load MCP servers if enabled
670
+ if load_mcp_servers:
671
+ mcp_clients = self._load_mcp_servers()
672
+ if mcp_clients:
673
+ self.tools.extend(mcp_clients)
674
+ logger.info(f"Loaded {len(mcp_clients)} MCP server(s)")
675
+
676
+ logger.info(f"Initialized {len(self.tools)} tools")
677
+
678
+ # 🎯 Smart model selection
679
+ self.agent_model, self.model = self._select_model()
680
+
681
+ # Create agent with self-healing
682
+ # load_tools_from_directory controlled by DEVDUCK_LOAD_TOOLS_FROM_DIR (default: false)
683
+ load_from_dir = (
684
+ os.getenv("DEVDUCK_LOAD_TOOLS_FROM_DIR", "false").lower() == "true"
344
685
  )
345
- logger.info("✅ DevDuck core tools loaded")
346
- except ImportError as e:
347
- logger.warning(f"DevDuck tools unavailable: {e}")
348
686
 
349
- # 2. STATIC: AgentCore tools (if AWS credentials available and not disabled)
350
- if os.getenv("DEVDUCK_DISABLE_AGENTCORE_TOOLS", "false").lower() != "true":
351
- try:
352
- boto3.client("sts").get_caller_identity()
353
- from .tools.agentcore_config import agentcore_config
354
- from .tools.agentcore_invoke import agentcore_invoke
355
- from .tools.agentcore_logs import agentcore_logs
356
- from .tools.agentcore_agents import agentcore_agents
357
-
358
- tools.extend(
359
- [
360
- agentcore_config,
361
- agentcore_invoke,
362
- agentcore_logs,
363
- agentcore_agents,
364
- ]
365
- )
366
- logger.info("✅ AgentCore tools loaded")
367
- except:
368
- pass
369
- else:
370
- logger.info(
371
- "⏭️ AgentCore tools disabled (DEVDUCK_DISABLE_AGENTCORE_TOOLS=true)"
687
+ self.agent = Agent(
688
+ model=self.agent_model,
689
+ tools=self.tools,
690
+ system_prompt=self._build_system_prompt(),
691
+ load_tools_from_directory=load_from_dir,
692
+ trace_attributes={
693
+ "session.id": self.session_id,
694
+ "user.id": self.env_info["hostname"],
695
+ "tags": ["Strands-Agents", "DevDuck"],
696
+ },
372
697
  )
373
698
 
374
- # 3. FLEXIBLE: Load tools from DEVDUCK_TOOLS env var
375
- tools_config = os.getenv("DEVDUCK_TOOLS")
699
+ # 🚀 AUTO-START SERVERS
700
+ if auto_start_servers and "--mcp" not in sys.argv:
701
+ self._start_servers()
376
702
 
377
- if tools_config:
378
- # Parse: "strands_tools:shell,editor:strands_fun_tools:clipboard"
379
- tools.extend(self._parse_and_load_tools(tools_config))
380
- else:
381
- # Default: Load all common tools
382
- tools.extend(self._load_default_tools())
703
+ # Start file watcher for auto hot-reload
704
+ self._start_file_watcher()
383
705
 
384
- return tools
706
+ logger.info(
707
+ f"DevDuck agent initialized successfully with model {self.model}"
708
+ )
385
709
 
386
- def _parse_and_load_tools(self, config):
710
+ except Exception as e:
711
+ logger.error(f"Initialization failed: {e}")
712
+ self._self_heal(e)
713
+
714
+ def _load_tools_from_config(self, config):
387
715
  """
388
- Parse DEVDUCK_TOOLS config and load specified tools.
716
+ Load tools based on DEVDUCK_TOOLS configuration.
389
717
 
390
718
  Format: package:tool1,tool2:package2:tool3
391
- Example: strands_tools:shell,editor:strands_fun_tools:clipboard,cursor
719
+ Example: strands_tools:shell,editor:strands_fun_tools:clipboard
720
+
721
+ Note: Only loads what's specified in config - no automatic additions
392
722
  """
393
- loaded_tools = []
723
+ tools = []
394
724
  current_package = None
395
725
 
396
726
  for segment in config.split(":"):
397
727
  segment = segment.strip()
398
728
 
399
- # Check if this segment is a package or tool list
400
- if "," not in segment and not segment.startswith("strands"):
401
- # Single tool from current package
402
- if current_package:
403
- tool = self._load_single_tool(current_package, segment)
404
- if tool:
405
- loaded_tools.append(tool)
729
+ # Check if segment is a package name (contains '.' or '_' and no ',')
730
+ is_package = "," not in segment and ("." in segment or "_" in segment)
731
+
732
+ if is_package:
733
+ # This is a package name - set as current package
734
+ current_package = segment
735
+ logger.debug(f"Switched to package: {current_package}")
406
736
  elif "," in segment:
407
737
  # Tool list from current package
408
738
  if current_package:
@@ -410,13 +740,17 @@ class DevDuck:
410
740
  tool_name = tool_name.strip()
411
741
  tool = self._load_single_tool(current_package, tool_name)
412
742
  if tool:
413
- loaded_tools.append(tool)
743
+ tools.append(tool)
744
+ elif current_package:
745
+ # Single tool from current package
746
+ tool = self._load_single_tool(current_package, segment)
747
+ if tool:
748
+ tools.append(tool)
414
749
  else:
415
- # Package name
416
- current_package = segment
750
+ logger.warning(f"Skipping segment '{segment}' - no package set")
417
751
 
418
- logger.info(f"Loaded {len(loaded_tools)} tools from DEVDUCK_TOOLS")
419
- return loaded_tools
752
+ logger.info(f"Loaded {len(tools)} tools from configuration")
753
+ return tools
420
754
 
421
755
  def _load_single_tool(self, package, tool_name):
422
756
  """Load a single tool from a package"""
@@ -429,80 +763,132 @@ class DevDuck:
429
763
  logger.warning(f"Failed to load {tool_name} from {package}: {e}")
430
764
  return None
431
765
 
432
- def _load_default_tools(self):
433
- """Load default tools when DEVDUCK_TOOLS is not set"""
434
- tools = []
766
+ def _load_mcp_servers(self):
767
+ """
768
+ Load MCP servers from MCP_SERVERS environment variable using direct loading.
769
+
770
+ Uses the experimental managed integration - MCPClient instances are passed
771
+ directly to Agent constructor without explicit context management.
772
+
773
+ Format: JSON with "mcpServers" object
774
+ Example: MCP_SERVERS='{"mcpServers": {"strands": {"command": "uvx", "args": ["strands-agents-mcp-server"]}}}'
775
+
776
+ Returns:
777
+ List of MCPClient instances ready for direct use in Agent
778
+ """
779
+ import json
780
+
781
+ mcp_servers_json = os.getenv("MCP_SERVERS")
782
+ if not mcp_servers_json:
783
+ logger.debug("No MCP_SERVERS environment variable found")
784
+ return []
435
785
 
436
- # strands-agents-tools (essential)
437
786
  try:
438
- from strands_tools import (
439
- shell,
440
- editor,
441
- file_read,
442
- file_write,
443
- calculator,
444
- image_reader,
445
- use_agent,
446
- load_tool,
447
- environment,
448
- mcp_client,
449
- retrieve,
450
- )
787
+ config = json.loads(mcp_servers_json)
788
+ mcp_servers_config = config.get("mcpServers", {})
451
789
 
452
- tools.extend(
453
- [
454
- shell,
455
- editor,
456
- file_read,
457
- file_write,
458
- calculator,
459
- image_reader,
460
- use_agent,
461
- load_tool,
462
- environment,
463
- mcp_client,
464
- retrieve,
465
- ]
466
- )
467
- logger.info("✅ strands-agents-tools loaded")
468
- except ImportError:
469
- logger.warning("strands-agents-tools unavailable")
790
+ if not mcp_servers_config:
791
+ logger.warning("MCP_SERVERS JSON has no 'mcpServers' key")
792
+ return []
470
793
 
471
- # strands-fun-tools (optional, skip in --mcp mode)
472
- if "--mcp" not in sys.argv:
473
- try:
474
- from strands_fun_tools import (
475
- listen,
476
- cursor,
477
- clipboard,
478
- screen_reader,
479
- yolo_vision,
480
- )
794
+ mcp_clients = []
481
795
 
482
- tools.extend([listen, cursor, clipboard, screen_reader, yolo_vision])
483
- logger.info("✅ strands-fun-tools loaded")
484
- except ImportError:
485
- logger.info("strands-fun-tools unavailable")
796
+ from strands.tools.mcp import MCPClient
797
+ from mcp import stdio_client, StdioServerParameters
798
+ from mcp.client.streamable_http import streamablehttp_client
799
+ from mcp.client.sse import sse_client
486
800
 
487
- return tools
801
+ for server_name, server_config in mcp_servers_config.items():
802
+ try:
803
+ logger.info(f"Loading MCP server: {server_name}")
804
+
805
+ # Determine transport type and create appropriate callable
806
+ if "command" in server_config:
807
+ # stdio transport
808
+ command = server_config["command"]
809
+ args = server_config.get("args", [])
810
+ env = server_config.get("env", None)
811
+
812
+ transport_callable = (
813
+ lambda cmd=command, a=args, e=env: stdio_client(
814
+ StdioServerParameters(command=cmd, args=a, env=e)
815
+ )
816
+ )
817
+
818
+ elif "url" in server_config:
819
+ # Determine if SSE or streamable HTTP based on URL path
820
+ url = server_config["url"]
821
+ headers = server_config.get("headers", None)
822
+
823
+ if "/sse" in url:
824
+ # SSE transport
825
+ transport_callable = lambda u=url: sse_client(u)
826
+ else:
827
+ # Streamable HTTP transport (default for HTTP)
828
+ transport_callable = (
829
+ lambda u=url, h=headers: streamablehttp_client(
830
+ url=u, headers=h
831
+ )
832
+ )
833
+ else:
834
+ logger.warning(
835
+ f"MCP server {server_name} has no 'command' or 'url' - skipping"
836
+ )
837
+ continue
838
+
839
+ # Create MCPClient with direct loading (experimental managed integration)
840
+ # No need for context managers - Agent handles lifecycle
841
+ prefix = server_config.get("prefix", server_name)
842
+ mcp_client = MCPClient(
843
+ transport_callable=transport_callable, prefix=prefix
844
+ )
845
+
846
+ mcp_clients.append(mcp_client)
847
+ logger.info(
848
+ f"✓ MCP server '{server_name}' loaded (prefix: {prefix})"
849
+ )
850
+
851
+ except Exception as e:
852
+ logger.error(f"Failed to load MCP server '{server_name}': {e}")
853
+ continue
488
854
 
489
- def _create_model(self):
490
- """Create model with smart provider selection"""
855
+ return mcp_clients
856
+
857
+ except json.JSONDecodeError as e:
858
+ logger.error(f"Invalid JSON in MCP_SERVERS: {e}")
859
+ return []
860
+ except Exception as e:
861
+ logger.error(f"Error loading MCP servers: {e}")
862
+ return []
863
+
864
+ def _select_model(self):
865
+ """
866
+ Smart model selection with fallback: Bedrock → MLX → Ollama
867
+
868
+ Returns:
869
+ Tuple of (model_instance, model_name)
870
+ """
491
871
  provider = os.getenv("MODEL_PROVIDER")
492
872
 
493
873
  if not provider:
494
874
  # Auto-detect: Bedrock → MLX → Ollama
495
875
  try:
876
+ # Try Bedrock if AWS credentials available
877
+ import boto3
878
+
496
879
  boto3.client("sts").get_caller_identity()
497
880
  provider = "bedrock"
498
881
  print("🦆 Using Bedrock")
499
882
  except:
500
- if self.os == "Darwin" and self.arch in ["arm64", "aarch64"]:
883
+ # Try MLX on Apple Silicon
884
+ if platform.system() == "Darwin" and platform.machine() in [
885
+ "arm64",
886
+ "aarch64",
887
+ ]:
501
888
  try:
502
889
  from strands_mlx import MLXModel
503
890
 
504
891
  provider = "mlx"
505
- self.model = "mlx-community/Qwen3-1.7B-4bit"
506
892
  print("🦆 Using MLX")
507
893
  except ImportError:
508
894
  provider = "ollama"
@@ -511,26 +897,43 @@ class DevDuck:
511
897
  provider = "ollama"
512
898
  print("🦆 Using Ollama")
513
899
 
514
- # Create model
900
+ # Create model based on provider
515
901
  if provider == "mlx":
516
902
  from strands_mlx import MLXModel
517
903
 
518
- return MLXModel(model_id=self.model, temperature=1)
904
+ model_name = "mlx-community/Qwen3-1.7B-4bit"
905
+ return MLXModel(model_id=model_name, temperature=1), model_name
906
+
519
907
  elif provider == "ollama":
520
908
  from strands.models.ollama import OllamaModel
521
909
 
522
- return OllamaModel(
523
- host="http://localhost:11434",
524
- model_id=self.model,
525
- temperature=1,
526
- keep_alive="5m",
910
+ os_type = platform.system()
911
+ if os_type == "Darwin":
912
+ model_name = "qwen3:1.7b"
913
+ elif os_type == "Linux":
914
+ model_name = "qwen3:30b"
915
+ else:
916
+ model_name = "qwen3:8b"
917
+
918
+ return (
919
+ OllamaModel(
920
+ host="http://localhost:11434",
921
+ model_id=model_name,
922
+ temperature=1,
923
+ keep_alive="5m",
924
+ ),
925
+ model_name,
527
926
  )
927
+
528
928
  else:
929
+ # Bedrock or other providers via create_model
529
930
  from strands_tools.utils.models.model import create_model
530
931
 
531
- return create_model(provider=provider)
932
+ model = create_model(provider=provider)
933
+ model_name = os.getenv("STRANDS_MODEL_ID", "bedrock")
934
+ return model, model_name
532
935
 
533
- def _build_prompt(self):
936
+ def _build_system_prompt(self):
534
937
  """Build adaptive system prompt based on environment
535
938
 
536
939
  IMPORTANT: The system prompt includes the agent's complete source code.
@@ -540,82 +943,91 @@ class DevDuck:
540
943
 
541
944
  Learning: Always check source code truth over conversation memory!
542
945
  """
946
+ # Current date and time
947
+ current_datetime = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
948
+ current_date = datetime.now().strftime("%A, %B %d, %Y")
949
+ current_time = datetime.now().strftime("%I:%M %p")
950
+
951
+ session_id = f"devduck-{datetime.now().strftime('%Y-%m-%d')}"
952
+ self.session_id = session_id
953
+
954
+ # Get own file path for self-modification awareness
955
+ own_file_path = Path(__file__).resolve()
956
+
957
+ # Get own source code for self-awareness
543
958
  own_code = get_own_source_code()
544
- recent_context = get_last_messages()
545
- recent_logs = get_recent_logs()
546
959
 
547
- # Detect if using Bedrock for AgentCore documentation
548
- provider = os.getenv("MODEL_PROVIDER", "")
549
- is_bedrock = provider == "bedrock" or "bedrock" in provider.lower()
960
+ # Get recent conversation history context (with error handling)
550
961
  try:
551
- if not is_bedrock:
552
- boto3.client("sts").get_caller_identity()
553
- is_bedrock = True
554
- except:
555
- pass
556
-
557
- # Build AgentCore documentation if using Bedrock
558
- agentcore_docs = ""
559
- if is_bedrock:
560
- handler_path = str(Path(__file__).parent / "agentcore_handler.py")
561
- agentcore_docs = f"""
562
-
563
- ## 🚀 AgentCore (Bedrock)
564
-
565
- Handler: `{handler_path}`
566
-
567
- ### Quick Deploy:
568
- ```python
569
- # Configure + launch
570
- agentcore_config(action="configure", agent_name="devduck",
571
- tools="strands_tools:shell,editor", auto_launch=True)
572
-
573
- # Invoke
574
- agentcore_invoke(prompt="test", agent_name="devduck")
575
-
576
- # Monitor
577
- agentcore_logs(agent_name="devduck")
578
- agentcore_agents(action="list")
579
- ```
580
-
581
- ### Key Params:
582
- - tools: "package:tool1,tool2:package2:tool3"
583
- - idle_timeout: 900s (default)
584
- - model_id: us.anthropic.claude-sonnet-4-5-20250929-v1:0
585
- """
962
+ recent_context = get_last_messages()
963
+ except Exception as e:
964
+ print(f"🦆 Warning: Could not load history context: {e}")
965
+ recent_context = ""
966
+
967
+ # Get recent logs for immediate visibility
968
+ try:
969
+ recent_logs = get_recent_logs()
970
+ except Exception as e:
971
+ print(f"🦆 Warning: Could not load recent logs: {e}")
972
+ recent_logs = ""
586
973
 
587
- return f"""🦆 DevDuck - self-adapting agent
974
+ return f"""🦆 You are DevDuck - an extreme minimalist, self-adapting agent.
588
975
 
589
- Environment: {self.os} {self.arch}
976
+ Environment: {self.env_info['os']} {self.env_info['arch']}
977
+ Python: {self.env_info['python']}
590
978
  Model: {self.model}
591
- CWD: {Path.cwd()}
979
+ Hostname: {self.env_info['hostname']}
980
+ Session ID: {session_id}
981
+ Current Time: {current_datetime} ({current_date} at {current_time})
982
+ My Path: {own_file_path}
592
983
 
593
984
  You are:
594
985
  - Minimalist: Brief, direct responses
986
+ - Self-healing: Adapt when things break
595
987
  - Efficient: Get things done fast
596
988
  - Pragmatic: Use what works
597
989
 
990
+ Current working directory: {self.env_info['cwd']}
991
+
598
992
  {recent_context}
599
993
  {recent_logs}
600
- {agentcore_docs}
601
-
602
- ## Your Code
603
994
 
995
+ ## Your Own Implementation:
604
996
  You have full access to your own source code for self-awareness and self-modification:
605
- ---
997
+
606
998
  {own_code}
607
- ---
608
999
 
609
- ## Hot Reload Active:
610
- - Save .py files in ./tools/ for instant tool creation
611
- - Use install_tools() to load from packages
612
- - No restart needed
1000
+ ## Hot Reload System Active:
1001
+ - **Instant Tool Creation** - Save any .py file in `./tools/` and it becomes immediately available
1002
+ - **No Restart Needed** - Tools are auto-loaded and ready to use instantly
1003
+ - **Live Development** - Modify existing tools while running and test immediately
1004
+ - **Full Python Access** - Create any Python functionality as a tool
1005
+ - **Agent Protection** - Hot-reload waits until agent finishes current task
1006
+
1007
+ ## Dynamic Tool Loading:
1008
+ - **Install Tools** - Use install_tools() to load tools from any Python package
1009
+ - Example: install_tools(action="install_and_load", package="strands-fun-tools", module="strands_fun_tools")
1010
+ - Expands capabilities without restart
1011
+ - Access to entire Python ecosystem
613
1012
 
614
1013
  ## Tool Configuration:
615
1014
  Set DEVDUCK_TOOLS for custom tools:
616
1015
  - Format: package:tool1,tool2:package2:tool3
617
1016
  - Example: strands_tools:shell,editor:strands_fun_tools:clipboard
618
- - Static tools always loaded: tcp, websocket, ipc, mcp_server, agentcore_*
1017
+ - Tools are filtered - only specified tools are loaded
1018
+
1019
+ ## MCP Integration:
1020
+ - **Expose as MCP Server** - Use mcp_server() to expose devduck via MCP protocol
1021
+ - Example: mcp_server(action="start", port=8000)
1022
+ - Connect from Claude Desktop, other agents, or custom clients
1023
+ - Full bidirectional communication
1024
+
1025
+ - **Load MCP Servers** - Set MCP_SERVERS env var to auto-load external MCP servers
1026
+ - Format: JSON with "mcpServers" object
1027
+ - Stdio servers: command, args, env keys
1028
+ - HTTP servers: url, headers keys
1029
+ - Example: MCP_SERVERS='{{"mcpServers": {{"strands": {{"command": "uvx", "args": ["strands-agents-mcp-server"]}}}}}}'
1030
+ - Tools from MCP servers automatically available in agent context
619
1031
 
620
1032
  ## Knowledge Base Integration:
621
1033
  - **Automatic RAG** - Set DEVDUCK_KNOWLEDGE_BASE_ID to enable automatic retrieval/storage
@@ -624,293 +1036,480 @@ Set DEVDUCK_TOOLS for custom tools:
624
1036
  - Seamless memory across sessions without manual tool calls
625
1037
 
626
1038
  ## System Prompt Management:
627
- - system_prompt(action='view') - View current
628
- - system_prompt(action='update', prompt='...') - Update
629
- - system_prompt(action='update', repository='owner/repo') - Sync to GitHub
1039
+ - **View**: system_prompt(action='view') - See current prompt
1040
+ - **Update Local**: system_prompt(action='update', prompt='new text') - Updates env var + .prompt file
1041
+ - **Update GitHub**: system_prompt(action='update', prompt='text', repository='cagataycali/devduck') - Syncs to repo variables
1042
+ - **Variable Name**: system_prompt(action='update', prompt='text', variable_name='CUSTOM_PROMPT') - Use custom var
1043
+ - **Add Context**: system_prompt(action='add_context', context='new learning') - Append without replacing
1044
+
1045
+ ### 🧠 Self-Improvement Pattern:
1046
+ When you learn something valuable during conversations:
1047
+ 1. Identify the new insight or pattern
1048
+ 2. Use system_prompt(action='add_context', context='...') to append it
1049
+ 3. Sync to GitHub: system_prompt(action='update', prompt=new_full_prompt, repository='owner/repo')
1050
+ 4. New learnings persist across sessions via SYSTEM_PROMPT env var
1051
+
1052
+ **Repository Integration**: Set repository='cagataycali/devduck' to sync prompts across deployments
630
1053
 
631
1054
  ## Shell Commands:
632
- - Prefix with ! to run shell commands
633
- - Example: ! ls -la
1055
+ - Prefix with ! to execute shell commands directly
1056
+ - Example: ! ls -la (lists files)
1057
+ - Example: ! pwd (shows current directory)
634
1058
 
635
- Response: MINIMAL WORDS, MAX PARALLELISM
1059
+ **Response Format:**
1060
+ - Tool calls: **MAXIMUM PARALLELISM - ALWAYS**
1061
+ - Communication: **MINIMAL WORDS**
1062
+ - Efficiency: **Speed is paramount**
636
1063
 
637
- ## Tool Building Guide:
1064
+ {os.getenv('SYSTEM_PROMPT', '')}"""
638
1065
 
639
- ### **@tool Decorator (Recommended):**
640
- ```python
641
- # ./tools/my_tool.py
642
- from strands import tool
1066
+ def _self_heal(self, error):
1067
+ """Attempt self-healing when errors occur"""
1068
+ logger.error(f"Self-healing triggered by error: {error}")
1069
+ print(f"🦆 Self-healing from: {error}")
643
1070
 
644
- @tool
645
- def my_tool(param1: str, param2: int = 10) -> str:
646
- \"\"\"Tool description.
647
-
648
- Args:
649
- param1: Description of param1
650
- param2: Description of param2 (default: 10)
651
-
652
- Returns:
653
- str: Description of return value
654
- \"\"\"
655
- # Implementation
656
- return f"Result: {{param1}} - {{param2}}"
657
- ```
658
-
659
- ### **Action-Based Pattern:**
660
- ```python
661
- from typing import Dict, Any
662
- from strands import tool
1071
+ # Prevent infinite recursion by tracking heal attempts
1072
+ if not hasattr(self, "_heal_count"):
1073
+ self._heal_count = 0
663
1074
 
664
- @tool
665
- def my_tool(action: str, data: str = None) -> Dict[str, Any]:
666
- \"\"\"Multi-action tool.
667
-
668
- Args:
669
- action: Action to perform (get, set, delete)
670
- data: Optional data for action
671
-
672
- Returns:
673
- Dict with status and content
674
- \"\"\"
675
- if action == "get":
676
- return {{"status": "success", "content": [{{"text": f"Got: {{data}}"}}]}}
677
- elif action == "set":
678
- return {{"status": "success", "content": [{{"text": f"Set: {{data}}"}}]}}
679
- else:
680
- return {{"status": "error", "content": [{{"text": f"Unknown action: {{action}}"}}]}}
681
- ```
1075
+ self._heal_count += 1
682
1076
 
683
- ### **Tool Best Practices:**
684
- 1. Use type hints for all parameters
685
- 2. Provide clear docstrings
686
- 3. Return consistent formats (str or Dict[str, Any])
687
- 4. Use action-based pattern for complex tools
688
- 5. Handle errors gracefully
689
- 6. Log important operations
1077
+ # Limit recursion - if we've tried more than 3 times, give up
1078
+ if self._heal_count > 2:
1079
+ print(f"🦆 Self-healing failed after {self._heal_count} attempts")
1080
+ print("🦆 Please fix the issue manually and restart")
1081
+ sys.exit(1)
690
1082
 
691
- {os.getenv('SYSTEM_PROMPT', '')}"""
1083
+ elif "connection" in str(error).lower():
1084
+ print("🦆 Connection issue - checking ollama service...")
1085
+ try:
1086
+ subprocess.run(["ollama", "serve"], check=False, timeout=2)
1087
+ except:
1088
+ pass
692
1089
 
693
- def _view_logs_impl(self, action, lines, pattern):
694
- """Implementation of view_logs tool"""
1090
+ # Retry initialization
695
1091
  try:
696
- if action == "view":
697
- if not LOG_FILE.exists():
698
- return {"status": "success", "content": [{"text": "No logs yet"}]}
699
- with open(LOG_FILE, "r") as f:
700
- all_lines = f.readlines()
701
- recent = all_lines[-lines:] if len(all_lines) > lines else all_lines
702
- return {
703
- "status": "success",
704
- "content": [
705
- {"text": f"Last {len(recent)} lines:\n\n{''.join(recent)}"}
706
- ],
707
- }
1092
+ self.__init__()
1093
+ except Exception as e2:
1094
+ print(f"🦆 Self-heal failed: {e2}")
1095
+ print("🦆 Running in minimal mode...")
1096
+ self.agent = None
1097
+
1098
+ def _is_port_available(self, port):
1099
+ """Check if a port is available"""
1100
+ try:
1101
+ test_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
1102
+ test_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
1103
+ test_socket.bind(("0.0.0.0", port))
1104
+ test_socket.close()
1105
+ return True
1106
+ except OSError:
1107
+ return False
1108
+
1109
+ def _is_socket_available(self, socket_path):
1110
+ """Check if a Unix socket is available"""
1111
+ import os
1112
+
1113
+ # If socket file doesn't exist, it's available
1114
+ if not os.path.exists(socket_path):
1115
+ return True
1116
+ # If it exists, try to connect to see if it's in use
1117
+ try:
1118
+ test_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
1119
+ test_socket.connect(socket_path)
1120
+ test_socket.close()
1121
+ return False # Socket is in use
1122
+ except (ConnectionRefusedError, FileNotFoundError):
1123
+ # Socket file exists but not in use - remove stale socket
1124
+ try:
1125
+ os.remove(socket_path)
1126
+ return True
1127
+ except:
1128
+ return False
1129
+ except Exception:
1130
+ return False
1131
+
1132
+ def _find_available_port(self, start_port, max_attempts=10):
1133
+ """Find an available port starting from start_port"""
1134
+ for offset in range(max_attempts):
1135
+ port = start_port + offset
1136
+ if self._is_port_available(port):
1137
+ return port
1138
+ return None
708
1139
 
709
- elif action == "search":
710
- if not pattern:
711
- return {
712
- "status": "error",
713
- "content": [{"text": "pattern required"}],
714
- }
715
- if not LOG_FILE.exists():
716
- return {"status": "success", "content": [{"text": "No logs yet"}]}
717
- with open(LOG_FILE, "r") as f:
718
- matches = [line for line in f if pattern.lower() in line.lower()]
719
- if not matches:
720
- return {
721
- "status": "success",
722
- "content": [{"text": f"No matches for: {pattern}"}],
723
- }
724
- return {
725
- "status": "success",
726
- "content": [
727
- {
728
- "text": f"Found {len(matches)} matches:\n\n{''.join(matches[-100:])}"
729
- }
730
- ],
731
- }
1140
+ def _find_available_socket(self, base_socket_path, max_attempts=10):
1141
+ """Find an available socket path"""
1142
+ if self._is_socket_available(base_socket_path):
1143
+ return base_socket_path
1144
+ # Try numbered alternatives
1145
+ for i in range(1, max_attempts):
1146
+ alt_socket = f"{base_socket_path}.{i}"
1147
+ if self._is_socket_available(alt_socket):
1148
+ return alt_socket
1149
+ return None
732
1150
 
733
- elif action == "clear":
734
- if LOG_FILE.exists():
735
- LOG_FILE.unlink()
736
- return {"status": "success", "content": [{"text": "Logs cleared"}]}
1151
+ def _start_servers(self):
1152
+ """Auto-start configured servers with port conflict handling"""
1153
+ logger.info("Auto-starting servers...")
1154
+ print("🦆 Auto-starting servers...")
737
1155
 
738
- else:
739
- return {
740
- "status": "error",
741
- "content": [{"text": f"Unknown action: {action}"}],
742
- }
743
- except Exception as e:
744
- return {"status": "error", "content": [{"text": f"Error: {e}"}]}
1156
+ # Start servers in order: IPC, TCP, WS, MCP
1157
+ server_order = ["ipc", "tcp", "ws", "mcp"]
745
1158
 
746
- def _start_servers(self):
747
- """Auto-start servers"""
748
- if self.enable_tcp:
749
- try:
750
- self.agent.tool.tcp(action="start_server", port=self.tcp_port)
751
- print(f"🦆 ✓ TCP: localhost:{self.tcp_port}")
752
- except Exception as e:
753
- logger.warning(f"TCP server failed: {e}")
1159
+ for server_type in server_order:
1160
+ if server_type not in self.servers:
1161
+ continue
754
1162
 
755
- if self.enable_ws:
756
- try:
757
- self.agent.tool.websocket(action="start_server", port=self.ws_port)
758
- print(f"🦆 ✓ WebSocket: localhost:{self.ws_port}")
759
- except Exception as e:
760
- logger.warning(f"WebSocket server failed: {e}")
1163
+ config = self.servers[server_type]
761
1164
 
762
- if self.enable_mcp:
763
- try:
764
- self.agent.tool.mcp_server(
765
- action="start",
766
- transport="http",
767
- port=self.mcp_port,
768
- expose_agent=True,
769
- agent=self.agent,
770
- )
771
- print(f"🦆 ✓ MCP: http://localhost:{self.mcp_port}/mcp")
772
- except Exception as e:
773
- logger.warning(f"MCP server failed: {e}")
1165
+ # Check if server is enabled
1166
+ if not config.get("enabled", True):
1167
+ continue
1168
+
1169
+ # Check for LOOKUP_KEY (conditional start based on env var)
1170
+ if "LOOKUP_KEY" in config:
1171
+ lookup_key = config["LOOKUP_KEY"]
1172
+ if not os.getenv(lookup_key):
1173
+ logger.info(f"Skipping {server_type} - {lookup_key} not set")
1174
+ continue
774
1175
 
775
- if self.enable_ipc:
1176
+ # Start the server with port conflict handling
776
1177
  try:
777
- self.agent.tool.ipc(action="start_server", socket_path=self.ipc_socket)
778
- print(f"🦆 IPC: {self.ipc_socket}")
779
- except Exception as e:
780
- logger.warning(f"IPC server failed: {e}")
1178
+ if server_type == "tcp":
1179
+ port = config.get("port", 9999)
1180
+
1181
+ # Check port availability BEFORE attempting to start
1182
+ if not self._is_port_available(port):
1183
+ alt_port = self._find_available_port(port + 1)
1184
+ if alt_port:
1185
+ logger.info(f"Port {port} in use, using {alt_port}")
1186
+ print(f"🦆 Port {port} in use, using {alt_port}")
1187
+ port = alt_port
1188
+ else:
1189
+ logger.warning(f"No available ports found for TCP server")
1190
+ continue
781
1191
 
782
- def _start_hot_reload(self):
783
- """Start hot-reload file watcher"""
1192
+ result = self.agent.tool.tcp(
1193
+ action="start_server", port=port, record_direct_tool_call=False
1194
+ )
784
1195
 
785
- self._watch_file = Path(__file__).resolve()
786
- self._last_modified = (
787
- self._watch_file.stat().st_mtime if self._watch_file.exists() else None
788
- )
789
- self._watcher_running = True
1196
+ if result.get("status") == "success":
1197
+ logger.info(f"✓ TCP server started on port {port}")
1198
+ print(f"🦆 TCP server: localhost:{port}")
790
1199
 
791
- def watcher_thread():
792
- import time
1200
+ elif server_type == "ws":
1201
+ port = config.get("port", 8080)
793
1202
 
794
- last_reload = 0
795
- debounce = 3 # seconds
1203
+ # Check port availability BEFORE attempting to start
1204
+ if not self._is_port_available(port):
1205
+ alt_port = self._find_available_port(port + 1)
1206
+ if alt_port:
1207
+ logger.info(f"Port {port} in use, using {alt_port}")
1208
+ print(f"🦆 Port {port} in use, using {alt_port}")
1209
+ port = alt_port
1210
+ else:
1211
+ logger.warning(
1212
+ f"No available ports found for WebSocket server"
1213
+ )
1214
+ continue
796
1215
 
797
- while self._watcher_running:
798
- try:
799
- if self._watch_file.exists():
800
- mtime = self._watch_file.stat().st_mtime
801
- current_time = time.time()
802
-
803
- if (
804
- self._last_modified
805
- and mtime > self._last_modified
806
- and current_time - last_reload > debounce
807
- ):
808
-
809
- print(f"🦆 Code changed - hot-reload triggered")
810
- self._last_modified = mtime
811
- last_reload = current_time
812
-
813
- if self._agent_executing:
814
- print("🦆 Reload pending (agent executing)")
815
- self._reload_pending = True
816
- else:
817
- self._hot_reload()
1216
+ result = self.agent.tool.websocket(
1217
+ action="start_server", port=port, record_direct_tool_call=False
1218
+ )
1219
+
1220
+ if result.get("status") == "success":
1221
+ logger.info(f"✓ WebSocket server started on port {port}")
1222
+ print(f"🦆 ✓ WebSocket server: localhost:{port}")
1223
+
1224
+ elif server_type == "mcp":
1225
+ port = config.get("port", 8000)
1226
+
1227
+ # Check port availability BEFORE attempting to start
1228
+ if not self._is_port_available(port):
1229
+ alt_port = self._find_available_port(port + 1)
1230
+ if alt_port:
1231
+ logger.info(f"Port {port} in use, using {alt_port}")
1232
+ print(f"🦆 Port {port} in use, using {alt_port}")
1233
+ port = alt_port
818
1234
  else:
819
- self._last_modified = mtime
820
- except Exception as e:
821
- logger.error(f"File watcher error: {e}")
1235
+ logger.warning(f"No available ports found for MCP server")
1236
+ continue
822
1237
 
823
- time.sleep(1)
1238
+ result = self.agent.tool.mcp_server(
1239
+ action="start",
1240
+ transport="http",
1241
+ port=port,
1242
+ expose_agent=True,
1243
+ agent=self.agent,
1244
+ record_direct_tool_call=False,
1245
+ )
824
1246
 
825
- thread = threading.Thread(target=watcher_thread, daemon=True)
826
- thread.start()
827
- logger.info(f"Hot-reload watching: {self._watch_file}")
1247
+ if result.get("status") == "success":
1248
+ logger.info(f"✓ MCP HTTP server started on port {port}")
1249
+ print(f"🦆 ✓ MCP server: http://localhost:{port}/mcp")
1250
+
1251
+ elif server_type == "ipc":
1252
+ socket_path = config.get("socket_path", "/tmp/devduck_main.sock")
1253
+
1254
+ # Check socket availability BEFORE attempting to start
1255
+ available_socket = self._find_available_socket(socket_path)
1256
+ if not available_socket:
1257
+ logger.warning(
1258
+ f"No available socket paths found for IPC server"
1259
+ )
1260
+ continue
1261
+
1262
+ if available_socket != socket_path:
1263
+ logger.info(
1264
+ f"Socket {socket_path} in use, using {available_socket}"
1265
+ )
1266
+ print(
1267
+ f"🦆 Socket {socket_path} in use, using {available_socket}"
1268
+ )
1269
+ socket_path = available_socket
1270
+
1271
+ result = self.agent.tool.ipc(
1272
+ action="start_server",
1273
+ socket_path=socket_path,
1274
+ record_direct_tool_call=False,
1275
+ )
828
1276
 
829
- def _hot_reload(self):
830
- """Hot-reload by restarting process"""
831
- logger.info("Hot-reload: restarting process")
832
- self._watcher_running = False
833
- os.execv(sys.executable, [sys.executable] + sys.argv)
1277
+ if result.get("status") == "success":
1278
+ logger.info(f" IPC server started on {socket_path}")
1279
+ print(f"🦆 ✓ IPC server: {socket_path}")
1280
+ # TODO: support custom file path here so we can trigger foreign python function like another file
1281
+ except Exception as e:
1282
+ logger.error(f"Failed to start {server_type} server: {e}")
1283
+ print(f"🦆 ⚠ {server_type.upper()} server failed: {e}")
834
1284
 
835
1285
  def __call__(self, query):
836
- """Call agent with KB integration"""
1286
+ """Make the agent callable with automatic knowledge base integration"""
837
1287
  if not self.agent:
838
- return "🦆 Agent unavailable"
1288
+ logger.warning("Agent unavailable - attempted to call with query")
1289
+ return "🦆 Agent unavailable - try: devduck.restart()"
839
1290
 
840
1291
  try:
1292
+ logger.info(f"Agent call started: {query[:100]}...")
1293
+
1294
+ # Mark agent as executing to prevent hot-reload interruption
841
1295
  self._agent_executing = True
842
1296
 
843
- # KB retrieval
844
- kb_id = os.getenv("DEVDUCK_KNOWLEDGE_BASE_ID")
845
- if kb_id:
1297
+ # 📚 Knowledge Base Retrieval (BEFORE agent runs)
1298
+ knowledge_base_id = os.getenv("DEVDUCK_KNOWLEDGE_BASE_ID")
1299
+ if knowledge_base_id and hasattr(self.agent, "tool"):
846
1300
  try:
847
- self.agent.tool.retrieve(text=query, knowledgeBaseId=kb_id)
848
- except:
849
- pass
1301
+ if "retrieve" in self.agent.tool_names:
1302
+ logger.info(f"Retrieving context from KB: {knowledge_base_id}")
1303
+ self.agent.tool.retrieve(
1304
+ text=query, knowledgeBaseId=knowledge_base_id
1305
+ )
1306
+ except Exception as e:
1307
+ logger.warning(f"KB retrieval failed: {e}")
850
1308
 
851
- # Run agent
1309
+ # Run the agent
852
1310
  result = self.agent(query)
853
1311
 
854
- # KB storage
855
- if kb_id:
1312
+ # 💾 Knowledge Base Storage (AFTER agent runs)
1313
+ if knowledge_base_id and hasattr(self.agent, "tool"):
856
1314
  try:
857
- self.agent.tool.store_in_kb(
858
- content=f"Input: {query}\nResult: {str(result)}",
859
- title=f"DevDuck: {datetime.now().strftime('%Y-%m-%d')} | {query[:500]}",
860
- knowledge_base_id=kb_id,
861
- )
862
- except:
863
- pass
1315
+ if "store_in_kb" in self.agent.tool_names:
1316
+ conversation_content = f"Input: {query}, Result: {result!s}"
1317
+ conversation_title = f"DevDuck: {datetime.now().strftime('%Y-%m-%d')} | {query[:500]}"
1318
+ self.agent.tool.store_in_kb(
1319
+ content=conversation_content,
1320
+ title=conversation_title,
1321
+ knowledge_base_id=knowledge_base_id,
1322
+ )
1323
+ logger.info(f"Stored conversation in KB: {knowledge_base_id}")
1324
+ except Exception as e:
1325
+ logger.warning(f"KB storage failed: {e}")
864
1326
 
1327
+ # Clear executing flag
865
1328
  self._agent_executing = False
866
1329
 
867
- # Check for pending reload
1330
+ # Check for pending hot-reload
868
1331
  if self._reload_pending:
869
- print("🦆 Agent finished - triggering pending reload")
1332
+ logger.info("Triggering pending hot-reload after agent completion")
1333
+ print("\n🦆 Agent finished - triggering pending hot-reload...")
870
1334
  self._hot_reload()
871
1335
 
872
1336
  return result
873
1337
  except Exception as e:
874
- self._agent_executing = False
875
- logger.error(f"Agent call failed: {e}")
876
- return f"🦆 Error: {e}"
1338
+ self._agent_executing = False # Reset flag on error
1339
+ logger.error(f"Agent call failed with error: {e}")
1340
+ self._self_heal(e)
1341
+ if self.agent:
1342
+ return self.agent(query)
1343
+ else:
1344
+ return f"🦆 Error: {e}"
1345
+
1346
+ def restart(self):
1347
+ """Restart the agent"""
1348
+ print("\n🦆 Restarting...")
1349
+ self.__init__()
1350
+
1351
+ def _start_file_watcher(self):
1352
+ """Start background file watcher for auto hot-reload"""
1353
+ import threading
1354
+
1355
+ logger.info("Starting file watcher for hot-reload")
1356
+ # Get the path to this file
1357
+ self._watch_file = Path(__file__).resolve()
1358
+ self._last_modified = (
1359
+ self._watch_file.stat().st_mtime if self._watch_file.exists() else None
1360
+ )
1361
+ self._watcher_running = True
1362
+ self._is_reloading = False
1363
+
1364
+ # Start watcher thread
1365
+ self._watcher_thread = threading.Thread(
1366
+ target=self._file_watcher_thread, daemon=True
1367
+ )
1368
+ self._watcher_thread.start()
1369
+ logger.info(f"File watcher started, monitoring {self._watch_file}")
1370
+
1371
+ def _file_watcher_thread(self):
1372
+ """Background thread that watches for file changes"""
1373
+ last_reload_time = 0
1374
+ debounce_seconds = 3 # 3 second debounce
1375
+
1376
+ while self._watcher_running:
1377
+ try:
1378
+ # Skip if currently reloading
1379
+ if self._is_reloading:
1380
+ time.sleep(1)
1381
+ continue
1382
+
1383
+ if self._watch_file.exists():
1384
+ current_mtime = self._watch_file.stat().st_mtime
1385
+ current_time = time.time()
1386
+
1387
+ # Check if file was modified AND debounce period has passed
1388
+ if (
1389
+ self._last_modified
1390
+ and current_mtime > self._last_modified
1391
+ and current_time - last_reload_time > debounce_seconds
1392
+ ):
1393
+ print(f"\n🦆 Detected changes in {self._watch_file.name}!")
1394
+ last_reload_time = current_time
1395
+
1396
+ # Check if agent is currently executing
1397
+ if self._agent_executing:
1398
+ logger.info(
1399
+ "Code change detected but agent is executing - reload pending"
1400
+ )
1401
+ print(
1402
+ "\n🦆 Agent is currently executing - reload will trigger after completion"
1403
+ )
1404
+ self._reload_pending = True
1405
+ # Don't update _last_modified yet - keep detecting the change
1406
+ else:
1407
+ # Safe to reload immediately
1408
+ self._last_modified = current_mtime
1409
+ logger.info(
1410
+ f"Code change detected in {self._watch_file.name} - triggering hot-reload"
1411
+ )
1412
+ time.sleep(
1413
+ 0.5
1414
+ ) # Small delay to ensure file write is complete
1415
+ self._hot_reload()
1416
+ else:
1417
+ # Update timestamp if no change or still in debounce
1418
+ if not self._reload_pending:
1419
+ self._last_modified = current_mtime
1420
+
1421
+ except Exception as e:
1422
+ logger.error(f"File watcher error: {e}")
1423
+
1424
+ # Check every 1 second
1425
+ time.sleep(1)
1426
+
1427
+ def _stop_file_watcher(self):
1428
+ """Stop the file watcher"""
1429
+ self._watcher_running = False
1430
+ logger.info("File watcher stopped")
877
1431
 
1432
+ def _hot_reload(self):
1433
+ """Hot-reload by restarting the entire Python process with fresh code"""
1434
+ logger.info("Hot-reload initiated")
1435
+ print("\n🦆 Hot-reloading via process restart...")
1436
+
1437
+ try:
1438
+ # Set reload flag to prevent recursive reloads during shutdown
1439
+ self._is_reloading = True
878
1440
 
879
- # Initialize
1441
+ # Update last_modified before reload to acknowledge the change
1442
+ if hasattr(self, "_watch_file") and self._watch_file.exists():
1443
+ self._last_modified = self._watch_file.stat().st_mtime
1444
+
1445
+ # Reset pending flag
1446
+ self._reload_pending = False
1447
+
1448
+ # Stop the file watcher
1449
+ if hasattr(self, "_watcher_running"):
1450
+ self._watcher_running = False
1451
+
1452
+ print("\n🦆 Restarting process with fresh code...")
1453
+
1454
+ # Restart the entire Python process
1455
+ # This ensures all code is freshly loaded
1456
+ os.execv(sys.executable, [sys.executable] + sys.argv)
1457
+
1458
+ except Exception as e:
1459
+ logger.error(f"Hot-reload failed: {e}")
1460
+ print(f"\n🦆 Hot-reload failed: {e}")
1461
+ print("\n🦆 Falling back to manual restart")
1462
+ self._is_reloading = False
1463
+
1464
+ def status(self):
1465
+ """Show current status"""
1466
+ return {
1467
+ "model": self.model,
1468
+ "env": self.env_info,
1469
+ "agent_ready": self.agent is not None,
1470
+ "tools": len(self.tools) if hasattr(self, "tools") else 0,
1471
+ "file_watcher": {
1472
+ "enabled": hasattr(self, "_watcher_running") and self._watcher_running,
1473
+ "watching": (
1474
+ str(self._watch_file) if hasattr(self, "_watch_file") else None
1475
+ ),
1476
+ },
1477
+ }
1478
+
1479
+
1480
+ # 🦆 Auto-initialize when imported
880
1481
  # Check environment variables to control server configuration
1482
+ # Also check if --mcp flag is present to skip auto-starting servers
881
1483
  _auto_start = os.getenv("DEVDUCK_AUTO_START_SERVERS", "true").lower() == "true"
882
1484
 
883
1485
  # Disable auto-start if --mcp flag is present (stdio mode)
884
1486
  if "--mcp" in sys.argv:
885
1487
  _auto_start = False
886
1488
 
887
- _tcp_port = int(os.getenv("DEVDUCK_TCP_PORT", "9999"))
888
- _ws_port = int(os.getenv("DEVDUCK_WS_PORT", "8080"))
889
- _mcp_port = int(os.getenv("DEVDUCK_MCP_PORT", "8000"))
890
- _ipc_socket = os.getenv("DEVDUCK_IPC_SOCKET", None)
891
- _enable_tcp = os.getenv("DEVDUCK_ENABLE_TCP", "true").lower() == "true"
892
- _enable_ws = os.getenv("DEVDUCK_ENABLE_WS", "true").lower() == "true"
893
- _enable_mcp = os.getenv("DEVDUCK_ENABLE_MCP", "true").lower() == "true"
894
- _enable_ipc = os.getenv("DEVDUCK_ENABLE_IPC", "true").lower() == "true"
895
-
896
- devduck = DevDuck(
897
- auto_start_servers=_auto_start,
898
- tcp_port=_tcp_port,
899
- ws_port=_ws_port,
900
- mcp_port=_mcp_port,
901
- ipc_socket=_ipc_socket,
902
- enable_tcp=_enable_tcp,
903
- enable_ws=_enable_ws,
904
- enable_mcp=_enable_mcp,
905
- enable_ipc=_enable_ipc,
906
- )
1489
+ devduck = DevDuck(auto_start_servers=_auto_start)
907
1490
 
908
1491
 
1492
+ # 🚀 Convenience functions
909
1493
  def ask(query):
910
- """Quick query"""
1494
+ """Quick query interface"""
911
1495
  return devduck(query)
912
1496
 
913
1497
 
1498
+ def status():
1499
+ """Quick status check"""
1500
+ return devduck.status()
1501
+
1502
+
1503
+ def restart():
1504
+ """Quick restart"""
1505
+ devduck.restart()
1506
+
1507
+
1508
+ def hot_reload():
1509
+ """Quick hot-reload without restart"""
1510
+ devduck._hot_reload()
1511
+
1512
+
914
1513
  def extract_commands_from_history():
915
1514
  """Extract commonly used commands from shell history for auto-completion."""
916
1515
  commands = set()
@@ -984,8 +1583,7 @@ def extract_commands_from_history():
984
1583
 
985
1584
 
986
1585
  def interactive():
987
- """Interactive REPL with history"""
988
- import time
1586
+ """Interactive REPL mode for devduck"""
989
1587
  from prompt_toolkit import prompt
990
1588
  from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
991
1589
  from prompt_toolkit.completion import WordCompleter
@@ -993,10 +1591,14 @@ def interactive():
993
1591
 
994
1592
  print("🦆 DevDuck")
995
1593
  print(f"📝 Logs: {LOG_DIR}")
996
- print("Type 'exit' to quit. Prefix with ! for shell commands.")
1594
+ print("Type 'exit', 'quit', or 'q' to quit.")
1595
+ print("Prefix with ! to run shell commands (e.g., ! ls -la)")
997
1596
  print("-" * 50)
1597
+ logger.info("Interactive mode started")
998
1598
 
999
- history = FileHistory(get_shell_history_file())
1599
+ # Set up prompt_toolkit with history
1600
+ history_file = get_shell_history_file()
1601
+ history = FileHistory(history_file)
1000
1602
 
1001
1603
  # Create completions from common commands and shell history
1002
1604
  base_commands = ["exit", "quit", "q", "help", "clear", "status", "reload"]
@@ -1012,42 +1614,61 @@ def interactive():
1012
1614
 
1013
1615
  while True:
1014
1616
  try:
1617
+ # Use prompt_toolkit for enhanced input with arrow key support
1015
1618
  q = prompt(
1016
1619
  "\n🦆 ",
1017
1620
  history=history,
1018
1621
  auto_suggest=AutoSuggestFromHistory(),
1019
1622
  completer=completer,
1020
1623
  complete_while_typing=True,
1021
- mouse_support=False,
1022
- ).strip()
1624
+ mouse_support=False, # breaks scrolling when enabled
1625
+ )
1023
1626
 
1024
1627
  # Reset interrupt count on successful prompt
1025
1628
  interrupt_count = 0
1026
1629
 
1630
+ # Check for exit command
1027
1631
  if q.lower() in ["exit", "quit", "q"]:
1632
+ print("\n🦆 Goodbye!")
1028
1633
  break
1029
1634
 
1030
- if not q:
1635
+ # Skip empty inputs
1636
+ if q.strip() == "":
1031
1637
  continue
1032
1638
 
1033
- # Shell commands
1639
+ # Handle shell commands with ! prefix
1034
1640
  if q.startswith("!"):
1035
- if devduck.agent:
1036
- devduck._agent_executing = True
1037
- result = devduck.agent.tool.shell(
1038
- command=q[1:].strip(), timeout=9000
1039
- )
1040
- devduck._agent_executing = False
1041
- append_to_shell_history(q, result["content"][0]["text"])
1042
-
1043
- if devduck._reload_pending:
1044
- print("🦆 Shell finished - triggering pending reload")
1045
- devduck._hot_reload()
1641
+ shell_command = q[1:].strip()
1642
+ try:
1643
+ if devduck.agent:
1644
+ devduck._agent_executing = (
1645
+ True # Prevent hot-reload during shell execution
1646
+ )
1647
+ result = devduck.agent.tool.shell(
1648
+ command=shell_command, timeout=9000
1649
+ )
1650
+ devduck._agent_executing = False
1651
+
1652
+ # Append shell command to history
1653
+ append_to_shell_history(q, result["content"][0]["text"])
1654
+
1655
+ # Check if reload was pending
1656
+ if devduck._reload_pending:
1657
+ print(
1658
+ "🦆 Shell command finished - triggering pending hot-reload..."
1659
+ )
1660
+ devduck._hot_reload()
1661
+ else:
1662
+ print("🦆 Agent unavailable")
1663
+ except Exception as e:
1664
+ devduck._agent_executing = False # Reset on error
1665
+ print(f"🦆 Shell command error: {e}")
1046
1666
  continue
1047
1667
 
1048
- # Agent query
1668
+ # Execute the agent with user input
1049
1669
  result = ask(q)
1050
- print(result)
1670
+
1671
+ # Append to shell history
1051
1672
  append_to_shell_history(q, str(result))
1052
1673
 
1053
1674
  except KeyboardInterrupt:
@@ -1066,12 +1687,14 @@ def interactive():
1066
1687
  print("\n🦆 Interrupted. Press Ctrl+C again to exit.")
1067
1688
 
1068
1689
  last_interrupt = current_time
1690
+ continue
1069
1691
  except Exception as e:
1070
1692
  print(f"🦆 Error: {e}")
1693
+ continue
1071
1694
 
1072
1695
 
1073
1696
  def cli():
1074
- """CLI entry point"""
1697
+ """CLI entry point for pip-installed devduck command"""
1075
1698
  import argparse
1076
1699
 
1077
1700
  parser = argparse.ArgumentParser(
@@ -1079,16 +1702,14 @@ def cli():
1079
1702
  formatter_class=argparse.RawDescriptionHelpFormatter,
1080
1703
  epilog="""
1081
1704
  Examples:
1082
- devduck # Interactive mode
1083
- devduck "query" # One-shot query
1084
- devduck --mcp # MCP stdio mode
1085
- devduck --tcp-port 9000 # Custom TCP port
1086
- devduck --no-tcp --no-ws # Disable TCP and WebSocket
1705
+ devduck # Start interactive mode
1706
+ devduck "your query here" # One-shot query
1707
+ devduck --mcp # MCP stdio mode (for Claude Desktop)
1087
1708
 
1088
1709
  Tool Configuration:
1089
1710
  export DEVDUCK_TOOLS="strands_tools:shell,editor:strands_fun_tools:clipboard"
1090
1711
 
1091
- MCP Config:
1712
+ Claude Desktop Config:
1092
1713
  {
1093
1714
  "mcpServers": {
1094
1715
  "devduck": {
@@ -1100,72 +1721,65 @@ MCP Config:
1100
1721
  """,
1101
1722
  )
1102
1723
 
1103
- parser.add_argument("query", nargs="*", help="Query")
1104
- parser.add_argument("--mcp", action="store_true", help="MCP stdio mode")
1105
-
1106
- # Server configuration
1107
- parser.add_argument(
1108
- "--tcp-port", type=int, default=9999, help="TCP server port (default: 9999)"
1109
- )
1110
- parser.add_argument(
1111
- "--ws-port",
1112
- type=int,
1113
- default=8080,
1114
- help="WebSocket server port (default: 8080)",
1115
- )
1116
- parser.add_argument(
1117
- "--mcp-port",
1118
- type=int,
1119
- default=8000,
1120
- help="MCP HTTP server port (default: 8000)",
1121
- )
1122
- parser.add_argument(
1123
- "--ipc-socket",
1124
- type=str,
1125
- default=None,
1126
- help="IPC socket path (default: /tmp/devduck_main.sock)",
1127
- )
1724
+ # Query argument
1725
+ parser.add_argument("query", nargs="*", help="Query to send to the agent")
1128
1726
 
1129
- # Server enable/disable flags
1130
- parser.add_argument("--no-tcp", action="store_true", help="Disable TCP server")
1131
- parser.add_argument("--no-ws", action="store_true", help="Disable WebSocket server")
1132
- parser.add_argument("--no-mcp", action="store_true", help="Disable MCP server")
1133
- parser.add_argument("--no-ipc", action="store_true", help="Disable IPC server")
1727
+ # MCP stdio mode flag
1134
1728
  parser.add_argument(
1135
- "--no-servers",
1729
+ "--mcp",
1136
1730
  action="store_true",
1137
- help="Disable all servers (no TCP, WebSocket, MCP, or IPC)",
1731
+ help="Start MCP server in stdio mode (for Claude Desktop integration)",
1138
1732
  )
1139
1733
 
1140
1734
  args = parser.parse_args()
1141
1735
 
1736
+ logger.info("CLI mode started")
1737
+
1738
+ # Handle --mcp flag for stdio mode
1142
1739
  if args.mcp:
1740
+ logger.info("Starting MCP server in stdio mode (blocking, foreground)")
1143
1741
  print("🦆 Starting MCP stdio server...", file=sys.stderr)
1144
- try:
1145
- devduck.agent.tool.mcp_server(
1146
- action="start",
1147
- transport="stdio",
1148
- expose_agent=True,
1149
- agent=devduck.agent,
1150
- )
1151
- except Exception as e:
1152
- print(f"🦆 Error: {e}", file=sys.stderr)
1742
+
1743
+ # Don't auto-start HTTP/TCP/WS servers for stdio mode
1744
+ if devduck.agent:
1745
+ try:
1746
+ # Start MCP server in stdio mode - this BLOCKS until terminated
1747
+ devduck.agent.tool.mcp_server(
1748
+ action="start",
1749
+ transport="stdio",
1750
+ expose_agent=True,
1751
+ agent=devduck.agent,
1752
+ record_direct_tool_call=False,
1753
+ )
1754
+ except Exception as e:
1755
+ logger.error(f"Failed to start MCP stdio server: {e}")
1756
+ print(f"🦆 Error: {e}", file=sys.stderr)
1757
+ sys.exit(1)
1758
+ else:
1759
+ print("🦆 Agent not available", file=sys.stderr)
1153
1760
  sys.exit(1)
1154
1761
  return
1155
1762
 
1156
1763
  if args.query:
1157
- result = ask(" ".join(args.query))
1764
+ query = " ".join(args.query)
1765
+ logger.info(f"CLI query: {query}")
1766
+ result = ask(query)
1158
1767
  print(result)
1159
1768
  else:
1769
+ # No arguments - start interactive mode
1160
1770
  interactive()
1161
1771
 
1162
1772
 
1163
- # Make module callable
1773
+ # 🦆 Make module directly callable: import devduck; devduck("query")
1164
1774
  class CallableModule(sys.modules[__name__].__class__):
1775
+ """Make the module itself callable"""
1776
+
1165
1777
  def __call__(self, query):
1778
+ """Allow direct module call: import devduck; devduck("query")"""
1166
1779
  return ask(query)
1167
1780
 
1168
1781
 
1782
+ # Replace module in sys.modules with callable version
1169
1783
  sys.modules[__name__].__class__ = CallableModule
1170
1784
 
1171
1785