devduck 0.5.4__py3-none-any.whl → 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
devduck/__init__.py 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,169 @@ 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
+
43
186
  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)
187
+ """Get the devduck-specific history file path."""
188
+ devduck_history = Path.home() / ".devduck_history"
189
+ if not devduck_history.exists():
190
+ devduck_history.touch(mode=0o600)
191
+ return str(devduck_history)
49
192
 
50
193
 
51
194
  def get_shell_history_files():
@@ -125,6 +268,32 @@ def parse_history_line(line, history_type):
125
268
  return None
126
269
 
127
270
 
271
+ def get_recent_logs():
272
+ """Get the last N lines from the log file for context."""
273
+ try:
274
+ log_line_count = int(os.getenv("DEVDUCK_LOG_LINE_COUNT", "50"))
275
+
276
+ if not LOG_FILE.exists():
277
+ return ""
278
+
279
+ with open(LOG_FILE, "r", encoding="utf-8", errors="ignore") as f:
280
+ all_lines = f.readlines()
281
+
282
+ recent_lines = (
283
+ all_lines[-log_line_count:]
284
+ if len(all_lines) > log_line_count
285
+ else all_lines
286
+ )
287
+
288
+ if not recent_lines:
289
+ return ""
290
+
291
+ log_content = "".join(recent_lines)
292
+ return f"\n\n## Recent Logs (last {len(recent_lines)} lines):\n```\n{log_content}```\n"
293
+ except Exception as e:
294
+ return f"\n\n## Recent Logs: Error reading logs - {e}\n"
295
+
296
+
128
297
  def get_last_messages():
129
298
  """Get the last N messages from multiple shell histories for context."""
130
299
  try:
@@ -184,225 +353,178 @@ def get_last_messages():
184
353
  return ""
185
354
 
186
355
 
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
356
  def append_to_shell_history(query, response):
208
- """Append interaction to history"""
209
- import time
210
-
357
+ """Append the interaction to devduck shell history."""
211
358
  try:
212
359
  history_file = get_shell_history_file()
213
360
  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
361
 
221
362
  with open(history_file, "a", encoding="utf-8") as f:
222
363
  f.write(f": {timestamp}:0;# devduck: {query}\n")
364
+ response_summary = (
365
+ str(response).replace("\n", " ")[
366
+ : int(os.getenv("DEVDUCK_RESPONSE_SUMMARY_LENGTH", "10000"))
367
+ ]
368
+ + "..."
369
+ )
223
370
  f.write(f": {timestamp}:0;# devduck_result: {response_summary}\n")
224
371
 
225
372
  os.chmod(history_file, 0o600)
226
- except:
373
+ except Exception:
227
374
  pass
228
375
 
229
376
 
377
+ # 🦆 The devduck agent
230
378
  class DevDuck:
231
- """Minimalist adaptive agent with flexible tool loading"""
232
-
233
379
  def __init__(
234
380
  self,
235
381
  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,
382
+ servers=None,
244
383
  ):
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):
384
+ """Initialize the minimalist adaptive agent
385
+
386
+ Args:
387
+ auto_start_servers: Enable automatic server startup
388
+ servers: Dict of server configs with optional env var lookups
389
+ Example: {
390
+ "tcp": {"port": 9999},
391
+ "ws": {"port": 8080, "LOOKUP_KEY": "SLACK_API_KEY"},
392
+ "mcp": {"port": 8000},
393
+ "ipc": {"socket_path": "/tmp/devduck.sock"}
394
+ }
302
395
  """
303
- Load tools with flexible configuration via DEVDUCK_TOOLS env var.
396
+ logger.info("Initializing DevDuck agent...")
397
+ try:
398
+ self.env_info = {
399
+ "os": platform.system(),
400
+ "arch": platform.machine(),
401
+ "python": sys.version_info,
402
+ "cwd": str(Path.cwd()),
403
+ "home": str(Path.home()),
404
+ "shell": os.environ.get("SHELL", "unknown"),
405
+ "hostname": socket.gethostname(),
406
+ }
407
+
408
+ # Execution state tracking for hot-reload
409
+ self._agent_executing = False
410
+ self._reload_pending = False
411
+
412
+ # Server configuration
413
+ if servers is None:
414
+ # Default server config from env vars
415
+ servers = {
416
+ "tcp": {
417
+ "port": int(os.getenv("DEVDUCK_TCP_PORT", "9999")),
418
+ "enabled": os.getenv("DEVDUCK_ENABLE_TCP", "true").lower()
419
+ == "true",
420
+ },
421
+ "ws": {
422
+ "port": int(os.getenv("DEVDUCK_WS_PORT", "8080")),
423
+ "enabled": os.getenv("DEVDUCK_ENABLE_WS", "true").lower()
424
+ == "true",
425
+ },
426
+ "mcp": {
427
+ "port": int(os.getenv("DEVDUCK_MCP_PORT", "8000")),
428
+ "enabled": os.getenv("DEVDUCK_ENABLE_MCP", "true").lower()
429
+ == "true",
430
+ },
431
+ "ipc": {
432
+ "socket_path": os.getenv(
433
+ "DEVDUCK_IPC_SOCKET", "/tmp/devduck_main.sock"
434
+ ),
435
+ "enabled": os.getenv("DEVDUCK_ENABLE_IPC", "true").lower()
436
+ == "true",
437
+ },
438
+ }
304
439
 
305
- Format: package:tool1,tool2:package2:tool3,tool4
306
- Example: strands_tools:shell,editor:strands_fun_tools:clipboard
440
+ self.servers = servers
307
441
 
