Import ducklm runtime
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""Tool registry and tool adapters."""
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from app.core.contracts import ToolResult, UserTask
|
||||
|
||||
|
||||
class BaseTool(ABC):
|
||||
name: str = ""
|
||||
description: str = ""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return getattr(self, '_name', self.__class__.__name__.replace('Tool', '').lower())
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return getattr(self, '_description', "")
|
||||
|
||||
@abstractmethod
|
||||
def execute(self, task: UserTask, args: dict[str, Any]) -> ToolResult:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PLUGINS_DIR = Path(__file__).parent / "plugins"
|
||||
|
||||
|
||||
class ToolDiscovery:
|
||||
"""Decentralized tool discovery system."""
|
||||
|
||||
def __init__(self, plugins_dir: Path | None = None) -> None:
|
||||
self._plugins_dir = plugins_dir or PLUGINS_DIR
|
||||
|
||||
def discover(self) -> dict[str, Any]:
|
||||
"""Discover all tools from plugins directory."""
|
||||
tools = {}
|
||||
|
||||
if not self._plugins_dir.exists():
|
||||
logger.warning(f"Plugins directory not found: {self._plugins_dir}")
|
||||
return tools
|
||||
|
||||
for folder in self._plugins_dir.iterdir():
|
||||
if not folder.is_dir():
|
||||
continue
|
||||
|
||||
manifest_file = folder / "manifest.json"
|
||||
if not manifest_file.exists():
|
||||
logger.warning(f"Missing manifest.json in {folder.name}")
|
||||
continue
|
||||
|
||||
try:
|
||||
manifest = self._load_manifest(manifest_file)
|
||||
|
||||
tool_name = manifest.get("name", folder.name)
|
||||
tools[tool_name] = {
|
||||
"manifest": manifest,
|
||||
"tool_class": folder.name,
|
||||
}
|
||||
logger.info(f"Discovered tool: {tool_name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load tool {folder.name}: {e}")
|
||||
continue
|
||||
|
||||
return tools
|
||||
|
||||
def _load_manifest(self, manifest_file: Path) -> dict[str, Any]:
|
||||
with open(manifest_file) as f:
|
||||
return json.load(f)
|
||||
|
||||
def _load_tool_class(self, tool_name: str, manifest: dict[str, Any]) -> Any:
|
||||
entrypoint = manifest.get("entrypoint", "Tool")
|
||||
module = importlib.import_module(f"app.tools.plugins.{tool_name}")
|
||||
tool_class = getattr(module, entrypoint)
|
||||
return tool_class
|
||||
|
||||
def get_tool_schemas(self) -> list[dict[str, Any]]:
|
||||
"""Get schemas for all discovered tools."""
|
||||
tools = self.discover()
|
||||
schemas = []
|
||||
|
||||
for name, data in tools.items():
|
||||
manifest = data.get("manifest", {})
|
||||
schemas.append({
|
||||
"name": name,
|
||||
"description": manifest.get("description", ""),
|
||||
"args_schema": manifest.get("args_schema", {}),
|
||||
"requires_permission": manifest.get("requires_permission", False),
|
||||
})
|
||||
|
||||
return schemas
|
||||
|
||||
|
||||
def discover_tools() -> dict[str, Any]:
|
||||
"""Convenience function for quick tool discovery."""
|
||||
discovery = ToolDiscovery()
|
||||
return discovery.discover()
|
||||
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.contracts import ToolResult, UserTask
|
||||
from app.tools.base import BaseTool
|
||||
from app.tools.sandbox import ToolSandbox
|
||||
|
||||
|
||||
class FileReadTool(BaseTool):
|
||||
name = "file_read"
|
||||
|
||||
def __init__(self, sandbox: ToolSandbox) -> None:
|
||||
self._sandbox = sandbox
|
||||
|
||||
def execute(self, task: UserTask, args: dict[str, object]) -> ToolResult:
|
||||
path = args.get("path")
|
||||
if not path:
|
||||
return ToolResult(tool=self.name, ok=False, error="Missing path")
|
||||
resolved = self._sandbox.ensure_path_allowed(str(path))
|
||||
content = resolved.read_text(encoding="utf-8")
|
||||
return ToolResult(
|
||||
tool=self.name,
|
||||
ok=True,
|
||||
output=content,
|
||||
metadata={"path": str(resolved), "size": len(content)},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.contracts import ToolResult, UserTask
|
||||
from app.tools.base import BaseTool
|
||||
from app.tools.sandbox import ToolSandbox
|
||||
|
||||
|
||||
class FileWriteTool(BaseTool):
|
||||
name = "file_write"
|
||||
|
||||
def __init__(self, sandbox: ToolSandbox) -> None:
|
||||
self._sandbox = sandbox
|
||||
|
||||
def execute(self, task: UserTask, args: dict[str, object]) -> ToolResult:
|
||||
path = args.get("path")
|
||||
content = str(args.get("content", ""))
|
||||
if not path:
|
||||
return ToolResult(tool=self.name, ok=False, error="Missing path")
|
||||
resolved = self._sandbox.ensure_path_allowed(str(path))
|
||||
resolved.parent.mkdir(parents=True, exist_ok=True)
|
||||
resolved.write_text(content, encoding="utf-8")
|
||||
return ToolResult(
|
||||
tool=self.name,
|
||||
ok=True,
|
||||
output=f"Wrote {len(content)} bytes",
|
||||
metadata={"path": str(resolved), "size": len(content)},
|
||||
)
|
||||
@@ -0,0 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.tools.base import BaseTool
|
||||
from app.core.contracts import ToolResult, UserTask
|
||||
from app.tools.sandbox import ToolSandbox
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MemoryInsertTool(BaseTool):
|
||||
_name = "memory_insert"
|
||||
_description = "Store information in memory"
|
||||
|
||||
def __init__(self, sandbox: ToolSandbox, memory_interface=None) -> None:
|
||||
super().__init__()
|
||||
self._sandbox = sandbox
|
||||
self._memory = memory_interface
|
||||
|
||||
def execute(self, task: UserTask, args: dict[str, Any]) -> ToolResult:
|
||||
text = args.get("text", "")
|
||||
kind = args.get("kind", "fact")
|
||||
source = args.get("source", "user")
|
||||
weight = args.get("weight", 0.5)
|
||||
|
||||
if not text:
|
||||
return ToolResult(tool="memory_insert", ok=False, output="", error="text is required")
|
||||
if not self._memory:
|
||||
return ToolResult(tool="memory_insert", ok=False, output="", error="Memory not available")
|
||||
|
||||
try:
|
||||
entry = self._memory.insert(
|
||||
text=text,
|
||||
kind=kind,
|
||||
source=source,
|
||||
task_id=task.task_id,
|
||||
session_id=task.session_id,
|
||||
weight=weight,
|
||||
)
|
||||
return ToolResult(
|
||||
tool="memory_insert",
|
||||
ok=True,
|
||||
output=f"Stored: {entry.id}",
|
||||
metadata={"entry_id": entry.id},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Memory insert failed: {e}")
|
||||
return ToolResult(tool="memory_insert", ok=False, output="", error=str(e))
|
||||
|
||||
|
||||
class MemorySearchTool(BaseTool):
|
||||
_name = "memory_search"
|
||||
_description = "Search memory for information"
|
||||
|
||||
def __init__(self, sandbox: ToolSandbox, memory_interface=None) -> None:
|
||||
super().__init__()
|
||||
self._sandbox = sandbox
|
||||
self._memory = memory_interface
|
||||
|
||||
def execute(self, task: UserTask, args: dict[str, Any]) -> ToolResult:
|
||||
query = args.get("query", "")
|
||||
top_k = args.get("top_k", 5)
|
||||
|
||||
if not query:
|
||||
return ToolResult(tool="memory_search", ok=False, output="", error="query is required")
|
||||
if not self._memory:
|
||||
return ToolResult(tool="memory_search", ok=False, output="", error="Memory not available")
|
||||
|
||||
try:
|
||||
results = self._memory.search(query, top_k=top_k)
|
||||
if not results:
|
||||
return ToolResult(tool="memory_search", ok=True, output="No results found", metadata={"count": 0})
|
||||
|
||||
output_lines = []
|
||||
for entry, score in results:
|
||||
output_lines.append(f"[{score:.2f}] {entry.text[:100]}")
|
||||
|
||||
return ToolResult(
|
||||
tool="memory_search",
|
||||
ok=True,
|
||||
output="\n".join(output_lines),
|
||||
metadata={"count": len(results)},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Memory search failed: {e}")
|
||||
return ToolResult(tool="memory_search", ok=False, output="", error=str(e))
|
||||
|
||||
|
||||
class MemoryListTool(BaseTool):
|
||||
_name = "memory_list"
|
||||
_description = "List recent memories"
|
||||
|
||||
def __init__(self, sandbox: ToolSandbox, memory_interface=None) -> None:
|
||||
super().__init__()
|
||||
self._sandbox = sandbox
|
||||
self._memory = memory_interface
|
||||
|
||||
def execute(self, task: UserTask, args: dict[str, Any]) -> ToolResult:
|
||||
limit = args.get("limit", 10)
|
||||
|
||||
if not self._memory:
|
||||
return ToolResult(tool="memory_list", ok=False, output="", error="Memory not available")
|
||||
|
||||
try:
|
||||
entries = self._memory.get_recent(limit=limit)
|
||||
if not entries:
|
||||
return ToolResult(tool="memory_list", ok=True, output="No memories", metadata={"count": 0})
|
||||
|
||||
output_lines = []
|
||||
for entry in entries:
|
||||
output_lines.append(f"{entry.kind}: {entry.text[:80]}")
|
||||
|
||||
return ToolResult(
|
||||
tool="memory_list",
|
||||
ok=True,
|
||||
output="\n".join(output_lines),
|
||||
metadata={"count": len(entries)},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Memory list failed: {e}")
|
||||
return ToolResult(tool="memory_list", ok=False, output="", error=str(e))
|
||||
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.contracts import ToolResult, UserTask
|
||||
from app.tools.base import BaseTool
|
||||
from app.tools.sandbox import ToolSandbox
|
||||
|
||||
|
||||
class Tool(BaseTool):
|
||||
name = "file_read"
|
||||
description = "Read file contents"
|
||||
|
||||
def __init__(self, sandbox: ToolSandbox) -> None:
|
||||
self._sandbox = sandbox
|
||||
|
||||
def execute(self, task: UserTask, args: dict[str, object]) -> ToolResult:
|
||||
path = args.get("path")
|
||||
if not path:
|
||||
return ToolResult(tool=self.name, ok=False, error="Missing path")
|
||||
try:
|
||||
resolved = self._sandbox.ensure_path_allowed(str(path))
|
||||
if not resolved.exists():
|
||||
return ToolResult(tool=self.name, ok=False, error=f"File not found: {path}")
|
||||
content = resolved.read_text(encoding="utf-8")
|
||||
return ToolResult(
|
||||
tool=self.name,
|
||||
ok=True,
|
||||
output=content,
|
||||
metadata={"path": str(resolved), "size": len(content)},
|
||||
)
|
||||
except PermissionError as e:
|
||||
return ToolResult(tool=self.name, ok=False, error=f"Access denied: {e}")
|
||||
except FileNotFoundError as e:
|
||||
return ToolResult(tool=self.name, ok=False, error=f"File not found: {path}")
|
||||
except Exception as e:
|
||||
return ToolResult(tool=self.name, ok=False, error=f"Error: {e}")
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "file_read",
|
||||
"version": "1.0",
|
||||
"entrypoint": "Tool",
|
||||
"description": "Read file contents from allowed paths",
|
||||
"args_schema": {
|
||||
"path": {"type": "string", "required": true, "description": "File path to read"}
|
||||
},
|
||||
"requires_permission": false
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.contracts import ToolResult, UserTask
|
||||
from app.tools.base import BaseTool
|
||||
from app.tools.sandbox import ToolSandbox
|
||||
|
||||
|
||||
class Tool(BaseTool):
|
||||
name = "file_write"
|
||||
description = "Write content to file"
|
||||
|
||||
def __init__(self, sandbox: ToolSandbox) -> None:
|
||||
self._sandbox = sandbox
|
||||
|
||||
def execute(self, task: UserTask, args: dict[str, object]) -> ToolResult:
|
||||
path = args.get("path")
|
||||
content = str(args.get("content", ""))
|
||||
if not path:
|
||||
return ToolResult(tool=self.name, ok=False, error="Missing path")
|
||||
try:
|
||||
resolved = self._sandbox.ensure_path_allowed(str(path))
|
||||
resolved.parent.mkdir(parents=True, exist_ok=True)
|
||||
resolved.write_text(content, encoding="utf-8")
|
||||
return ToolResult(
|
||||
tool=self.name,
|
||||
ok=True,
|
||||
output=f"Wrote {len(content)} bytes",
|
||||
metadata={"path": str(resolved), "size": len(content)},
|
||||
)
|
||||
except PermissionError as e:
|
||||
return ToolResult(tool=self.name, ok=False, error=f"Access denied: {e}")
|
||||
except Exception as e:
|
||||
return ToolResult(tool=self.name, ok=False, error=f"Error: {e}")
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "file_write",
|
||||
"version": "1.0",
|
||||
"entrypoint": "Tool",
|
||||
"description": "Write content to file",
|
||||
"args_schema": {
|
||||
"path": {"type": "string", "required": true, "description": "File path to write"},
|
||||
"content": {"type": "string", "required": true, "description": "Content to write"}
|
||||
},
|
||||
"requires_permission": true
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from app.core.contracts import ToolResult, UserTask
|
||||
from app.tools.base import BaseTool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Tool(BaseTool):
|
||||
name = "memory"
|
||||
description = "Memory operations: insert, search, list"
|
||||
|
||||
def __init__(self, memory_interface=None) -> None:
|
||||
self._memory = memory_interface
|
||||
|
||||
def execute(self, task: UserTask, args: dict[str, Any]) -> ToolResult:
|
||||
action = args.get("action", "search")
|
||||
|
||||
if action == "insert":
|
||||
return self._insert(task, args)
|
||||
elif action == "search":
|
||||
return self._search(task, args)
|
||||
elif action == "list":
|
||||
return self._list(task, args)
|
||||
else:
|
||||
return ToolResult(tool=self.name, ok=False, error=f"Unknown action: {action}")
|
||||
|
||||
def _insert(self, task: UserTask, args: dict[str, Any]) -> ToolResult:
|
||||
text = args.get("text", "")
|
||||
kind = args.get("kind", "fact")
|
||||
source = args.get("source", "user")
|
||||
weight = args.get("weight", 0.5)
|
||||
|
||||
if not text:
|
||||
return ToolResult(tool=self.name, ok=False, output="", error="text is required")
|
||||
if not self._memory:
|
||||
return ToolResult(tool=self.name, ok=False, output="", error="Memory not available")
|
||||
|
||||
try:
|
||||
entry = self._memory.insert(
|
||||
text=text,
|
||||
kind=kind,
|
||||
source=source,
|
||||
task_id=task.task_id,
|
||||
session_id=task.session_id,
|
||||
weight=weight,
|
||||
)
|
||||
return ToolResult(
|
||||
tool=self.name,
|
||||
ok=True,
|
||||
output=f"Stored: {entry.id}",
|
||||
metadata={"entry_id": entry.id},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Memory insert failed: {e}")
|
||||
return ToolResult(tool=self.name, ok=False, output="", error=str(e))
|
||||
|
||||
def _search(self, task: UserTask, args: dict[str, Any]) -> ToolResult:
|
||||
query = args.get("query", "")
|
||||
top_k = args.get("top_k", 5)
|
||||
|
||||
if not query:
|
||||
return ToolResult(tool=self.name, ok=False, output="", error="query is required")
|
||||
if not self._memory:
|
||||
return ToolResult(tool=self.name, ok=False, output="", error="Memory not available")
|
||||
|
||||
try:
|
||||
results = self._memory.search(query, top_k=top_k)
|
||||
if not results:
|
||||
return ToolResult(tool=self.name, ok=True, output="No results found", metadata={"count": 0})
|
||||
|
||||
output_lines = []
|
||||
for entry, score in results:
|
||||
output_lines.append(f"[{score:.2f}] {entry.text[:100]}")
|
||||
|
||||
return ToolResult(
|
||||
tool=self.name,
|
||||
ok=True,
|
||||
output="\n".join(output_lines),
|
||||
metadata={"count": len(results)},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Memory search failed: {e}")
|
||||
return ToolResult(tool=self.name, ok=False, output="", error=str(e))
|
||||
|
||||
def _list(self, task: UserTask, args: dict[str, Any]) -> ToolResult:
|
||||
limit = args.get("limit", 10)
|
||||
|
||||
if not self._memory:
|
||||
return ToolResult(tool=self.name, ok=False, output="", error="Memory not available")
|
||||
|
||||
try:
|
||||
entries = self._memory.get_recent(limit=limit)
|
||||
if not entries:
|
||||
return ToolResult(tool=self.name, ok=True, output="No memories", metadata={"count": 0})
|
||||
|
||||
output_lines = []
|
||||
for entry in entries:
|
||||
output_lines.append(f"{entry.kind}: {entry.text[:80]}")
|
||||
|
||||
return ToolResult(
|
||||
tool=self.name,
|
||||
ok=True,
|
||||
output="\n".join(output_lines),
|
||||
metadata={"count": len(entries)},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Memory list failed: {e}")
|
||||
return ToolResult(tool=self.name, ok=False, output="", error=str(e))
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "memory",
|
||||
"version": "1.0",
|
||||
"entrypoint": "Tool",
|
||||
"description": "Memory operations: insert, search, list",
|
||||
"args_schema": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"description": "Action: insert, search, or list",
|
||||
"enum": ["insert", "search", "list"]
|
||||
},
|
||||
"text": {"type": "string", "required": false, "description": "Text to store (insert)"},
|
||||
"query": {"type": "string", "required": false, "description": "Query string (search)"},
|
||||
"kind": {"type": "string", "required": false, "description": "Memory kind: fact, command, etc"},
|
||||
"source": {"type": "string", "required": false, "description": "Source: user, system, etc"},
|
||||
"weight": {"type": "number", "required": false, "description": "Memory weight 0-1"},
|
||||
"top_k": {"type": "number", "required": false, "description": "Max results (search)"},
|
||||
"limit": {"type": "number", "required": false, "description": "Max entries (list)"}
|
||||
},
|
||||
"requires_permission": false
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.contracts import ToolResult, UserTask
|
||||
from app.tools.base import BaseTool
|
||||
from app.tools.sandbox import ToolSandbox
|
||||
|
||||
|
||||
class Tool(BaseTool):
|
||||
name = "shell_exec"
|
||||
description = "Execute shell commands"
|
||||
|
||||
def __init__(self, sandbox: ToolSandbox) -> None:
|
||||
self._sandbox = sandbox
|
||||
|
||||
def execute(self, task: UserTask, args: dict[str, object]) -> ToolResult:
|
||||
command = str(args.get("command", "")).strip()
|
||||
if not command:
|
||||
return ToolResult(tool=self.name, ok=False, error="Missing command", metadata={"exit_code": -1})
|
||||
cwd = args.get("cwd")
|
||||
stdin_secret = args.get("stdin_secret")
|
||||
completed = self._sandbox.run_shell(
|
||||
command=command,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
stdin_data=str(stdin_secret) if stdin_secret is not None else None,
|
||||
)
|
||||
output = completed.stdout if completed.returncode == 0 else completed.stderr or completed.stdout
|
||||
return ToolResult(
|
||||
tool=self.name,
|
||||
ok=completed.returncode == 0,
|
||||
output=output,
|
||||
error=None if completed.returncode == 0 else f"Command failed with exit code {completed.returncode}",
|
||||
metadata={"exit_code": completed.returncode},
|
||||
)
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "shell_exec",
|
||||
"version": "1.0",
|
||||
"entrypoint": "Tool",
|
||||
"description": "Execute shell commands in sandboxed environment",
|
||||
"args_schema": {
|
||||
"command": {"type": "string", "required": true, "description": "Shell command to execute"},
|
||||
"cwd": {"type": "string", "required": false, "description": "Working directory"},
|
||||
"stdin_secret": {"type": "string", "required": false, "description": "Data to pass via stdin"}
|
||||
},
|
||||
"requires_permission": true
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Callable
|
||||
|
||||
from app.tools.base import BaseTool
|
||||
from app.tools.discover import ToolDiscovery
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ToolRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._tools: dict[str, BaseTool] = {}
|
||||
self._schemas: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def register(self, tool: BaseTool) -> None:
|
||||
self._tools[tool.name] = tool
|
||||
|
||||
def discover_and_init(
|
||||
self,
|
||||
init_factory: Callable[[dict], BaseTool] | None = None,
|
||||
) -> None:
|
||||
"""Discover tools from plugins and initialize them."""
|
||||
discovery = ToolDiscovery()
|
||||
discovered = discovery.discover()
|
||||
|
||||
for name, data in discovered.items():
|
||||
manifest = data.get("manifest", {})
|
||||
|
||||
if init_factory:
|
||||
tool = init_factory({"name": name, "manifest": manifest})
|
||||
else:
|
||||
tool_instance = data.get("instance")
|
||||
if tool_instance:
|
||||
self._tools[name] = tool_instance
|
||||
self._schemas[name] = {
|
||||
"description": manifest.get("description", ""),
|
||||
"args_schema": manifest.get("args_schema", {}),
|
||||
"requires_permission": manifest.get("requires_permission", False),
|
||||
}
|
||||
logger.info(f"Registered tool: {name}")
|
||||
logger.warning(f"No init_factory provided for {name}")
|
||||
|
||||
def get(self, name: str) -> BaseTool:
|
||||
if name not in self._tools:
|
||||
raise KeyError(f"Tool {name} is not registered")
|
||||
return self._tools[name]
|
||||
|
||||
def list_names(self) -> list[str]:
|
||||
return list(self._tools.keys())
|
||||
|
||||
def get_schema(self, name: str) -> dict[str, Any]:
|
||||
return self._schemas.get(name, {})
|
||||
|
||||
def list_schemas(self) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{"name": name, **schema}
|
||||
for name, schema in self._schemas.items()
|
||||
]
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ToolSandbox:
|
||||
"""Applies simple working directory and timeout restrictions."""
|
||||
|
||||
def __init__(self, allowed_root: str | Path, timeout_ms: int) -> None:
|
||||
self._allowed_root = Path(allowed_root).resolve()
|
||||
self._timeout_seconds = max(timeout_ms / 1000, 1)
|
||||
|
||||
def ensure_path_allowed(self, path: str | Path) -> Path:
|
||||
resolved = Path(path).expanduser().resolve()
|
||||
# Permission-first model: path is allowed if it exists
|
||||
# Permission service will handle write/shell restrictions
|
||||
return resolved
|
||||
|
||||
def run_shell(
|
||||
self,
|
||||
command: str,
|
||||
cwd: str | Path | None = None,
|
||||
stdin_data: str | None = None,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
working_directory = self.ensure_path_allowed(cwd or self._allowed_root)
|
||||
env = {"PATH": os.environ.get("PATH", "")}
|
||||
return subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
cwd=str(working_directory),
|
||||
env=env,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
input=stdin_data,
|
||||
timeout=self._timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.contracts import ToolResult, UserTask
|
||||
from app.tools.base import BaseTool
|
||||
from app.tools.sandbox import ToolSandbox
|
||||
|
||||
|
||||
class ShellExecTool(BaseTool):
|
||||
name = "shell_exec"
|
||||
|
||||
def __init__(self, sandbox: ToolSandbox) -> None:
|
||||
self._sandbox = sandbox
|
||||
|
||||
def execute(self, task: UserTask, args: dict[str, object]) -> ToolResult:
|
||||
command = str(args.get("command", "")).strip()
|
||||
if not command:
|
||||
return ToolResult(tool=self.name, ok=False, error="Missing command", metadata={"exit_code": -1})
|
||||
cwd = args.get("cwd")
|
||||
stdin_secret = args.get("stdin_secret")
|
||||
password = args.get("password")
|
||||
|
||||
if password:
|
||||
command = f'echo "{password}" | sudo -S {command}'
|
||||
|
||||
completed = self._sandbox.run_shell(
|
||||
command=command,
|
||||
cwd=str(cwd) if cwd else None,
|
||||
stdin_data=str(stdin_secret) if stdin_secret is not None else None,
|
||||
)
|
||||
output = completed.stdout if completed.returncode == 0 else completed.stderr or completed.stdout
|
||||
error_output = completed.stderr or completed.stdout
|
||||
|
||||
is_sudo_error = (
|
||||
completed.returncode != 0 and
|
||||
("permission denied" in error_output.lower() or
|
||||
"incorrect password" in error_output.lower() or
|
||||
"sudo: password incorrect" in error_output.lower() or
|
||||
"wrong password" in error_output.lower())
|
||||
)
|
||||
|
||||
return ToolResult(
|
||||
tool=self.name,
|
||||
ok=completed.returncode == 0,
|
||||
output=output,
|
||||
error=None if completed.returncode == 0 else f"Command failed with exit code {completed.returncode}",
|
||||
metadata={"exit_code": completed.returncode, "needs_sudo": is_sudo_error},
|
||||
)
|
||||
Reference in New Issue
Block a user