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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


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

devduck/__init__.py CHANGED
@@ -183,6 +183,194 @@ Last Modified: {modified}"""
183
183
  return {"status": "error", "content": [{"text": f"Error: {str(e)}"}]}
184
184
 
185
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
+
186
374
  def get_shell_history_file():
187
375
  """Get the devduck-specific history file path."""
188
376
  devduck_history = Path.home() / ".devduck_history"
@@ -380,6 +568,7 @@ class DevDuck:
380
568
  self,
381
569
  auto_start_servers=True,
382
570
  servers=None,
571
+ load_mcp_servers=True,
383
572
  ):
384
573
  """Initialize the minimalist adaptive agent
385
574
 
@@ -392,6 +581,7 @@ class DevDuck:
392
581
  "mcp": {"port": 8000},
393
582
  "ipc": {"socket_path": "/tmp/devduck.sock"}
394
583
  }
584
+ load_mcp_servers: Load MCP servers from MCP_SERVERS env var
395
585
  """
396
586
  logger.info("Initializing DevDuck agent...")
397
587
  try:
@@ -441,14 +631,13 @@ class DevDuck:
441
631
 
442
632
  from strands import Agent, tool
443
633
 
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()
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)
452
641
 
453
642
  # Wrap view_logs_tool with @tool decorator
454
643
  @tool
@@ -460,23 +649,51 @@ class DevDuck:
460
649
  """View and manage DevDuck logs."""
461
650
  return view_logs_tool(action, lines, pattern)
462
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
+
463
663
  # Add built-in tools to the toolset
464
- core_tools.extend([view_logs])
664
+ core_tools.extend([view_logs, manage_tools])
465
665
 
466
666
  # Assign tools
467
667
  self.tools = core_tools
468
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
+
469
676
  logger.info(f"Initialized {len(self.tools)} tools")
470
677
 
471
678
  # 🎯 Smart model selection
472
679
  self.agent_model, self.model = self._select_model()
473
680
 
474
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"
685
+ )
686
+
475
687
  self.agent = Agent(
476
688
  model=self.agent_model,
477
689
  tools=self.tools,
478
690
  system_prompt=self._build_system_prompt(),
479
- load_tools_from_directory=True,
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
+ },
480
697
  )
481
698
 
482
699
  # 🚀 AUTO-START SERVERS
@@ -500,31 +717,22 @@ class DevDuck:
500
717
 
501
718
  Format: package:tool1,tool2:package2:tool3
502
719
  Example: strands_tools:shell,editor:strands_fun_tools:clipboard
720
+
721
+ Note: Only loads what's specified in config - no automatic additions
503
722
  """
504
723
  tools = []
505
-
506
- # Always load DevDuck core tools
507
- tools.extend(self._load_devduck_tools())
508
-
509
- # Parse and load configured tools
510
724
  current_package = None
511
725
 
512
726
  for segment in config.split(":"):
513
727
  segment = segment.strip()
514
728
 
515
- # Check if this segment is a package or tool list
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
- ):
523
- # Single tool from current package
524
- if current_package:
525
- tool = self._load_single_tool(current_package, segment)
526
- if tool:
527
- tools.append(tool)
729
+ # Check if segment is a package name (contains '.' or '_' and no ',')
730
+ is_package = "," not in segment and ("." in segment or "_" in segment)
731
+
732
+ if is_package:
733
+ # This is a package name - set as current package
734
+ current_package = segment
735
+ logger.debug(f"Switched to package: {current_package}")
528
736
  elif "," in segment:
529
737
  # Tool list from current package
530
738
  if current_package:
@@ -533,11 +741,15 @@ class DevDuck:
533
741
  tool = self._load_single_tool(current_package, tool_name)
534
742
  if tool:
535
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)
536
749
  else:
537
- # Package name
538
- current_package = segment
750
+ logger.warning(f"Skipping segment '{segment}' - no package set")
539
751
 
540
- logger.info(f"Loaded tools from DEVDUCK_TOOLS configuration")
752
+ logger.info(f"Loaded {len(tools)} tools from configuration")
541
753
  return tools
542
754
 
543
755
  def _load_single_tool(self, package, tool_name):
@@ -551,134 +763,103 @@ class DevDuck:
551
763
  logger.warning(f"Failed to load {tool_name} from {package}: {e}")
552
764
  return None
553
765
 