308
- Static tools (always loaded):
309
- - DevDuck's own tools (tcp, websocket, ipc, etc.)
310
- - AgentCore tools (if AWS credentials available)
311
- """
312
- tools = []
442
+ from strands import Agent, tool
313
443
 
314
- # 1. STATIC: Core DevDuck tools (always load)
315
- 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,
444
+ # 🧰 Load tools with flexible configuration
445
+ tools_config = os.getenv("DEVDUCK_TOOLS")
446
+ if tools_config:
447
+ logger.info(f"Loading tools from DEVDUCK_TOOLS: {tools_config}")
448
+ core_tools = self._load_tools_from_config(tools_config)
449
+ else:
450
+ logger.info("Loading default tool set")
451
+ core_tools = self._load_default_tools()
452
+
453
+ # Wrap view_logs_tool with @tool decorator
454
+ @tool
455
+ def view_logs(
456
+ action: str = "view",
457
+ lines: int = 100,
458
+ pattern: str = None,
459
+ ) -> Dict[str, Any]:
460
+ """View and manage DevDuck logs."""
461
+ return view_logs_tool(action, lines, pattern)
462
+
463
+ # Add built-in tools to the toolset
464
+ core_tools.extend([view_logs])
465
+
466
+ # Assign tools
467
+ self.tools = core_tools
468
+
469
+ logger.info(f"Initialized {len(self.tools)} tools")
470
+
471
+ # 🎯 Smart model selection
472
+ self.agent_model, self.model = self._select_model()
473
+
474
+ # Create agent with self-healing
475
+ self.agent = Agent(
476
+ model=self.agent_model,
477
+ tools=self.tools,
478
+ system_prompt=self._build_system_prompt(),
479
+ load_tools_from_directory=True,
328
480
  )
329
481
 
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
- ]
344
- )
345
- logger.info("✅ DevDuck core tools loaded")
346
- except ImportError as e:
347
- logger.warning(f"DevDuck tools unavailable: {e}")
482
+ # 🚀 AUTO-START SERVERS
483
+ if auto_start_servers and "--mcp" not in sys.argv:
484
+ self._start_servers()
348
485
 
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
486
+ # Start file watcher for auto hot-reload
487
+ self._start_file_watcher()
357
488
 
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
489
  logger.info(
371
- "⏭️ AgentCore tools disabled (DEVDUCK_DISABLE_AGENTCORE_TOOLS=true)"
490
+ f"DevDuck agent initialized successfully with model {self.model}"
372
491
  )
373
492
 
374
- # 3. FLEXIBLE: Load tools from DEVDUCK_TOOLS env var
375
- tools_config = os.getenv("DEVDUCK_TOOLS")
376
-
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())
383
-
384
- return tools
493
+ except Exception as e:
494
+ logger.error(f"Initialization failed: {e}")
495
+ self._self_heal(e)
385
496
 
386
- def _parse_and_load_tools(self, config):
497
+ def _load_tools_from_config(self, config):
387
498
  """
388
- Parse DEVDUCK_TOOLS config and load specified tools.
499
+ Load tools based on DEVDUCK_TOOLS configuration.
389
500
 
390
501
  Format: package:tool1,tool2:package2:tool3
391
- Example: strands_tools:shell,editor:strands_fun_tools:clipboard,cursor
502
+ Example: strands_tools:shell,editor:strands_fun_tools:clipboard
392
503
  """
393
- loaded_tools = []
504
+ tools = []
505
+
506
+ # Always load DevDuck core tools
507
+ tools.extend(self._load_devduck_tools())
508
+
509
+ # Parse and load configured tools
394
510
  current_package = None
395
511
 
396
512
  for segment in config.split(":"):
397
513
  segment = segment.strip()
398
514
 
399
515
  # Check if this segment is a package or tool list
400
- if "," not in segment and not segment.startswith("strands"):
516
+ if "," not in segment and not any(
517
+ segment.startswith(pkg)
518
+ for pkg in [
519
+ "strands_",
520
+ "devduck",
521
+ ] # TODO: we should accept any python library here.
522
+ ):
401
523
  # Single tool from current package
402
524
  if current_package:
403
525
  tool = self._load_single_tool(current_package, segment)
404
526
  if tool:
405
- loaded_tools.append(tool)
527
+ tools.append(tool)
406
528
  elif "," in segment:
407
529
  # Tool list from current package
408
530
  if current_package:
@@ -410,13 +532,13 @@ class DevDuck:
410
532
  tool_name = tool_name.strip()
411
533
  tool = self._load_single_tool(current_package, tool_name)
412
534
  if tool:
413
- loaded_tools.append(tool)
535
+ tools.append(tool)
414
536
  else:
415
537
  # Package name
416
538
  current_package = segment
417
539
 
418
- logger.info(f"Loaded {len(loaded_tools)} tools from DEVDUCK_TOOLS")
419
- return loaded_tools
540
+ logger.info(f"Loaded tools from DEVDUCK_TOOLS configuration")
541
+ return tools
420
542
 
421
543
  def _load_single_tool(self, package, tool_name):
422
544
  """Load a single tool from a package"""
@@ -430,10 +552,13 @@ class DevDuck:
430
552
  return None
431
553
 
432
554
  def _load_default_tools(self):
433
- """Load default tools when DEVDUCK_TOOLS is not set"""
555
+ """Load default comprehensive tool set"""
434
556
  tools = []
435
557
 
436
- # strands-agents-tools (essential)
558
+ # Always load DevDuck core tools
559
+ tools.extend(self._load_devduck_tools())
560
+
561
+ # Load strands-agents-tools (essential)
437
562
  try:
438
563
  from strands_tools import (
439
564
  shell,
@@ -466,9 +591,9 @@ class DevDuck:
466
591
  )
467
592
  logger.info("✅ strands-agents-tools loaded")
468
593
  except ImportError:
469
- logger.warning("strands-agents-tools unavailable")
594
+ logger.info("strands-agents-tools unavailable")
470
595
 
471
- # strands-fun-tools (optional, skip in --mcp mode)
596
+ # Load strands-fun-tools (optional, skip in --mcp mode)
472
597
  if "--mcp" not in sys.argv:
473
598
  try:
474
599
  from strands_fun_tools import (
@@ -486,23 +611,103 @@ class DevDuck:
486
611
 
487
612
  return tools
488
613
 
489
- def _create_model(self):
490
- """Create model with smart provider selection"""
614
+ def _load_devduck_tools(self):
615
+ """Load DevDuck's core tools (always available)"""
616
+ tools = []
617
+ try:
618
+ from .tools import (
619
+ tcp,
620
+ websocket,
621
+ ipc,
622
+ mcp_server,
623
+ install_tools,
624
+ use_github,
625
+ create_subagent,
626
+ store_in_kb,
627
+ system_prompt,
628
+ state_manager,
629
+ tray,
630
+ ambient,
631
+ )
632
+
633
+ tools.extend(
634
+ [
635
+ tcp,
636
+ websocket,
637
+ ipc,
638
+ mcp_server,
639
+ install_tools,
640
+ use_github,
641
+ create_subagent,
642
+ store_in_kb,
643
+ system_prompt,
644
+ state_manager,
645
+ tray,
646
+ ambient,
647
+ ]
648
+ )
649
+ logger.info("✅ DevDuck core tools loaded")
650
+ except ImportError as e:
651
+ logger.warning(f"DevDuck tools unavailable: {e}")
652
+
653
+ # Load AgentCore tools if AWS credentials available (conditional)
654
+ if os.getenv("DEVDUCK_DISABLE_AGENTCORE_TOOLS", "false").lower() != "true":
655
+ try:
656
+ import boto3
657
+
658
+ boto3.client("sts").get_caller_identity()
659
+
660
+ from .tools.agentcore_config import agentcore_config
661
+ from .tools.agentcore_invoke import agentcore_invoke
662
+ from .tools.agentcore_logs import agentcore_logs
663
+ from .tools.agentcore_agents import agentcore_agents
664
+
665
+ tools.extend(
666
+ [
667
+ agentcore_config,
668
+ agentcore_invoke,
669
+ agentcore_logs,
670
+ agentcore_agents,
671
+ ]
672
+ )
673
+ logger.info("✅ AgentCore tools loaded")
674
+ except Exception as e:
675
+ logger.debug(f"AgentCore tools unavailable: {e}")
676
+ else:
677
+ logger.info(
678
+ "⏭️ AgentCore tools disabled (DEVDUCK_DISABLE_AGENTCORE_TOOLS=true)"
679
+ )
680
+
681
+ return tools
682
+
683
+ def _select_model(self):
684
+ """
685
+ Smart model selection with fallback: Bedrock → MLX → Ollama
686
+
687
+ Returns:
688
+ Tuple of (model_instance, model_name)
689
+ """
491
690
  provider = os.getenv("MODEL_PROVIDER")
