|
# retoor <retoor@molodetz.nl>
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
from typing import Any
|
|
from urllib.parse import quote, urlencode
|
|
|
|
from ..config import Settings
|
|
from ..errors import (
|
|
AuthRequiredError,
|
|
DeviiError,
|
|
ToolInputError,
|
|
error_result,
|
|
unexpected_result,
|
|
)
|
|
from ..agentic.controller import AgenticController
|
|
from ..agentic.state import record_mutation
|
|
from ..avatar import AvatarController
|
|
from ..chunks import ChunkController, get_store, serve_resource, wrap_if_large
|
|
from ..cost import CostController
|
|
from ..docs import DocsController
|
|
from ..fetch import FetchController
|
|
from ..http_client import PlatformClient
|
|
from ..rsearch import RsearchController
|
|
from ..tasks.controller import TaskController
|
|
from ..text import format_response
|
|
from .spec import Action, Catalog
|
|
|
|
MUTATING_METHODS = ("POST", "DELETE", "PUT", "PATCH")
|
|
|
|
CONFIRM_REQUIRED = {
|
|
"project_set_readonly",
|
|
"customize_set_css",
|
|
"customize_set_js",
|
|
"customize_reset",
|
|
"project_delete_file",
|
|
"delete_project",
|
|
"delete_media",
|
|
}
|
|
|
|
DESTRUCTIVE_COMMAND = re.compile(
|
|
r"(?:^|[\s;&|`(])(?:sudo\s+)?(rm|rmdir|unlink|shred|truncate|wipefs|mkfs\S*|dd)(?:\s|$)"
|
|
r"|>\s*/"
|
|
r"|\bdrop\s+(?:table|database)\b"
|
|
r"|\bdelete\s+from\b"
|
|
r"|\bgit\s+clean\b"
|
|
r"|\s-delete\b",
|
|
re.IGNORECASE,
|
|
)
|
|
|
|
logger = logging.getLogger("devii.dispatch")
|
|
|
|
|
|
def _is_confirmed(arguments: dict[str, Any]) -> bool:
|
|
return str(arguments.get("confirm", "")).strip().lower() in (
|
|
"true",
|
|
"1",
|
|
"yes",
|
|
"on",
|
|
)
|
|
|
|
|
|
def _is_destructive_command(arguments: dict[str, Any]) -> bool:
|
|
return bool(DESTRUCTIVE_COMMAND.search(str(arguments.get("command", ""))))
|
|
|
|
|
|
def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError | None:
|
|
if _is_confirmed(arguments):
|
|
return None
|
|
if name in ("customize_set_css", "customize_set_js"):
|
|
scope = str(arguments.get("scope", "")).strip() or "(unspecified)"
|
|
return ToolInputError(
|
|
"Before saving, confirm the scope with the user: should this apply to THIS page type "
|
|
f"('{scope}') or to the whole site ('global')? Ask them, then call again with the chosen "
|
|
"scope and confirm=true."
|
|
)
|
|
if name == "customize_reset":
|
|
return ToolInputError(
|
|
"Deleting customizations cannot be undone. Ask the user to confirm, then call again with "
|
|
"confirm=true."
|
|
)
|
|
if name == "project_set_readonly":
|
|
return ToolInputError(
|
|
"Setting a project read-only makes every file immutable and blocks all further "
|
|
"edits. Ask the user to confirm this explicitly first, then call again with "
|
|
"confirm=true."
|
|
)
|
|
if name == "project_delete_file":
|
|
path = str(arguments.get("path", "")).strip() or "(unspecified)"
|
|
return ToolInputError(
|
|
f"Deleting '{path}' is permanent and cannot be undone. Show the user the exact path, "
|
|
"get explicit confirmation, then call again with confirm=true."
|
|
)
|
|
if name == "delete_project":
|
|
return ToolInputError(
|
|
"Deleting a project permanently removes the project and ALL of its files. Ask the user "
|
|
"to confirm this explicitly first, then call again with confirm=true."
|
|
)
|
|
if name == "delete_media":
|
|
return ToolInputError(
|
|
"Deleting media removes it from every display surface, including its parent post or "
|
|
"project. Show the user the exact media, get explicit confirmation, then call again "
|
|
"with confirm=true."
|
|
)
|
|
if (
|
|
name == "container_instance_action"
|
|
and str(arguments.get("action", "")).strip().lower() == "delete"
|
|
):
|
|
return ToolInputError(
|
|
"Deleting a container instance is irreversible and destroys its state. Ask the user to "
|
|
"confirm explicitly, then call again with confirm=true."
|
|
)
|
|
if name == "container_exec" and _is_destructive_command(arguments):
|
|
command = str(arguments.get("command", "")).strip()
|
|
return ToolInputError(
|
|
"This command can permanently delete files or data (it contains a destructive operation "
|
|
f"such as rm, dd, truncate, or drop): {command!r}. Show the user the exact command, get "
|
|
"explicit confirmation, then call again with confirm=true."
|
|
)
|
|
return None
|
|
|
|
|
|
class Dispatcher:
|
|
def __init__(
|
|
self,
|
|
catalog: Catalog,
|
|
client: PlatformClient,
|
|
settings: Settings,
|
|
tasks: TaskController,
|
|
agentic: AgenticController,
|
|
avatar: AvatarController | None = None,
|
|
browser: Any = None,
|
|
is_admin: bool = False,
|
|
quota_provider: Any = None,
|
|
owner_kind: str = "guest",
|
|
owner_id: str = "",
|
|
virtual_tools: Any = None,
|
|
behavior: Any = None,
|
|
) -> None:
|
|
self._actions = catalog.by_name()
|
|
self._client = client
|
|
self._settings = settings
|
|
self._tasks = tasks
|
|
self._agentic = agentic
|
|
self._avatar = avatar
|
|
self._browser = browser
|
|
self._is_admin = is_admin
|
|
self._fetch = FetchController(settings)
|
|
self._docs = DocsController(settings)
|
|
self._cost = CostController(quota_provider=quota_provider)
|
|
self._chunks = ChunkController(settings)
|
|
self._rsearch = RsearchController(settings)
|
|
from ..container import ContainerController
|
|
|
|
self._container = ContainerController(client)
|
|
from ..customization import CustomizationController
|
|
|
|
self._customization = CustomizationController(owner_kind, owner_id)
|
|
self._virtual_tools = virtual_tools
|
|
self._behavior = behavior
|
|
self._read_files: set[tuple[str, str]] = set()
|
|
|
|
@staticmethod
|
|
def _file_key(arguments: dict[str, Any]) -> tuple[str, str] | None:
|
|
from devplacepy.project_files import (
|
|
normalize_path,
|
|
ProjectFileError as _PFError,
|
|
)
|
|
|
|
raw = arguments.get("path")
|
|
if not raw:
|
|
return None
|
|
try:
|
|
path = normalize_path(raw)
|
|
except _PFError:
|
|
return None
|
|
return str(arguments.get("project_slug", "")), path
|
|
|
|
def is_read_only(self, name: str) -> bool:
|
|
action = self._actions.get(name)
|
|
return bool(action and action.is_read_only)
|
|
|
|
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
|
action = self._actions.get(name)
|
|
if action is None:
|
|
if self._virtual_tools is not None and self._virtual_tools.has(name):
|
|
logger.info("Dispatch virtual tool %s args=%s", name, list(arguments))
|
|
try:
|
|
return await self._virtual_tools.run(name, arguments)
|
|
except DeviiError as exc:
|
|
return error_result(exc)
|
|
except Exception as exc: # noqa: BLE001 - surfaced to the model as data
|
|
logger.exception("Virtual tool %s crashed", name)
|
|
return unexpected_result(exc)
|
|
return error_result(ToolInputError(f"Unknown tool: {name}"))
|
|
|
|
logger.info("Dispatch %s args=%s", name, list(arguments))
|
|
try:
|
|
if action.requires_auth and not self._client.authenticated:
|
|
raise AuthRequiredError(
|
|
"Not authenticated. Ask the user for credentials and call the login tool first.",
|
|
tool=name,
|
|
)
|
|
if action.requires_admin and not self._is_admin:
|
|
raise AuthRequiredError(
|
|
"This information is restricted to administrators.",
|
|
tool=name,
|
|
)
|
|
guard = confirmation_error(name, arguments)
|
|
if guard is not None:
|
|
raise guard
|
|
resource_key = self._resource_key(action, arguments)
|
|
if resource_key:
|
|
cached = serve_resource(resource_key, self._settings.max_response_chars)
|
|
if cached is not None:
|
|
logger.info("Resource cache hit for %s (%s)", name, resource_key)
|
|
return cached
|
|
result = await self._run(action, arguments)
|
|
if action.handler == "chunks":
|
|
return result
|
|
return wrap_if_large(
|
|
result, self._settings.max_response_chars, resource_key
|
|
)
|
|
except DeviiError as exc:
|
|
logger.info("Dispatch %s failed: %s", name, exc.message)
|
|
return error_result(exc)
|
|
except Exception as exc: # noqa: BLE001 - surfaced to the model as data
|
|
logger.exception("Dispatch %s crashed", name)
|
|
return unexpected_result(exc)
|
|
|
|
async def _run(self, action: Action, arguments: dict[str, Any]) -> str:
|
|
if action.handler == "status":
|
|
return json.dumps(
|
|
{
|
|
"authenticated": self._client.authenticated,
|
|
"user": self._client.username,
|
|
},
|
|
ensure_ascii=False,
|
|
)
|
|
|
|
if action.handler == "login":
|
|
result = await self._client.login(
|
|
email=self._require(arguments, "email"),
|
|
password=self._require(arguments, "password"),
|
|
remember_me=str(arguments.get("remember_me", "on")).lower()
|
|
not in ("", "false", "off", "no"),
|
|
)
|
|
return json.dumps(result, ensure_ascii=False)
|
|
|
|
if action.handler == "logout":
|
|
return json.dumps(await self._client.logout(), ensure_ascii=False)
|
|
|
|
if action.handler == "task":
|
|
return await self._tasks.dispatch(action.name, arguments)
|
|
|
|
if action.handler == "agentic":
|
|
return await self._agentic.dispatch(action.name, arguments)
|
|
|
|
if action.handler == "fetch":
|
|
return await self._fetch.dispatch(action.name, arguments)
|
|
|
|
if action.handler == "docs":
|
|
return await self._docs.dispatch(action.name, arguments)
|
|
|
|
if action.handler == "cost":
|
|
return await self._cost.dispatch(action.name, arguments)
|
|
|
|
if action.handler == "chunks":
|
|
return await self._chunks.dispatch(action.name, arguments)
|
|
|
|
if action.handler == "rsearch":
|
|
return await self._rsearch.dispatch(action.name, arguments)
|
|
|
|
if action.handler == "container":
|
|
return await self._container.dispatch(action.name, arguments)
|
|
|
|
if action.handler == "customization":
|
|
return await self._customization.dispatch(action.name, arguments)
|
|
|
|
if action.handler == "behavior":
|
|
if self._behavior is None:
|
|
return error_result(
|
|
ToolInputError(
|
|
"Behavior configuration is not available in this context."
|
|
)
|
|
)
|
|
return await self._behavior.dispatch(action.name, arguments)
|
|
|
|
if action.handler == "virtual_tool":
|
|
if self._virtual_tools is None:
|
|
return error_result(
|
|
ToolInputError(
|
|
"User-defined tools are not available in this context."
|
|
)
|
|
)
|
|
return await self._virtual_tools.dispatch(action.name, arguments)
|
|
|
|
if action.handler == "avatar":
|
|
if self._avatar is None:
|
|
return error_result(
|
|
ToolInputError(
|
|
"No avatar is attached; devii's on-screen actions need the web interface."
|
|
)
|
|
)
|
|
return await self._avatar.dispatch(action.name, arguments)
|
|
|
|
if action.handler == "client":
|
|
if self._browser is None:
|
|
return error_result(
|
|
ToolInputError(
|
|
"No browser is attached; client-side actions need the web interface."
|
|
)
|
|
)
|
|
return await self._browser.dispatch(action.name, arguments)
|
|
|
|
return await self._run_http(action, arguments)
|
|
|
|
def _build_request(
|
|
self, action: Action, arguments: dict[str, Any]
|
|
) -> tuple[str, dict[str, Any], dict[str, Any], tuple[str, str] | None]:
|
|
url_path = action.path
|
|
params: dict[str, Any] = {}
|
|
data: dict[str, Any] = {}
|
|
file_field: tuple[str, str] | None = None
|
|
|
|
for param in action.params:
|
|
if param.name not in arguments or arguments[param.name] is None:
|
|
if param.required:
|
|
raise ToolInputError(
|
|
f"Missing required parameter '{param.name}' for {action.name}."
|
|
)
|
|
continue
|
|
value = arguments[param.name]
|
|
if param.location == "path":
|
|
url_path = url_path.replace(
|
|
"{" + param.name + "}", quote(str(value), safe="")
|
|
)
|
|
elif param.location == "query":
|
|
params[param.name] = value
|
|
elif param.location == "body":
|
|
data[param.name] = value
|
|
elif param.location == "file":
|
|
file_field = (param.name, str(value))
|
|
|
|
if action.freeform_body:
|
|
extra = arguments.get("form_fields") or {}
|
|
if isinstance(extra, dict):
|
|
data.update({str(k): v for k, v in extra.items()})
|
|
|
|
return url_path, params, data, file_field
|
|
|
|
def _resource_key(self, action: Action, arguments: dict[str, Any]) -> str | None:
|
|
try:
|
|
if action.handler == "fetch" and action.name == "fetch_url":
|
|
url = str(arguments.get("url", "")).strip()
|
|
if not url:
|
|
return None
|
|
if "://" not in url:
|
|
url = "https://" + url
|
|
return f"fetch:{url}"
|
|
if action.handler == "http" and action.method == "GET":
|
|
url_path, params, _, _ = self._build_request(action, arguments)
|
|
query = urlencode(sorted((str(k), str(v)) for k, v in params.items()))
|
|
return f"http:GET {url_path}?{query}"
|
|
except ToolInputError:
|
|
return None
|
|
return None
|
|
|
|
async def _file_exists(self, arguments: dict[str, Any]) -> bool:
|
|
slug = str(arguments.get("project_slug", "")).strip()
|
|
path = str(arguments.get("path", "")).strip()
|
|
if not slug or not path:
|
|
return False
|
|
response = await self._client.call(
|
|
method="GET",
|
|
path=f"/projects/{quote(slug, safe='')}/files/raw",
|
|
params={"path": path},
|
|
headers={"X-Requested-With": "fetch"},
|
|
)
|
|
return response.status_code == 200
|
|
|
|
async def _run_http(self, action: Action, arguments: dict[str, Any]) -> str:
|
|
if action.name == "project_write_file":
|
|
key = self._file_key(arguments)
|
|
if (
|
|
key is not None
|
|
and key not in self._read_files
|
|
and await self._file_exists(arguments)
|
|
):
|
|
raise ToolInputError(
|
|
f"Read '{key[1]}' before overwriting it. It already exists; call "
|
|
"project_read_file first. For an existing file prefer the line tools "
|
|
"(project_replace_lines, project_insert_lines, project_delete_lines, "
|
|
"project_append_file); project_write_file replaces the entire file."
|
|
)
|
|
|
|
url_path, params, data, file_field = self._build_request(action, arguments)
|
|
|
|
headers = {"X-Requested-With": "fetch"} if action.ajax else None
|
|
response = await self._client.call(
|
|
method=action.method,
|
|
path=url_path,
|
|
params=params or None,
|
|
data=data or None,
|
|
file_field=file_field,
|
|
headers=headers,
|
|
)
|
|
if action.name in (
|
|
"project_read_file",
|
|
"project_read_lines",
|
|
"project_write_file",
|
|
):
|
|
key = self._file_key(arguments)
|
|
if key is not None:
|
|
self._read_files.add(key)
|
|
if action.method in MUTATING_METHODS:
|
|
record_mutation(action.name)
|
|
store = get_store()
|
|
if store is not None:
|
|
store.invalidate_resources(prefix="http:")
|
|
return format_response(response)
|
|
|
|
@staticmethod
|
|
def _require(arguments: dict[str, Any], key: str) -> str:
|
|
value = arguments.get(key)
|
|
if value is None or str(value).strip() == "":
|
|
raise ToolInputError(f"Missing required field '{key}'.")
|
|
return str(value)
|