554
- def _load_default_tools(self):
555
- """Load default comprehensive tool set"""
556
- tools = []
766
+ def _load_mcp_servers(self):
767
+ """
768
+ Load MCP servers from MCP_SERVERS environment variable using direct loading.
557
769
 
558
- # Always load DevDuck core tools
559
- tools.extend(self._load_devduck_tools())
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 []
560
785
 
561
- # Load strands-agents-tools (essential)
562
786
  try:
563
- from strands_tools import (
564
- shell,
565
- editor,
566
- file_read,
567
- file_write,
568
- calculator,
569
- image_reader,
570
- use_agent,
571
- load_tool,
572
- environment,
573
- mcp_client,
574
- retrieve,
575
- )
787
+ config = json.loads(mcp_servers_json)
788
+ mcp_servers_config = config.get("mcpServers", {})
576
789
 
577
- tools.extend(
578
- [
579
- shell,
580
- editor,
581
- file_read,
582
- file_write,
583
- calculator,
584
- image_reader,
585
- use_agent,
586
- load_tool,
587
- environment,
588
- mcp_client,
589
- retrieve,
590
- ]
591
- )
592
- logger.info("✅ strands-agents-tools loaded")
593
- except ImportError:
594
- logger.info("strands-agents-tools unavailable")
790
+ if not mcp_servers_config:
791
+ logger.warning("MCP_SERVERS JSON has no 'mcpServers' key")
792
+ return []
595
793
 
596
- # Load strands-fun-tools (optional, skip in --mcp mode)
597
- if "--mcp" not in sys.argv:
598
- try:
599
- from strands_fun_tools import (
600
- listen,
601
- cursor,
602
- clipboard,
603
- screen_reader,
604
- yolo_vision,
605
- )
794
+ mcp_clients = []
606
795
 
607
- tools.extend([listen, cursor, clipboard, screen_reader, yolo_vision])
608
- logger.info("✅ strands-fun-tools loaded")
609
- except ImportError:
610
- logger.info("strands-fun-tools unavailable")
796
+ from strands.tools.mcp import MCPClient
797
+ from mcp import stdio_client, StdioServerParameters
798
+ from mcp.client.streamable_http import streamablehttp_client
799
+ from mcp.client.sse import sse_client
611
800
 
612
- return tools
801
+ for server_name, server_config in mcp_servers_config.items():
802
+ try:
803
+ logger.info(f"Loading MCP server: {server_name}")
804
+
805
+ # Determine transport type and create appropriate callable
806
+ if "command" in server_config:
807
+ # stdio transport
808
+ command = server_config["command"]
809
+ args = server_config.get("args", [])
810
+ env = server_config.get("env", None)
811
+
812
+ transport_callable = (
813
+ lambda cmd=command, a=args, e=env: stdio_client(
814
+ StdioServerParameters(command=cmd, args=a, env=e)
815
+ )
816
+ )
613
817
 
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
- )
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)
632
822
 
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}")
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
652
838
 
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
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
+ )
657
845
 
658
- boto3.client("sts").get_caller_identity()
846
+ mcp_clients.append(mcp_client)
847
+ logger.info(
848
+ f"✓ MCP server '{server_name}' loaded (prefix: {prefix})"
849
+ )
659
850
 
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
- )
851
+ except Exception as e:
852
+ logger.error(f"Failed to load MCP server '{server_name}': {e}")
853
+ continue
680
854
 
681
- return tools
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 []
682
863
 
683
864
  def _select_model(self):
684
865
  """
@@ -768,6 +949,7 @@ class DevDuck:
768
949
  current_time = datetime.now().strftime("%I:%M %p")
769
950
 
770
951
  session_id = f"devduck-{datetime.now().strftime('%Y-%m-%d')}"
952
+ self.session_id = session_id
771
953
 
772
954
  # Get own file path for self-modification awareness
773
955
  own_file_path = Path(__file__).resolve()
@@ -834,12 +1016,19 @@ Set DEVDUCK_TOOLS for custom tools:
834
1016
  - Example: strands_tools:shell,editor:strands_fun_tools:clipboard
835
1017
  - Tools are filtered - only specified tools are loaded
836
1018
 
837
- ## MCP Server:
1019
+ ## MCP Integration:
838
1020
  - **Expose as MCP Server** - Use mcp_server() to expose devduck via MCP protocol
839
1021
  - Example: mcp_server(action="start", port=8000)
840
1022
  - Connect from Claude Desktop, other agents, or custom clients