492
691
 
493
692
  if not provider:
494
693
  # Auto-detect: Bedrock → MLX → Ollama
495
694
  try:
695
+ # Try Bedrock if AWS credentials available
696
+ import boto3
697
+
496
698
  boto3.client("sts").get_caller_identity()
497
699
  provider = "bedrock"
498
700
  print("🦆 Using Bedrock")
499
701
  except:
500
- if self.os == "Darwin" and self.arch in ["arm64", "aarch64"]:
702
+ # Try MLX on Apple Silicon
703
+ if platform.system() == "Darwin" and platform.machine() in [
704
+ "arm64",
705
+ "aarch64",
706
+ ]:
501
707
  try:
502
708
  from strands_mlx import MLXModel
503
709
 
504
710
  provider = "mlx"
505
- self.model = "mlx-community/Qwen3-1.7B-4bit"
506
711
  print("🦆 Using MLX")
507
712
  except ImportError:
508
713
  provider = "ollama"
@@ -511,26 +716,43 @@ class DevDuck:
511
716
  provider = "ollama"
512
717
  print("🦆 Using Ollama")
513
718
 
514
- # Create model
719
+ # Create model based on provider
515
720
  if provider == "mlx":
516
721
  from strands_mlx import MLXModel
517
722
 
518
- return MLXModel(model_id=self.model, temperature=1)
723
+ model_name = "mlx-community/Qwen3-1.7B-4bit"
724
+ return MLXModel(model_id=model_name, temperature=1), model_name
725
+
519
726
  elif provider == "ollama":
520
727
  from strands.models.ollama import OllamaModel
521
728
 
