forked from retoor/devplacepy
feat: add project fork async job service with cli prune/clear commands and shared container image build target
This commit is contained in:
@@ -212,11 +212,14 @@ ACTIONS: tuple[Action, ...] = (
|
||||
summary="Mark a project private (owner-only) or public",
|
||||
description=(
|
||||
"Set value=true to hide the project from everyone except its owner (and administrators), "
|
||||
"or value=false to make it public again. Only the project owner may change this."
|
||||
"or value=false to make it public again. This is a significant change: you MUST ask the "
|
||||
"user for explicit confirmation BEFORE calling it, and only pass confirm=true once they "
|
||||
"have agreed. Only the project owner may change this."
|
||||
),
|
||||
params=(
|
||||
path("project_slug", "Project slug or uid."),
|
||||
body("value", "true to make the project private, false to make it public.", required=True),
|
||||
body("confirm", "Must be true, set only after the user has explicitly confirmed.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
@@ -419,6 +422,33 @@ ACTIONS: tuple[Action, ...] = (
|
||||
params=(path("uid", "Zip job uid returned by zip_project."),),
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="fork_project",
|
||||
method="POST",
|
||||
path="/projects/{project_slug}/fork",
|
||||
summary="Fork a project into a new project owned by the current user",
|
||||
description=(
|
||||
"Queues a background fork job and returns {uid, status_url}. Poll the status_url with "
|
||||
"fork_status until status is 'done', then give the user the project_url of the new fork."
|
||||
),
|
||||
params=(
|
||||
path("project_slug", "Slug or uid of the project to fork."),
|
||||
body("title", "Title for the new forked project.", required=True),
|
||||
),
|
||||
requires_auth=True,
|
||||
),
|
||||
Action(
|
||||
name="fork_status",
|
||||
method="GET",
|
||||
path="/forks/{uid}",
|
||||
summary="Check a fork job and obtain the new project once finished",
|
||||
description=(
|
||||
"Returns the job status. When status is 'done', project_url points at the new forked "
|
||||
"project; while 'pending' or 'running', poll again shortly."
|
||||
),
|
||||
params=(path("uid", "Fork job uid returned by fork_project."),),
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="search_users",
|
||||
method="GET",
|
||||
|
||||
@@ -110,4 +110,22 @@ CLIENT_ACTIONS: tuple[Action, ...] = (
|
||||
handler="client",
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="open_terminal",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Open a floating interactive terminal window attached to a running container instance",
|
||||
description=(
|
||||
CLIENT + " Opens a new xterm.js window in the user's browser connected to the container's "
|
||||
"interactive shell (admin only). Resolve the instance first with container_list_instances, "
|
||||
"then pass the project slug and the instance slug or uid."
|
||||
),
|
||||
handler="client",
|
||||
requires_admin=True,
|
||||
params=(
|
||||
arg("project_slug", "Project slug or uid that owns the container.", required=True),
|
||||
arg("instance", "Container instance slug or uid.", required=True),
|
||||
arg("label", "Optional window title (defaults to the instance name)."),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -10,47 +10,6 @@ def arg(name: str, description: str, required: bool = False, kind: str = "string
|
||||
SLUG = arg("project_slug", "Project slug or uid that owns the container resources.", required=True)
|
||||
|
||||
CONTAINER_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="container_list_dockerfiles",
|
||||
method="LOCAL", path="", handler="container", requires_admin=True, read_only=True,
|
||||
summary="List the Dockerfiles, builds, and instances of a project",
|
||||
params=(SLUG,),
|
||||
),
|
||||
Action(
|
||||
name="container_create_dockerfile",
|
||||
method="LOCAL", path="", handler="container", requires_admin=True,
|
||||
summary="Create a Dockerfile in a project and queue its first build",
|
||||
description="With no content a lean python-slim default builds in seconds and serves /app on port 8000 (long-lived, ingress-ready). Pass full content for anything else; for browser automation start from python-slim and add 'pip install playwright && playwright install --with-deps chromium'.",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("name", "Image name, lowercase letters/digits/.-_ (max 63 chars).", required=True),
|
||||
arg("description", "Optional description."),
|
||||
arg("tags", "Optional comma separated tags."),
|
||||
arg("content", "Optional full Dockerfile content."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="container_update_dockerfile",
|
||||
method="LOCAL", path="", handler="container", requires_admin=True,
|
||||
summary="Replace a Dockerfile's content, creating a new immutable version and queueing a build",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("dockerfile", "Dockerfile name, slug, or uid.", required=True),
|
||||
arg("content", "New Dockerfile content.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="container_build",
|
||||
method="LOCAL", path="", handler="container", requires_admin=True,
|
||||
summary="Rebuild the current version of a Dockerfile",
|
||||
params=(SLUG, arg("dockerfile", "Dockerfile name, slug, or uid.", required=True)),
|
||||
),
|
||||
Action(
|
||||
name="container_build_status",
|
||||
method="LOCAL", path="", handler="container", requires_admin=True, read_only=True,
|
||||
summary="Get a build's status and recent log tail",
|
||||
params=(SLUG, arg("build_uid", "Build uid.", required=True)),
|
||||
),
|
||||
Action(
|
||||
name="container_list_instances",
|
||||
method="LOCAL", path="", handler="container", requires_admin=True, read_only=True,
|
||||
@@ -60,10 +19,9 @@ CONTAINER_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="container_create_instance",
|
||||
method="LOCAL", path="", handler="container", requires_admin=True,
|
||||
summary="Create and start a container instance from a Dockerfile's latest successful build",
|
||||
summary="Create and start a container instance (runs the shared ppy image with the project files mounted at /app)",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("dockerfile", "Dockerfile name, slug, or uid.", required=True),
|
||||
arg("name", "Instance name.", required=True),
|
||||
arg("boot_command", "Optional command to run on boot, e.g. 'python app.py'."),
|
||||
arg("restart_policy", "never, always, on-failure, or unless-stopped."),
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .spec import Action, Param
|
||||
|
||||
|
||||
def arg(name: str, description: str, required: bool = False, kind: str = "string") -> Param:
|
||||
return Param(name=name, location="body", description=description, required=required, type=kind)
|
||||
|
||||
|
||||
SCOPE = (
|
||||
"Either 'global' (applies to every page of the site for this user) or a page-type string "
|
||||
"as returned by get_page_context.pageType (e.g. '/posts/{slug}'), which applies to every "
|
||||
"page of that type."
|
||||
)
|
||||
|
||||
CUSTOMIZATION_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="customize_list",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="List the user's saved CSS/JS customizations (scopes, languages, sizes)",
|
||||
description=(
|
||||
"Returns every stored customization for the current user (or guest session), with its "
|
||||
"scope, language, enabled flag and a short preview of the code."
|
||||
),
|
||||
handler="customization",
|
||||
requires_auth=False,
|
||||
read_only=True,
|
||||
),
|
||||
Action(
|
||||
name="customize_get",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Read the full saved CSS or JS code for one scope",
|
||||
description="Use this before editing so you build on existing customization rather than overwriting it.",
|
||||
handler="customization",
|
||||
requires_auth=False,
|
||||
read_only=True,
|
||||
params=(
|
||||
arg("scope", SCOPE, required=True),
|
||||
arg("lang", "Which code to read: 'css' or 'js'.", required=True),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="customize_set_css",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Permanently save custom CSS for a page type or the whole site",
|
||||
description=(
|
||||
"Persists custom CSS for the current user. Before calling, ask the user whether it should "
|
||||
"apply to the current page TYPE or to the whole site (global). The CSS is the final, full "
|
||||
"stylesheet for that scope and replaces any previously saved CSS for it."
|
||||
),
|
||||
handler="customization",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
arg("scope", SCOPE, required=True),
|
||||
arg("css", "The full CSS source to save for this scope.", required=True),
|
||||
arg("confirm", "Must be true after the user has confirmed the scope.", required=True, kind="boolean"),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="customize_set_js",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Permanently save custom JavaScript for a page type or the whole site",
|
||||
description=(
|
||||
"Persists custom JavaScript for the current user. Before calling, ask the user whether it "
|
||||
"should apply to the current page TYPE or to the whole site (global). The code runs after "
|
||||
"the application boots, with access to window, document and the global 'app'. It is the "
|
||||
"final, full script for that scope and replaces any previously saved JS for it."
|
||||
),
|
||||
handler="customization",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
arg("scope", SCOPE, required=True),
|
||||
arg("js", "The full JavaScript source to save for this scope.", required=True),
|
||||
arg("confirm", "Must be true after the user has confirmed the scope.", required=True, kind="boolean"),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="customize_reset",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Delete saved customizations, restoring the default look and behaviour",
|
||||
description=(
|
||||
"Removes saved customizations. Omit lang to remove both CSS and JS for the scope. Pass "
|
||||
"scope='all' to remove every customization for this user. This cannot be undone."
|
||||
),
|
||||
handler="customization",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
arg("scope", SCOPE + " Use 'all' to remove every customization.", required=True),
|
||||
arg("lang", "Optional: limit the reset to 'css' or 'js'. Omit to remove both."),
|
||||
arg("confirm", "Must be true after the user has confirmed the deletion.", required=True, kind="boolean"),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -30,7 +30,7 @@ from .spec import Action, Catalog
|
||||
|
||||
MUTATING_METHODS = ("POST", "DELETE", "PUT", "PATCH")
|
||||
|
||||
CONFIRM_REQUIRED = {"project_set_readonly"}
|
||||
CONFIRM_REQUIRED = {"project_set_readonly", "project_set_private", "customize_set_css", "customize_set_js", "customize_reset"}
|
||||
|
||||
logger = logging.getLogger("devii.dispatch")
|
||||
|
||||
@@ -40,13 +40,36 @@ def _is_confirmed(arguments: dict[str, Any]) -> bool:
|
||||
|
||||
|
||||
def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError | None:
|
||||
if name in CONFIRM_REQUIRED and not _is_confirmed(arguments):
|
||||
if name not in CONFIRM_REQUIRED or _is_confirmed(arguments):
|
||||
return None
|
||||
if name in ("customize_set_css", "customize_set_js"):
|
||||
scope = str(arguments.get("scope", "")).strip() or "(unspecified)"
|
||||
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 "
|
||||
"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."
|
||||
)
|
||||
return None
|
||||
if name == "project_set_private":
|
||||
making_private = str(arguments.get("value", "")).strip().lower() in ("true", "1", "yes", "on")
|
||||
change = (
|
||||
"hide the project from everyone except its owner and administrators"
|
||||
if making_private
|
||||
else "make the project public so everyone can see it and all its files"
|
||||
)
|
||||
return ToolInputError(
|
||||
f"Changing a project's visibility will {change}. Ask the user to confirm this explicitly "
|
||||
"first, then call again with confirm=true."
|
||||
)
|
||||
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."
|
||||
)
|
||||
|
||||
|
||||
class Dispatcher:
|
||||
@@ -61,6 +84,9 @@ class Dispatcher:
|
||||
browser: Any = None,
|
||||
is_admin: bool = False,
|
||||
quota_provider: Any = None,
|
||||
owner_kind: str = "guest",
|
||||
owner_id: str = "",
|
||||
virtual_tools: Any = None,
|
||||
) -> None:
|
||||
self._actions = catalog.by_name()
|
||||
self._client = client
|
||||
@@ -77,6 +103,9 @@ class Dispatcher:
|
||||
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._read_files: set[tuple[str, str]] = set()
|
||||
|
||||
@staticmethod
|
||||
@@ -98,6 +127,15 @@ class Dispatcher:
|
||||
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))
|
||||
@@ -177,6 +215,16 @@ class Dispatcher:
|
||||
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 == "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(
|
||||
|
||||
@@ -87,23 +87,27 @@ SYSTEM_PROMPT = (
|
||||
"A project owner can mark a project private (project_set_private) so only the owner and "
|
||||
"administrators can see it, and read-only (project_set_readonly) so every file becomes immutable. "
|
||||
"When a project is read-only, all file writes fail with 'project is read-only'; to edit such a "
|
||||
"project you must first ask the owner for permission and set it writable again. Marking a project "
|
||||
"read-only is significant and largely locks it down, so you MUST obtain explicit user confirmation "
|
||||
"before calling project_set_readonly with value=true, and only then pass confirm=true; never set a "
|
||||
"project read-only on your own initiative.\n\n"
|
||||
"project you must first ask the owner for permission and set it writable again. Changing a "
|
||||
"project's visibility or read-only state is significant, so you MUST obtain explicit user "
|
||||
"confirmation before calling project_set_private or project_set_readonly (in either direction), "
|
||||
"and only then pass confirm=true; never change visibility or lock a project on your own "
|
||||
"initiative.\n\n"
|
||||
"CONTAINERS (admin only)\n"
|
||||
"When you are an administrator you can manage a project's Docker containers with the container_* "
|
||||
"tools: author Dockerfiles (a high-quality default is provided), build versioned images "
|
||||
"asynchronously, create and control instances "
|
||||
"(container_instance_action: start/stop/restart/pause/resume/delete/sync), read logs, run one-shot "
|
||||
"commands (container_exec), inspect stats, and schedule starts/stops. Builds and instances run on "
|
||||
"When you are an administrator you can manage a project's containers with the container_* tools: "
|
||||
"create and control instances (container_create_instance; container_instance_action: "
|
||||
"start/stop/restart/pause/resume/delete/sync), read logs, run one-shot commands (container_exec), "
|
||||
"inspect stats, and schedule starts/stops. There is no image building: every instance runs the "
|
||||
"shared prebuilt 'ppy' image (Python + Playwright + common libraries) with the project's files "
|
||||
"mounted at /app, so creating an instance is instant. If a project needs an extra Python package, "
|
||||
"install it at runtime via container_exec ('pip install ...') - no sudo is needed. Instances run on "
|
||||
"the host docker daemon, so confirm destructive actions and never expose backend or infrastructure "
|
||||
"detail. These tools are unavailable to non-administrators. "
|
||||
"Builds are asynchronous and can take a while for heavy base images. "
|
||||
"container_build_status reports a 'status' (queued, building, success, failed, cancelled) "
|
||||
"and a 'terminal' flag: a status of queued or building is NORMAL in-progress, not an error - keep "
|
||||
"polling patiently (wait between polls) and do NOT change the Dockerfile or start a new build until "
|
||||
"'terminal' is true. Only treat status 'failed' (or a 'build_error' field) as a failure to fix. "
|
||||
"When the user asks to attach, open, show, reopen, or get back a terminal/shell for a container, "
|
||||
"ALWAYS call open_terminal (resolve the instance with container_list_instances first if you do not "
|
||||
"already know its slug/uid). Call it EVERY time it is asked - even if you opened a terminal earlier "
|
||||
"in this conversation - because the user may have closed the window; open_terminal reopens the "
|
||||
"window or focuses the existing one, and (thanks to tmux) it re-attaches to the same running shell. "
|
||||
"Never reply that the terminal is 'already open' without actually calling open_terminal. "
|
||||
"A running instance can be published with an ingress slug; the container tools then return an "
|
||||
"'ingress_url'. To reach a published service, use that 'ingress_url' (the configured public URL, "
|
||||
"e.g. https://host/p/<slug>) - never localhost or 127.0.0.1, which web tools refuse. If ingress_url "
|
||||
@@ -116,6 +120,35 @@ SYSTEM_PROMPT = (
|
||||
"information the platform cannot provide and the user wants it. Never use them to answer questions "
|
||||
"about this DevPlace instance, its users, posts, settings, or metrics - those have dedicated "
|
||||
"platform tools. When platform tools can serve the request, do not call rsearch.\n\n"
|
||||
"CUSTOMIZATION (CSS AND JAVASCRIPT)\n"
|
||||
"The user can permanently customize the LOOK (custom CSS) and BEHAVIOUR (custom JavaScript) of "
|
||||
"the site through the customize_* tools. Every customization is scoped either to the current "
|
||||
"page TYPE (it then applies to every page of that type, for example all post pages, never a "
|
||||
"single specific post) or GLOBALLY (every page). It applies only to this user's own sessions; a "
|
||||
"signed-out guest gets customizations for their current session. To learn the current page type, "
|
||||
"call get_page_context and read its pageType field, and pass that exact value as the scope; pass "
|
||||
"scope='global' for the whole site. "
|
||||
"Iterate by PREVIEWING live first: inject a draft with run_js, replacing an element with id "
|
||||
"'devii-preview-css' (a <style>) or 'devii-preview-js' (run the code), so the user sees it "
|
||||
"immediately WITHOUT saving. Do not persist while experimenting. Before you SAVE "
|
||||
"(customize_set_css / customize_set_js) you MUST ask the user whether it is for the current page "
|
||||
"type or the whole site; only after they choose, call the tool with that scope and confirm=true. "
|
||||
"Saved code is the full, final stylesheet or script for that scope and replaces what was there, "
|
||||
"so call customize_get first and build on the existing code instead of dropping it. Saved JS runs "
|
||||
"after the application boots, with access to window, document and the global 'app'. After saving, "
|
||||
"call reload_page so the server-applied version is shown. List existing customizations with "
|
||||
"customize_list, and remove them with customize_reset (confirm=true; scope='all' removes "
|
||||
"everything) to restore the default look and behaviour.\n\n"
|
||||
"USER-DEFINED TOOLS (VIBE TOOLS)\n"
|
||||
"The user can invent new tools for you by describing them ('when I say X, do Y'). When they do, "
|
||||
"call tool_create with a short trigger-oriented description (so you know when to use it), the "
|
||||
"instruction prompt the tool should run, and an input_description for its single free-form input. "
|
||||
"The new tool becomes callable on the next turn and appears in your toolset; when called, it "
|
||||
"re-runs you on its stored prompt plus the user's input and returns the result. Manage these with "
|
||||
"tool_list, tool_get, tool_update (including enabling/disabling), and tool_delete. Use eval(prompt) "
|
||||
"to run yourself on an arbitrary prompt and get the result. A user-defined tool covers all of that "
|
||||
"user's sessions (for a guest, only the current session). Nesting is bounded: do not design a tool "
|
||||
"or eval that keeps calling itself, as the self-evaluation depth is limited.\n\n"
|
||||
"RESPONSE STYLE\n"
|
||||
"Replies are plain, concise, and professional. Never use emojis, decorative symbols, or "
|
||||
"celebratory language; report outcomes matter-of-factly. State what changed using the "
|
||||
|
||||
@@ -98,4 +98,20 @@ AGENTIC_ACTIONS: tuple[Action, ...] = (
|
||||
arg("allowed_tools", "Optional list of tool names the sub-agent may use.", kind="array"),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="eval",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Run a prompt through a fresh Devii sub-agent and return its result (self-evaluation)",
|
||||
description=(
|
||||
"Executes the given prompt as if it were a new request to yourself, with full tool "
|
||||
"access, and returns the result. This is the engine behind user-defined tools. Nesting is "
|
||||
"limited, so do not build deep self-calling chains."
|
||||
),
|
||||
handler="agentic",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
arg("prompt", "The instruction to run as a fresh sub-agent request.", required=True),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -10,7 +10,14 @@ from ..config import Settings
|
||||
from ..errors import ToolInputError
|
||||
from .lessons import LessonStore
|
||||
from .loop import TraceCallback, react_loop
|
||||
from .state import AgentState, get_state
|
||||
from .state import (
|
||||
MAX_EVAL_DEPTH,
|
||||
AgentState,
|
||||
get_eval_depth,
|
||||
get_state,
|
||||
reset_eval_depth,
|
||||
set_eval_depth,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("devii.agentic.controller")
|
||||
|
||||
@@ -58,6 +65,7 @@ class AgenticController:
|
||||
"forget_lessons": self._forget,
|
||||
"verify": self._verify,
|
||||
"delegate": self._delegate,
|
||||
"eval": self._eval,
|
||||
}
|
||||
handler = handlers.get(name)
|
||||
if handler is None:
|
||||
@@ -147,12 +155,53 @@ class AgenticController:
|
||||
{"status": "success", "verified": bool(confirmed), "summary": summary}, ensure_ascii=False
|
||||
)
|
||||
|
||||
async def _spawn(
|
||||
self, prompt: str, tools: list[dict[str, Any]], system_prompt: str = SUB_AGENT_SYSTEM_PROMPT
|
||||
) -> tuple[str, AgentState]:
|
||||
if self._llm is None or self._dispatcher is None:
|
||||
raise ToolInputError("Sub-agent execution is not available in this context.")
|
||||
depth = get_eval_depth()
|
||||
if depth >= MAX_EVAL_DEPTH:
|
||||
raise ToolInputError(
|
||||
"Nested self-evaluation limit reached; a tool or eval cannot keep calling itself."
|
||||
)
|
||||
messages = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt},
|
||||
]
|
||||
sub_state = AgentState()
|
||||
token = set_eval_depth(depth + 1)
|
||||
try:
|
||||
result = await react_loop(
|
||||
llm=self._llm,
|
||||
dispatcher=self._dispatcher,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
state=sub_state,
|
||||
settings=self._settings,
|
||||
max_iterations=self._settings.delegate_max_iterations,
|
||||
plan_required=self._settings.plan_required,
|
||||
verify_required=self._settings.verify_required,
|
||||
on_trace=self._on_trace,
|
||||
cost_tracker=self._cost_tracker,
|
||||
chunk_store=self._chunk_store,
|
||||
)
|
||||
finally:
|
||||
reset_eval_depth(token)
|
||||
return result, sub_state
|
||||
|
||||
async def run_subagent(self, prompt: str) -> str:
|
||||
prompt = str(prompt or "").strip()
|
||||
if not prompt:
|
||||
raise ToolInputError("A non-empty prompt is required.")
|
||||
tools = [tool for tool in self._tools if tool["function"]["name"] != NO_DELEGATE]
|
||||
result, _ = await self._spawn(prompt, tools)
|
||||
return result
|
||||
|
||||
async def _delegate(self, arguments: dict[str, Any]) -> str:
|
||||
task = str(arguments.get("task", "")).strip()
|
||||
if not task:
|
||||
raise ToolInputError("delegate requires a task description.")
|
||||
if self._llm is None or self._dispatcher is None:
|
||||
raise ToolInputError("Delegation is not available in this context.")
|
||||
allowed = arguments.get("allowed_tools")
|
||||
if allowed:
|
||||
allowed_set = {str(name) for name in allowed}
|
||||
@@ -163,26 +212,7 @@ class AgenticController:
|
||||
]
|
||||
else:
|
||||
tools = [tool for tool in self._tools if tool["function"]["name"] != NO_DELEGATE]
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": SUB_AGENT_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": task},
|
||||
]
|
||||
sub_state = AgentState()
|
||||
result = await react_loop(
|
||||
llm=self._llm,
|
||||
dispatcher=self._dispatcher,
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
state=sub_state,
|
||||
settings=self._settings,
|
||||
max_iterations=self._settings.delegate_max_iterations,
|
||||
plan_required=self._settings.plan_required,
|
||||
verify_required=self._settings.verify_required,
|
||||
on_trace=self._on_trace,
|
||||
cost_tracker=self._cost_tracker,
|
||||
chunk_store=self._chunk_store,
|
||||
)
|
||||
result, sub_state = await self._spawn(task, tools)
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "success",
|
||||
@@ -193,3 +223,10 @@ class AgenticController:
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
async def _eval(self, arguments: dict[str, Any]) -> str:
|
||||
prompt = str(arguments.get("prompt", "")).strip()
|
||||
if not prompt:
|
||||
raise ToolInputError("eval requires a non-empty prompt.")
|
||||
result = await self.run_subagent(prompt)
|
||||
return json.dumps({"status": "success", "result": result}, ensure_ascii=False)
|
||||
|
||||
@@ -7,6 +7,9 @@ from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
_active_state: contextvars.ContextVar = contextvars.ContextVar("devii_agent_state", default=None)
|
||||
_eval_depth: contextvars.ContextVar = contextvars.ContextVar("devii_eval_depth", default=0)
|
||||
|
||||
MAX_EVAL_DEPTH = 2
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -35,3 +38,15 @@ def record_mutation(name: str) -> None:
|
||||
state = _active_state.get()
|
||||
if state is not None:
|
||||
state.mutations.add(name)
|
||||
|
||||
|
||||
def get_eval_depth() -> int:
|
||||
return _eval_depth.get()
|
||||
|
||||
|
||||
def set_eval_depth(value: int) -> contextvars.Token:
|
||||
return _eval_depth.set(value)
|
||||
|
||||
|
||||
def reset_eval_depth(token: contextvars.Token) -> None:
|
||||
_eval_depth.reset(token)
|
||||
|
||||
@@ -41,15 +41,6 @@ class ContainerController:
|
||||
raise ToolInputError(f"project not found: {slug}")
|
||||
return project
|
||||
|
||||
def _dockerfile(self, project: dict, ref: str) -> dict:
|
||||
df = store.get_dockerfile(ref)
|
||||
if df is None or df["project_uid"] != project["uid"]:
|
||||
for candidate in store.list_dockerfiles(project["uid"]):
|
||||
if candidate["name"] == ref:
|
||||
return candidate
|
||||
raise ToolInputError(f"dockerfile not found: {ref}")
|
||||
return df
|
||||
|
||||
def _instance(self, project: dict, ref: str) -> dict:
|
||||
inst = store.get_instance(ref)
|
||||
if inst is None or inst["project_uid"] != project["uid"]:
|
||||
@@ -59,19 +50,6 @@ class ContainerController:
|
||||
raise ToolInputError(f"instance not found: {ref}")
|
||||
return inst
|
||||
|
||||
def _build_view(self, build: dict) -> dict:
|
||||
if not build:
|
||||
return {}
|
||||
terminal = build["status"] in (store.BUILD_SUCCESS, store.BUILD_FAILED, store.BUILD_CANCELLED)
|
||||
view = {
|
||||
"uid": build["uid"], "status": build["status"], "terminal": terminal,
|
||||
"build_number": build.get("build_number"), "image_tag": build.get("image_tag"),
|
||||
"image_id": build.get("image_id") or None, "duration_ms": build.get("duration_ms"),
|
||||
}
|
||||
if build.get("error"):
|
||||
view["build_error"] = build["error"]
|
||||
return view
|
||||
|
||||
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
handler = getattr(self, "_" + name[len("container_"):], None) if name.startswith("container_") else None
|
||||
if handler is None:
|
||||
@@ -81,44 +59,6 @@ class ContainerController:
|
||||
except ContainerError as exc:
|
||||
return json.dumps({"error": "container_error", "message": str(exc)})
|
||||
|
||||
async def _list_dockerfiles(self, arguments) -> str:
|
||||
project = self._project(arguments)
|
||||
return json.dumps({
|
||||
"dockerfiles": store.list_dockerfiles(project["uid"]),
|
||||
"instances": store.list_instances(project["uid"]),
|
||||
}, ensure_ascii=False, default=str)
|
||||
|
||||
async def _create_dockerfile(self, arguments) -> str:
|
||||
project = self._project(arguments)
|
||||
result = await api.create_dockerfile(
|
||||
project, self._actor_user(), name=str(arguments.get("name", "")),
|
||||
description=str(arguments.get("description", "")), tags=str(arguments.get("tags", "")),
|
||||
content=arguments.get("content") or None)
|
||||
return json.dumps({"dockerfile": result["dockerfile"], "build": self._build_view(result["build"])}, default=str)
|
||||
|
||||
async def _update_dockerfile(self, arguments) -> str:
|
||||
project = self._project(arguments)
|
||||
df = self._dockerfile(project, str(arguments.get("dockerfile", "")))
|
||||
result = await api.save_version(df, self._actor_user(), str(arguments.get("content", "")))
|
||||
return json.dumps({"changed": result["changed"], "build": self._build_view(result["build"])}, default=str)
|
||||
|
||||
async def _build(self, arguments) -> str:
|
||||
project = self._project(arguments)
|
||||
df = self._dockerfile(project, str(arguments.get("dockerfile", "")))
|
||||
version = store.current_version(df)
|
||||
if version is None:
|
||||
raise ToolInputError("dockerfile has no version to build")
|
||||
build = await api.enqueue_build(df, version, owner=("user", self._actor_user()["uid"]))
|
||||
return json.dumps({"build": self._build_view(build)}, default=str)
|
||||
|
||||
async def _build_status(self, arguments) -> str:
|
||||
build = store.get_build(str(arguments.get("build_uid", "")))
|
||||
if build is None:
|
||||
raise ToolInputError("build not found")
|
||||
view = self._build_view(build)
|
||||
view["logs_tail"] = (build.get("logs") or "")[-4000:]
|
||||
return json.dumps(view, default=str)
|
||||
|
||||
async def _list_instances(self, arguments) -> str:
|
||||
project = self._project(arguments)
|
||||
instances = store.list_instances(project["uid"])
|
||||
@@ -128,13 +68,9 @@ class ContainerController:
|
||||
|
||||
async def _create_instance(self, arguments) -> str:
|
||||
project = self._project(arguments)
|
||||
df = self._dockerfile(project, str(arguments.get("dockerfile", "")))
|
||||
successes = [b for b in store.list_builds(df["uid"]) if b["status"] == store.BUILD_SUCCESS]
|
||||
if not successes:
|
||||
raise ToolInputError("dockerfile has no successful build yet")
|
||||
autostart = str(arguments.get("autostart", "true")).lower() not in ("false", "0", "no", "off")
|
||||
inst = await api.create_instance(
|
||||
project, df, successes[0], name=str(arguments.get("name", "")),
|
||||
project, name=str(arguments.get("name", "")),
|
||||
boot_command=str(arguments.get("boot_command", "")), env=arguments.get("env", ""),
|
||||
cpu_limit=str(arguments.get("cpu_limit", "")), mem_limit=str(arguments.get("mem_limit", "")),
|
||||
ports=arguments.get("ports", ""), restart_policy=str(arguments.get("restart_policy", "never")),
|
||||
@@ -199,7 +135,6 @@ class ContainerController:
|
||||
"ingress_url": self._ingress_url(inst),
|
||||
"runtime": api.instance_runtime(inst),
|
||||
"stats": api.instance_stats(inst["uid"]),
|
||||
"dockerfile_stats": api.dockerfile_stats(inst["dockerfile_uid"]),
|
||||
}, default=str)
|
||||
|
||||
async def _schedule(self, arguments) -> str:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.devii.customization.controller import CustomizationController
|
||||
|
||||
__all__ = ["CustomizationController"]
|
||||
@@ -0,0 +1,125 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from devplacepy.database import (
|
||||
CUSTOMIZATION_LANGS,
|
||||
delete_custom_override,
|
||||
get_custom_override,
|
||||
list_custom_overrides,
|
||||
set_custom_override,
|
||||
)
|
||||
from devplacepy.services.devii.errors import ToolInputError
|
||||
|
||||
logger = logging.getLogger("devii.customization")
|
||||
|
||||
PREVIEW_CHARS = 160
|
||||
|
||||
|
||||
class CustomizationController:
|
||||
def __init__(self, owner_kind: str, owner_id: str) -> None:
|
||||
self._owner_kind = owner_kind
|
||||
self._owner_id = owner_id
|
||||
|
||||
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
if name == "customize_list":
|
||||
return self._list()
|
||||
if name == "customize_get":
|
||||
return self._get(arguments)
|
||||
if name == "customize_set_css":
|
||||
return self._set(arguments, "css", "css")
|
||||
if name == "customize_set_js":
|
||||
return self._set(arguments, "js", "js")
|
||||
if name == "customize_reset":
|
||||
return self._reset(arguments)
|
||||
raise ToolInputError(f"Unknown customization tool: {name}")
|
||||
|
||||
def _scope(self, arguments: dict[str, Any]) -> str:
|
||||
scope = str(arguments.get("scope", "")).strip()
|
||||
if not scope:
|
||||
raise ToolInputError("A 'scope' is required: 'global' or a page-type string.")
|
||||
return scope
|
||||
|
||||
def _lang(self, arguments: dict[str, Any]) -> str | None:
|
||||
raw = arguments.get("lang")
|
||||
if raw is None or str(raw).strip() == "":
|
||||
return None
|
||||
lang = str(raw).strip().lower()
|
||||
if lang not in CUSTOMIZATION_LANGS:
|
||||
raise ToolInputError("'lang' must be 'css' or 'js'.")
|
||||
return lang
|
||||
|
||||
def _list(self) -> str:
|
||||
rows = list_custom_overrides(self._owner_kind, self._owner_id)
|
||||
items = [
|
||||
{
|
||||
"scope": row.get("scope"),
|
||||
"lang": row.get("lang"),
|
||||
"enabled": bool(row.get("enabled", 1)),
|
||||
"chars": len(row.get("code") or ""),
|
||||
"preview": (row.get("code") or "")[:PREVIEW_CHARS],
|
||||
"updated_at": row.get("updated_at"),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
return json.dumps({"status": "success", "customizations": items}, ensure_ascii=False)
|
||||
|
||||
def _get(self, arguments: dict[str, Any]) -> str:
|
||||
scope = self._scope(arguments)
|
||||
lang = self._lang(arguments)
|
||||
if lang is None:
|
||||
raise ToolInputError("'lang' must be 'css' or 'js'.")
|
||||
row = get_custom_override(self._owner_kind, self._owner_id, scope, lang)
|
||||
if not row:
|
||||
return json.dumps(
|
||||
{"status": "success", "scope": scope, "lang": lang, "code": None}, ensure_ascii=False
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "success",
|
||||
"scope": scope,
|
||||
"lang": lang,
|
||||
"enabled": bool(row.get("enabled", 1)),
|
||||
"code": row.get("code") or "",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
def _set(self, arguments: dict[str, Any], lang: str, code_key: str) -> str:
|
||||
scope = self._scope(arguments)
|
||||
code = arguments.get(code_key)
|
||||
if code is None or str(code).strip() == "":
|
||||
raise ToolInputError(f"'{code_key}' code is required.")
|
||||
set_custom_override(self._owner_kind, self._owner_id, scope, lang, str(code))
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "success",
|
||||
"saved": True,
|
||||
"scope": scope,
|
||||
"lang": lang,
|
||||
"note": "Saved. Call reload_page so the user sees it applied by the server.",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
def _reset(self, arguments: dict[str, Any]) -> str:
|
||||
scope = self._scope(arguments)
|
||||
lang = self._lang(arguments)
|
||||
if scope == "all":
|
||||
removed = delete_custom_override(self._owner_kind, self._owner_id, scope=None, lang=lang)
|
||||
else:
|
||||
removed = delete_custom_override(self._owner_kind, self._owner_id, scope=scope, lang=lang)
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "success",
|
||||
"removed": removed,
|
||||
"scope": scope,
|
||||
"lang": lang or "all",
|
||||
"note": "Removed. Call reload_page so the user sees the default restored.",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
@@ -14,6 +14,7 @@ from .llm import LLMClient
|
||||
from .session import DeviiSession
|
||||
from .store import ConversationStore, TurnAudit, UsageLedger
|
||||
from .tasks.store import TaskStore, memory_db
|
||||
from .virtual_tools import VirtualToolStore
|
||||
|
||||
logger = logging.getLogger("devii.hub")
|
||||
|
||||
@@ -48,9 +49,10 @@ class DeviiHub:
|
||||
owned_db = db if owner_kind == "user" else memory_db()
|
||||
task_store = TaskStore(owned_db, owner_kind, owner_id)
|
||||
lessons = LessonStore(owned_db, owner_kind, owner_id)
|
||||
virtual_tool_store = VirtualToolStore(owned_db, owner_kind, owner_id)
|
||||
session = DeviiSession(
|
||||
owner_kind, owner_id, username, settings, llm, lessons, pricing, task_store,
|
||||
self._stores, is_admin=is_admin,
|
||||
virtual_tool_store, self._stores, is_admin=is_admin,
|
||||
)
|
||||
if owner_kind == "user":
|
||||
saved = self._stores["conversations"].load(owner_kind, owner_id)
|
||||
|
||||
@@ -8,10 +8,12 @@ from .actions.chunk_actions import CHUNK_ACTIONS
|
||||
from .actions.client_actions import CLIENT_ACTIONS
|
||||
from .actions.container_actions import CONTAINER_ACTIONS
|
||||
from .actions.cost_actions import COST_ACTIONS
|
||||
from .actions.customization_actions import CUSTOMIZATION_ACTIONS
|
||||
from .actions.docs_actions import DOCS_ACTIONS
|
||||
from .actions.fetch_actions import FETCH_ACTIONS
|
||||
from .actions.rsearch_actions import RSEARCH_ACTIONS
|
||||
from .actions.spec import Catalog
|
||||
from .virtual_tools.actions import VIRTUAL_TOOL_ACTIONS
|
||||
from .agentic.actions import AGENTIC_ACTIONS
|
||||
from .tasks.actions import TASK_ACTIONS
|
||||
|
||||
@@ -27,4 +29,6 @@ CATALOG = Catalog(
|
||||
+ CHUNK_ACTIONS
|
||||
+ RSEARCH_ACTIONS
|
||||
+ CONTAINER_ACTIONS
|
||||
+ CUSTOMIZATION_ACTIONS
|
||||
+ VIRTUAL_TOOL_ACTIONS
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ from .http_client import PlatformClient
|
||||
from .llm import LLMClient
|
||||
from .registry import CATALOG
|
||||
from .tasks import Scheduler, TaskController, TaskStore
|
||||
from .virtual_tools import VirtualToolController, VirtualToolStore
|
||||
|
||||
logger = logging.getLogger("devii.session")
|
||||
|
||||
@@ -53,6 +54,7 @@ class DeviiSession:
|
||||
lessons: LessonStore,
|
||||
pricing: Pricing,
|
||||
task_store: TaskStore,
|
||||
virtual_tool_store: VirtualToolStore,
|
||||
stores: dict[str, Any],
|
||||
is_admin: bool = False,
|
||||
) -> None:
|
||||
@@ -72,10 +74,16 @@ class DeviiSession:
|
||||
self.agentic = AgenticController(lessons, settings)
|
||||
self.cost = CostTracker(pricing=pricing)
|
||||
self.chunks = ChunkStore()
|
||||
self._virtual_tool_store = virtual_tool_store
|
||||
self.virtual_tools = VirtualToolController(
|
||||
virtual_tool_store, self.agentic.run_subagent, set(CATALOG.by_name())
|
||||
)
|
||||
self.dispatcher = Dispatcher(
|
||||
CATALOG, self.client, settings, self.task_controller, self.agentic,
|
||||
avatar=self.avatar, browser=self.browser,
|
||||
is_admin=is_admin, quota_provider=self._quota_snapshot,
|
||||
owner_kind=owner_kind, owner_id=owner_id,
|
||||
virtual_tools=self.virtual_tools,
|
||||
)
|
||||
self.tools = CATALOG.tool_schemas_for(self.client.authenticated, is_admin)
|
||||
self._system_prompt = _system_prompt_for(is_admin)
|
||||
@@ -248,6 +256,7 @@ class DeviiSession:
|
||||
error = ""
|
||||
try:
|
||||
async with self._lock:
|
||||
self._refresh_tools()
|
||||
reply = await self.agent.respond(text)
|
||||
await self._emit({"type": "reply", "text": reply}, buffer=True)
|
||||
except Exception as exc: # noqa: BLE001 - reported to the browser, recorded for audit
|
||||
@@ -257,6 +266,11 @@ class DeviiSession:
|
||||
finally:
|
||||
self._record_turn(turn_id, started_at, text, reply, error, before)
|
||||
|
||||
def _refresh_tools(self) -> None:
|
||||
builtin = CATALOG.tool_schemas_for(self.client.authenticated, self.is_admin)
|
||||
virtual = self._virtual_tool_store.tool_schemas()
|
||||
self.tools[:] = builtin + virtual
|
||||
|
||||
def _quota_snapshot(self) -> dict[str, Any]:
|
||||
spent = self._ledger.spent_24h(self.owner_kind, self.owner_id)
|
||||
turns = self._ledger.turns_24h(self.owner_kind, self.owner_id)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from devplacepy.services.devii.virtual_tools.controller import VirtualToolController
|
||||
from devplacepy.services.devii.virtual_tools.store import VirtualToolStore
|
||||
|
||||
__all__ = ["VirtualToolController", "VirtualToolStore"]
|
||||
@@ -0,0 +1,73 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..actions.spec import Action, Param
|
||||
|
||||
|
||||
def arg(name: str, description: str, required: bool = False, kind: str = "string") -> Param:
|
||||
return Param(name=name, location="body", description=description, required=required, type=kind)
|
||||
|
||||
|
||||
VIRTUAL_TOOL_ACTIONS: tuple[Action, ...] = (
|
||||
Action(
|
||||
name="tool_create",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Create a new user-defined tool that runs a stored prompt when called",
|
||||
description=(
|
||||
"Use when the user asks you to remember a new capability ('when I say X, do Y'). The new "
|
||||
"tool becomes callable on the next turn; when called it re-runs you on its stored prompt "
|
||||
"plus the user's input. Give a clear trigger-oriented description so you know when to use it."
|
||||
),
|
||||
handler="virtual_tool",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
arg("name", "Tool name: a letter then letters, digits, or underscores (2-41 chars).", required=True),
|
||||
arg("description", "When to use the tool and what it does (your trigger hint).", required=True),
|
||||
arg("prompt", "The instruction the tool runs each time it is called.", required=True),
|
||||
arg("input_description", "What the single free-form 'input' argument should contain."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="tool_list",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="List the user's defined tools with their descriptions and status",
|
||||
handler="virtual_tool",
|
||||
requires_auth=False,
|
||||
),
|
||||
Action(
|
||||
name="tool_get",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Read a user-defined tool's full prompt and settings",
|
||||
handler="virtual_tool",
|
||||
requires_auth=False,
|
||||
params=(arg("name", "Tool name to read.", required=True),),
|
||||
),
|
||||
Action(
|
||||
name="tool_update",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Update a user-defined tool's description, prompt, input hint, or enabled state",
|
||||
handler="virtual_tool",
|
||||
requires_auth=False,
|
||||
params=(
|
||||
arg("name", "Tool name to update.", required=True),
|
||||
arg("description", "New description (optional)."),
|
||||
arg("prompt", "New stored prompt (optional)."),
|
||||
arg("input_description", "New input hint (optional)."),
|
||||
arg("enabled", "Enable (true) or disable (false) the tool without deleting it.", kind="boolean"),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="tool_delete",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Delete a user-defined tool",
|
||||
handler="virtual_tool",
|
||||
requires_auth=False,
|
||||
params=(arg("name", "Tool name to delete.", required=True),),
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,140 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any, Awaitable, Callable
|
||||
|
||||
from devplacepy.services.devii.errors import ToolInputError
|
||||
from devplacepy.services.devii.virtual_tools.store import VirtualToolStore
|
||||
|
||||
logger = logging.getLogger("devii.virtual_tools.controller")
|
||||
|
||||
NAME_PATTERN = re.compile(r"^[a-zA-Z][a-zA-Z0-9_]{1,40}$")
|
||||
PREVIEW_CHARS = 200
|
||||
|
||||
Evaluator = Callable[[str], Awaitable[str]]
|
||||
|
||||
|
||||
def _serialize(row: dict[str, Any], full: bool = False) -> dict[str, Any]:
|
||||
data = {
|
||||
"name": row.get("name"),
|
||||
"description": row.get("description"),
|
||||
"input_description": row.get("input_description") or "",
|
||||
"enabled": bool(row.get("enabled", 1)),
|
||||
"updated_at": row.get("updated_at"),
|
||||
}
|
||||
if full:
|
||||
data["prompt"] = row.get("prompt") or ""
|
||||
else:
|
||||
data["prompt_preview"] = (row.get("prompt") or "")[:PREVIEW_CHARS]
|
||||
return data
|
||||
|
||||
|
||||
class VirtualToolController:
|
||||
def __init__(self, store: VirtualToolStore, evaluator: Evaluator, builtin_names: set[str]) -> None:
|
||||
self._store = store
|
||||
self._evaluator = evaluator
|
||||
self._builtin_names = builtin_names
|
||||
|
||||
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
handlers = {
|
||||
"tool_create": self._create,
|
||||
"tool_list": self._list,
|
||||
"tool_get": self._get,
|
||||
"tool_update": self._update,
|
||||
"tool_delete": self._delete,
|
||||
}
|
||||
handler = handlers.get(name)
|
||||
if handler is None:
|
||||
raise ToolInputError(f"Unknown virtual-tool action: {name}")
|
||||
return handler(arguments)
|
||||
|
||||
def has(self, name: str) -> bool:
|
||||
row = self._store.find(name)
|
||||
return bool(row and row.get("enabled", 1))
|
||||
|
||||
async def run(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
row = self._store.find(name)
|
||||
if not row or not row.get("enabled", 1):
|
||||
raise ToolInputError(f"No enabled virtual tool named '{name}'.")
|
||||
user_input = str(arguments.get("input", "")).strip()
|
||||
prompt = row.get("prompt") or ""
|
||||
composed = f"{prompt}\n\nUser input: {user_input}" if user_input else prompt
|
||||
result = await self._evaluator(composed)
|
||||
return json.dumps({"status": "success", "tool": name, "result": result}, ensure_ascii=False)
|
||||
|
||||
def _require_name(self, arguments: dict[str, Any]) -> str:
|
||||
name = str(arguments.get("name", "")).strip()
|
||||
if not name:
|
||||
raise ToolInputError("A tool 'name' is required.")
|
||||
return name
|
||||
|
||||
def _create(self, arguments: dict[str, Any]) -> str:
|
||||
name = self._require_name(arguments)
|
||||
if not NAME_PATTERN.match(name):
|
||||
raise ToolInputError(
|
||||
"Tool name must be 2-41 characters: a letter followed by letters, digits, or underscores."
|
||||
)
|
||||
if name in self._builtin_names:
|
||||
raise ToolInputError(f"'{name}' is a built-in tool name; choose a different name.")
|
||||
if self._store.find(name):
|
||||
raise ToolInputError(f"A tool named '{name}' already exists; use tool_update to change it.")
|
||||
description = str(arguments.get("description", "")).strip()
|
||||
prompt = str(arguments.get("prompt", "")).strip()
|
||||
if not description:
|
||||
raise ToolInputError("A 'description' is required so the tool can be triggered correctly.")
|
||||
if not prompt:
|
||||
raise ToolInputError("A 'prompt' is required: the instruction the tool runs when called.")
|
||||
record = {
|
||||
"uid": uuid.uuid4().hex,
|
||||
"name": name,
|
||||
"description": description,
|
||||
"prompt": prompt,
|
||||
"input_description": str(arguments.get("input_description", "")).strip(),
|
||||
"enabled": 1,
|
||||
}
|
||||
stored = self._store.create(record)
|
||||
return json.dumps({"status": "created", "tool": _serialize(stored)}, ensure_ascii=False)
|
||||
|
||||
def _list(self, arguments: dict[str, Any]) -> str:
|
||||
rows = self._store.list()
|
||||
return json.dumps(
|
||||
{"count": len(rows), "tools": [_serialize(row) for row in rows]}, ensure_ascii=False
|
||||
)
|
||||
|
||||
def _get(self, arguments: dict[str, Any]) -> str:
|
||||
name = self._require_name(arguments)
|
||||
row = self._store.find(name)
|
||||
if not row:
|
||||
raise ToolInputError(f"No tool named '{name}'.")
|
||||
return json.dumps({"tool": _serialize(row, full=True)}, ensure_ascii=False)
|
||||
|
||||
def _update(self, arguments: dict[str, Any]) -> str:
|
||||
name = self._require_name(arguments)
|
||||
row = self._store.find(name)
|
||||
if not row:
|
||||
raise ToolInputError(f"No tool named '{name}'.")
|
||||
changes: dict[str, Any] = {}
|
||||
if "description" in arguments and str(arguments["description"]).strip():
|
||||
changes["description"] = str(arguments["description"]).strip()
|
||||
if "prompt" in arguments and str(arguments["prompt"]).strip():
|
||||
changes["prompt"] = str(arguments["prompt"]).strip()
|
||||
if "input_description" in arguments:
|
||||
changes["input_description"] = str(arguments["input_description"]).strip()
|
||||
if "enabled" in arguments and arguments["enabled"] is not None:
|
||||
changes["enabled"] = 1 if str(arguments["enabled"]).strip().lower() in ("1", "true", "yes", "on") else 0
|
||||
if not changes:
|
||||
raise ToolInputError("No updatable fields supplied (description, prompt, input_description, enabled).")
|
||||
self._store.update(name, changes)
|
||||
return json.dumps({"status": "updated", "tool": _serialize(self._store.find(name))}, ensure_ascii=False)
|
||||
|
||||
def _delete(self, arguments: dict[str, Any]) -> str:
|
||||
name = self._require_name(arguments)
|
||||
deleted = self._store.delete(name)
|
||||
if not deleted:
|
||||
raise ToolInputError(f"No tool named '{name}'.")
|
||||
return json.dumps({"status": "deleted", "name": name}, ensure_ascii=False)
|
||||
@@ -0,0 +1,88 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("devii.virtual_tools.store")
|
||||
|
||||
TABLE = "devii_virtual_tools"
|
||||
INDEXED_COLUMNS = (["owner_kind", "owner_id"], ["owner_kind", "owner_id", "name"])
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _schema_for(row: dict[str, Any]) -> dict[str, Any]:
|
||||
input_description = row.get("input_description") or "Free-form input for this tool."
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": row["name"],
|
||||
"description": f"{row.get('description', '')} (your custom tool)",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {"type": "string", "description": input_description},
|
||||
},
|
||||
"required": [],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class VirtualToolStore:
|
||||
def __init__(self, db: Any, owner_kind: str, owner_id: str) -> None:
|
||||
self._db = db
|
||||
self._owner_kind = owner_kind
|
||||
self._owner_id = owner_id
|
||||
self._ensure_indexes()
|
||||
|
||||
def _ensure_indexes(self) -> None:
|
||||
if TABLE not in self._db.tables:
|
||||
return
|
||||
table = self._db[TABLE]
|
||||
for columns in INDEXED_COLUMNS:
|
||||
table.create_index(columns)
|
||||
|
||||
@property
|
||||
def _table(self) -> Any:
|
||||
return self._db[TABLE]
|
||||
|
||||
@property
|
||||
def _scope(self) -> dict[str, str]:
|
||||
return {"owner_kind": self._owner_kind, "owner_id": self._owner_id}
|
||||
|
||||
def find(self, name: str) -> dict[str, Any] | None:
|
||||
if TABLE not in self._db.tables:
|
||||
return None
|
||||
return self._table.find_one(name=name, **self._scope)
|
||||
|
||||
def list(self) -> list[dict[str, Any]]:
|
||||
if TABLE not in self._db.tables:
|
||||
return []
|
||||
return list(self._table.find(**self._scope))
|
||||
|
||||
def create(self, record: dict[str, Any]) -> dict[str, Any]:
|
||||
now = _now_iso()
|
||||
record = {**record, **self._scope, "created_at": now, "updated_at": now}
|
||||
self._table.insert(record)
|
||||
logger.info("Virtual tool created name=%s owner=%s/%s", record.get("name"), self._owner_kind, self._owner_id)
|
||||
return record
|
||||
|
||||
def update(self, name: str, changes: dict[str, Any]) -> None:
|
||||
changes = {**changes, "name": name, **self._scope, "updated_at": _now_iso()}
|
||||
self._table.update(changes, ["name", "owner_kind", "owner_id"])
|
||||
logger.debug("Virtual tool updated name=%s changes=%s", name, list(changes))
|
||||
|
||||
def delete(self, name: str) -> bool:
|
||||
deleted = self._table.delete(name=name, **self._scope)
|
||||
logger.info("Virtual tool deleted name=%s ok=%s", name, deleted)
|
||||
return bool(deleted)
|
||||
|
||||
def tool_schemas(self) -> list[dict[str, Any]]:
|
||||
return [_schema_for(row) for row in self.list() if row.get("enabled", 1)]
|
||||
Reference in New Issue
Block a user