841
1023
  - Full bidirectional communication
842
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
1031
+
843
1032
  ## Knowledge Base Integration:
844
1033
  - **Automatic RAG** - Set DEVDUCK_KNOWLEDGE_BASE_ID to enable automatic retrieval/storage
845
1034
  - Before each query: Retrieves relevant context from knowledge base
@@ -1000,7 +1189,9 @@ When you learn something valuable during conversations:
1000
1189
  logger.warning(f"No available ports found for TCP server")
1001
1190
  continue
1002
1191
 
1003
- result = self.agent.tool.tcp(action="start_server", port=port)
1192
+ result = self.agent.tool.tcp(
1193
+ action="start_server", port=port, record_direct_tool_call=False
1194
+ )
1004
1195
 
1005
1196
  if result.get("status") == "success":
1006
1197
  logger.info(f"✓ TCP server started on port {port}")
@@ -1022,7 +1213,9 @@ When you learn something valuable during conversations:
1022
1213
  )
1023
1214
  continue
1024
1215
 
1025
- result = self.agent.tool.websocket(action="start_server", port=port)
1216
+ result = self.agent.tool.websocket(
1217
+ action="start_server", port=port, record_direct_tool_call=False
1218
+ )
1026
1219
 
1027
1220
  if result.get("status") == "success":
1028
1221
  logger.info(f"✓ WebSocket server started on port {port}")
@@ -1048,6 +1241,7 @@ When you learn something valuable during conversations:
1048
1241
  port=port,
1049
1242
  expose_agent=True,
1050
1243
  agent=self.agent,
1244
+ record_direct_tool_call=False,
1051
1245
  )
1052
1246
 
1053
1247
  if result.get("status") == "success":
@@ -1075,7 +1269,9 @@ When you learn something valuable during conversations:
1075
1269
  socket_path = available_socket
1076
1270
 
1077
1271
  result = self.agent.tool.ipc(
1078
- action="start_server", socket_path=socket_path
1272
+ action="start_server",
1273
+ socket_path=socket_path,
1274
+ record_direct_tool_call=False,
1079
1275
  )
1080
1276
 
1081
1277
  if result.get("status") == "success":
@@ -1094,6 +1290,7 @@ When you learn something valuable during conversations:
1094
1290
 
1095
1291
  try:
1096
1292
  logger.info(f"Agent call started: {query[:100]}...")
1293
+
1097
1294
  # Mark agent as executing to prevent hot-reload interruption
1098
1295
  self._agent_executing = True
1099
1296
 
@@ -1133,7 +1330,7 @@ When you learn something valuable during conversations:
1133
1330
  # Check for pending hot-reload
1134
1331
  if self._reload_pending:
1135
1332
  logger.info("Triggering pending hot-reload after agent completion")
1136
- print("🦆 Agent finished - triggering pending hot-reload...")
1333
+ print("\n🦆 Agent finished - triggering pending hot-reload...")
1137
1334
  self._hot_reload()
1138
1335
 
1139
1336
  return result
@@ -1148,7 +1345,7 @@ When you learn something valuable during conversations:
1148
1345
 
1149
1346
  def restart(self):
1150
1347
  """Restart the agent"""
1151
- print("🦆 Restarting...")
1348
+ print("\n🦆 Restarting...")
1152
1349
  self.__init__()
1153
1350
 
1154
1351
  def _start_file_watcher(self):
@@ -1193,7 +1390,7 @@ When you learn something valuable during conversations:
1193
1390
  and current_mtime > self._last_modified
1194
1391
  and current_time - last_reload_time > debounce_seconds
1195
1392
  ):
1196
- print(f"🦆 Detected changes in {self._watch_file.name}!")
1393
+ print(f"\n🦆 Detected changes in {self._watch_file.name}!")
1197
1394
  last_reload_time = current_time
1198
1395
 
1199
1396
  # Check if agent is currently executing
@@ -1202,7 +1399,7 @@ When you learn something valuable during conversations:
1202
1399
  "Code change detected but agent is executing - reload pending"
1203
1400
  )
1204
1401
  print(
1205
- "🦆 Agent is currently executing - reload will trigger after completion"
1402
+ "\n🦆 Agent is currently executing - reload will trigger after completion"
1206
1403
  )
1207
1404
  self._reload_pending = True
1208
1405
  # Don't update _last_modified yet - keep detecting the change
