# 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_private",
"project_set_readonly",
"customize_set_css",
"customize_set_js",
"customize_reset",
"project_delete_file",
"delete_project",
"delete_media",
"regenerate_api_key",
"delete_post",
"delete_comment",
"delete_gist",
"delete_attachment",
"admin_media_purge",
"admin_delete_news",
"admin_reset_all_ai_quota",
"admin_reset_guest_ai_quota",
"admin_reset_user_ai_quota",
"backup_delete",
"backup_schedule_delete",
"notification_reset",
"db_insert_row",
"db_update_row",
"db_delete_row",
"gateway_provider_delete",
"gateway_model_delete",
}
CONDITIONAL_CONFIRM = {
"container_instance_action",
"container_exec",
}
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", ""))))
_DEVII_MECHANIC_EVENTS = {
"update_behavior": "devii.behavior.update",
"tool_create": "devii.tool.create",
"tool_update": "devii.tool.update",
"tool_delete": "devii.tool.delete",
"create_task": "devii.task.create",
"update_task": "devii.task.update",
"delete_task": "devii.task.delete",
"reflect": "devii.lesson.reflect",
"forget_lessons": "devii.lesson.forget",
"customize_set_css": "devii.customization.css.set",
"customize_set_js": "devii.customization.js.set",
"customize_reset": "devii.customization.reset",
"notification_set": "devii.notification.set",
"notification_reset": "devii.notification.reset",
}
_DEVII_CONTAINER_EVENTS = {
"container_create_instance": "container.instance.create",
"container_configure_instance": "container.instance.configure",
"container_exec": "container.instance.exec",
"container_schedule": "container.schedule.create",
}
def _safe_args(arguments: dict[str, Any]) -> dict[str, Any]:
out: dict[str, Any] = {}
for key, value in arguments.items():
text = str(value)
out[key] = text if len(text) <= 200 else text[:200] + "..."
return out
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 == "notification_reset":
return ToolInputError(
"Resetting clears every notification preference and restores the platform defaults; it "
"cannot be undone. Ask the user to confirm, then call again with confirm=true."
)
if name == "project_set_private":
value = str(arguments.get("value", "")).strip()
return ToolInputError(
f"Setting a project {'private' if value == 'true' else 'public'} "
"hides it from (or exposes it to) other members. Ask the user to confirm this "
"change explicitly, 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 == "regenerate_api_key":
return ToolInputError(
"Regenerating your API key immediately invalidates the current key, which may break "
"scripts or services that use it. Ask the user to confirm explicitly, then call again "
"with confirm=true."
)
if name == "delete_post":
slug = str(arguments.get("slug", arguments.get("post_slug", ""))).strip()
return ToolInputError(
f"Deleting the post '{slug}' is permanent and removes it from the site. "
"Ask the user to confirm explicitly, then call again with confirm=true."
)
if name == "delete_comment":
uid = str(arguments.get("uid", "")).strip() or "(unspecified)"
return ToolInputError(
f"Deleting comment '{uid}' is permanent and cannot be undone. "
"Ask the user to confirm explicitly, then call again with confirm=true."
)
if name == "delete_gist":
uid = str(arguments.get("uid", "")).strip() or "(unspecified)"
return ToolInputError(
f"Deleting gist '{uid}' is permanent. Show the user the exact gist, "
"get explicit confirmation, then call again with confirm=true."
)
if name == "delete_attachment":
uid = str(arguments.get("uid", "")).strip() or "(unspecified)"
return ToolInputError(
f"Deleting attachment '{uid}' is permanent and removes it from its parent. "
"Ask the user to confirm explicitly, 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."
)
if name == "db_insert_row":
table = str(arguments.get("table", "")).strip() or "(unspecified)"
return ToolInputError(
f"This writes a new row directly into the '{table}' table. Show the user the exact "
"table and values, get explicit confirmation, then call again with confirm=true."
)
if name == "db_update_row":
table = str(arguments.get("table", "")).strip() or "(unspecified)"
return ToolInputError(
f"This updates an existing row in the '{table}' table directly. Show the user the "
"exact row and new values, get explicit confirmation, then call again with confirm=true."
)
if name == "db_delete_row":
table = str(arguments.get("table", "")).strip() or "(unspecified)"
hard = str(arguments.get("hard", "")).strip().lower() in ("true", "1", "yes", "on")
kind = "PERMANENTLY purges" if hard else "soft-deletes"
return ToolInputError(
f"This {kind} a row in the '{table}' table. Show the user the exact row, get explicit "
"confirmation, then call again with confirm=true."
)
if name in CONFIRM_REQUIRED:
return ToolInputError(
"This removes the item as a soft delete: it disappears from every surface and is only "
"restorable from the admin trash. Show the user exactly what will be deleted, 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._owner_kind = owner_kind
self._owner_id = owner_id
self._fetch = FetchController(settings)
self._docs = DocsController(settings, is_admin=is_admin)
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)
from ..notification import NotificationController
self._notification = NotificationController(owner_kind, owner_id)
from ..ai_correction import AiCorrectionController
self._ai_correction = AiCorrectionController(owner_kind, owner_id)
from ..ai_modifier import AiModifierController
self._ai_modifier = AiModifierController(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)
self._audit_mechanic(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)
def _audit_mechanic(self, action: Action, arguments: dict[str, Any]) -> None:
from devplacepy.services.audit import record as audit
name = action.name
event_key = _DEVII_MECHANIC_EVENTS.get(name)
if event_key is None and action.handler == "container":
if name == "container_instance_action":
event_key = f"container.instance.{arguments.get('action', 'action')}"
else:
event_key = _DEVII_CONTAINER_EVENTS.get(name)
if event_key is None:
return
actor_kind = "user" if self._owner_kind == "user" else self._owner_kind
audit.record_system(
event_key,
actor_kind=actor_kind,
actor_uid=self._owner_id if self._owner_kind == "user" else None,
actor_role="admin" if self._is_admin else (actor_kind if actor_kind != "user" else "member"),
origin="devii",
via_agent=1,
summary=f"Devii {name} for {self._owner_id or self._owner_kind}",
metadata={"tool": name, "args": _safe_args(arguments)},
)
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 == "notification":
return await self._notification.dispatch(action.name, arguments)
if action.handler == "ai_correction":
return await self._ai_correction.dispatch(action.name, arguments)
if action.handler == "ai_modifier":
return await self._ai_modifier.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 and not action.is_read_only:
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)