522
- return OllamaModel(
523
- host="http://localhost:11434",
524
- model_id=self.model,
525
- temperature=1,
526
- keep_alive="5m",
729
+ os_type = platform.system()
730
+ if os_type == "Darwin":
731
+ model_name = "qwen3:1.7b"
732
+ elif os_type == "Linux":
733
+ model_name = "qwen3:30b"
734
+ else:
735
+ model_name = "qwen3:8b"
736
+
737
+ return (
738
+ OllamaModel(
739
+ host="http://localhost:11434",
740
+ model_id=model_name,
741
+ temperature=1,
742
+ keep_alive="5m",
743
+ ),
744
+ model_name,
527
745
  )
746
+
528
747
  else:
748
+ # Bedrock or other providers via create_model
529
749
  from strands_tools.utils.models.model import create_model
530
750
 
531
- return create_model(provider=provider)
751
+ model = create_model(provider=provider)
752
+ model_name = os.getenv("STRANDS_MODEL_ID", "bedrock")
753
+ return model, model_name
532
754
 
533
- def _build_prompt(self):
755
+ def _build_system_prompt(self):
534
756
  """Build adaptive system prompt based on environment
535
757
 
536
758
  IMPORTANT: The system prompt includes the agent's complete source code.
@@ -540,82 +762,83 @@ class DevDuck:
540
762
 
541
763
  Learning: Always check source code truth over conversation memory!
542
764
  """
765
+ # Current date and time
766
+ current_datetime = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
767
+ current_date = datetime.now().strftime("%A, %B %d, %Y")
768
+ current_time = datetime.now().strftime("%I:%M %p")
769
+
770
+ session_id = f"devduck-{datetime.now().strftime('%Y-%m-%d')}"
771
+
772
+ # Get own file path for self-modification awareness
773
+ own_file_path = Path(__file__).resolve()
774
+
775
+ # Get own source code for self-awareness
543
776
  own_code = get_own_source_code()
544
- recent_context = get_last_messages()
545
- recent_logs = get_recent_logs()
546
777
 
547
- # Detect if using Bedrock for AgentCore documentation
548
- provider = os.getenv("MODEL_PROVIDER", "")
549
- is_bedrock = provider == "bedrock" or "bedrock" in provider.lower()
778
+ # Get recent conversation history context (with error handling)
550
779
  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
- """
780
+ recent_context = get_last_messages()
781
+ except Exception as e:
782
+ print(f"🦆 Warning: Could not load history context: {e}")
783
+ recent_context = ""
784
+
785
+ # Get recent logs for immediate visibility
786
+ try:
787
+ recent_logs = get_recent_logs()
788
+ except Exception as e:
789
+ print(f"🦆 Warning: Could not load recent logs: {e}")
790
+ recent_logs = ""
586
791
 
587
- return f"""🦆 DevDuck - self-adapting agent
792
+ return f"""🦆 You are DevDuck - an extreme minimalist, self-adapting agent.
588
793
 
589
- Environment: {self.os} {self.arch}
794
+ Environment: {self.env_info['os']} {self.env_info['arch']}
795
+ Python: {self.env_info['python']}
590
796
  Model: {self.model}
591
- CWD: {Path.cwd()}
797
+ Hostname: {self.env_info['hostname']}
798
+ Session ID: {session_id}
799
+ Current Time: {current_datetime} ({current_date} at {current_time})
800
+ My Path: {own_file_path}
592
801
 
593
802
  You are:
594
803
  - Minimalist: Brief, direct responses
804
+ - Self-healing: Adapt when things break
595
805
  - Efficient: Get things done fast
596
806
  - Pragmatic: Use what works
597
807
 
808
+ Current working directory: {self.env_info['cwd']}
809
+
598
810
  {recent_context}
599
811
  {recent_logs}
600
- {agentcore_docs}
601
-
602
- ## Your Code
603
812
 
813
+ ## Your Own Implementation:
604
814
  You have full access to your own source code for self-awareness and self-modification:
605
- ---
815
+
606
816
  {own_code}
607
- ---
608
817
 
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
818
+ ## Hot Reload System Active:
819
+ - **Instant Tool Creation** - Save any .py file in `./tools/` and it becomes immediately available
820
+ - **No Restart Needed** - Tools are auto-loaded and ready to use instantly
821
+ - **Live Development** - Modify existing tools while running and test immediately
822
+ - **Full Python Access** - Create any Python functionality as a tool
823
+ - **Agent Protection** - Hot-reload waits until agent finishes current task
824
+
825
+ ## Dynamic Tool Loading:
826
+ - **Install Tools** - Use install_tools() to load tools from any Python package
827
+ - Example: install_tools(action="install_and_load", package="strands-fun-tools", module="strands_fun_tools")
828
+ - Expands capabilities without restart
829
+ - Access to entire Python ecosystem
613
830
 
614
831
  ## Tool Configuration:
615
832
  Set DEVDUCK_TOOLS for custom tools:
616
833
  - Format: package:tool1,tool2:package2:tool3
617
834
  - Example: strands_tools:shell,editor:strands_fun_tools:clipboard
618
- - Static tools always loaded: tcp, websocket, ipc, mcp_server, agentcore_*
835
+ - Tools are filtered - only specified tools are loaded
836
+
837
+ ## MCP Server:
838
+ - **Expose as MCP Server** - Use mcp_server() to expose devduck via MCP protocol
839
+ - Example: mcp_server(action="start", port=8000)
840
+ - Connect from Claude Desktop, other agents, or custom clients
841
+ - Full bidirectional communication
619
842
 
620
843
  ## Knowledge Base Integration:
621
844
  - **Automatic RAG** - Set DEVDUCK_KNOWLEDGE_BASE_ID to enable automatic retrieval/storage
@@ -624,293 +847,472 @@ Set DEVDUCK_TOOLS for custom tools:
624
847
  - Seamless memory across sessions without manual tool calls
625
848
 
626
849
  ## 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
850
+ - **View**: system_prompt(action='view') - See current prompt
851
+ - **Update Local**: system_prompt(action='update', prompt='new text') - Updates env var + .prompt file
852
+ - **Update GitHub**: system_prompt(action='update', prompt='text', repository='cagataycali/devduck') - Syncs to repo variables
853
+ - **Variable Name**: system_prompt(action='update', prompt='text', variable_name='CUSTOM_PROMPT') - Use custom var
854
+ - **Add Context**: system_prompt(action='add_context', context='new learning') - Append without replacing
855
+
856
+ ### 🧠 Self-Improvement Pattern:
857
+ When you learn something valuable during conversations:
858
+ 1. Identify the new insight or pattern
859
+ 2. Use system_prompt(action='add_context', context='...') to append it
860
+ 3. Sync to GitHub: system_prompt(action='update', prompt=new_full_prompt, repository='owner/repo')
861
+ 4. New learnings persist across sessions via SYSTEM_PROMPT env var
862
+
863
+ **Repository Integration**: Set repository='cagataycali/devduck' to sync prompts across deployments
630
864
 
631
865
  ## Shell Commands:
632
- - Prefix with ! to run shell commands
633
- - Example: ! ls -la
866
+ - Prefix with ! to execute shell commands directly
867
+ - Example: ! ls -la (lists files)
868
+ - Example: ! pwd (shows current directory)
634
869
 
635
- Response: MINIMAL WORDS, MAX PARALLELISM
870
+ **Response Format:**
871
+ - Tool calls: **MAXIMUM PARALLELISM - ALWAYS**
872
+ - Communication: **MINIMAL WORDS**
873
+ - Efficiency: **Speed is paramount**
636
874
 
637
- ## Tool Building Guide:
875
+ {os.getenv('SYSTEM_PROMPT', '')}"""
638
876
 
639
- ### **@tool Decorator (Recommended):**
640
- ```python
641
- # ./tools/my_tool.py
642
- from strands import tool
877
+ def _self_heal(self, error):
878
+ """Attempt self-healing when errors occur"""
879
+ logger.error(f"Self-healing triggered by error: {error}")
880
+ print(f"🦆 Self-healing from: {error}")
643
881
 
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
882
+ # Prevent infinite recursion by tracking heal attempts
883
+ if not hasattr(self, "_heal_count"):
884
+ self._heal_count = 0
663
885
 
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
- ```
886
+ self._heal_count += 1
682
887
 
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
888
+ # Limit recursion - if we've tried more than 3 times, give up
889
+ if self._heal_count > 2:
890
+ print(f"🦆 Self-healing failed after {self._heal_count} attempts")
891
+ print("🦆 Please fix the issue manually and restart")
892
+ sys.exit(1)
690
893
 
691
- {os.getenv('SYSTEM_PROMPT', '')}"""
894
+ elif "connection" in str(error).lower():
895
+ print("🦆 Connection issue - checking ollama service...")
896
+ try:
897
+ subprocess.run(["ollama", "serve"], check=False, timeout=2)
898
+ except:
899
+ pass
692
900
 
693
- def _view_logs_impl(self, action, lines, pattern):
694
- """Implementation of view_logs tool"""
901
+ # Retry initialization
695
902
  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
- }
903
+ self.__init__()
904
+ except Exception as e2:
905
+ print(f"🦆 Self-heal failed: {e2}")
906
+ print("🦆 Running in minimal mode...")
907
+ self.agent = None
908
+
909
+ def _is_port_available(self, port):
910
+ """Check if a port is available"""
911
+ try:
912
+ test_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
913
+ test_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
914
+ test_socket.bind(("0.0.0.0", port))
915
+ test_socket.close()
916
+ return True
917
+ except OSError:
918
+ return False
919
+
920
+ def _is_socket_available(self, socket_path):
921
+ """Check if a Unix socket is available"""
922
+ import os
923
+
924
+ # If socket file doesn't exist, it's available
925
+ if not os.path.exists(socket_path):
926
+ return True
927
+ # If it exists, try to connect to see if it's in use
928
+ try:
929
+ test_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
930
+ test_socket.connect(socket_path)
931
+ test_socket.close()
932
+ return False # Socket is in use
933
+ except (ConnectionRefusedError, FileNotFoundError):
934
+ # Socket file exists but not in use - remove stale socket
935
+ try:
936
+ os.remove(socket_path)
937
+ return True
938
+ except:
939
+ return False
940
+ except Exception:
941
+ return False
942
+
943
+ def _find_available_port(self, start_port, max_attempts=10):
944
+ """Find an available port starting from start_port"""
945
+ for offset in range(max_attempts):
946
+ port = start_port + offset
947
+ if self._is_port_available(port):
948
+ return port
949
+ return None
708
950
 
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
- }
951
+ def _find_available_socket(self, base_socket_path, max_attempts=10):
952
+ """Find an available socket path"""
953
+ if self._is_socket_available(base_socket_path):
954
+ return base_socket_path
955
+ # Try numbered alternatives
956
+ for i in range(1, max_attempts):
957
+ alt_socket = f"{base_socket_path}.{i}"
958
+ if self._is_socket_available(alt_socket):
959
+ return alt_socket
960
+ return None
732
961
 
733
- elif action == "clear":
734
- if LOG_FILE.exists():
735
- LOG_FILE.unlink()
736
- return {"status": "success", "content": [{"text": "Logs cleared"}]}
962
+ def _start_servers(self):
963
+ """Auto-start configured servers with port conflict handling"""
964
+ logger.info("Auto-starting servers...")
965
+ print("🦆 Auto-starting servers...")
737
966
 
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}"}]}
967
+ # Start servers in order: IPC, TCP, WS, MCP
968
+ server_order = ["ipc", "tcp", "ws", "mcp"]
745
969
 
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}")
970
+ for server_type in server_order:
971
+ if server_type not in self.servers:
972
+ continue
754
973
 
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}")
974
+ config = self.servers[server_type]
761
975
 
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}")
976
+ # Check if server is enabled
977
+ if not config.get("enabled", True):
978
+ continue
979
+
980
+ # Check for LOOKUP_KEY (conditional start based on env var)
981
+ if "LOOKUP_KEY" in config:
982
+ lookup_key = config["LOOKUP_KEY"]
983
+ if not os.getenv(lookup_key):
984
+ logger.info(f"Skipping {server_type} - {lookup_key} not set")
985
+ continue
774
986
 
775
- if self.enable_ipc:
987
+ # Start the server with port conflict handling
776
988
  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}")
989
+ if server_type == "tcp":
990
+ port = config.get("port", 9999)
991
+
992
+ # Check port availability BEFORE attempting to start
993
+ if not self._is_port_available(port):
994
+ alt_port = self._find_available_port(port + 1)
995
+ if alt_port:
996
+ logger.info(f"Port {port} in use, using {alt_port}")
997
+ print(f"🦆 Port {port} in use, using {alt_port}")
998
+ port = alt_port
999
+ else:
1000
+ logger.warning(f"No available ports found for TCP server")
1001
+ continue
781
1002
 
782
- def _start_hot_reload(self):
783
- """Start hot-reload file watcher"""
1003
+ result = self.agent.tool.tcp(action="start_server", port=port)
784
1004
 
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
1005
+ if result.get("status") == "success":
1006
+ logger.info(f"✓ TCP server started on port {port}")
1007
+ print(f"🦆 TCP server: localhost:{port}")
1008
+
1009
+ elif server_type == "ws":
1010
+ port = config.get("port", 8080)
790
1011
 
791
- def watcher_thread():
792
- import time
1012
+ # Check port availability BEFORE attempting to start
1013
+ if not self._is_port_available(port):
1014
+ alt_port = self._find_available_port(port + 1)
1015
+ if alt_port:
1016
+ logger.info(f"Port {port} in use, using {alt_port}")
1017
+ print(f"🦆 Port {port} in use, using {alt_port}")
1018
+ port = alt_port
1019
+ else:
1020
+ logger.warning(
1021
+ f"No available ports found for WebSocket server"
1022
+ )
1023
+ continue
793
1024
 
794
- last_reload = 0
795
- debounce = 3 # seconds
1025
+ result = self.agent.tool.websocket(action="start_server", port=port)
796
1026
 
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()
1027
+ if result.get("status") == "success":
1028
+ logger.info(f"✓ WebSocket server started on port {port}")
1029
+ print(f"🦆 ✓ WebSocket server: localhost:{port}")
1030
+
1031
+ elif server_type == "mcp":
1032
+ port = config.get("port", 8000)
1033
+
1034
+ # Check port availability BEFORE attempting to start
1035
+ if not self._is_port_available(port):
1036
+ alt_port = self._find_available_port(port + 1)
1037
+ if alt_port:
1038
+ logger.info(f"Port {port} in use, using {alt_port}")
1039
+ print(f"🦆 Port {port} in use, using {alt_port}")
1040
+ port = alt_port
818
1041
  else:
819
- self._last_modified = mtime
820
- except Exception as e:
821
- logger.error(f"File watcher error: {e}")
1042
+ logger.warning(f"No available ports found for MCP server")
1043
+ continue
822
1044
 
823
- time.sleep(1)
1045
+ result = self.agent.tool.mcp_server(
1046
+ action="start",
1047
+ transport="http",
1048
+ port=port,
1049
+ expose_agent=True,
1050
+ agent=self.agent,
1051
+ )
824
1052
 
825
- thread = threading.Thread(target=watcher_thread, daemon=True)
826
- thread.start()
827
- logger.info(f"Hot-reload watching: {self._watch_file}")
1053
+ if result.get("status") == "success":
1054
+ logger.info(f"✓ MCP HTTP server started on port {port}")
1055
+ print(f"🦆 ✓ MCP server: http://localhost:{port}/mcp")
1056
+
1057
+ elif server_type == "ipc":
1058
+ socket_path = config.get("socket_path", "/tmp/devduck_main.sock")
1059
+
1060
+ # Check socket availability BEFORE attempting to start
1061
+ available_socket = self._find_available_socket(socket_path)
1062
+ if not available_socket:
1063
+ logger.warning(
1064
+ f"No available socket paths found for IPC server"
1065
+ )
1066
+ continue
1067
+
1068
+ if available_socket != socket_path:
1069
+ logger.info(
1070
+ f"Socket {socket_path} in use, using {available_socket}"
1071
+ )
1072
+ print(
1073
+ f"🦆 Socket {socket_path} in use, using {available_socket}"
1074
+ )
1075
+ socket_path = available_socket
1076
+
1077
+ result = self.agent.tool.ipc(
1078
+ action="start_server", socket_path=socket_path
1079
+ )
828
1080
 
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)
1081
+ if result.get("status") == "success":
1082
+ logger.info(f" IPC server started on {socket_path}")
1083
+ print(f"🦆 ✓ IPC server: {socket_path}")
1084
+ # TODO: support custom file path here so we can trigger foreign python function like another file
1085
+ except Exception as e:
1086
+ logger.error(f"Failed to start {server_type} server: {e}")
1087
+ print(f"🦆 ⚠ {server_type.upper()} server failed: {e}")
834
1088
 
835
1089
  def __call__(self, query):
836
- """Call agent with KB integration"""
1090
+ """Make the agent callable with automatic knowledge base integration"""
837
1091
  if not self.agent:
838
- return "🦆 Agent unavailable"
1092
+ logger.warning("Agent unavailable - attempted to call with query")
1093
+ return "🦆 Agent unavailable - try: devduck.restart()"
839
1094
 
840
1095
  try:
1096
+ logger.info(f"Agent call started: {query[:100]}...")
1097
+ # Mark agent as executing to prevent hot-reload interruption
841
1098
  self._agent_executing = True
842
1099
 
843
- # KB retrieval
844
- kb_id = os.getenv("DEVDUCK_KNOWLEDGE_BASE_ID")
845
- if kb_id:
1100
+ # 📚 Knowledge Base Retrieval (BEFORE agent runs)
1101
+ knowledge_base_id = os.getenv("DEVDUCK_KNOWLEDGE_BASE_ID")
1102
+ if knowledge_base_id and hasattr(self.agent, "tool"):
846
1103
  try:
847
- self.agent.tool.retrieve(text=query, knowledgeBaseId=kb_id)
848
- except:
849
- pass
1104
+ if "retrieve" in self.agent.tool_names:
1105
+ logger.info(f"Retrieving context from KB: {knowledge_base_id}")
1106
+ self.agent.tool.retrieve(
1107
+ text=query, knowledgeBaseId=knowledge_base_id
1108
+ )
1109
+ except Exception as e:
1110
+ logger.warning(f"KB retrieval failed: {e}")
850
1111
 
851
- # Run agent
1112
+ # Run the agent
852
1113
  result = self.agent(query)
853
1114
 
854
- # KB storage
855
- if kb_id:
1115
+ # 💾 Knowledge Base Storage (AFTER agent runs)
1116
+ if knowledge_base_id and hasattr(self.agent, "tool"):
856
1117
  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
1118
+ if "store_in_kb" in self.agent.tool_names:
1119
+ conversation_content = f"Input: {query}, Result: {result!s}"
1120
+ conversation_title = f"DevDuck: {datetime.now().strftime('%Y-%m-%d')} | {query[:500]}"
1121
+ self.agent.tool.store_in_kb(
1122
+ content=conversation_content,
1123
+ title=conversation_title,
1124
+ knowledge_base_id=knowledge_base_id,
1125
+ )
1126
+ logger.info(f"Stored conversation in KB: {knowledge_base_id}")
1127
+ except Exception as e:
1128
+ logger.warning(f"KB storage failed: {e}")
864
1129
 
1130
+ # Clear executing flag
865
1131
  self._agent_executing = False
866
1132
 
867
- # Check for pending reload
1133
+ # Check for pending hot-reload
868
1134
  if self._reload_pending:
869
- print("🦆 Agent finished - triggering pending reload")
1135
+ logger.info("Triggering pending hot-reload after agent completion")
1136
+ print("🦆 Agent finished - triggering pending hot-reload...")
870
1137
  self._hot_reload()
871
1138
 
872
1139
  return result
873
1140
  except Exception as e:
874
- self._agent_executing = False
875
- logger.error(f"Agent call failed: {e}")
876
- return f"🦆 Error: {e}"
1141
+ self._agent_executing = False # Reset flag on error
1142
+ logger.error(f"Agent call failed with error: {e}")
1143
+ self._self_heal(e)
1144
+ if self.agent:
1145
+ return self.agent(query)
1146
+ else:
1147
+ return f"🦆 Error: {e}"
1148
+
1149
+ def restart(self):
1150
+ """Restart the agent"""
1151
+ print("🦆 Restarting...")
1152
+ self.__init__()
1153
+
1154
+ def _start_file_watcher(self):
1155
+ """Start background file watcher for auto hot-reload"""
1156
+ import threading
1157
+
1158
+ logger.info("Starting file watcher for hot-reload")
1159
+ # Get the path to this file
1160
+ self._watch_file = Path(__file__).resolve()
1161
+ self._last_modified = (
1162
+ self._watch_file.stat().st_mtime if self._watch_file.exists() else None
1163
+ )
1164
+ self._watcher_running = True
1165
+ self._is_reloading = False
1166
+
1167
+ # Start watcher thread
1168
+ self._watcher_thread = threading.Thread(
1169
+ target=self._file_watcher_thread, daemon=True
1170
+ )
1171
+ self._watcher_thread.start()
1172
+ logger.info(f"File watcher started, monitoring {self._watch_file}")
1173
+
1174
+ def _file_watcher_thread(self):
1175
+ """Background thread that watches for file changes"""
1176
+ last_reload_time = 0
1177
+ debounce_seconds = 3 # 3 second debounce
1178
+
1179
+ while self._watcher_running:
1180
+ try:
1181
+ # Skip if currently reloading
1182
+ if self._is_reloading:
1183
+ time.sleep(1)
1184
+ continue
1185
+
1186
+ if self._watch_file.exists():
1187
+ current_mtime = self._watch_file.stat().st_mtime
1188
+ current_time = time.time()
1189
+
1190
+ # Check if file was modified AND debounce period has passed
1191
+ if (
1192
+ self._last_modified
1193
+ and current_mtime > self._last_modified
1194
+ and current_time - last_reload_time > debounce_seconds
1195
+ ):
1196
+ print(f"🦆 Detected changes in {self._watch_file.name}!")
1197
+ last_reload_time = current_time
1198
+
1199
+ # Check if agent is currently executing
1200
+ if self._agent_executing:
1201
+ logger.info(
1202
+ "Code change detected but agent is executing - reload pending"
1203
+ )
1204
+ print(
1205
+ "🦆 Agent is currently executing - reload will trigger after completion"
1206
+ )
1207
+ self._reload_pending = True
1208
+ # Don't update _last_modified yet - keep detecting the change
1209
+ else:
1210
+ # Safe to reload immediately
1211
+ self._last_modified = current_mtime
1212
+ logger.info(
1213
+ f"Code change detected in {self._watch_file.name} - triggering hot-reload"
1214
+ )
1215
+ time.sleep(
1216
+ 0.5
1217
+ ) # Small delay to ensure file write is complete
1218
+ self._hot_reload()
1219
+ else:
1220
+ # Update timestamp if no change or still in debounce
1221
+ if not self._reload_pending:
1222
+ self._last_modified = current_mtime
1223
+
1224
+ except Exception as e:
1225
+ logger.error(f"File watcher error: {e}")
1226
+
1227
+ # Check every 1 second
1228
+ time.sleep(1)
1229
+
1230
+ def _stop_file_watcher(self):
1231
+ """Stop the file watcher"""
1232
+ self._watcher_running = False
1233
+ logger.info("File watcher stopped")
1234
+
1235
+ def _hot_reload(self):
1236
+ """Hot-reload by restarting the entire Python process with fresh code"""
1237
+ logger.info("Hot-reload initiated")
1238
+ print("🦆 Hot-reloading via process restart...")
1239
+
1240
+ try:
1241
+ # Set reload flag to prevent recursive reloads during shutdown
1242
+ self._is_reloading = True
1243
+
1244
+ # Update last_modified before reload to acknowledge the change
1245
+ if hasattr(self, "_watch_file") and self._watch_file.exists():
1246
+ self._last_modified = self._watch_file.stat().st_mtime
877
1247
 
1248
+ # Reset pending flag
1249
+ self._reload_pending = False
878
1250
 
879
- # Initialize
1251
+ # Stop the file watcher
1252
+ if hasattr(self, "_watcher_running"):
1253
+ self._watcher_running = False
1254
+
1255
+ print("🦆 Restarting process with fresh code...")
1256
+
1257
+ # Restart the entire Python process
1258
+ # This ensures all code is freshly loaded
1259
+ os.execv(sys.executable, [sys.executable] + sys.argv)
1260
+
1261
+ except Exception as e:
1262
+ logger.error(f"Hot-reload failed: {e}")
1263
+ print(f"🦆 Hot-reload failed: {e}")
1264
+ print("🦆 Falling back to manual restart")
1265
+ self._is_reloading = False
1266
+
1267
+ def status(self):
1268
+ """Show current status"""
1269
+ return {
1270
+ "model": self.model,
1271
+ "env": self.env_info,
1272
+ "agent_ready": self.agent is not None,
1273
+ "tools": len(self.tools) if hasattr(self, "tools") else 0,
1274
+ "file_watcher": {
1275
+ "enabled": hasattr(self, "_watcher_running") and self._watcher_running,
1276
+ "watching": (
1277
+ str(self._watch_file) if hasattr(self, "_watch_file") else None
1278
+ ),
1279
+ },
1280
+ }
1281
+
1282
+
1283
+ # 🦆 Auto-initialize when imported
880
1284
  # Check environment variables to control server configuration
1285
+ # Also check if --mcp flag is present to skip auto-starting servers
881
1286
  _auto_start = os.getenv("DEVDUCK_AUTO_START_SERVERS", "true").lower() == "true"
882
1287
 
883
1288
  # Disable auto-start if --mcp flag is present (stdio mode)
884
1289
  if "--mcp" in sys.argv:
885
1290
  _auto_start = False
886
1291
 
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
- )
1292
+ devduck = DevDuck(auto_start_servers=_auto_start)
907
1293
 
908
1294
 
1295
+ # 🚀 Convenience functions
909
1296
  def ask(query):
910
- """Quick query"""
1297
+ """Quick query interface"""
911
1298
  return devduck(query)
912
1299
 
913
1300
 
1301
+ def status():
1302
+ """Quick status check"""
1303
+ return devduck.status()
1304
+
1305
+
1306
+ def restart():
1307
+ """Quick restart"""
1308
+ devduck.restart()
1309
+
1310
+
1311
+ def hot_reload():
1312
+ """Quick hot-reload without restart"""
1313
+ devduck._hot_reload()
1314
+
1315
+
914
1316
  def extract_commands_from_history():
915
1317
  """Extract commonly used commands from shell history for auto-completion."""
916
1318
  commands = set()
@@ -984,8 +1386,7 @@ def extract_commands_from_history():
984
1386
 
985
1387
 
986
1388
  def interactive():
987
- """Interactive REPL with history"""
988
- import time
1389
+ """Interactive REPL mode for devduck"""
989
1390
  from prompt_toolkit import prompt
990
1391
  from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
991
1392
  from prompt_toolkit.completion import WordCompleter
@@ -993,10 +1394,14 @@ def interactive():
993
1394
 
994
1395
  print("🦆 DevDuck")
995
1396
  print(f"📝 Logs: {LOG_DIR}")
996
- print("Type 'exit' to quit. Prefix with ! for shell commands.")
1397
+ print("Type 'exit', 'quit', or 'q' to quit.")
1398
+ print("Prefix with ! to run shell commands (e.g., ! ls -la)")
997
1399
  print("-" * 50)
1400
+ logger.info("Interactive mode started")
998
1401
 
999
- history = FileHistory(get_shell_history_file())
1402
+ # Set up prompt_toolkit with history
1403
+ history_file = get_shell_history_file()
1404
+ history = FileHistory(history_file)
1000
1405
 
1001
1406
  # Create completions from common commands and shell history
1002
1407
  base_commands = ["exit", "quit", "q", "help", "clear", "status", "reload"]
@@ -1012,42 +1417,61 @@ def interactive():
1012
1417
 
1013
1418
  while True:
1014
1419
  try:
1420
+ # Use prompt_toolkit for enhanced input with arrow key support
1015
1421
  q = prompt(
1016
1422
  "\n🦆 ",
1017
1423
  history=history,
1018
1424
  auto_suggest=AutoSuggestFromHistory(),
1019
1425
  completer=completer,
1020
1426
  complete_while_typing=True,
1021
- mouse_support=False,
1022
- ).strip()
1427
+ mouse_support=False, # breaks scrolling when enabled
1428
+ )
1023
1429
 
1024
1430
  # Reset interrupt count on successful prompt
1025
1431
  interrupt_count = 0
1026
1432
 
1433
+ # Check for exit command
1027
1434
  if q.lower() in ["exit", "quit", "q"]:
1435
+ print("\n🦆 Goodbye!")
1028
1436
  break
1029
1437
 
1030
- if not q:
1438
+ # Skip empty inputs
1439
+ if q.strip() == "":
1031
1440
  continue
1032
1441
 
1033
- # Shell commands
1442
+ # Handle shell commands with ! prefix
1034
1443
  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()
1444
+ shell_command = q[1:].strip()
1445
+ try:
1446
+ if devduck.agent:
1447
+ devduck._agent_executing = (
1448
+ True # Prevent hot-reload during shell execution
1449
+ )
1450
+ result = devduck.agent.tool.shell(
1451
+ command=shell_command, timeout=9000
1452
+ )
1453
+ devduck._agent_executing = False
1454
+
1455
+ # Append shell command to history
1456
+ append_to_shell_history(q, result["content"][0]["text"])
1457
+
1458
+ # Check if reload was pending
1459
+ if devduck._reload_pending:
1460
+ print(
1461
+ "🦆 Shell command finished - triggering pending hot-reload..."
1462
+ )
1463
+ devduck._hot_reload()
1464
+ else:
1465
+ print("🦆 Agent unavailable")
1466
+ except Exception as e:
1467
+ devduck._agent_executing = False # Reset on error
1468
+ print(f"🦆 Shell command error: {e}")
1046
1469
  continue
1047
1470
 
1048
- # Agent query
1471
+ # Execute the agent with user input
1049
1472
  result = ask(q)
1050
- print(result)
1473
+
1474
+ # Append to shell history
1051
1475
  append_to_shell_history(q, str(result))
1052
1476
 
1053
1477
  except KeyboardInterrupt:
@@ -1066,12 +1490,14 @@ def interactive():
1066
1490
  print("\n🦆 Interrupted. Press Ctrl+C again to exit.")
1067
1491
 
1068
1492
  last_interrupt = current_time
1493
+ continue
1069
1494
  except Exception as e:
1070
1495
  print(f"🦆 Error: {e}")
1496
+ continue
1071
1497
 
1072
1498
 
1073
1499
  def cli():
1074
- """CLI entry point"""
1500
+ """CLI entry point for pip-installed devduck command"""
1075
1501
  import argparse
1076
1502
 
1077
1503
  parser = argparse.ArgumentParser(
@@ -1079,16 +1505,14 @@ def cli():
1079
1505
  formatter_class=argparse.RawDescriptionHelpFormatter,
1080
1506
  epilog="""
1081
1507
  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
1508
+ devduck # Start interactive mode
1509
+ devduck "your query here" # One-shot query
1510
+ devduck --mcp # MCP stdio mode (for Claude Desktop)
1087
1511
 
1088
1512
  Tool Configuration:
1089
1513
  export DEVDUCK_TOOLS="strands_tools:shell,editor:strands_fun_tools:clipboard"
1090
1514
 
1091
- MCP Config:
1515
+ Claude Desktop Config:
1092
1516
  {
1093
1517
  "mcpServers": {
1094
1518
  "devduck": {
@@ -1100,72 +1524,64 @@ MCP Config:
1100
1524
  """,
1101
1525
  )
1102
1526
 
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
- )
1527
+ # Query argument
1528
+ parser.add_argument("query", nargs="*", help="Query to send to the agent")
1128
1529
 
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")
1530
+ # MCP stdio mode flag
1134
1531
  parser.add_argument(
1135
- "--no-servers",
1532
+ "--mcp",
1136
1533
  action="store_true",
1137
- help="Disable all servers (no TCP, WebSocket, MCP, or IPC)",
1534
+ help="Start MCP server in stdio mode (for Claude Desktop integration)",
1138
1535
  )
1139
1536
 
1140
1537
  args = parser.parse_args()
1141
1538
 
1539
+ logger.info("CLI mode started")
1540
+
1541
+ # Handle --mcp flag for stdio mode
1142
1542
  if args.mcp:
1543
+ logger.info("Starting MCP server in stdio mode (blocking, foreground)")
1143
1544
  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)
1545
+
1546
+ # Don't auto-start HTTP/TCP/WS servers for stdio mode
1547
+ if devduck.agent:
1548
+ try:
1549
+ # Start MCP server in stdio mode - this BLOCKS until terminated
1550
+ devduck.agent.tool.mcp_server(
1551
+ action="start",
1552
+ transport="stdio",
1553
+ expose_agent=True,
1554
+ agent=devduck.agent,
1555
+ )
1556
+ except Exception as e:
1557
+ logger.error(f"Failed to start MCP stdio server: {e}")
1558
+ print(f"🦆 Error: {e}", file=sys.stderr)
1559
+ sys.exit(1)
1560
+ else:
1561
+ print("🦆 Agent not available", file=sys.stderr)
1153
1562
  sys.exit(1)
1154
1563
  return
1155
1564
 
1156
1565
  if args.query:
1157
- result = ask(" ".join(args.query))
1566
+ query = " ".join(args.query)
1567
+ logger.info(f"CLI query: {query}")
1568
+ result = ask(query)
1158
1569
  print(result)
1159
1570
  else:
1571
+ # No arguments - start interactive mode
1160
1572
  interactive()
1161
1573
 
1162
1574
 
1163
- # Make module callable
1575
+ # 🦆 Make module directly callable: import devduck; devduck("query")
1164
1576
  class CallableModule(sys.modules[__name__].__class__):
1577
+ """Make the module itself callable"""
1578
+
1165
1579
  def __call__(self, query):
1580
+ """Allow direct module call: import devduck; devduck("query")"""
1166
1581
  return ask(query)
1167
1582
 
1168
1583
 
1584
+ # Replace module in sys.modules with callable version
1169
1585
  sys.modules[__name__].__class__ = CallableModule
1170
1586
 
1171
1587