@@ -1235,7 +1432,7 @@ When you learn something valuable during conversations:
1235
1432
  def _hot_reload(self):
1236
1433
  """Hot-reload by restarting the entire Python process with fresh code"""
1237
1434
  logger.info("Hot-reload initiated")
1238
- print("🦆 Hot-reloading via process restart...")
1435
+ print("\n🦆 Hot-reloading via process restart...")
1239
1436
 
1240
1437
  try:
1241
1438
  # Set reload flag to prevent recursive reloads during shutdown
@@ -1252,7 +1449,7 @@ When you learn something valuable during conversations:
1252
1449
  if hasattr(self, "_watcher_running"):
1253
1450
  self._watcher_running = False
1254
1451
 
1255
- print("🦆 Restarting process with fresh code...")
1452
+ print("\n🦆 Restarting process with fresh code...")
1256
1453
 
1257
1454
  # Restart the entire Python process
1258
1455
  # This ensures all code is freshly loaded
@@ -1260,8 +1457,8 @@ When you learn something valuable during conversations:
1260
1457
 
1261
1458
  except Exception as e:
1262
1459
  logger.error(f"Hot-reload failed: {e}")
1263
- print(f"🦆 Hot-reload failed: {e}")
1264
- print("🦆 Falling back to manual restart")
1460
+ print(f"\n🦆 Hot-reload failed: {e}")
1461
+ print("\n🦆 Falling back to manual restart")
1265
1462
  self._is_reloading = False
1266
1463
 
1267
1464
  def status(self):
@@ -1552,6 +1749,7 @@ Claude Desktop Config:
1552
1749
  transport="stdio",
1553
1750
  expose_agent=True,
1554
1751
  agent=devduck.agent,
1752
+ record_direct_tool_call=False,
1555
1753
  )
1556
1754
  except Exception as e:
1557
1755
  logger.error(f"Failed to start MCP stdio server: {e}")
