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 +1226 -612
- devduck/_version.py +2 -2
- devduck/tools/__init__.py +3 -0
- devduck/tools/agentcore_config.py +1 -0
- devduck/tools/agentcore_invoke.py +1 -0
- devduck/tools/install_tools.py +103 -2
- devduck/tools/ipc.py +4 -1
- devduck/tools/state_manager.py +292 -0
- devduck/tools/tcp.py +6 -0
- devduck/tools/websocket.py +7 -1
- {devduck-0.5.4.dist-info → devduck-0.7.0.dist-info}/METADATA +158 -8
- {devduck-0.5.4.dist-info → devduck-0.7.0.dist-info}/RECORD +16 -15
- {devduck-0.5.4.dist-info → devduck-0.7.0.dist-info}/WHEEL +0 -0
- {devduck-0.5.4.dist-info → devduck-0.7.0.dist-info}/entry_points.txt +0 -0
- {devduck-0.5.4.dist-info → devduck-0.7.0.dist-info}/licenses/LICENSE +0 -0
- {devduck-0.5.4.dist-info → devduck-0.7.0.dist-info}/top_level.txt +0 -0
devduck/__init__.py
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
2
|
+
"""
|
|
3
|
+
🦆 devduck - extreme minimalist self-adapting agent
|
|
4
|
+
one file. self-healing. runtime dependencies. adaptive.
|
|
5
|
+
"""
|
|
3
6
|
import sys
|
|
4
|
-
import
|
|
7
|
+
import subprocess
|
|
5
8
|
import os
|
|
6
9
|
import platform
|
|
10
|
+
import socket
|
|
7
11
|
import logging
|
|
8
12
|
import tempfile
|
|
9
|
-
import
|
|
13
|
+
import time
|
|
14
|
+
import warnings
|
|
10
15
|
from pathlib import Path
|
|
11
16
|
from datetime import datetime
|
|
12
|
-
import
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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"#
|
|
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
|
-
|
|
46
|
-
if not
|
|
47
|
-
|
|
48
|
-
return str(
|
|
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
|
-
|
|
237
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
# 1. STATIC: Core DevDuck tools (always load)
|
|
586
|
+
logger.info("Initializing DevDuck agent...")
|
|
315
587
|
try:
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
-
|
|
375
|
-
|
|
699
|
+
# 🚀 AUTO-START SERVERS
|
|
700
|
+
if auto_start_servers and "--mcp" not in sys.argv:
|
|
701
|
+
self._start_servers()
|
|
376
702
|
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
706
|
+
logger.info(
|
|
707
|
+
f"DevDuck agent initialized successfully with model {self.model}"
|
|
708
|
+
)
|
|
385
709
|
|
|
386
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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
|
-
|
|
416
|
-
current_package = segment
|
|
750
|
+
logger.warning(f"Skipping segment '{segment}' - no package set")
|
|
417
751
|
|
|
418
|
-
logger.info(f"
|
|
419
|
-
return
|
|
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
|
|
433
|
-
"""
|
|
434
|
-
|
|
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
|
-
|
|
439
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
-
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
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
|
-
|
|
490
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
|
|
932
|
+
model = create_model(provider=provider)
|
|
933
|
+
model_name = os.getenv("STRANDS_MODEL_ID", "bedrock")
|
|
934
|
+
return model, model_name
|
|
532
935
|
|
|
533
|
-
def
|
|
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
|
-
#
|
|
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
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
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
|
|
611
|
-
-
|
|
612
|
-
-
|
|
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
|
-
-
|
|
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') -
|
|
628
|
-
- system_prompt(action='update', prompt='
|
|
629
|
-
- system_prompt(action='update', repository='
|
|
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
|
|
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
|
|
1059
|
+
**Response Format:**
|
|
1060
|
+
- Tool calls: **MAXIMUM PARALLELISM - ALWAYS**
|
|
1061
|
+
- Communication: **MINIMAL WORDS**
|
|
1062
|
+
- Efficiency: **Speed is paramount**
|
|
636
1063
|
|
|
637
|
-
|
|
1064
|
+
{os.getenv('SYSTEM_PROMPT', '')}"""
|
|
638
1065
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
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
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
-
|
|
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
|
-
|
|
694
|
-
"""Implementation of view_logs tool"""
|
|
1090
|
+
# Retry initialization
|
|
695
1091
|
try:
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
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
|
-
|
|
739
|
-
|
|
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
|
-
|
|
747
|
-
|
|
748
|
-
|
|
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
|
-
|
|
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
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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
|
-
|
|
1176
|
+
# Start the server with port conflict handling
|
|
776
1177
|
try:
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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
|
-
|
|
783
|
-
|
|
1192
|
+
result = self.agent.tool.tcp(
|
|
1193
|
+
action="start_server", port=port, record_direct_tool_call=False
|
|
1194
|
+
)
|
|
784
1195
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
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
|
-
|
|
792
|
-
|
|
1200
|
+
elif server_type == "ws":
|
|
1201
|
+
port = config.get("port", 8080)
|
|
793
1202
|
|
|
794
|
-
|
|
795
|
-
|
|
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
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
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
|
-
|
|
820
|
-
|
|
821
|
-
logger.error(f"File watcher error: {e}")
|
|
1235
|
+
logger.warning(f"No available ports found for MCP server")
|
|
1236
|
+
continue
|
|
822
1237
|
|
|
823
|
-
|
|
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
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
-
"""
|
|
1286
|
+
"""Make the agent callable with automatic knowledge base integration"""
|
|
837
1287
|
if not self.agent:
|
|
838
|
-
|
|
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
|
-
#
|
|
844
|
-
|
|
845
|
-
if
|
|
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.
|
|
848
|
-
|
|
849
|
-
|
|
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
|
-
#
|
|
855
|
-
if
|
|
1312
|
+
# 💾 Knowledge Base Storage (AFTER agent runs)
|
|
1313
|
+
if knowledge_base_id and hasattr(self.agent, "tool"):
|
|
856
1314
|
try:
|
|
857
|
-
self.agent.
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
|
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'
|
|
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
|
-
|
|
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
|
-
)
|
|
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
|
-
|
|
1635
|
+
# Skip empty inputs
|
|
1636
|
+
if q.strip() == "":
|
|
1031
1637
|
continue
|
|
1032
1638
|
|
|
1033
|
-
#
|
|
1639
|
+
# Handle shell commands with ! prefix
|
|
1034
1640
|
if q.startswith("!"):
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
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
|
-
#
|
|
1668
|
+
# Execute the agent with user input
|
|
1049
1669
|
result = ask(q)
|
|
1050
|
-
|
|
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 #
|
|
1083
|
-
devduck "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
|
-
|
|
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
|
-
|
|
1104
|
-
parser.add_argument("
|
|
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
|
-
#
|
|
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
|
-
"--
|
|
1729
|
+
"--mcp",
|
|
1136
1730
|
action="store_true",
|
|
1137
|
-
help="
|
|
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
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
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
|
-
|
|
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
|
|