feat: add project fork async job service with cli prune/clear commands and shared container image build target

This commit is contained in:
2026-06-09 14:06:02 +00:00
parent c565e3b55c
commit 05c6fa7f3b
90 changed files with 6889 additions and 1146 deletions
+31 -1
View File
@@ -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(
+47 -14
View File
@@ -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),
),
),
)
+60 -23
View File
@@ -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,
)
+3 -1
View File
@@ -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)
+4
View File
@@ -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
)
+14
View File
@@ -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)]