devduck/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.6.0'
32
- __version_tuple__ = version_tuple = (0, 6, 0)
31
+ __version__ = version = '0.7.0'
32
+ __version_tuple__ = version_tuple = (0, 7, 0)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -407,6 +407,7 @@ def agentcore_invoke(
407
407
  response_text = (
408
408
  "\n".join(str(e) for e in events) if events else "No response content"
409
409
  )
410
+ print("\n")
410
411
 
411
412
  return {
412
413
  "status": "success",
@@ -29,7 +29,7 @@ def install_tools(
29
29
  and loading their tools into the agent's registry at runtime.
30
30
 
31
31
  Args:
32
- action: Action to perform - "install", "load", "install_and_load", "list_loaded"
32
+ action: Action to perform - "install", "load", "install_and_load", "list_loaded", "list_available"
33
33
  package: Python package to install (e.g., "strands-agents-tools", "strands-fun-tools")
34
34
  module: Module to import tools from (e.g., "strands_tools", "strands_fun_tools")
35
35
  tool_names: Optional list of specific tools to load. If None, loads all available tools
@@ -39,6 +39,13 @@ def install_tools(
39
39
  Result dictionary with status and content
40
40
 
41
41
  Examples:
42
+ # List available tools in a package (without loading)
43
+ install_tools(
44
+ action="list_available",
45
+ package="strands-fun-tools",
46
+ module="strands_fun_tools"
47
+ )
48
+
42
49
  # Install and load all tools from strands-agents-tools
43
50
  install_tools(
44
51
  action="install_and_load",
@@ -79,13 +86,15 @@ def install_tools(
79
86
  return _load_tools_from_module(module, tool_names, agent)
80
87
  elif action == "list_loaded":
81
88
  return _list_loaded_tools(agent)
89
+ elif action == "list_available":
90
+ return _list_available_tools(package, module)
82
91
  else:
83
92
  return {
84
93
  "status": "error",
85
94
  "content": [
86
95
  {
87
96
  "text": f"❌ Unknown action: {action}\n\n"
88
- f"Valid actions: install, load, install_and_load, list_loaded"
97
+ f"Valid actions: install, load, install_and_load, list_loaded, list_available"
89
98
  }
90
99
  ],
91
100
  }
@@ -306,3 +315,95 @@ def _list_loaded_tools(agent: Any) -> Dict[str, Any]:
306
315
  "status": "error",
307
316
  "content": [{"text": f"❌ Failed to list tools: {str(e)}"}],
308
317
  }
318
+
319
+
320
+ def _list_available_tools(package: Optional[str], module: str) -> Dict[str, Any]:
321
+ """List available tools in a package without loading them."""
322
+ if not module:
323
+ return {
324
+ "status": "error",
325
+ "content": [
326
+ {"text": "❌ module parameter is required for list_available action"}
327
+ ],
328
+ }
329
+
330
+ try:
331
+ # Try to import the module
332
+ try:
333
+ imported_module = importlib.import_module(module)
334
+ logger.info(f"Module {module} already installed")
335
+ except ImportError:
336
+ # Module not installed - try to install package first
337
+ if not package:
338
+ return {
339
+ "status": "error",
340
+ "content": [
341
+ {
342
+ "text": f"❌ Module {module} not found and no package specified to install.\n\n"
343
+ f"Please provide the 'package' parameter to install first."
344
+ }
345
+ ],
346
+ }
347
+
348
+ logger.info(f"Module {module} not found, installing package {package}")
349
+ install_result = _install_package(package)
350
+ if install_result["status"] == "error":
351
+ return install_result
352
+
353
+ # Try importing again after installation
354
+ try:
355
+ imported_module = importlib.import_module(module)
356
+ except ImportError as e:
357
+ return {
358
+ "status": "error",
359
+ "content": [
360
+ {
361
+ "text": f"❌ Failed to import {module} even after installing {package}: {str(e)}"
362
+ }
363
+ ],
364
+ }
365
+
366
+ # Discover tools in the module
367
+ available_tools = {}
368
+ for attr_name in dir(imported_module):
369
+ attr = getattr(imported_module, attr_name)
370
+ # Check if it's a tool (has tool_name and tool_spec attributes)
371
+ if hasattr(attr, "tool_name") and hasattr(attr, "tool_spec"):
372
+ tool_spec = attr.tool_spec
373
+ description = tool_spec.get("description", "No description available")
374
+ available_tools[attr.tool_name] = description
375
+
376
+ if not available_tools:
377
+ return {
378
+ "status": "success",
379
+ "content": [{"text": f"⚠️ No tools found in module: {module}"}],
380
+ }
381
+
382
+ # Build result message
383
+ result_lines = [
384
+ f"📦 **Available Tools in {module} ({len(available_tools)})**\n"
385
+ ]
386
+
387
+ for tool_name, description in sorted(available_tools.items()):
388
+ # Truncate long descriptions
389
+ if len(description) > 100:
390
+ description = description[:97] + "..."
391
+
392
+ result_lines.append(f"**{tool_name}**")
393
+ result_lines.append(f" {description}\n")
394
+
395
+ result_lines.append(f"\n💡 To load these tools, use:")
396
+ result_lines.append(f" install_tools(action='load', module='{module}')")
397
+ result_lines.append(f" # Or load specific tools:")
398
+ result_lines.append(
399
+ f" install_tools(action='load', module='{module}', tool_names=['tool1', 'tool2'])"
400
+ )
401
+
402
+ return {"status": "success", "content": [{"text": "\n".join(result_lines)}]}
403
+
404
+ except Exception as e:
405
+ logger.exception(f"Error listing available tools from {module}")
406
+ return {
407
+ "status": "error",
408
+ "content": [{"text": f"❌ Failed to list available tools: {str(e)}"}],
409
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devduck
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: 🦆 Extreme minimalist self-adapting AI agent - one file, self-healing, runtime dependencies
5
5
  Author-email: Cagatay Cali <cagataycali@icloud.com>
6
6
  License: Apache-2.0
@@ -53,6 +53,8 @@ Dynamic: license-file
53
53
 
54
54
  One Python file that adapts to your environment, fixes itself, and expands capabilities at runtime.
55
55
 
56
+ Learn more: https://duck.nyc
57
+
56
58
  ## 🎬 See It In Action
57
59
 
58
60
  | Feature | What You'll See | Video |
@@ -176,7 +178,7 @@ devduck("refactor my code to use async/await")
176
178
 
177
179
  | Provider | Setup | When to Use |
178
180
  |----------|-------|-------------|
179
- | **Bedrock** (auto-detected) | [Get API key](https://console.aws.amazon.com/bedrock) → `export AWS_BEARER_TOKEN_BEDROCK=...` | Production (auto-selected if credentials found) |
181
+ | **Bedrock** (auto-detected) | [Get API key](https://console.aws.amazon.com/bedrock) → `export AWS_BEARER_TOKEN_BEDROCK=...` | Auto-selected if credentials found |
180
182
  | **MLX** (macOS auto-detected) | Auto-detected on Apple Silicon | Local, optimized for M-series Macs |
181
183
  | **Ollama** (fallback) | `ollama pull qwen3:1.7b` | Local, free, private (used if Bedrock/MLX unavailable) |
182
184
  | **Anthropic** | `export ANTHROPIC_API_KEY=...` | Claude API direct access |
@@ -304,22 +306,75 @@ No restart. No configuration. Just works.
304
306
  |----------|---------|---------|
305
307
  | `MODEL_PROVIDER` | Auto-detect | `bedrock`, `anthropic`, `github`, `mlx`, `ollama` |
306
308
  | `STRANDS_MODEL_ID` | Auto | Model name (e.g., `qwen3:1.7b`, `claude-sonnet-4`) |
307
- | `DEVDUCK_TOOLS` | All | `pkg:tool1,tool2:pkg2:tool3` |
309
+ | `DEVDUCK_TOOLS` | 37 default tools | `package:tool1,tool2:package2:tool3` format |
310
+ | `DEVDUCK_LOAD_TOOLS_FROM_DIR` | `false` | `true`/`false` - Auto-load tools from `./tools/` directory |
308
311
  | `DEVDUCK_KNOWLEDGE_BASE_ID` | - | Bedrock KB ID for auto-RAG |
309
312
  | `DEVDUCK_TCP_PORT` | `9999` | TCP server port |
310
313
  | `DEVDUCK_ENABLE_TCP` | `true` | Enable/disable TCP |
311
314
 
312
- **Minimal config (shell + editor only):**
315
+ ### Tool Configuration Format
316
+
317
+ **Format:** `package:tool1,tool2:package2:tool3`
318
+
319
+ **Directory Auto-Loading:**
320
+
321
+ By default, DevDuck **does not** automatically load tools from the `./tools/` directory. This gives you explicit control over which tools are loaded. To enable automatic loading of tools from `./tools/`, set:
322
+
313
323
  ```bash
324
+ export DEVDUCK_LOAD_TOOLS_FROM_DIR=true
325
+ devduck
326
+ ```
327
+
328
+ When enabled, any `.py` file in `./tools/` with a `@tool` decorator will be loaded automatically. When disabled (default), you control tool loading via `DEVDUCK_TOOLS` or runtime `manage_tools()` calls.
329
+
330
+ **Examples:**
331
+ ```bash
332
+ # Minimal (shell + editor only)
314
333
  export DEVDUCK_TOOLS="strands_tools:shell,editor"
334
+
335
+ # Dev tools only
336
+ export DEVDUCK_TOOLS="strands_tools:shell,editor,file_read,file_write,calculator"
337
+
338
+ # Full DevDuck + Strands (no fun tools)
339
+ export DEVDUCK_TOOLS="devduck.tools:tcp,websocket,mcp_server,use_github:strands_tools:shell,editor,file_read"
340
+
341
+ # Custom package
342
+ export DEVDUCK_TOOLS="my_tools:custom_tool,another_tool:strands_tools:shell"
343
+
315
344
  devduck
316
345
  ```
317
346
 
347
+ **Runtime tool management:**
348
+ ```python
349
+ # List loaded tools
350
+ manage_tools(action="list")
351
+
352
+ # Add tools at runtime
353
+ manage_tools(action="add", package="strands_fun_tools", tool_names="cursor,clipboard")
354
+
355
+ # Remove tools
356
+ manage_tools(action="remove", tool_names="cursor,clipboard")
357
+
358
+ # Reload specific tools
359
+ manage_tools(action="reload", tool_names="shell,editor")
360
+
361
+ # Reload all (restart agent)
362
+ manage_tools(action="reload")
363
+ ```
364
+
365
+ **Discover tools before loading:**
366
+ ```python
367
+ # List available tools in a package
368
+ install_tools(action="list_available", package="strands-fun-tools", module="strands_fun_tools")
369
+ ```
370
+
318
371
  ---
319
372
 
320
373
 
321
374
  ## MCP Integration
322
375
 
376
+ ### Expose DevDuck as MCP Server
377
+
323
378
  **Claude Desktop** (`~/Library/Application Support/Claude/claude_desktop_config.json`):
324
379
  ```json
325
380
  {
@@ -334,6 +389,83 @@ devduck
334
389
 
335
390
  Restart Claude → DevDuck tools appear automatically.
336
391
 
392
+ ### Load External MCP Servers
393
+
394
+ DevDuck can act as an MCP client and load tools from external MCP servers automatically.
395
+
396
+ **Setup:**
397
+ ```bash
398
+ export MCP_SERVERS='{
399
+ "mcpServers": {
400
+ "strands": {
401
+ "command": "uvx",
402
+ "args": ["strands-agents-mcp-server"]
403
+ }
404
+ }
405
+ }'
406
+ devduck
407
+ ```
408
+
409
+ **Supported Transport Types:**
410
+
411
+ | Transport | Configuration | Example |
412
+ |-----------|--------------|---------|
413
+ | **stdio** | `command`, `args`, `env` | Executables via stdin/stdout |
414
+ | **HTTP** | `url`, `headers` | Remote servers via HTTP |
415
+ | **SSE** | `url` (with `/sse` path) | Server-Sent Events streaming |
416
+
417
+ **Examples:**
418
+
419
+ ```bash
420
+ # Stdio server
421
+ export MCP_SERVERS='{
422
+ "mcpServers": {
423
+ "myserver": {
424
+ "command": "python",
425
+ "args": ["server.py"],
426
+ "env": {"API_KEY": "secret"}
427
+ }
428
+ }
429
+ }'
430
+
431
+ # HTTP server
432
+ export MCP_SERVERS='{
433
+ "mcpServers": {
434
+ "remote": {
435
+ "url": "https://api.example.com/mcp",
436
+ "headers": {"Authorization": "Bearer token"}
437
+ }
438
+ }
439
+ }'
440
+
441
+ # SSE server
442
+ export MCP_SERVERS='{
443
+ "mcpServers": {
444
+ "events": {
445
+ "url": "https://api.example.com/sse"
446
+ }
447
+ }
448
+ }'
449
+
450
+ # Multiple servers
451
+ export MCP_SERVERS='{
452
+ "mcpServers": {
453
+ "strands": {
454
+ "command": "uvx",
455
+ "args": ["strands-agents-mcp-server"]
456
+ },
457
+ "remote": {
458
+ "url": "https://api.example.com/mcp"
459
+ }
460
+ }
461
+ }'
462
+
463
+ devduck
464
+ # Tools from all MCP servers automatically available
465
+ ```
466
+
467
+ **Tool Prefixing:** Each MCP server's tools are prefixed with the server name (e.g., `strands_tool_name`)
468
+
337
469
  ---
338
470
 
339
471
  ## Troubleshooting
@@ -1,6 +1,6 @@
1
- devduck/__init__.py,sha256=mRu9U3EYnxYhFs2xi_a8fX6-peFLgoeM31zlFGbXb3k,57904
1
+ devduck/__init__.py,sha256=okf01FoPFrPpiwuGdQcx9Y7kLndn22JuVkA117L6g-4,67753
2
2
  devduck/__main__.py,sha256=aeF2RR4k7lzSR2X1QKV9XQPCKhtsH0JYUv2etBBqmL0,145
3
- devduck/_version.py,sha256=MAYWefOLb6kbIRub18WSzK6ggSjz1LNLy9aDRlX9Ea4,704
3
+ devduck/_version.py,sha256=uLbRjFSUZAgfl7V7O8zKV5Db36k7tz87ZIVq3l2SWs0,704
4
4
  devduck/agentcore_handler.py,sha256=0DKJTTjoH9P8a70G0f5dOIIwy6bjqaN46voAWaSOpDY,2221
5
5
  devduck/test_redduck.py,sha256=ILtKKMuoyVfmhnibmbojpbOsqbcKooZv4j9qtE2LWdw,1750
6
6
  devduck/tools/__init__.py,sha256=AmIy8MInaClaZ71fqzy4EQJnBWsLkrv4QW9IIN7UQyw,1367
@@ -8,11 +8,11 @@ devduck/tools/_ambient_input.py,sha256=3lBgLO81BvkxjgTrQc-EuxNLXmO1oPUt2Ysg1jR4F
8
8
  devduck/tools/_tray_app.py,sha256=E4rtJcegRsBs_FdQVGdA-0Ax7uxVb6AbuyqjwCArHj0,19405
9
9
  devduck/tools/agentcore_agents.py,sha256=fiDNhl7R2tVbp1mEOySJTfGXwap5q3COenYOjiJDE_g,6488
10
10
  devduck/tools/agentcore_config.py,sha256=sUD1SrLAqTHjgHctZtVRDz_BvLG_nRB3z6g3EcrcvTM,14780
11
- devduck/tools/agentcore_invoke.py,sha256=SMKqVAig_cZEBL-W5gfumUpPFIHC9CSRSY9BJbnx6wY,17449
11
+ devduck/tools/agentcore_invoke.py,sha256=iHOeV8mh1QeJ_dj06MRHbJ1VnvY4WIQxqruxIDw0mz0,17469
12
12
  devduck/tools/agentcore_logs.py,sha256=A3YQIoRErJtvzeaMSPNqOLX1BH-vYTbYKs1NXoCnC5E,10222
13
13
  devduck/tools/ambient.py,sha256=HB1ZhfeOdOaMU0xe4e44VNUT_-DQ5SY7sl3r4r-4X44,4806
14
14
  devduck/tools/create_subagent.py,sha256=UzRz9BmU4PbTveZROEpZ311aH-u-i6x89gttu-CniAE,24687
15
- devduck/tools/install_tools.py,sha256=wm_67b9IfY-2wRuWgxuEKhaSIV5vNfbGmZL3G9dGi2A,10348
15
+ devduck/tools/install_tools.py,sha256=3uzRg5lEHX-L6gxnFn3mIKjGYDJ3h_AdwGnEwKA9qR0,14284
16
16
  devduck/tools/ipc.py,sha256=e3KJeR2HmCKEtVLGNOtf6CeFi3pTDehwd7Fu4JJ19Ms,18607
17
17
  devduck/tools/mcp_server.py,sha256=Ybp0PcJKW2TOvghsRL-i8Guqc9WokPwOD2bhVgzoj6Q,21490
18
18
  devduck/tools/state_manager.py,sha256=hrleqdVoCboNd8R3wDRUXVKYCZdGoe1j925i948LTHc,10563
@@ -22,9 +22,9 @@ devduck/tools/tcp.py,sha256=w2m_Jf6vZ4NYu0AwgZd7C7eKs4No2EVHZ2WYIl_Bt0A,22017
22
22
  devduck/tools/tray.py,sha256=FgVhUtLdsdv5_ERK-RyAIpDE8Zb0IfoqhHQdwMxrHUQ,7547
23
23
  devduck/tools/use_github.py,sha256=nr3JSGk48mKUobpgW__2gu6lFyUj93a1XRs3I6vH8W4,13682
24
24
  devduck/tools/websocket.py,sha256=A8bqgdDZs8hcf2HctkJzQOzMvb5mXUC7YZ-xqkOyn94,16959
25
- devduck-0.6.0.dist-info/licenses/LICENSE,sha256=UANcoWwfVeuM9597WUkjEQbzqIUH0bJoE9Tpwgj_LvU,11345
26
- devduck-0.6.0.dist-info/METADATA,sha256=u3rog7cs0uapLjeO8Oi8bZkoOjeOSQkWKRj65vwggkw,14518
27
- devduck-0.6.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
28
- devduck-0.6.0.dist-info/entry_points.txt,sha256=BAMQaIg_BLZQOTk12bT7hy1dE9oGPLt-_dTbI4cnBnQ,40
29
- devduck-0.6.0.dist-info/top_level.txt,sha256=ySXWlVronp8xHYfQ_Hdfr463e0EnbWuqyuxs94EU7yk,8
30
- devduck-0.6.0.dist-info/RECORD,,
25
+ devduck-0.7.0.dist-info/licenses/LICENSE,sha256=UANcoWwfVeuM9597WUkjEQbzqIUH0bJoE9Tpwgj_LvU,11345
26
+ devduck-0.7.0.dist-info/METADATA,sha256=I7la6VIQ76TqoZkaJ-GbY09Mt5duZ_rMGtW8D1lD-OM,17748
27
+ devduck-0.7.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
28
+ devduck-0.7.0.dist-info/entry_points.txt,sha256=BAMQaIg_BLZQOTk12bT7hy1dE9oGPLt-_dTbI4cnBnQ,40
29
+ devduck-0.7.0.dist-info/top_level.txt,sha256=ySXWlVronp8xHYfQ_Hdfr463e0EnbWuqyuxs94EU7yk,8
30
+ devduck-0.7.0.dist-info/RECORD,,