fix: normalize unicode escape sequences and reformat multi-line expressions across codebase

This commit is contained in:
2026-06-09 16:48:08 +00:00
parent 66dfda88bc
commit c4f2937415
175 changed files with 12660 additions and 4175 deletions
@@ -5,8 +5,16 @@ 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)
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,
)
DEVII = (
@@ -31,7 +39,8 @@ AVATAR_ACTIONS: tuple[Action, ...] = (
path="",
summary="Hide devii from the screen (plays a goodbye/hide animation)",
description=(
DEVII + " This already plays a goodbye animation, so do not queue a separate "
DEVII
+ " This already plays a goodbye animation, so do not queue a separate "
"wave/goodbye animation immediately before calling it - hiding interrupts a still-"
"queued animation."
),
@@ -66,7 +75,8 @@ AVATAR_ACTIONS: tuple[Action, ...] = (
path="",
summary="Play a specific named animation (use avatar_list_animations first)",
description=(
DEVII + " A following avatar_hide or avatar_stop interrupts an animation that is "
DEVII
+ " A following avatar_hide or avatar_stop interrupts an animation that is "
"still playing, so do not hide immediately after if you want it to finish."
),
handler="avatar",
@@ -144,6 +154,12 @@ AVATAR_ACTIONS: tuple[Action, ...] = (
description=DEVII,
handler="avatar",
requires_auth=False,
params=(arg("name", "Character name (e.g. Clippy, Merlin, Bonzi, Genie, Rover).", required=True),),
params=(
arg(
"name",
"Character name (e.g. Clippy, Merlin, Bonzi, Genie, Rover).",
required=True,
),
),
),
)
+115 -24
View File
@@ -10,7 +10,9 @@ def path(name: str, description: str, required: bool = True) -> Param:
def query(name: str, description: str, required: bool = False) -> Param:
return Param(name=name, location="query", description=description, required=required)
return Param(
name=name, location="query", description=description, required=required
)
def body(name: str, description: str, required: bool = False) -> Param:
@@ -110,7 +112,10 @@ ACTIONS: tuple[Action, ...] = (
body("project_uid", "Attach the post to a project uid."),
body("attachment_uids", ATTACHMENTS),
body("poll_question", "Optional poll question."),
body("poll_options", "Poll options as a JSON array of strings, or one option per line, or comma separated. At least two are required for the poll to be created."),
body(
"poll_options",
"Poll options as a JSON array of strings, or one option per line, or comma separated. At least two are required for the poll to be created.",
),
),
),
Action(
@@ -118,7 +123,12 @@ ACTIONS: tuple[Action, ...] = (
method="GET",
path="/posts/{post_slug}",
summary="View a single post by slug",
params=(path("post_slug", "Exact post slug copied from a /posts/... link in a feed or listing response; do not build it from the title."),),
params=(
path(
"post_slug",
"Exact post slug copied from a /posts/... link in a feed or listing response; do not build it from the title.",
),
),
),
Action(
name="edit_post",
@@ -126,12 +136,21 @@ ACTIONS: tuple[Action, ...] = (
path="/posts/edit/{post_slug}",
summary="Edit an existing post",
params=(
path("post_slug", "Exact post slug copied from a /posts/... link in a feed or listing response; do not build it from the title."),
path(
"post_slug",
"Exact post slug copied from a /posts/... link in a feed or listing response; do not build it from the title.",
),
body("content", "Updated post body.", required=True),
body("title", "Updated title."),
body("topic", "Updated topic."),
body("poll_question", "Optional poll question. Adds a poll to a post that does not already have one."),
body("poll_options", "Poll options as a JSON array of strings, or one option per line, or comma separated. At least two are required for the poll to be created."),
body(
"poll_question",
"Optional poll question. Adds a poll to a post that does not already have one.",
),
body(
"poll_options",
"Poll options as a JSON array of strings, or one option per line, or comma separated. At least two are required for the poll to be created.",
),
),
),
Action(
@@ -139,7 +158,12 @@ ACTIONS: tuple[Action, ...] = (
method="POST",
path="/posts/delete/{post_slug}",
summary="Delete a post",
params=(path("post_slug", "Exact post slug copied from a /posts/... link in a feed or listing response; do not build it from the title."),),
params=(
path(
"post_slug",
"Exact post slug copied from a /posts/... link in a feed or listing response; do not build it from the title.",
),
),
),
Action(
name="create_comment",
@@ -180,7 +204,12 @@ ACTIONS: tuple[Action, ...] = (
method="GET",
path="/projects/{project_slug}",
summary="View a project by slug",
params=(path("project_slug", "Exact project slug copied from a /projects/... link in a listing response; do not build it from the title."),),
params=(
path(
"project_slug",
"Exact project slug copied from a /projects/... link in a listing response; do not build it from the title.",
),
),
),
Action(
name="create_project",
@@ -203,7 +232,12 @@ ACTIONS: tuple[Action, ...] = (
method="POST",
path="/projects/delete/{project_slug}",
summary="Delete a project",
params=(path("project_slug", "Exact project slug copied from a /projects/... link in a listing response; do not build it from the title."),),
params=(
path(
"project_slug",
"Exact project slug copied from a /projects/... link in a listing response; do not build it from the title.",
),
),
),
Action(
name="project_set_private",
@@ -218,8 +252,16 @@ ACTIONS: tuple[Action, ...] = (
),
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),
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(
@@ -236,8 +278,16 @@ ACTIONS: tuple[Action, ...] = (
),
params=(
path("project_slug", "Project slug or uid."),
body("value", "true to make the project read-only, false to make it writable.", required=True),
body("confirm", "Must be true, set only after the user has explicitly confirmed.", required=True),
body(
"value",
"true to make the project read-only, false to make it writable.",
required=True,
),
body(
"confirm",
"Must be true, set only after the user has explicitly confirmed.",
required=True,
),
),
),
Action(
@@ -257,7 +307,11 @@ ACTIONS: tuple[Action, ...] = (
description="Returns the file metadata plus the text content. Binary files return a url instead of content.",
params=(
path("project_slug", "Project slug or uid."),
query("path", "Relative file path inside the project, e.g. src/main.py.", required=True),
query(
"path",
"Relative file path inside the project, e.g. src/main.py.",
required=True,
),
),
requires_auth=False,
),
@@ -328,7 +382,11 @@ ACTIONS: tuple[Action, ...] = (
params=(
path("project_slug", "Project slug or uid."),
body("path", "Relative file path.", required=True),
body("at", "Insert before this 1-indexed line (1 prepends, total+1 appends).", required=True),
body(
"at",
"Insert before this 1-indexed line (1 prepends, total+1 appends).",
required=True,
),
body("content", "Text to insert.", required=True),
),
),
@@ -374,7 +432,9 @@ ACTIONS: tuple[Action, ...] = (
summary="Create a directory (and parents) in a project filesystem",
params=(
path("project_slug", "Project slug or uid."),
body("path", "Relative directory path, e.g. src/components.", required=True),
body(
"path", "Relative directory path, e.g. src/components.", required=True
),
),
),
Action(
@@ -558,8 +618,15 @@ ACTIONS: tuple[Action, ...] = (
ajax=True,
params=(
path("target_type", TARGET_TYPE),
path("target_uid", "Uid of the target, copied from a listing response; do not invent it."),
body("value", "Vote value: 1 to upvote, -1 to downvote (re-send to remove).", required=True),
path(
"target_uid",
"Uid of the target, copied from a listing response; do not invent it.",
),
body(
"value",
"Vote value: 1 to upvote, -1 to downvote (re-send to remove).",
required=True,
),
),
),
Action(
@@ -571,7 +638,10 @@ ACTIONS: tuple[Action, ...] = (
ajax=True,
params=(
path("target_type", TARGET_TYPE),
path("target_uid", "Uid of the target, copied from a listing response; do not invent it."),
path(
"target_uid",
"Uid of the target, copied from a listing response; do not invent it.",
),
body("emoji", "One of the allowed reaction emoji.", required=True),
),
),
@@ -591,7 +661,10 @@ ACTIONS: tuple[Action, ...] = (
ajax=True,
params=(
path("target_type", TARGET_TYPE),
path("target_uid", "Uid of the target, copied from a listing response; do not invent it."),
path(
"target_uid",
"Uid of the target, copied from a listing response; do not invent it.",
),
),
),
Action(
@@ -681,7 +754,12 @@ ACTIONS: tuple[Action, ...] = (
method="GET",
path="/gists/{gist_slug}",
summary="View a gist by slug",
params=(path("gist_slug", "Exact gist slug copied from a /gists/... link in a listing response; do not build it from the title."),),
params=(
path(
"gist_slug",
"Exact gist slug copied from a /gists/... link in a listing response; do not build it from the title.",
),
),
),
Action(
name="create_gist",
@@ -702,7 +780,10 @@ ACTIONS: tuple[Action, ...] = (
path="/gists/edit/{gist_slug}",
summary="Edit a gist",
params=(
path("gist_slug", "Exact gist slug copied from a /gists/... link in a listing response; do not build it from the title."),
path(
"gist_slug",
"Exact gist slug copied from a /gists/... link in a listing response; do not build it from the title.",
),
body("title", "Gist title.", required=True),
body("source_code", "Gist source code.", required=True),
body("description", "Gist description."),
@@ -714,7 +795,12 @@ ACTIONS: tuple[Action, ...] = (
method="POST",
path="/gists/delete/{gist_slug}",
summary="Delete a gist",
params=(path("gist_slug", "Exact gist slug copied from a /gists/... link in a listing response; do not build it from the title."),),
params=(
path(
"gist_slug",
"Exact gist slug copied from a /gists/... link in a listing response; do not build it from the title.",
),
),
),
Action(
name="list_news",
@@ -728,7 +814,12 @@ ACTIONS: tuple[Action, ...] = (
method="GET",
path="/news/{news_slug}",
summary="View a news article by slug",
params=(path("news_slug", "Exact news slug copied from a /news/... link in a listing response; do not build it from the title."),),
params=(
path(
"news_slug",
"Exact news slug copied from a /news/... link in a listing response; do not build it from the title.",
),
),
),
Action(
name="upload_file",
@@ -5,8 +5,16 @@ 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)
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,
)
CLIENT = (
@@ -20,7 +28,8 @@ CLIENT_ACTIONS: tuple[Action, ...] = (
method="LOCAL",
path="",
summary="Read the user's current page: URL, title, viewport, scroll, selected text, visible headings, and whether they are signed in",
description=CLIENT + " Use this to understand where the user is and what they are looking at before acting or guiding them.",
description=CLIENT
+ " Use this to understand where the user is and what they are looking at before acting or guiding them.",
handler="client",
requires_auth=False,
read_only=True,
@@ -31,7 +40,8 @@ CLIENT_ACTIONS: tuple[Action, ...] = (
path="",
summary="Execute JavaScript in the user's browser and return its result",
description=(
CLIENT + " The code is the body of an async function; use 'return value' to return a "
CLIENT
+ " The code is the body of an async function; use 'return value' to return a "
"JSON-serializable result. You have full access to window and document. Use this for "
"anything not covered by the dedicated tools: read or change the DOM, drive a live "
"demo, inspect state, or update the screen. Prefer the dedicated tools "
@@ -40,7 +50,11 @@ CLIENT_ACTIONS: tuple[Action, ...] = (
handler="client",
requires_auth=False,
params=(
arg("code", "JavaScript to run as an async function body. Return a JSON-serializable value.", required=True),
arg(
"code",
"JavaScript to run as an async function body. Return a JSON-serializable value.",
required=True,
),
),
),
Action(
@@ -48,11 +62,16 @@ CLIENT_ACTIONS: tuple[Action, ...] = (
method="LOCAL",
path="",
summary="Highlight an element on screen with an outline and an optional callout label, for live tutorials",
description=CLIENT + " Scrolls the element into view and draws an attention outline. Call clear_highlights to remove it.",
description=CLIENT
+ " Scrolls the element into view and draws an attention outline. Call clear_highlights to remove it.",
handler="client",
requires_auth=False,
params=(
arg("selector", "CSS selector, or the element's exact visible text (e.g. a heading or link label).", required=True),
arg(
"selector",
"CSS selector, or the element's exact visible text (e.g. a heading or link label).",
required=True,
),
arg("label", "Optional callout text shown next to the element."),
),
),
@@ -75,7 +94,11 @@ CLIENT_ACTIONS: tuple[Action, ...] = (
requires_auth=False,
params=(
arg("text", "Message to display.", required=True),
arg("duration_ms", "How long to show it, in milliseconds (default 4000).", kind="integer"),
arg(
"duration_ms",
"How long to show it, in milliseconds (default 4000).",
kind="integer",
),
),
),
Action(
@@ -86,7 +109,13 @@ CLIENT_ACTIONS: tuple[Action, ...] = (
description=CLIENT,
handler="client",
requires_auth=False,
params=(arg("selector", "CSS selector, or the element's exact visible text.", required=True),),
params=(
arg(
"selector",
"CSS selector, or the element's exact visible text.",
required=True,
),
),
),
Action(
name="navigate_to",
@@ -94,7 +123,8 @@ CLIENT_ACTIONS: tuple[Action, ...] = (
path="",
summary="Send the user's browser to a URL",
description=(
CLIENT + " Use a same-origin path like /feed or /docs/index.html, or a full URL. The "
CLIENT
+ " Use a same-origin path like /feed or /docs/index.html, or a full URL. The "
"page reloads; the user's Devii session and conversation persist and reconnect automatically."
),
handler="client",
@@ -106,7 +136,8 @@ CLIENT_ACTIONS: tuple[Action, ...] = (
method="LOCAL",
path="",
summary="Refresh the user's current page, e.g. after something changed",
description=CLIENT + " The Devii session and conversation persist and reconnect automatically.",
description=CLIENT
+ " The Devii session and conversation persist and reconnect automatically.",
handler="client",
requires_auth=False,
),
@@ -116,14 +147,19 @@ CLIENT_ACTIONS: tuple[Action, ...] = (
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 "
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(
"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)."),
),
@@ -3,51 +3,93 @@
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)
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,
)
SLUG = arg("project_slug", "Project slug or uid that owns the container resources.", required=True)
SLUG = arg(
"project_slug",
"Project slug or uid that owns the container resources.",
required=True,
)
CONTAINER_ACTIONS: tuple[Action, ...] = (
Action(
name="container_list_instances",
method="LOCAL", path="", handler="container", requires_admin=True, read_only=True,
method="LOCAL",
path="",
handler="container",
requires_admin=True,
read_only=True,
summary="List a project's container instances and their status",
params=(SLUG,),
),
Action(
name="container_create_instance",
method="LOCAL", path="", handler="container", requires_admin=True,
method="LOCAL",
path="",
handler="container",
requires_admin=True,
summary="Create and start a container instance (runs the shared ppy image with the project files mounted at /app)",
params=(
SLUG,
arg("name", "Instance name.", required=True),
arg("boot_command", "Optional command to run on boot, e.g. 'python app.py'."),
arg(
"boot_command", "Optional command to run on boot, e.g. 'python app.py'."
),
arg("restart_policy", "never, always, on-failure, or unless-stopped."),
arg("env", "Optional env vars as KEY=VALUE lines."),
arg("ports", "Port maps per line or comma separated. Use a bare container port (e.g. '8899') to auto-assign a unique host port above 20000, or 'host:container' to pin one."),
arg(
"ports",
"Port maps per line or comma separated. Use a bare container port (e.g. '8899') to auto-assign a unique host port above 20000, or 'host:container' to pin one.",
),
arg("cpu_limit", "Optional CPU limit, e.g. 1 or 1.5."),
arg("mem_limit", "Optional memory limit, e.g. 512m or 1g."),
arg("autostart", "Start immediately ('true' or 'false', default true)."),
arg("ingress_slug", "Optional public ingress slug; the service is then reachable at /p/<slug>."),
arg("ingress_port", "Container port to publish at /p/<slug> (must be one of the mapped ports).", kind="integer"),
arg(
"ingress_slug",
"Optional public ingress slug; the service is then reachable at /p/<slug>.",
),
arg(
"ingress_port",
"Container port to publish at /p/<slug> (must be one of the mapped ports).",
kind="integer",
),
),
),
Action(
name="container_instance_action",
method="LOCAL", path="", handler="container", requires_admin=True,
method="LOCAL",
path="",
handler="container",
requires_admin=True,
summary="Control an instance: start, stop, restart, pause, resume, delete, or sync",
description="sync imports the container /app workspace back into the project files.",
params=(
SLUG,
arg("instance", "Instance name, slug, or uid.", required=True),
arg("action", "start, stop, restart, pause, resume, delete, or sync.", required=True),
arg(
"action",
"start, stop, restart, pause, resume, delete, or sync.",
required=True,
),
),
),
Action(
name="container_logs",
method="LOCAL", path="", handler="container", requires_admin=True, read_only=True,
method="LOCAL",
path="",
handler="container",
requires_admin=True,
read_only=True,
summary="Read the recent logs of a running instance",
params=(
SLUG,
@@ -57,7 +99,10 @@ CONTAINER_ACTIONS: tuple[Action, ...] = (
),
Action(
name="container_exec",
method="LOCAL", path="", handler="container", requires_admin=True,
method="LOCAL",
path="",
handler="container",
requires_admin=True,
summary="Run a one-shot command inside a running instance and return its output",
params=(
SLUG,
@@ -67,13 +112,20 @@ CONTAINER_ACTIONS: tuple[Action, ...] = (
),
Action(
name="container_stats",
method="LOCAL", path="", handler="container", requires_admin=True, read_only=True,
method="LOCAL",
path="",
handler="container",
requires_admin=True,
read_only=True,
summary="Get aggregated resource and runtime statistics for an instance",
params=(SLUG, arg("instance", "Instance name, slug, or uid.", required=True)),
),
Action(
name="container_schedule",
method="LOCAL", path="", handler="container", requires_admin=True,
method="LOCAL",
path="",
handler="container",
requires_admin=True,
summary="Schedule a start or stop of an instance (cron, interval, or one-time)",
params=(
SLUG,
@@ -5,8 +5,16 @@ 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)
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 = (
@@ -58,7 +66,12 @@ CUSTOMIZATION_ACTIONS: tuple[Action, ...] = (
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"),
arg(
"confirm",
"Must be true after the user has confirmed the scope.",
required=True,
kind="boolean",
),
),
),
Action(
@@ -76,8 +89,17 @@ CUSTOMIZATION_ACTIONS: tuple[Action, ...] = (
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"),
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(
@@ -92,9 +114,21 @@ CUSTOMIZATION_ACTIONS: tuple[Action, ...] = (
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"),
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",
),
),
),
)
+49 -11
View File
@@ -30,13 +30,24 @@ from .spec import Action, Catalog
MUTATING_METHODS = ("POST", "DELETE", "PUT", "PATCH")
CONFIRM_REQUIRED = {"project_set_readonly", "project_set_private", "customize_set_css", "customize_set_js", "customize_reset"}
CONFIRM_REQUIRED = {
"project_set_readonly",
"project_set_private",
"customize_set_css",
"customize_set_js",
"customize_reset",
}
logger = logging.getLogger("devii.dispatch")
def _is_confirmed(arguments: dict[str, Any]) -> bool:
return str(arguments.get("confirm", "")).strip().lower() in ("true", "1", "yes", "on")
return str(arguments.get("confirm", "")).strip().lower() in (
"true",
"1",
"yes",
"on",
)
def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError | None:
@@ -55,7 +66,12 @@ def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError |
"confirm=true."
)
if name == "project_set_private":
making_private = str(arguments.get("value", "")).strip().lower() in ("true", "1", "yes", "on")
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
@@ -102,15 +118,21 @@ class Dispatcher:
self._chunks = ChunkController(settings)
self._rsearch = RsearchController(settings)
from ..container import ContainerController
self._container = ContainerController(client)
from ..customization import CustomizationController
self._customization = CustomizationController(owner_kind, owner_id)
self._virtual_tools = virtual_tools
self._read_files: set[tuple[str, str]] = set()
@staticmethod
def _file_key(arguments: dict[str, Any]) -> tuple[str, str] | None:
from devplacepy.project_files import normalize_path, ProjectFileError as _PFError
from devplacepy.project_files import (
normalize_path,
ProjectFileError as _PFError,
)
raw = arguments.get("path")
if not raw:
return None
@@ -162,7 +184,9 @@ class Dispatcher:
result = await self._run(action, arguments)
if action.handler == "chunks":
return result
return wrap_if_large(result, self._settings.max_response_chars, resource_key)
return wrap_if_large(
result, self._settings.max_response_chars, resource_key
)
except DeviiError as exc:
logger.info("Dispatch %s failed: %s", name, exc.message)
return error_result(exc)
@@ -184,7 +208,8 @@ class Dispatcher:
result = await self._client.login(
email=self._require(arguments, "email"),
password=self._require(arguments, "password"),
remember_me=str(arguments.get("remember_me", "on")).lower() not in ("", "false", "off", "no"),
remember_me=str(arguments.get("remember_me", "on")).lower()
not in ("", "false", "off", "no"),
)
return json.dumps(result, ensure_ascii=False)
@@ -221,7 +246,9 @@ class Dispatcher:
if action.handler == "virtual_tool":
if self._virtual_tools is None:
return error_result(
ToolInputError("User-defined tools are not available in this context.")
ToolInputError(
"User-defined tools are not available in this context."
)
)
return await self._virtual_tools.dispatch(action.name, arguments)
@@ -257,11 +284,14 @@ class Dispatcher:
if param.name not in arguments or arguments[param.name] is None:
if param.required:
raise ToolInputError(
f"Missing required parameter '{param.name}' for {action.name}.")
f"Missing required parameter '{param.name}' for {action.name}."
)
continue
value = arguments[param.name]
if param.location == "path":
url_path = url_path.replace("{" + param.name + "}", quote(str(value), safe=""))
url_path = url_path.replace(
"{" + param.name + "}", quote(str(value), safe="")
)
elif param.location == "query":
params[param.name] = value
elif param.location == "body":
@@ -309,7 +339,11 @@ class Dispatcher:
async def _run_http(self, action: Action, arguments: dict[str, Any]) -> str:
if action.name == "project_write_file":
key = self._file_key(arguments)
if key is not None and key not in self._read_files and await self._file_exists(arguments):
if (
key is not None
and key not in self._read_files
and await self._file_exists(arguments)
):
raise ToolInputError(
f"Read '{key[1]}' before overwriting it. It already exists; call "
"project_read_file first. For an existing file prefer the line tools "
@@ -328,7 +362,11 @@ class Dispatcher:
file_field=file_field,
headers=headers,
)
if action.name in ("project_read_file", "project_read_lines", "project_write_file"):
if action.name in (
"project_read_file",
"project_read_lines",
"project_write_file",
):
key = self._file_key(arguments)
if key is not None:
self._read_files.add(key)
@@ -25,11 +25,35 @@ RSEARCH_ACTIONS: tuple[Action, ...] = (
requires_auth=False,
read_only=True,
params=(
Param(name="query", location="body", description="The web search query.", required=True),
Param(name="count", location="body", description="Number of results (1-100, default 10).", type="integer"),
Param(name="content", location="body", description="Fetch full page content for each result.", type="boolean"),
Param(name="deep", location="body", description="Run a deeper research pass.", type="boolean"),
Param(name="type", location="body", description="Result type: 'web' (default) or 'images'."),
Param(
name="query",
location="body",
description="The web search query.",
required=True,
),
Param(
name="count",
location="body",
description="Number of results (1-100, default 10).",
type="integer",
),
Param(
name="content",
location="body",
description="Fetch full page content for each result.",
type="boolean",
),
Param(
name="deep",
location="body",
description="Run a deeper research pass.",
type="boolean",
),
Param(
name="type",
location="body",
description="Result type: 'web' (default) or 'images'.",
),
),
),
Action(
@@ -46,9 +70,24 @@ RSEARCH_ACTIONS: tuple[Action, ...] = (
requires_auth=False,
read_only=True,
params=(
Param(name="query", location="body", description="The question or prompt to answer.", required=True),
Param(name="content", location="body", description="Let the AI read full page content while answering.", type="boolean"),
Param(name="count", location="body", description="Max sources to consider (1-100, default 10).", type="integer"),
Param(
name="query",
location="body",
description="The question or prompt to answer.",
required=True,
),
Param(
name="content",
location="body",
description="Let the AI read full page content while answering.",
type="boolean",
),
Param(
name="count",
location="body",
description="Max sources to consider (1-100, default 10).",
type="integer",
),
),
),
Action(
@@ -65,9 +104,23 @@ RSEARCH_ACTIONS: tuple[Action, ...] = (
requires_auth=False,
read_only=True,
params=(
Param(name="prompt", location="body", description="The prompt to send.", required=True),
Param(name="json", location="body", description="Force a valid-JSON-only response.", type="boolean"),
Param(name="system", location="body", description="Optional system message to steer the answer."),
Param(
name="prompt",
location="body",
description="The prompt to send.",
required=True,
),
Param(
name="json",
location="body",
description="Force a valid-JSON-only response.",
type="boolean",
),
Param(
name="system",
location="body",
description="Optional system message to steer the answer.",
),
),
),
Action(
@@ -83,7 +136,12 @@ RSEARCH_ACTIONS: tuple[Action, ...] = (
requires_auth=False,
read_only=True,
params=(
Param(name="url", location="body", description="Public URL of the image to describe.", required=True),
Param(
name="url",
location="body",
description="Public URL of the image to describe.",
required=True,
),
),
),
)
+22 -4
View File
@@ -28,8 +28,20 @@ class Action:
requires_auth: bool = True
requires_admin: bool = False
handler: Literal[
"http", "login", "logout", "status", "task", "agentic", "avatar", "client", "fetch",
"docs", "cost", "chunks", "rsearch", "container"
"http",
"login",
"logout",
"status",
"task",
"agentic",
"avatar",
"client",
"fetch",
"docs",
"cost",
"chunks",
"rsearch",
"container",
] = "http"
freeform_body: bool = False
ajax: bool = False
@@ -62,7 +74,11 @@ class Action:
"description": "Additional form fields as key/value string pairs.",
"additionalProperties": {"type": "string"},
}
text = self.summary if not self.description else f"{self.summary}. {self.description}"
text = (
self.summary
if not self.description
else f"{self.summary}. {self.description}"
)
return {
"type": "function",
"function": {
@@ -88,7 +104,9 @@ class Catalog:
def tool_schemas(self) -> list[dict[str, Any]]:
return [action.tool_schema() for action in self.actions]
def tool_schemas_for(self, authenticated: bool, is_admin: bool = False) -> list[dict[str, Any]]:
def tool_schemas_for(
self, authenticated: bool, is_admin: bool = False
) -> list[dict[str, Any]]:
return [
action.tool_schema()
for action in self.actions
+3 -1
View File
@@ -198,7 +198,9 @@ class Agent:
self._on_trace = on_trace
self._cost_tracker = cost_tracker
self._chunk_store = chunk_store
self._messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
self._messages: list[dict[str, Any]] = [
{"role": "system", "content": system_prompt}
]
async def respond(self, user_text: str) -> str:
self._inject_recalled_lessons(user_text)
@@ -6,4 +6,10 @@ from .lessons import LessonStore
from .loop import react_loop
from .state import AgentState
__all__ = ["AGENTIC_ACTIONS", "AgenticController", "LessonStore", "react_loop", "AgentState"]
__all__ = [
"AGENTIC_ACTIONS",
"AgenticController",
"LessonStore",
"react_loop",
"AgentState",
]
+70 -14
View File
@@ -5,8 +5,16 @@ 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)
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,
)
AGENTIC_ACTIONS: tuple[Action, ...] = (
@@ -20,9 +28,22 @@ AGENTIC_ACTIONS: tuple[Action, ...] = (
requires_auth=False,
params=(
arg("goal", "One-line restatement of the user's goal.", required=True),
arg("steps", "Ordered list of step objects, each with id, action, depends_on.", required=True, kind="array"),
arg("success_criteria", "Concrete criteria for declaring the task complete.", required=True),
arg("confidence", "Self-estimate of plan correctness from 0.0 to 1.0.", kind="number"),
arg(
"steps",
"Ordered list of step objects, each with id, action, depends_on.",
required=True,
kind="array",
),
arg(
"success_criteria",
"Concrete criteria for declaring the task complete.",
required=True,
),
arg(
"confidence",
"Self-estimate of plan correctness from 0.0 to 1.0.",
kind="number",
),
),
),
Action(
@@ -34,9 +55,21 @@ AGENTIC_ACTIONS: tuple[Action, ...] = (
handler="agentic",
requires_auth=False,
params=(
arg("observation", "What was observed: the failure mode or notable outcome.", required=True),
arg("conclusion", "Diagnosis or interpretation of the observation.", required=True),
arg("next_action", "The chosen next step or the rule to apply in future.", required=True),
arg(
"observation",
"What was observed: the failure mode or notable outcome.",
required=True,
),
arg(
"conclusion",
"Diagnosis or interpretation of the observation.",
required=True,
),
arg(
"next_action",
"The chosen next step or the rule to apply in future.",
required=True,
),
arg("tags", "Optional comma-separated keywords to aid later recall."),
),
),
@@ -49,7 +82,11 @@ AGENTIC_ACTIONS: tuple[Action, ...] = (
handler="agentic",
requires_auth=False,
params=(
arg("query", "Natural-language or keyword query describing the current situation.", required=True),
arg(
"query",
"Natural-language or keyword query describing the current situation.",
required=True,
),
arg("k", "Number of lessons to return.", kind="integer"),
),
),
@@ -66,7 +103,10 @@ AGENTIC_ACTIONS: tuple[Action, ...] = (
handler="agentic",
requires_auth=False,
params=(
arg("query", "Optional topic/keywords; only matching lessons are forgotten. Omit to forget everything."),
arg(
"query",
"Optional topic/keywords; only matching lessons are forgotten. Omit to forget everything.",
),
),
),
Action(
@@ -82,7 +122,11 @@ AGENTIC_ACTIONS: tuple[Action, ...] = (
requires_auth=False,
params=(
arg("summary", "What was confirmed and how.", required=True),
arg("confirmed", "Whether the change was confirmed successful.", kind="boolean"),
arg(
"confirmed",
"Whether the change was confirmed successful.",
kind="boolean",
),
),
),
Action(
@@ -94,8 +138,16 @@ AGENTIC_ACTIONS: tuple[Action, ...] = (
handler="agentic",
requires_auth=False,
params=(
arg("task", "Clear, scoped task description for the sub-agent.", required=True),
arg("allowed_tools", "Optional list of tool names the sub-agent may use.", kind="array"),
arg(
"task",
"Clear, scoped task description for the sub-agent.",
required=True,
),
arg(
"allowed_tools",
"Optional list of tool names the sub-agent may use.",
kind="array",
),
),
),
Action(
@@ -111,7 +163,11 @@ AGENTIC_ACTIONS: tuple[Action, ...] = (
handler="agentic",
requires_auth=False,
params=(
arg("prompt", "The instruction to run as a fresh sub-agent request.", required=True),
arg(
"prompt",
"The instruction to run as a fresh sub-agent request.",
required=True,
),
),
),
)
@@ -32,7 +32,9 @@ def find_compaction_split(messages: list[dict[str, Any]], keep_tail: int) -> int
return 1
async def compact_messages(llm: Any, messages: list[dict[str, Any]], keep_tail: int) -> list[dict[str, Any]]:
async def compact_messages(
llm: Any, messages: list[dict[str, Any]], keep_tail: int
) -> list[dict[str, Any]]:
if len(messages) < keep_tail + 3:
return messages
split = find_compaction_split(messages, keep_tail)
+44 -12
View File
@@ -92,7 +92,12 @@ class AgenticController:
if confidence < 0.6:
advice = "Confidence is below 0.6 - gather more context or recall() past lessons before executing."
return json.dumps(
{"status": "success", "plan_recorded": True, "step_count": len(steps), "advice": advice},
{
"status": "success",
"plan_recorded": True,
"step_count": len(steps),
"advice": advice,
},
ensure_ascii=False,
)
@@ -101,13 +106,19 @@ class AgenticController:
conclusion = str(arguments.get("conclusion", "")).strip()
next_action = str(arguments.get("next_action", "")).strip()
if not (observation and conclusion and next_action):
raise ToolInputError("reflect requires observation, conclusion, and next_action.")
raise ToolInputError(
"reflect requires observation, conclusion, and next_action."
)
tags = str(arguments.get("tags", "") or "").strip()
record = self._lessons.add(observation, conclusion, next_action, tags)
state = get_state()
if state is not None:
state.reflections.append(
{"observation": observation, "conclusion": conclusion, "next_action": next_action}
{
"observation": observation,
"conclusion": conclusion,
"next_action": next_action,
}
)
return json.dumps(
{
@@ -122,9 +133,15 @@ class AgenticController:
query = str(arguments.get("query", "")).strip()
if not query:
raise ToolInputError("recall requires a query.")
k = int(arguments.get("k", self._settings.recall_top_k) or self._settings.recall_top_k)
k = int(
arguments.get("k", self._settings.recall_top_k)
or self._settings.recall_top_k
)
hits = self._lessons.search(query, k=k)
return json.dumps({"status": "success", "count": len(hits), "lessons": hits}, ensure_ascii=False)
return json.dumps(
{"status": "success", "count": len(hits), "lessons": hits},
ensure_ascii=False,
)
async def _forget(self, arguments: dict[str, Any]) -> str:
query = str(arguments.get("query", "") or "").strip()
@@ -137,7 +154,11 @@ class AgenticController:
else:
removed = self._lessons.clear()
return json.dumps(
{"status": "success", "forgotten": removed, "remaining": self._lessons.count()},
{
"status": "success",
"forgotten": removed,
"remaining": self._lessons.count(),
},
ensure_ascii=False,
)
@@ -152,14 +173,20 @@ class AgenticController:
if state is not None and confirmed:
state.verified = True
return json.dumps(
{"status": "success", "verified": bool(confirmed), "summary": summary}, ensure_ascii=False
{"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
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.")
raise ToolInputError(
"Sub-agent execution is not available in this context."
)
depth = get_eval_depth()
if depth >= MAX_EVAL_DEPTH:
raise ToolInputError(
@@ -194,7 +221,9 @@ class AgenticController:
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]
tools = [
tool for tool in self._tools if tool["function"]["name"] != NO_DELEGATE
]
result, _ = await self._spawn(prompt, tools)
return result
@@ -208,10 +237,13 @@ class AgenticController:
tools = [
tool
for tool in self._tools
if tool["function"]["name"] in allowed_set and tool["function"]["name"] != NO_DELEGATE
if tool["function"]["name"] in allowed_set
and tool["function"]["name"] != NO_DELEGATE
]
else:
tools = [tool for tool in self._tools if tool["function"]["name"] != NO_DELEGATE]
tools = [
tool for tool in self._tools if tool["function"]["name"] != NO_DELEGATE
]
result, sub_state = await self._spawn(task, tools)
return json.dumps(
{
+9 -3
View File
@@ -59,7 +59,9 @@ class LessonStore:
return 0
return self._table.count(**self._scope)
def add(self, observation: str, conclusion: str, next_action: str, tags: str = "") -> dict[str, Any]:
def add(
self, observation: str, conclusion: str, next_action: str, tags: str = ""
) -> dict[str, Any]:
record = {
"uid": uuid.uuid4().hex,
"observation": observation,
@@ -72,7 +74,9 @@ class LessonStore:
}
self._table.insert(record)
self._dirty = True
logger.info("Lesson stored owner=%s/%s tags=%s", self._owner_kind, self._owner_id, tags)
logger.info(
"Lesson stored owner=%s/%s tags=%s", self._owner_kind, self._owner_id, tags
)
return record
def all(self) -> list[dict[str, Any]]:
@@ -92,7 +96,9 @@ class LessonStore:
if TABLE in self._db.tables:
self._table.delete(**self._scope)
self._dirty = True
logger.info("Cleared %d lesson(s) for owner=%s/%s", n, self._owner_kind, self._owner_id)
logger.info(
"Cleared %d lesson(s) for owner=%s/%s", n, self._owner_kind, self._owner_id
)
return n
def _rebuild(self) -> None:
+54 -21
View File
@@ -104,7 +104,10 @@ def normalize(message: dict[str, Any]) -> dict[str, Any]:
def _is_error(result: str) -> bool:
try:
return isinstance(json.loads(result), dict) and json.loads(result).get("error") is not None
return (
isinstance(json.loads(result), dict)
and json.loads(result).get("error") is not None
)
except (ValueError, TypeError):
return False
@@ -128,19 +131,30 @@ async def _run_tool_call(dispatcher: Any, call: dict[str, Any]) -> str:
name = function.get("name", "")
raw_arguments = function.get("arguments") or "{}"
try:
arguments = json.loads(raw_arguments) if isinstance(raw_arguments, str) else raw_arguments
arguments = (
json.loads(raw_arguments)
if isinstance(raw_arguments, str)
else raw_arguments
)
except json.JSONDecodeError as exc:
return json.dumps({
"error": "tool_input_truncated",
"message": (
f"The arguments for {name or 'this tool'} were cut off and could not be parsed "
f"({exc.msg} at position {exc.pos}); the model output hit its length limit. "
"Emit only one write tool call per turn (do not batch several file writes into a "
"single response) and resend this one call on its own."
),
})
return json.dumps(
{
"error": "tool_input_truncated",
"message": (
f"The arguments for {name or 'this tool'} were cut off and could not be parsed "
f"({exc.msg} at position {exc.pos}); the model output hit its length limit. "
"Emit only one write tool call per turn (do not batch several file writes into a "
"single response) and resend this one call on its own."
),
}
)
if not isinstance(arguments, dict):
return json.dumps({"error": "tool_input_error", "message": "Invalid arguments: arguments must be an object"})
return json.dumps(
{
"error": "tool_input_error",
"message": "Invalid arguments: arguments must be an object",
}
)
return await dispatcher.dispatch(name, arguments)
@@ -172,7 +186,9 @@ async def react_loop(
if context_size(messages) > settings.context_compact_threshold:
trace("compact")
messages[:] = await compact_messages(llm, messages, settings.context_keep_tail)
messages[:] = await compact_messages(
llm, messages, settings.context_keep_tail
)
try:
message = await llm.complete(messages, tools)
@@ -184,10 +200,14 @@ async def react_loop(
tool_calls = message.get("tool_calls") or []
if tool_calls:
needs_plan = plan_required and state.plan is None and any(
call["function"]["name"] != "plan"
and not dispatcher.is_read_only(call["function"]["name"])
for call in tool_calls
needs_plan = (
plan_required
and state.plan is None
and any(
call["function"]["name"] != "plan"
and not dispatcher.is_read_only(call["function"]["name"])
for call in tool_calls
)
)
if needs_plan:
trace("plan-gate")
@@ -197,13 +217,19 @@ async def react_loop(
"role": "tool",
"tool_call_id": call.get("id", ""),
"name": call.get("function", {}).get("name", ""),
"content": json.dumps({"error": "protocol", "message": PLAN_VIOLATION}),
"content": json.dumps(
{"error": "protocol", "message": PLAN_VIOLATION}
),
}
)
continue
for call in tool_calls:
trace("call", call.get("function", {}).get("name", ""), call_label(call))
trace(
"call",
call.get("function", {}).get("name", ""),
call_label(call),
)
results = await asyncio.gather(
*[_run_tool_call(dispatcher, call) for call in tool_calls]
@@ -212,7 +238,10 @@ async def react_loop(
any_error = False
for call, result in zip(tool_calls, results):
if len(result) > OUTPUT_CAP_CHARS:
result = result[:OUTPUT_CAP_CHARS] + f"\n...[truncated {len(result)} chars]"
result = (
result[:OUTPUT_CAP_CHARS]
+ f"\n...[truncated {len(result)} chars]"
)
name = call.get("function", {}).get("name", "")
messages.append(
{
@@ -224,7 +253,11 @@ async def react_loop(
)
label = call_label(call)
summary = _summary(result)
detail = " ".join(part for part in (label, f"({summary})" if summary else "") if part)
detail = " ".join(
part
for part in (label, f"({summary})" if summary else "")
if part
)
if _is_error(result):
any_error = True
trace("err", name, detail)
+6 -2
View File
@@ -6,8 +6,12 @@ import contextvars
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)
_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
@@ -37,14 +37,16 @@ class AvatarController:
return self._request is not None
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
action = name[len(PREFIX):] if name.startswith(PREFIX) else name
action = name[len(PREFIX) :] if name.startswith(PREFIX) else name
if self._request is None:
return json.dumps(UNAVAILABLE, ensure_ascii=False)
try:
result = await self._request(action, arguments)
except Exception as exc: # noqa: BLE001 - surfaced to the model as data
logger.exception("Avatar action %s failed", action)
return json.dumps({"status": "error", "action": action, "message": str(exc)})
return json.dumps(
{"status": "error", "action": action, "message": str(exc)}
)
if isinstance(result, dict) and result.get("error"):
return json.dumps(
@@ -52,5 +54,6 @@ class AvatarController:
ensure_ascii=False,
)
return json.dumps(
{"status": "success", "action": action, "result": result}, ensure_ascii=False
{"status": "success", "action": action, "result": result},
ensure_ascii=False,
)
@@ -20,7 +20,10 @@ class ChunkController:
store = get_store()
if store is None:
return json.dumps(
{"status": "unavailable", "message": "No chunk store is active for this session."}
{
"status": "unavailable",
"message": "No chunk store is active for this session.",
}
)
chunk_id = str(arguments.get("chunk_id", "")).strip()
if not chunk_id:
@@ -38,7 +41,10 @@ class ChunkController:
)
offset = max(0, int(arguments.get("offset", 0) or 0))
length = int(arguments.get("length", self._settings.max_response_chars) or self._settings.max_response_chars)
length = int(
arguments.get("length", self._settings.max_response_chars)
or self._settings.max_response_chars
)
length = max(1, min(length, self._settings.max_response_chars))
total = len(text)
+6 -2
View File
@@ -14,7 +14,9 @@ logger = logging.getLogger("devii.chunks")
STORE_MAX_ENTRIES = 16
STORE_MAX_CHARS = 8_000_000
_active_store: contextvars.ContextVar = contextvars.ContextVar("devii_chunk_store", default=None)
_active_store: contextvars.ContextVar = contextvars.ContextVar(
"devii_chunk_store", default=None
)
class ChunkStore:
@@ -97,7 +99,9 @@ def chunk_envelope(text: str, chunk_id: str, max_chars: int) -> str:
)
def wrap_if_large(result: str, max_chars: int, resource_key: Optional[str] = None) -> str:
def wrap_if_large(
result: str, max_chars: int, resource_key: Optional[str] = None
) -> str:
if len(result) <= max_chars:
return result
store = get_store()
+54 -15
View File
@@ -87,8 +87,13 @@ def _make_executor(
async def execute(prompt: str) -> str:
worker = Agent(
settings, llm, dispatcher, tools,
lessons=lessons, cost_tracker=cost_tracker, chunk_store=chunk_store,
settings,
llm,
dispatcher,
tools,
lessons=lessons,
cost_tracker=cost_tracker,
chunk_store=chunk_store,
)
return await worker.respond(prompt)
@@ -113,6 +118,7 @@ def _resolve_owner(settings: Settings) -> tuple[str, str]:
same per-user memory, tasks, and admin analytics as the web session for that account."""
try:
from devplacepy.database import db, get_table
if "users" not in db.tables:
return "user", "cli"
users = get_table("users")
@@ -132,12 +138,18 @@ def _build_stores(owner_kind: str, owner_id: str):
if owner_id == "cli":
owned_db = dataset.connect(f"sqlite:///{CLI_TASKS_DB}")
else:
from devplacepy.database import db as owned_db # share the platform DB for this account
return TaskStore(owned_db, owner_kind, owner_id), LessonStore(owned_db, owner_kind, owner_id)
from devplacepy.database import (
db as owned_db,
) # share the platform DB for this account
return TaskStore(owned_db, owner_kind, owner_id), LessonStore(
owned_db, owner_kind, owner_id
)
async def run(settings: Settings, prompt: Optional[str] = None) -> None:
client = PlatformClient(settings.base_url, settings.timeout_seconds, settings.platform_api_key)
client = PlatformClient(
settings.base_url, settings.timeout_seconds, settings.platform_api_key
)
llm = LLMClient(settings)
greeting = await _bootstrap_auth(client, settings)
@@ -147,15 +159,27 @@ async def run(settings: Settings, prompt: Optional[str] = None) -> None:
cost_tracker = CostTracker()
chunk_store = ChunkStore()
agentic = AgenticController(lessons, settings)
dispatcher = Dispatcher(CATALOG, client, settings, controller, agentic, is_admin=True)
dispatcher = Dispatcher(
CATALOG, client, settings, controller, agentic, is_admin=True
)
tools = CATALOG.tool_schemas_for(client.authenticated, is_admin=True)
agentic.bind(
llm=llm, dispatcher=dispatcher, tools=tools,
on_trace=_trace, cost_tracker=cost_tracker, chunk_store=chunk_store,
llm=llm,
dispatcher=dispatcher,
tools=tools,
on_trace=_trace,
cost_tracker=cost_tracker,
chunk_store=chunk_store,
)
agent = Agent(
settings, llm, dispatcher, tools,
lessons=lessons, on_trace=_trace, cost_tracker=cost_tracker, chunk_store=chunk_store,
settings,
llm,
dispatcher,
tools,
lessons=lessons,
on_trace=_trace,
cost_tracker=cost_tracker,
chunk_store=chunk_store,
)
if prompt is not None:
@@ -216,11 +240,19 @@ def _parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
prog="devii",
description="Agentic chat assistant that manages a DevPlace account.",
)
parser.add_argument("-p", "--prompt", help="Run a single prompt non-interactively and exit.")
parser.add_argument("--api-key", help="DevPlace API key for automatic authentication (Bearer).")
parser.add_argument(
"-p", "--prompt", help="Run a single prompt non-interactively and exit."
)
parser.add_argument(
"--api-key", help="DevPlace API key for automatic authentication (Bearer)."
)
parser.add_argument("--email", help="Login email for automatic basic-auth login.")
parser.add_argument("--password", help="Login password for automatic basic-auth login.")
parser.add_argument("--basic-auth", help="Shorthand credentials in the form email:password.")
parser.add_argument(
"--password", help="Login password for automatic basic-auth login."
)
parser.add_argument(
"--basic-auth", help="Shorthand credentials in the form email:password."
)
parser.add_argument("--base-url", help="Override the platform base URL.")
parser.add_argument("--ai-url", help="Override the model endpoint URL.")
parser.add_argument("--ai-key", help="Override the model API key.")
@@ -248,7 +280,14 @@ def _settings_from_args(args: argparse.Namespace) -> Settings:
return replace(settings, **overrides)
NOISY_LOGGERS = ("httpcore", "httpx", "httpcore.http11", "httpcore.connection", "asyncio", "urllib3")
NOISY_LOGGERS = (
"httpcore",
"httpx",
"httpcore.http11",
"httpcore.connection",
"asyncio",
"urllib3",
)
def main(argv: Optional[list[str]] = None) -> None:
@@ -49,10 +49,16 @@ class ClientController:
result = await self._request(name, arguments)
except Exception as exc: # noqa: BLE001 - surfaced to the model as data
logger.exception("Client action %s failed", name)
return json.dumps({"status": "error", "action": name, "message": str(exc)}, ensure_ascii=False)
return json.dumps(
{"status": "error", "action": name, "message": str(exc)},
ensure_ascii=False,
)
if isinstance(result, dict) and result.get("error"):
return json.dumps(
{"status": "error", "action": name, "message": str(result["error"])}, ensure_ascii=False
{"status": "error", "action": name, "message": str(result["error"])},
ensure_ascii=False,
)
return json.dumps({"status": "success", "action": name, "result": result}, ensure_ascii=False)
return json.dumps(
{"status": "success", "action": name, "result": result}, ensure_ascii=False
)
+50 -17
View File
@@ -16,7 +16,9 @@ CONTEXT_WINDOW_TOKENS = 1_048_576
MAX_OUTPUT_TOKENS = 384_000
SYSTEM_RESERVE_TOKENS = 64_000
CHARS_PER_TOKEN = 3
CONTEXT_INPUT_BUDGET_TOKENS = CONTEXT_WINDOW_TOKENS - MAX_OUTPUT_TOKENS - SYSTEM_RESERVE_TOKENS
CONTEXT_INPUT_BUDGET_TOKENS = (
CONTEXT_WINDOW_TOKENS - MAX_OUTPUT_TOKENS - SYSTEM_RESERVE_TOKENS
)
MIN_TIMEOUT_SECONDS = 300.0
DEFAULT_TIMEOUT_SECONDS = 300.0
@@ -73,6 +75,7 @@ def _default_ai_key() -> str:
return env_key
try:
from devplacepy.database import internal_gateway_key
key = internal_gateway_key()
if key:
return key
@@ -88,36 +91,56 @@ def load_settings() -> Settings:
ai_model=os.environ.get("DEVII_AI_MODEL", DEFAULT_AI_MODEL),
base_url=os.environ.get("DEVII_BASE_URL", DEFAULT_BASE_URL).rstrip("/"),
timeout_seconds=float(os.environ.get("DEVII_TIMEOUT", DEFAULT_TIMEOUT_SECONDS)),
max_response_chars=int(os.environ.get("DEVII_MAX_RESPONSE", DEFAULT_MAX_RESPONSE_CHARS)),
max_tool_iterations=int(os.environ.get("DEVII_MAX_TOOL_ITERATIONS", DEFAULT_MAX_TOOL_ITERATIONS)),
max_response_chars=int(
os.environ.get("DEVII_MAX_RESPONSE", DEFAULT_MAX_RESPONSE_CHARS)
),
max_tool_iterations=int(
os.environ.get("DEVII_MAX_TOOL_ITERATIONS", DEFAULT_MAX_TOOL_ITERATIONS)
),
log_level=os.environ.get("DEVII_LOG_LEVEL", "WARNING").upper(),
log_path=os.environ.get("DEVII_LOG_PATH", "devii.log"),
tasks_db_path=os.environ.get("DEVII_TASKS_DB", "devii_tasks.db"),
scheduler_tick_seconds=float(os.environ.get("DEVII_SCHEDULER_TICK", "1.0")),
lessons_db_path=os.environ.get("DEVII_LESSONS_DB", "devii_lessons.db"),
delegate_max_iterations=int(
os.environ.get("DEVII_DELEGATE_MAX_ITERATIONS", DEFAULT_DELEGATE_MAX_ITERATIONS)
os.environ.get(
"DEVII_DELEGATE_MAX_ITERATIONS", DEFAULT_DELEGATE_MAX_ITERATIONS
)
),
context_compact_threshold=int(
os.environ.get("DEVII_CONTEXT_COMPACT_THRESHOLD", DEFAULT_CONTEXT_COMPACT_THRESHOLD)
os.environ.get(
"DEVII_CONTEXT_COMPACT_THRESHOLD", DEFAULT_CONTEXT_COMPACT_THRESHOLD
)
),
context_keep_tail=int(
os.environ.get("DEVII_CONTEXT_KEEP_TAIL", DEFAULT_CONTEXT_KEEP_TAIL)
),
context_keep_tail=int(os.environ.get("DEVII_CONTEXT_KEEP_TAIL", DEFAULT_CONTEXT_KEEP_TAIL)),
recall_top_k=int(os.environ.get("DEVII_RECALL_TOP_K", DEFAULT_RECALL_TOP_K)),
plan_required=os.environ.get("DEVII_PLAN_REQUIRED", "1").lower() not in ("0", "false", "no"),
verify_required=os.environ.get("DEVII_VERIFY_REQUIRED", "1").lower() not in ("0", "false", "no"),
plan_required=os.environ.get("DEVII_PLAN_REQUIRED", "1").lower()
not in ("0", "false", "no"),
verify_required=os.environ.get("DEVII_VERIFY_REQUIRED", "1").lower()
not in ("0", "false", "no"),
platform_api_key=os.environ.get("DEVII_PLATFORM_API_KEY", ""),
login_email=os.environ.get("DEVII_LOGIN_EMAIL", ""),
login_password=os.environ.get("DEVII_LOGIN_PASSWORD", ""),
fetch_max_chars=int(os.environ.get("DEVII_FETCH_MAX_CHARS", DEFAULT_FETCH_MAX_CHARS)),
fetch_max_chars=int(
os.environ.get("DEVII_FETCH_MAX_CHARS", DEFAULT_FETCH_MAX_CHARS)
),
fetch_timeout_seconds=float(
os.environ.get("DEVII_FETCH_TIMEOUT", DEFAULT_FETCH_TIMEOUT_SECONDS)
),
fetch_max_bytes=int(os.environ.get("DEVII_FETCH_MAX_BYTES", DEFAULT_FETCH_MAX_BYTES)),
fetch_max_bytes=int(
os.environ.get("DEVII_FETCH_MAX_BYTES", DEFAULT_FETCH_MAX_BYTES)
),
fetch_allow_private=os.environ.get("DEVII_FETCH_ALLOW_PRIVATE", "0").lower()
in ("1", "true", "yes", "on"),
allow_eval=os.environ.get("DEVII_ALLOW_EVAL", "1").lower() in ("1", "true", "yes", "on"),
rsearch_enabled=os.environ.get("DEVII_RSEARCH_ENABLED", "1").lower() in ("1", "true", "yes", "on"),
rsearch_url=os.environ.get("DEVII_RSEARCH_URL", DEFAULT_RSEARCH_URL).rstrip("/"),
allow_eval=os.environ.get("DEVII_ALLOW_EVAL", "1").lower()
in ("1", "true", "yes", "on"),
rsearch_enabled=os.environ.get("DEVII_RSEARCH_ENABLED", "1").lower()
in ("1", "true", "yes", "on"),
rsearch_url=os.environ.get("DEVII_RSEARCH_URL", DEFAULT_RSEARCH_URL).rstrip(
"/"
),
rsearch_timeout_seconds=float(
os.environ.get("DEVII_RSEARCH_TIMEOUT", DEFAULT_RSEARCH_TIMEOUT_SECONDS)
),
@@ -148,7 +171,9 @@ FIELD_RSEARCH_TIMEOUT = "devii_rsearch_timeout"
LESSONS_DB_PATH = os.environ.get("DEVII_LESSONS_DB", "devii_lessons.db")
def effective_daily_limit(config: dict, owner_kind: str, is_admin: bool = False) -> float:
def effective_daily_limit(
config: dict, owner_kind: str, is_admin: bool = False
) -> float:
if is_admin:
return float(config.get(FIELD_ADMIN_DAILY_USD, 0.0) or 0.0)
if owner_kind == "guest":
@@ -157,7 +182,11 @@ def effective_daily_limit(config: dict, owner_kind: str, is_admin: bool = False)
def build_settings(
config: dict, base_url: str, api_key: str, owner_kind: str = "guest", is_admin: bool = False
config: dict,
base_url: str,
api_key: str,
owner_kind: str = "guest",
is_admin: bool = False,
) -> Settings:
configured_key = config[FIELD_AI_KEY] or str(uuid.uuid4())
ai_key = api_key if (owner_kind == "user" and api_key) else configured_key
@@ -184,12 +213,16 @@ def build_settings(
login_email="",
login_password="",
fetch_max_chars=DEFAULT_FETCH_MAX_CHARS,
fetch_timeout_seconds=float(config.get(FIELD_FETCH_TIMEOUT) or DEFAULT_FETCH_TIMEOUT_SECONDS),
fetch_timeout_seconds=float(
config.get(FIELD_FETCH_TIMEOUT) or DEFAULT_FETCH_TIMEOUT_SECONDS
),
fetch_max_bytes=DEFAULT_FETCH_MAX_BYTES,
fetch_allow_private=False,
allow_eval=bool(config.get(FIELD_ALLOW_EVAL, True)),
rsearch_enabled=bool(config.get(FIELD_RSEARCH_ENABLED, True)),
rsearch_url=(config.get(FIELD_RSEARCH_URL) or DEFAULT_RSEARCH_URL).rstrip("/"),
rsearch_timeout_seconds=float(config.get(FIELD_RSEARCH_TIMEOUT) or DEFAULT_RSEARCH_TIMEOUT_SECONDS),
rsearch_timeout_seconds=float(
config.get(FIELD_RSEARCH_TIMEOUT) or DEFAULT_RSEARCH_TIMEOUT_SECONDS
),
daily_limit_usd=effective_daily_limit(config, owner_kind, is_admin),
)
@@ -23,6 +23,7 @@ class ContainerController:
if not slug:
return None
from devplacepy.seo import public_base_url
base = public_base_url()
return f"{base}/p/{slug}" if base else f"/p/{slug}"
@@ -51,7 +52,11 @@ class ContainerController:
return inst
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
handler = getattr(self, "_" + name[len("container_"):], None) if name.startswith("container_") else None
handler = (
getattr(self, "_" + name[len("container_") :], None)
if name.startswith("container_")
else None
)
if handler is None:
raise ToolInputError(f"unknown container tool: {name}")
try:
@@ -68,15 +73,29 @@ class ContainerController:
async def _create_instance(self, arguments) -> str:
project = self._project(arguments)
autostart = str(arguments.get("autostart", "true")).lower() not in ("false", "0", "no", "off")
autostart = str(arguments.get("autostart", "true")).lower() not in (
"false",
"0",
"no",
"off",
)
inst = await api.create_instance(
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")),
autostart=autostart, ingress_slug=str(arguments.get("ingress_slug", "")),
ingress_port=arguments.get("ingress_port"), actor=("user", self._actor_user()["uid"]))
return json.dumps({"instance": inst, "ingress_url": self._ingress_url(inst)}, default=str)
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")),
autostart=autostart,
ingress_slug=str(arguments.get("ingress_slug", "")),
ingress_port=arguments.get("ingress_port"),
actor=("user", self._actor_user()["uid"]),
)
return json.dumps(
{"instance": inst, "ingress_url": self._ingress_url(inst)}, default=str
)
async def _instance_action(self, arguments) -> str:
project = self._project(arguments)
@@ -111,7 +130,12 @@ class ContainerController:
lines.append(line)
tail = int(arguments.get("tail", 200) or 200)
await get_backend().logs(inst["container_id"], follow=False, tail=max(1, min(tail, 2000)), on_log=collect)
await get_backend().logs(
inst["container_id"],
follow=False,
tail=max(1, min(tail, 2000)),
on_log=collect,
)
return json.dumps({"logs": "\n".join(lines)})
async def _exec(self, arguments) -> str:
@@ -122,20 +146,32 @@ class ContainerController:
command = str(arguments.get("command", "")).strip()
if not command:
raise ToolInputError("command is required")
result = await get_backend().exec(inst["container_id"], ["/bin/sh", "-c", command])
result = await get_backend().exec(
inst["container_id"], ["/bin/sh", "-c", command]
)
actor = self._actor_user()
store.record_event(inst, "exec", "user", actor["uid"], {"command": command, "exit_code": result.exit_code})
store.record_event(
inst,
"exec",
"user",
actor["uid"],
{"command": command, "exit_code": result.exit_code},
)
return json.dumps({"exit_code": result.exit_code, "output": result.output})
async def _stats(self, arguments) -> str:
project = self._project(arguments)
inst = self._instance(project, str(arguments.get("instance", "")))
return json.dumps({
"instance": inst["name"], "status": inst["status"],
"ingress_url": self._ingress_url(inst),
"runtime": api.instance_runtime(inst),
"stats": api.instance_stats(inst["uid"]),
}, default=str)
return json.dumps(
{
"instance": inst["name"],
"status": inst["status"],
"ingress_url": self._ingress_url(inst),
"runtime": api.instance_runtime(inst),
"stats": api.instance_stats(inst["uid"]),
},
default=str,
)
async def _schedule(self, arguments) -> str:
project = self._project(arguments)
@@ -143,9 +179,11 @@ class ContainerController:
run_at = arguments.get("run_at")
try:
schedule = Schedule(
kind=str(arguments.get("kind", "")), cron=arguments.get("cron") or None,
kind=str(arguments.get("kind", "")),
cron=arguments.get("cron") or None,
run_at=from_iso(run_at) if run_at else None,
every_seconds=arguments.get("every_seconds"))
every_seconds=arguments.get("every_seconds"),
)
except ValueError as exc:
raise ToolInputError(str(exc))
sched = api.add_schedule(inst, str(arguments.get("action", "")), schedule)
+8 -1
View File
@@ -3,4 +3,11 @@
from .controller import CostController
from .tracker import CostTracker, Pricing, record_usage, reset_tracker, set_tracker
__all__ = ["CostTracker", "Pricing", "CostController", "record_usage", "set_tracker", "reset_tracker"]
__all__ = [
"CostTracker",
"Pricing",
"CostController",
"record_usage",
"set_tracker",
"reset_tracker",
]
+17 -5
View File
@@ -10,21 +10,33 @@ from .tracker import get_tracker
class CostController:
def __init__(self, quota_provider: Optional[Callable[[], dict[str, Any]]] = None) -> None:
def __init__(
self, quota_provider: Optional[Callable[[], dict[str, Any]]] = None
) -> None:
self._quota_provider = quota_provider
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
if name == "usage_quota":
if self._quota_provider is None:
return json.dumps(
{"status": "unavailable", "message": "No quota information is available for this session."}
{
"status": "unavailable",
"message": "No quota information is available for this session.",
}
)
return json.dumps({"status": "success", **self._quota_provider()}, ensure_ascii=False)
return json.dumps(
{"status": "success", **self._quota_provider()}, ensure_ascii=False
)
if name == "cost_stats":
tracker = get_tracker()
if tracker is None:
return json.dumps(
{"status": "unavailable", "message": "No cost tracker is active for this session."}
{
"status": "unavailable",
"message": "No cost tracker is active for this session.",
}
)
return json.dumps({"status": "success", **tracker.stats()}, ensure_ascii=False)
return json.dumps(
{"status": "success", **tracker.stats()}, ensure_ascii=False
)
raise ToolInputError(f"Unknown cost tool: {name}")
+17 -5
View File
@@ -28,7 +28,9 @@ class Pricing:
DEFAULT_PRICING = Pricing()
_active_tracker: contextvars.ContextVar = contextvars.ContextVar("devii_cost_tracker", default=None)
_active_tracker: contextvars.ContextVar = contextvars.ContextVar(
"devii_cost_tracker", default=None
)
def _now() -> datetime:
@@ -76,12 +78,18 @@ class CostTracker:
self.cache_miss_tokens += miss
self.reasoning_tokens += reasoning
logger.debug(
"Cost record: +%d prompt (+%d hit/+%d miss), +%d completion", prompt, hit, miss, completion
"Cost record: +%d prompt (+%d hit/+%d miss), +%d completion",
prompt,
hit,
miss,
completion,
)
def cost_usd(self) -> dict[str, float]:
cache_hit = self.cache_hit_tokens / PER_MILLION * self.pricing.cache_hit_per_m
cache_miss = self.cache_miss_tokens / PER_MILLION * self.pricing.cache_miss_per_m
cache_miss = (
self.cache_miss_tokens / PER_MILLION * self.pricing.cache_miss_per_m
)
output = self.completion_tokens / PER_MILLION * self.pricing.output_per_m
total = cache_hit + cache_miss + output
return {
@@ -95,7 +103,9 @@ class CostTracker:
elapsed = max((_now() - self.started_at).total_seconds(), 0.0)
costs = self.cost_usd()
requests = self.requests or 1
cache_hit_rate = (self.cache_hit_tokens / self.prompt_tokens) if self.prompt_tokens else 0.0
cache_hit_rate = (
(self.cache_hit_tokens / self.prompt_tokens) if self.prompt_tokens else 0.0
)
per_minute = (self.requests / elapsed * 60.0) if elapsed > 0 else 0.0
return {
"model": self.pricing.model,
@@ -120,7 +130,9 @@ class CostTracker:
"averages": {
"tokens_per_request": round(self.total_tokens / requests, 2),
"prompt_tokens_per_request": round(self.prompt_tokens / requests, 2),
"completion_tokens_per_request": round(self.completion_tokens / requests, 2),
"completion_tokens_per_request": round(
self.completion_tokens / requests, 2
),
"cost_usd_per_request": round(costs["total"] / requests, 8),
},
"session": {
@@ -41,7 +41,9 @@ class CustomizationController:
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.")
raise ToolInputError(
"A 'scope' is required: 'global' or a page-type string."
)
return scope
def _lang(self, arguments: dict[str, Any]) -> str | None:
@@ -66,7 +68,9 @@ class CustomizationController:
}
for row in rows
]
return json.dumps({"status": "success", "customizations": items}, ensure_ascii=False)
return json.dumps(
{"status": "success", "customizations": items}, ensure_ascii=False
)
def _get(self, arguments: dict[str, Any]) -> str:
scope = self._scope(arguments)
@@ -76,7 +80,8 @@ class CustomizationController:
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
{"status": "success", "scope": scope, "lang": lang, "code": None},
ensure_ascii=False,
)
return json.dumps(
{
@@ -110,9 +115,13 @@ class CustomizationController:
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)
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)
removed = delete_custom_override(
self._owner_kind, self._owner_id, scope=scope, lang=lang
)
return json.dumps(
{
"status": "success",
+17 -4
View File
@@ -51,7 +51,9 @@ class DocsController:
response.raise_for_status()
markdown = response.text
except httpx.HTTPError as exc:
raise NetworkError(f"Could not load documentation: {exc}", url=url) from exc
raise NetworkError(
f"Could not load documentation: {exc}", url=url
) from exc
self._sections = self._split(markdown)
logger.info("Loaded %d documentation sections", len(self._sections))
return self._sections
@@ -78,7 +80,9 @@ class DocsController:
query = str(arguments.get("query", "")).strip()
if not query:
raise ToolInputError("search_docs requires a query.")
max_results = int(arguments.get("max_results", DEFAULT_MAX_RESULTS) or DEFAULT_MAX_RESULTS)
max_results = int(
arguments.get("max_results", DEFAULT_MAX_RESULTS) or DEFAULT_MAX_RESULTS
)
max_results = max(1, min(max_results, 10))
sections = await self._load()
@@ -100,10 +104,19 @@ class DocsController:
scored.sort(key=lambda item: item[0], reverse=True)
results = [
{"title": heading, "score": round(score, 2), "content": truncate(text, SECTION_CHARS)}
{
"title": heading,
"score": round(score, 2),
"content": truncate(text, SECTION_CHARS),
}
for score, heading, text in scored[:max_results]
]
return json.dumps(
{"status": "success", "query": query, "count": len(results), "results": results},
{
"status": "success",
"query": query,
"count": len(results),
"results": results,
},
ensure_ascii=False,
)
@@ -94,7 +94,11 @@ class FetchController:
body, final_url, content_type, status = await self._download(url)
if "html" in content_type or "xml" in content_type or content_type.startswith("text/"):
if (
"html" in content_type
or "xml" in content_type
or content_type.startswith("text/")
):
title_match = TITLE.search(body)
title = html_to_text(title_match.group(1)) if title_match else ""
content = html_to_text(body)
+13 -5
View File
@@ -12,12 +12,14 @@ from .errors import AuthRequiredError, NetworkError, ToolInputError, UpstreamErr
logger = logging.getLogger("devii.http")
LOGIN_FAILURE_MARKERS = ("Invalid email or password", "name=\"password\"")
LOGIN_FAILURE_MARKERS = ("Invalid email or password", 'name="password"')
LOGIN_PATH = "/auth/login"
class PlatformClient:
def __init__(self, base_url: str, timeout_seconds: float, api_key: str = "") -> None:
def __init__(
self, base_url: str, timeout_seconds: float, api_key: str = ""
) -> None:
headers = {"User-Agent": "devii/0.1", "Accept": "application/json"}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
@@ -37,7 +39,9 @@ class PlatformClient:
def session_cookie(self) -> str:
return self._client.cookies.get("session") or ""
async def login(self, email: str, password: str, remember_me: bool = True) -> dict[str, Any]:
async def login(
self, email: str, password: str, remember_me: bool = True
) -> dict[str, Any]:
form = {
"email": email,
"password": password,
@@ -136,8 +140,12 @@ class PlatformClient:
logger.debug("Response %s %s -> %s", method, path, response.status_code)
return response
except httpx.TimeoutException as exc:
raise NetworkError(f"Request timed out: {method} {path}", path=path) from exc
raise NetworkError(
f"Request timed out: {method} {path}", path=path
) from exc
except httpx.ConnectError as exc:
raise NetworkError(f"Could not connect: {method} {path}", path=path) from exc
raise NetworkError(
f"Could not connect: {method} {path}", path=path
) from exc
except httpx.HTTPError as exc:
raise NetworkError(f"HTTP error: {exc}", path=path) from exc
+23 -4
View File
@@ -36,7 +36,12 @@ class DeviiHub:
return self._stores["ledger"]
def get_or_create(
self, owner_kind: str, owner_id: str, username: str, api_key: str, base_url: str,
self,
owner_kind: str,
owner_id: str,
username: str,
api_key: str,
base_url: str,
is_admin: bool = False,
) -> DeviiSession:
key = (owner_kind, owner_id)
@@ -51,15 +56,29 @@ class DeviiHub:
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,
virtual_tool_store, self._stores, is_admin=is_admin,
owner_kind,
owner_id,
username,
settings,
llm,
lessons,
pricing,
task_store,
virtual_tool_store,
self._stores,
is_admin=is_admin,
)
if owner_kind == "user":
saved = self._stores["conversations"].load(owner_kind, owner_id)
if saved:
session.restore_history(saved)
self._sessions[key] = session
logger.info("Created session %s/%s (total %d)", owner_kind, owner_id, len(self._sessions))
logger.info(
"Created session %s/%s (total %d)",
owner_kind,
owner_id,
len(self._sessions),
)
return session
def find(self, owner_kind: str, owner_id: str) -> DeviiSession | None:
+13 -4
View File
@@ -66,14 +66,19 @@ class LLMClient:
raise LLMError("Model response contained no message.", body=str(data)[:500])
record_usage(data.get("usage"))
logger.debug("LLM response received (tool_calls=%s)", bool(message.get("tool_calls")))
logger.debug(
"LLM response received (tool_calls=%s)", bool(message.get("tool_calls"))
)
return message
async def summarize(self, text: str) -> str:
payload = {
"model": self._settings.ai_model,
"messages": [
{"role": "system", "content": "You are a precise technical summarizer."},
{
"role": "system",
"content": "You are a precise technical summarizer.",
},
{"role": "user", "content": text},
],
"temperature": 0.0,
@@ -83,12 +88,16 @@ class LLMClient:
except httpx.HTTPError as exc:
raise LLMError(f"Could not reach the model endpoint: {exc}") from exc
if response.status_code >= 400:
raise LLMError(f"Model endpoint returned {response.status_code}: {self._reason(response)}")
raise LLMError(
f"Model endpoint returned {response.status_code}: {self._reason(response)}"
)
try:
data = response.json()
content = data["choices"][0]["message"]["content"] or ""
except (ValueError, KeyError, IndexError) as exc:
raise LLMError("Model summarization returned an unexpected response.") from exc
raise LLMError(
"Model summarization returned an unexpected response."
) from exc
record_usage(data.get("usage"))
return content
+51 -32
View File
@@ -19,7 +19,9 @@ USER_AGENT = "devii/0.1"
def _flag(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
return "true" if str(value).strip().lower() in ("1", "true", "yes", "on") else "false"
return (
"true" if str(value).strip().lower() in ("1", "true", "yes", "on") else "false"
)
class RsearchController:
@@ -65,7 +67,9 @@ class RsearchController:
try:
return response.json()
except ValueError as exc:
raise UpstreamError("rsearch returned a non-JSON response", path=path) from exc
raise UpstreamError(
"rsearch returned a non-JSON response", path=path
) from exc
def _require_text(self, arguments: dict[str, Any], key: str) -> str:
value = str(arguments.get(key, "")).strip()
@@ -105,14 +109,17 @@ class RsearchController:
}
for item in results
]
return json.dumps({
"status": "success",
"query": data.get("query", query),
"type": result_type,
"source": data.get("source"),
"count": len(compact),
"results": compact,
}, ensure_ascii=False)
return json.dumps(
{
"status": "success",
"query": data.get("query", query),
"type": result_type,
"source": data.get("source"),
"count": len(compact),
"results": compact,
},
ensure_ascii=False,
)
async def _answer(self, arguments: dict[str, Any]) -> str:
query = self._require_text(arguments, "query")
@@ -124,36 +131,48 @@ class RsearchController:
}
data = await self._request("/search", params)
results = data.get("results") or []
return json.dumps({
"status": "success",
"query": query,
"answer": data.get("ai_response"),
"answer_error": data.get("ai_error"),
"sources": [
{"title": item.get("title"), "url": item.get("url")}
for item in results
],
}, ensure_ascii=False)
return json.dumps(
{
"status": "success",
"query": query,
"answer": data.get("ai_response"),
"answer_error": data.get("ai_error"),
"sources": [
{"title": item.get("title"), "url": item.get("url")}
for item in results
],
},
ensure_ascii=False,
)
async def _chat(self, arguments: dict[str, Any]) -> str:
prompt = self._require_text(arguments, "prompt")
params: dict[str, Any] = {"prompt": prompt, "json": _flag(arguments.get("json"))}
params: dict[str, Any] = {
"prompt": prompt,
"json": _flag(arguments.get("json")),
}
system = str(arguments.get("system", "")).strip()
if system:
params["system"] = system
data = await self._request("/chat", params)
return json.dumps({
"status": "success",
"response": data.get("response"),
"json_mode": data.get("json_mode", False),
}, ensure_ascii=False)
return json.dumps(
{
"status": "success",
"response": data.get("response"),
"json_mode": data.get("json_mode", False),
},
ensure_ascii=False,
)
async def _describe(self, arguments: dict[str, Any]) -> str:
url = self._require_text(arguments, "url")
data = await self._request("/describe", {"url": url})
return json.dumps({
"status": "success",
"url": url,
"mime_type": data.get("mime_type"),
"description": data.get("description"),
}, ensure_ascii=False)
return json.dumps(
{
"status": "success",
"url": url,
"mime_type": data.get("mime_type"),
"description": data.get("description"),
},
ensure_ascii=False,
)
+214 -67
View File
@@ -29,61 +29,183 @@ class DeviiService(BaseService):
"with every turn recorded to a usage ledger and an audit log."
)
config_fields = [
ConfigField(config.FIELD_AI_URL, "AI URL", type="url", default=DEFAULT_AI_URL,
help="OpenAI-compatible chat-completions endpoint Devii reasons with.", group="AI"),
ConfigField(config.FIELD_AI_MODEL, "AI model", type="str", default=DEFAULT_AI_MODEL,
help="Model name sent to the AI endpoint.", group="AI"),
ConfigField(config.FIELD_AI_KEY, "AI API key", type="password", default="", secret=True,
help="Defaults to the DEVII_AI_KEY env var, then the gateway's internal key.", group="AI"),
ConfigField("devii_base_url", "Platform base URL", type="url", default="",
help="Origin Devii drives via each user's API key. Blank uses this instance.",
group="AI"),
ConfigField(config.FIELD_TIMEOUT, "AI request timeout (seconds)", type="float",
default=config.DEFAULT_TIMEOUT_SECONDS, minimum=config.MIN_TIMEOUT_SECONDS,
help="Read timeout for each AI chat-completions call. Must be at least as large "
"as the gateway's upstream timeout or large prompts abort early. Minimum five minutes.",
group="Reliability"),
ConfigField(config.FIELD_FETCH_TIMEOUT, "Web fetch timeout (seconds)", type="float",
default=config.DEFAULT_FETCH_TIMEOUT_SECONDS, minimum=config.MIN_TIMEOUT_SECONDS,
help="Read timeout for the fetch tool when Devii retrieves a URL. Minimum five minutes.",
group="Reliability"),
ConfigField(config.FIELD_PLAN_REQUIRED, "Require plan step", type="bool", default=True,
help="Force a plan() call before any tool use.", group="Agent"),
ConfigField(config.FIELD_VERIFY_REQUIRED, "Require verify step", type="bool", default=True,
help="Force a verify() call after mutating actions.", group="Agent"),
ConfigField(config.FIELD_MAX_ITERATIONS, "Max tool iterations", type="int", default=40,
minimum=1, maximum=200, help="Upper bound on tool-loop iterations per turn.",
group="Agent"),
ConfigField(config.FIELD_ALLOW_EVAL, "Allow JavaScript execution", type="bool", default=True,
help="Let Devii run JavaScript in the user's own browser (run_js tool). "
"Other client tools (navigate, reload, highlight, toast, context) are unaffected.",
group="Agent"),
ConfigField(config.FIELD_USER_DAILY_USD, "Max USD per user / 24h", type="float", default=1.0,
minimum=0, help="Rolling 24-hour spend cap for a signed-in user.", group="Limits"),
ConfigField(config.FIELD_GUEST_DAILY_USD, "Max USD per guest / 24h", type="float", default=0.05,
minimum=0, help="Rolling 24-hour spend cap for an anonymous guest.", group="Limits"),
ConfigField(config.FIELD_ADMIN_DAILY_USD, "Max USD per admin / 24h", type="float", default=0.0,
minimum=0, help="Rolling 24-hour spend cap for administrators. 0 = unlimited.", group="Limits"),
ConfigField(config.FIELD_GUESTS_ENABLED, "Allow guests", type="bool", default=True,
help="When off, only signed-in users may use Devii.", group="Limits"),
ConfigField(config.FIELD_PRICE_CACHE_HIT, "Price cache-hit / 1M", type="float", default=0.0028,
minimum=0, help="USD per million cache-hit input tokens.", group="Pricing"),
ConfigField(config.FIELD_PRICE_CACHE_MISS, "Price cache-miss / 1M", type="float", default=0.14,
minimum=0, help="USD per million cache-miss input tokens.", group="Pricing"),
ConfigField(config.FIELD_PRICE_OUTPUT, "Price output / 1M", type="float", default=0.28,
minimum=0, help="USD per million output tokens.", group="Pricing"),
ConfigField(config.FIELD_RSEARCH_ENABLED, "Enable web search tools", type="bool", default=True,
help="Allow the external rsearch_* tools (web/image search, web-grounded AI answer, "
"chat, image description). When off, those tools are refused. These reach an external "
"public service, not this platform.", group="Web search"),
ConfigField(config.FIELD_RSEARCH_URL, "Web search service URL", type="url",
default=config.DEFAULT_RSEARCH_URL,
help="Base URL of the rsearch-compatible service the rsearch_* tools call.",
group="Web search"),
ConfigField(config.FIELD_RSEARCH_TIMEOUT, "Web search timeout (seconds)", type="float",
default=config.DEFAULT_RSEARCH_TIMEOUT_SECONDS, minimum=config.MIN_TIMEOUT_SECONDS,
help="Read timeout for rsearch_* calls. Web-grounded answers can take several "
"minutes, so this is generous by default. Minimum five minutes.", group="Web search"),
ConfigField(
config.FIELD_AI_URL,
"AI URL",
type="url",
default=DEFAULT_AI_URL,
help="OpenAI-compatible chat-completions endpoint Devii reasons with.",
group="AI",
),
ConfigField(
config.FIELD_AI_MODEL,
"AI model",
type="str",
default=DEFAULT_AI_MODEL,
help="Model name sent to the AI endpoint.",
group="AI",
),
ConfigField(
config.FIELD_AI_KEY,
"AI API key",
type="password",
default="",
secret=True,
help="Defaults to the DEVII_AI_KEY env var, then the gateway's internal key.",
group="AI",
),
ConfigField(
"devii_base_url",
"Platform base URL",
type="url",
default="",
help="Origin Devii drives via each user's API key. Blank uses this instance.",
group="AI",
),
ConfigField(
config.FIELD_TIMEOUT,
"AI request timeout (seconds)",
type="float",
default=config.DEFAULT_TIMEOUT_SECONDS,
minimum=config.MIN_TIMEOUT_SECONDS,
help="Read timeout for each AI chat-completions call. Must be at least as large "
"as the gateway's upstream timeout or large prompts abort early. Minimum five minutes.",
group="Reliability",
),
ConfigField(
config.FIELD_FETCH_TIMEOUT,
"Web fetch timeout (seconds)",
type="float",
default=config.DEFAULT_FETCH_TIMEOUT_SECONDS,
minimum=config.MIN_TIMEOUT_SECONDS,
help="Read timeout for the fetch tool when Devii retrieves a URL. Minimum five minutes.",
group="Reliability",
),
ConfigField(
config.FIELD_PLAN_REQUIRED,
"Require plan step",
type="bool",
default=True,
help="Force a plan() call before any tool use.",
group="Agent",
),
ConfigField(
config.FIELD_VERIFY_REQUIRED,
"Require verify step",
type="bool",
default=True,
help="Force a verify() call after mutating actions.",
group="Agent",
),
ConfigField(
config.FIELD_MAX_ITERATIONS,
"Max tool iterations",
type="int",
default=40,
minimum=1,
maximum=200,
help="Upper bound on tool-loop iterations per turn.",
group="Agent",
),
ConfigField(
config.FIELD_ALLOW_EVAL,
"Allow JavaScript execution",
type="bool",
default=True,
help="Let Devii run JavaScript in the user's own browser (run_js tool). "
"Other client tools (navigate, reload, highlight, toast, context) are unaffected.",
group="Agent",
),
ConfigField(
config.FIELD_USER_DAILY_USD,
"Max USD per user / 24h",
type="float",
default=1.0,
minimum=0,
help="Rolling 24-hour spend cap for a signed-in user.",
group="Limits",
),
ConfigField(
config.FIELD_GUEST_DAILY_USD,
"Max USD per guest / 24h",
type="float",
default=0.05,
minimum=0,
help="Rolling 24-hour spend cap for an anonymous guest.",
group="Limits",
),
ConfigField(
config.FIELD_ADMIN_DAILY_USD,
"Max USD per admin / 24h",
type="float",
default=0.0,
minimum=0,
help="Rolling 24-hour spend cap for administrators. 0 = unlimited.",
group="Limits",
),
ConfigField(
config.FIELD_GUESTS_ENABLED,
"Allow guests",
type="bool",
default=True,
help="When off, only signed-in users may use Devii.",
group="Limits",
),
ConfigField(
config.FIELD_PRICE_CACHE_HIT,
"Price cache-hit / 1M",
type="float",
default=0.0028,
minimum=0,
help="USD per million cache-hit input tokens.",
group="Pricing",
),
ConfigField(
config.FIELD_PRICE_CACHE_MISS,
"Price cache-miss / 1M",
type="float",
default=0.14,
minimum=0,
help="USD per million cache-miss input tokens.",
group="Pricing",
),
ConfigField(
config.FIELD_PRICE_OUTPUT,
"Price output / 1M",
type="float",
default=0.28,
minimum=0,
help="USD per million output tokens.",
group="Pricing",
),
ConfigField(
config.FIELD_RSEARCH_ENABLED,
"Enable web search tools",
type="bool",
default=True,
help="Allow the external rsearch_* tools (web/image search, web-grounded AI answer, "
"chat, image description). When off, those tools are refused. These reach an external "
"public service, not this platform.",
group="Web search",
),
ConfigField(
config.FIELD_RSEARCH_URL,
"Web search service URL",
type="url",
default=config.DEFAULT_RSEARCH_URL,
help="Base URL of the rsearch-compatible service the rsearch_* tools call.",
group="Web search",
),
ConfigField(
config.FIELD_RSEARCH_TIMEOUT,
"Web search timeout (seconds)",
type="float",
default=config.DEFAULT_RSEARCH_TIMEOUT_SECONDS,
minimum=config.MIN_TIMEOUT_SECONDS,
help="Read timeout for rsearch_* calls. Web-grounded answers can take several "
"minutes, so this is generous by default. Minimum five minutes.",
group="Web search",
),
]
def __init__(self):
@@ -93,15 +215,19 @@ class DeviiService(BaseService):
def hub(self):
if self._hub is None:
from .hub import DeviiHub
self._hub = DeviiHub(self._build_settings)
return self._hub
def effective_config(self) -> dict:
from devplacepy.database import internal_gateway_key
cfg = self.get_config()
cfg[config.FIELD_AI_KEY] = (cfg[config.FIELD_AI_KEY]
or os.environ.get("DEVII_AI_KEY", "")
or internal_gateway_key())
cfg[config.FIELD_AI_KEY] = (
cfg[config.FIELD_AI_KEY]
or os.environ.get("DEVII_AI_KEY", "")
or internal_gateway_key()
)
return cfg
def instance_base_url(self) -> str:
@@ -119,7 +245,13 @@ class DeviiService(BaseService):
output_per_m=float(cfg[config.FIELD_PRICE_OUTPUT]),
)
def _build_settings(self, api_key: str, base_url: str, owner_kind: str = "guest", is_admin: bool = False):
def _build_settings(
self,
api_key: str,
base_url: str,
owner_kind: str = "guest",
is_admin: bool = False,
):
cfg = self.effective_config()
settings = build_settings(cfg, base_url, api_key, owner_kind, is_admin)
return settings, self._pricing(cfg)
@@ -149,7 +281,9 @@ class DeviiService(BaseService):
pruned = hub.ledger.prune(48)
removed = await hub.gc_idle()
if pruned or removed:
self.log(f"Housekeeping: pruned {pruned} ledger rows, closed {removed} idle sessions")
self.log(
f"Housekeeping: pruned {pruned} ledger rows, closed {removed} idle sessions"
)
async def on_disable(self) -> None:
if self._hub is not None:
@@ -167,13 +301,26 @@ class DeviiService(BaseService):
{"label": "Active sessions", "value": active},
{"label": "Connections", "value": conns},
{"label": "Spend 24h", "value": f"${spent:.4f}"},
{"label": "User cap 24h", "value": f"${float(cfg[config.FIELD_USER_DAILY_USD]):.2f}"},
{"label": "Guest cap 24h", "value": f"${float(cfg[config.FIELD_GUEST_DAILY_USD]):.2f}"},
{"label": "Admin cap 24h", "value": (
f"${float(cfg[config.FIELD_ADMIN_DAILY_USD]):.2f}"
if float(cfg[config.FIELD_ADMIN_DAILY_USD]) > 0 else "unlimited"
)},
{"label": "Guests", "value": "on" if cfg[config.FIELD_GUESTS_ENABLED] else "off"},
{
"label": "User cap 24h",
"value": f"${float(cfg[config.FIELD_USER_DAILY_USD]):.2f}",
},
{
"label": "Guest cap 24h",
"value": f"${float(cfg[config.FIELD_GUEST_DAILY_USD]):.2f}",
},
{
"label": "Admin cap 24h",
"value": (
f"${float(cfg[config.FIELD_ADMIN_DAILY_USD]):.2f}"
if float(cfg[config.FIELD_ADMIN_DAILY_USD]) > 0
else "unlimited"
),
},
{
"label": "Guests",
"value": "on" if cfg[config.FIELD_GUESTS_ENABLED] else "off",
},
{"label": "Model", "value": cfg[config.FIELD_AI_MODEL]},
]
}
+120 -41
View File
@@ -66,7 +66,9 @@ class DeviiSession:
self.persist_conversation = owner_kind == "user"
self._llm = llm
self._lessons = lessons
self.client = PlatformClient(settings.base_url, settings.timeout_seconds, settings.platform_api_key)
self.client = PlatformClient(
settings.base_url, settings.timeout_seconds, settings.platform_api_key
)
self.avatar = AvatarController()
self.browser = ClientController(allow_eval=settings.allow_eval)
self.store = task_store
@@ -79,25 +81,45 @@ class DeviiSession:
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,
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)
self.agentic.bind(
llm=llm, dispatcher=self.dispatcher, tools=self.tools,
on_trace=self._trace, cost_tracker=self.cost, chunk_store=self.chunks,
llm=llm,
dispatcher=self.dispatcher,
tools=self.tools,
on_trace=self._trace,
cost_tracker=self.cost,
chunk_store=self.chunks,
)
self.agent = Agent(
settings, llm, self.dispatcher, self.tools, lessons=lessons,
on_trace=self._trace, cost_tracker=self.cost, chunk_store=self.chunks,
settings,
llm,
self.dispatcher,
self.tools,
lessons=lessons,
on_trace=self._trace,
cost_tracker=self.cost,
chunk_store=self.chunks,
system_prompt=self._system_prompt,
)
self.scheduler = Scheduler(
self.store, self._make_executor(), self._task_event, settings.scheduler_tick_seconds
self.store,
self._make_executor(),
self._task_event,
settings.scheduler_tick_seconds,
)
self._conv = stores["conversations"]
self._ledger = stores["ledger"]
@@ -129,7 +151,11 @@ class DeviiSession:
content = message.get("content")
if role not in ("user", "assistant") or not content:
continue
if role == "user" and isinstance(content, str) and content.startswith(INTERNAL_PREFIXES):
if (
role == "user"
and isinstance(content, str)
and content.startswith(INTERNAL_PREFIXES)
):
continue
visible.append({"role": role, "content": content})
return visible
@@ -138,8 +164,14 @@ class DeviiSession:
async def execute(prompt: str) -> str:
async with self._lock:
worker = Agent(
self.settings, self._llm, self.dispatcher, self.tools, lessons=self._lessons,
on_trace=self._trace, cost_tracker=self.cost, chunk_store=self.chunks,
self.settings,
self._llm,
self.dispatcher,
self.tools,
lessons=self._lessons,
on_trace=self._trace,
cost_tracker=self.cost,
chunk_store=self.chunks,
system_prompt=self._system_prompt,
)
return await worker.respond(prompt)
@@ -149,7 +181,11 @@ class DeviiSession:
def attach(self, ws: Any) -> None:
self._conns.add(ws)
self._attach_seq += 1
self._conn_meta[ws] = {"seq": self._attach_seq, "visible": True, "focused": False}
self._conn_meta[ws] = {
"seq": self._attach_seq,
"visible": True,
"focused": False,
}
self._connected.set()
self._disconnected.clear()
self.avatar.bind(self._avatar_request)
@@ -161,7 +197,12 @@ class DeviiSession:
pending = self._buffer
self._buffer = []
asyncio.create_task(self._flush(pending))
logger.info("Session %s/%s attached (%d conns)", self.owner_kind, self.owner_id, len(self._conns))
logger.info(
"Session %s/%s attached (%d conns)",
self.owner_kind,
self.owner_id,
len(self._conns),
)
def detach(self, ws: Any) -> None:
self._conns.discard(ws)
@@ -171,7 +212,12 @@ class DeviiSession:
self._disconnected.set()
self.avatar.unbind()
self.browser.unbind()
logger.info("Session %s/%s detached (%d conns)", self.owner_kind, self.owner_id, len(self._conns))
logger.info(
"Session %s/%s detached (%d conns)",
self.owner_kind,
self.owner_id,
len(self._conns),
)
def set_visibility(self, ws: Any, visible: bool, focused: bool) -> None:
meta = self._conn_meta.get(ws)
@@ -212,7 +258,9 @@ class DeviiSession:
if history:
await self._send_to(ws, {"type": "history", "messages": history})
else:
await self._send_to(ws, {"type": "reply", "text": await self.bootstrap_greeting()})
await self._send_to(
ws, {"type": "reply", "text": await self.bootstrap_greeting()}
)
async def reset_conversation(self) -> None:
async with self._lock:
@@ -227,7 +275,11 @@ class DeviiSession:
try:
self._conv.clear(self.owner_kind, self.owner_id)
except Exception: # noqa: BLE001 - clearing storage must not break the socket
logger.exception("Failed to clear conversation for %s/%s", self.owner_kind, self.owner_id)
logger.exception(
"Failed to clear conversation for %s/%s",
self.owner_kind,
self.owner_id,
)
await self._emit({"type": "clear"}, buffer=False)
def spawn_turn(self, text: str) -> None:
@@ -296,31 +348,44 @@ class DeviiSession:
after = self._cost_snapshot()
usage = {
"prompt_tokens": after["prompt_tokens"] - before["prompt_tokens"],
"completion_tokens": after["completion_tokens"] - before["completion_tokens"],
"completion_tokens": after["completion_tokens"]
- before["completion_tokens"],
"cache_hit_tokens": after["cache_hit_tokens"] - before["cache_hit_tokens"],
"cache_miss_tokens": after["cache_miss_tokens"] - before["cache_miss_tokens"],
"cache_miss_tokens": after["cache_miss_tokens"]
- before["cache_miss_tokens"],
}
cost_delta = round(after["cost_usd"] - before["cost_usd"], 8)
try:
if self.persist_conversation:
self._conv.save(self.owner_kind, self.owner_id, self.agent._messages)
self._ledger.record(self.owner_kind, self.owner_id, turn_id, usage, cost_delta, self.settings.ai_model)
self._audit.record({
"turn_id": turn_id,
"owner_kind": self.owner_kind,
"owner_id": self.owner_id,
"username": self.username,
"started_at": started_at,
"ended_at": _now_iso(),
"prompt": prompt,
"reply": reply,
"iterations": after["requests"] - before["requests"],
"tool_calls": self._turn_tool_calls,
"cost_usd": cost_delta,
"error": error,
})
self._ledger.record(
self.owner_kind,
self.owner_id,
turn_id,
usage,
cost_delta,
self.settings.ai_model,
)
self._audit.record(
{
"turn_id": turn_id,
"owner_kind": self.owner_kind,
"owner_id": self.owner_id,
"username": self.username,
"started_at": started_at,
"ended_at": _now_iso(),
"prompt": prompt,
"reply": reply,
"iterations": after["requests"] - before["requests"],
"tool_calls": self._turn_tool_calls,
"cost_usd": cost_delta,
"error": error,
}
)
except Exception: # noqa: BLE001 - persistence must never break a turn
logger.exception("Failed to persist turn for %s/%s", self.owner_kind, self.owner_id)
logger.exception(
"Failed to persist turn for %s/%s", self.owner_kind, self.owner_id
)
def resolve_query(self, query_id: str, payload: Any) -> None:
future = self._pending.pop(query_id, None)
@@ -347,10 +412,14 @@ class DeviiSession:
token = self.client.session_cookie()
if token:
self._pending_session = token
asyncio.create_task(self._emit({"type": "auth", "action": "adopt"}, buffer=False))
asyncio.create_task(
self._emit({"type": "auth", "action": "adopt"}, buffer=False)
)
elif name == "logout":
self._pending_session = None
asyncio.create_task(self._emit({"type": "auth", "action": "logout"}, buffer=False))
asyncio.create_task(
self._emit({"type": "auth", "action": "logout"}, buffer=False)
)
def take_pending_session(self) -> str | None:
token = self._pending_session
@@ -381,7 +450,9 @@ class DeviiSession:
async def _client_request(self, action: str, args: dict[str, Any]) -> Any:
return await self._browser_request("client", action, args)
async def _browser_request(self, channel: str, action: str, args: dict[str, Any]) -> Any:
async def _browser_request(
self, channel: str, action: str, args: dict[str, Any]
) -> Any:
loop = asyncio.get_event_loop()
deadline = loop.time() + BROWSER_REQUEST_DEADLINE_SECONDS
self._query_seq += 1
@@ -397,7 +468,9 @@ class DeviiSession:
try:
await asyncio.wait_for(self._connected.wait(), timeout=remaining)
except asyncio.TimeoutError as exc:
raise RuntimeError(f"No browser connected to handle '{action}'") from exc
raise RuntimeError(
f"No browser connected to handle '{action}'"
) from exc
ws = self._pick_target()
if ws is None:
continue
@@ -423,7 +496,12 @@ class DeviiSession:
def _task_event(self, kind: str, row: dict[str, Any], payload: str) -> None:
label = row.get("label") or row.get("uid", "task")
message = {"type": "task", "kind": kind, "label": label, "payload": payload[:RESULT_NOTICE_CHARS]}
message = {
"type": "task",
"kind": kind,
"label": label,
"payload": payload[:RESULT_NOTICE_CHARS],
}
asyncio.create_task(self._emit(message, buffer=True))
@@ -443,4 +521,5 @@ def _system_prompt_for(is_admin: bool) -> str:
def _now_iso() -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat()
+31 -17
View File
@@ -31,7 +31,9 @@ class ConversationStore:
def load(self, owner_kind: str, owner_id: str) -> list[dict[str, Any]] | None:
if CONVERSATIONS not in db.tables:
return None
row = get_table(CONVERSATIONS).find_one(owner_kind=owner_kind, owner_id=owner_id)
row = get_table(CONVERSATIONS).find_one(
owner_kind=owner_kind, owner_id=owner_id
)
if not row or not row.get("messages"):
return None
try:
@@ -40,7 +42,9 @@ class ConversationStore:
logger.warning("Corrupt conversation for %s/%s", owner_kind, owner_id)
return None
def save(self, owner_kind: str, owner_id: str, messages: list[dict[str, Any]]) -> None:
def save(
self, owner_kind: str, owner_id: str, messages: list[dict[str, Any]]
) -> None:
now = _iso(_now())
record = {
"owner_kind": owner_kind,
@@ -49,7 +53,11 @@ class ConversationStore:
"updated_at": now,
}
table = get_table(CONVERSATIONS)
existing = table.find_one(owner_kind=owner_kind, owner_id=owner_id) if CONVERSATIONS in db.tables else None
existing = (
table.find_one(owner_kind=owner_kind, owner_id=owner_id)
if CONVERSATIONS in db.tables
else None
)
if existing:
table.update({**record, "id": existing["id"]}, ["id"])
else:
@@ -71,18 +79,20 @@ class UsageLedger:
cost_usd: float,
model: str,
) -> None:
get_table(LEDGER).insert({
"owner_kind": owner_kind,
"owner_id": owner_id,
"turn_id": turn_id,
"created_at": _iso(_now()),
"prompt_tokens": int(usage.get("prompt_tokens", 0)),
"completion_tokens": int(usage.get("completion_tokens", 0)),
"cache_hit_tokens": int(usage.get("cache_hit_tokens", 0)),
"cache_miss_tokens": int(usage.get("cache_miss_tokens", 0)),
"cost_usd": float(cost_usd),
"model": model,
})
get_table(LEDGER).insert(
{
"owner_kind": owner_kind,
"owner_id": owner_id,
"turn_id": turn_id,
"created_at": _iso(_now()),
"prompt_tokens": int(usage.get("prompt_tokens", 0)),
"completion_tokens": int(usage.get("completion_tokens", 0)),
"cache_hit_tokens": int(usage.get("cache_hit_tokens", 0)),
"cache_miss_tokens": int(usage.get("cache_miss_tokens", 0)),
"cost_usd": float(cost_usd),
"model": model,
}
)
def spent_24h(self, owner_kind: str, owner_id: str) -> float:
if LEDGER not in db.tables:
@@ -91,7 +101,9 @@ class UsageLedger:
rows = db.query(
f"SELECT COALESCE(SUM(cost_usd), 0) AS total FROM {LEDGER} "
"WHERE owner_kind = :owner_kind AND owner_id = :owner_id AND created_at >= :cutoff",
owner_kind=owner_kind, owner_id=owner_id, cutoff=cutoff,
owner_kind=owner_kind,
owner_id=owner_id,
cutoff=cutoff,
)
for row in rows:
return float(row["total"] or 0.0)
@@ -104,7 +116,9 @@ class UsageLedger:
rows = db.query(
f"SELECT COUNT(*) AS n FROM {LEDGER} "
"WHERE owner_kind = :owner_kind AND owner_id = :owner_id AND created_at >= :cutoff",
owner_kind=owner_kind, owner_id=owner_id, cutoff=cutoff,
owner_kind=owner_kind,
owner_id=owner_id,
cutoff=cutoff,
)
for row in rows:
return int(row["n"] or 0)
+23 -5
View File
@@ -5,8 +5,16 @@ from __future__ import annotations
from ..actions.spec import Action, Param
def field(name: str, description: str, required: bool = False, kind: str = "string") -> Param:
return Param(name=name, location="body", description=description, required=required, type=kind)
def field(
name: str, description: str, required: bool = False, kind: str = "string"
) -> Param:
return Param(
name=name,
location="body",
description=description,
required=required,
type=kind,
)
SCHEDULE_FIELDS: tuple[Param, ...] = (
@@ -20,8 +28,16 @@ SCHEDULE_FIELDS: tuple[Param, ...] = (
"run_at",
"For kind=once: absolute UTC time in ISO 8601, e.g. 2026-06-09T14:30:00.",
),
field("delay_seconds", "For kind=once: run this many seconds from now.", kind="integer"),
field("every_seconds", "For kind=interval: number of seconds between runs.", kind="integer"),
field(
"delay_seconds",
"For kind=once: run this many seconds from now.",
kind="integer",
),
field(
"every_seconds",
"For kind=interval: number of seconds between runs.",
kind="integer",
),
field(
"start_at",
"For kind=interval: optional ISO 8601 UTC time of the first run. "
@@ -73,7 +89,9 @@ TASK_ACTIONS: tuple[Action, ...] = (
requires_auth=False,
params=(
field("enabled_only", "Only return enabled tasks.", kind="boolean"),
field("status", "Filter by status: pending, running, done, error, disabled."),
field(
"status", "Filter by status: pending, running, done, error, disabled."
),
),
),
Action(
+42 -10
View File
@@ -15,7 +15,15 @@ from .store import TaskStore
logger = logging.getLogger("devii.tasks.controller")
SCHEDULE_KEYS = ("kind", "run_at", "delay_seconds", "every_seconds", "start_at", "cron", "max_runs")
SCHEDULE_KEYS = (
"kind",
"run_at",
"delay_seconds",
"every_seconds",
"start_at",
"cron",
"max_runs",
)
RESULT_PREVIEW_CHARS = 500
TRUTHY = {"1", "true", "yes", "on"}
@@ -105,7 +113,10 @@ class TaskController:
)
rows.sort(key=lambda row: row.get("next_run_at") or "")
return json.dumps(
{"count": len(rows), "tasks": [_serialize(row, preview=True) for row in rows]},
{
"count": len(rows),
"tasks": [_serialize(row, preview=True) for row in rows],
},
ensure_ascii=False,
)
@@ -127,7 +138,9 @@ class TaskController:
if "enabled" in arguments:
changes["enabled"] = _as_bool(arguments.get("enabled"))
if any(key in arguments and arguments[key] is not None for key in SCHEDULE_KEYS):
if any(
key in arguments and arguments[key] is not None for key in SCHEDULE_KEYS
):
merged = {key: row.get(key) for key in SCHEDULE_KEYS}
for key in SCHEDULE_KEYS:
if key in arguments and arguments[key] is not None:
@@ -137,10 +150,16 @@ class TaskController:
changes["next_run_at"] = to_iso(schedule.first_run(now_utc()))
changes["status"] = "pending"
if changes.get("enabled") and row.get("status") in ("done", "disabled", "error"):
if changes.get("enabled") and row.get("status") in (
"done",
"disabled",
"error",
):
changes.setdefault("status", "pending")
if not changes.get("next_run_at") and not row.get("next_run_at"):
schedule = self._build_schedule({key: row.get(key) for key in SCHEDULE_KEYS})
schedule = self._build_schedule(
{key: row.get(key) for key in SCHEDULE_KEYS}
)
changes["next_run_at"] = to_iso(schedule.first_run(now_utc()))
if changes.get("enabled") is False:
changes["status"] = "disabled"
@@ -150,7 +169,10 @@ class TaskController:
self._store.update(row["uid"], changes)
return json.dumps(
{"status": "updated", "task": _serialize(self._store.get(row["uid"]), preview=True)},
{
"status": "updated",
"task": _serialize(self._store.get(row["uid"]), preview=True),
},
ensure_ascii=False,
)
@@ -168,12 +190,18 @@ class TaskController:
{"enabled": True, "status": "pending", "next_run_at": to_iso(now_utc())},
)
return json.dumps(
{"status": "queued", "uid": row["uid"], "note": "Will execute on the next scheduler tick."},
{
"status": "queued",
"uid": row["uid"],
"note": "Will execute on the next scheduler tick.",
},
ensure_ascii=False,
)
def _build_schedule(self, source: dict[str, Any]) -> Schedule:
payload = {key: source.get(key) for key in SCHEDULE_KEYS if source.get(key) is not None}
payload = {
key: source.get(key) for key in SCHEDULE_KEYS if source.get(key) is not None
}
try:
return Schedule(**payload)
except ValidationError as exc:
@@ -195,10 +223,14 @@ class TaskController:
return row
def compute_followup(row: dict[str, Any], reference: Any) -> tuple[dict[str, Any], bool]:
def compute_followup(
row: dict[str, Any], reference: Any
) -> tuple[dict[str, Any], bool]:
run_count = int(row.get("run_count") or 0) + 1
max_runs = row.get("max_runs")
upcoming = next_run(row.get("kind"), row.get("every_seconds"), row.get("cron"), reference)
upcoming = next_run(
row.get("kind"), row.get("every_seconds"), row.get("cron"), reference
)
changes: dict[str, Any] = {"run_count": run_count, "last_run_at": to_iso(reference)}
if upcoming is None or (max_runs is not None and run_count >= int(max_runs)):
+6 -2
View File
@@ -50,7 +50,9 @@ def _parse_field(field: str, low: int, high: int) -> set[int]:
def cron_next(expr: str, after: datetime) -> datetime:
fields = expr.split()
if len(fields) != 5:
raise ValueError("Cron expression must have 5 fields: minute hour dom month dow")
raise ValueError(
"Cron expression must have 5 fields: minute hour dom month dow"
)
minutes = _parse_field(fields[0], 0, 59)
hours = _parse_field(fields[1], 0, 23)
days = _parse_field(fields[2], 1, 31)
@@ -139,7 +141,9 @@ class Schedule(BaseModel):
}
def next_run(kind: str, every_seconds: Optional[int], cron: Optional[str], reference: datetime) -> Optional[datetime]:
def next_run(
kind: str, every_seconds: Optional[int], cron: Optional[str], reference: datetime
) -> Optional[datetime]:
if kind == "interval" and every_seconds:
return reference + timedelta(seconds=int(every_seconds))
if kind == "cron" and cron:
+5 -1
View File
@@ -77,7 +77,11 @@ class Scheduler:
logger.exception("Task uid=%s crashed", uid)
self._store.update(
uid,
{"status": "error", "last_error": str(exc), "last_run_at": to_iso(now_utc())},
{
"status": "error",
"last_error": str(exc),
"last_run_at": to_iso(now_utc()),
},
)
self._on_event("error", row, str(exc))
return
+25 -6
View File
@@ -10,7 +10,13 @@ import dataset
logger = logging.getLogger("devii.tasks.store")
TABLE = "devii_tasks"
INDEXED_COLUMNS = (["owner_kind", "owner_id"], ["uid"], ["enabled"], ["next_run_at"], ["status"])
INDEXED_COLUMNS = (
["owner_kind", "owner_id"],
["uid"],
["enabled"],
["next_run_at"],
["status"],
)
def memory_db() -> Any:
@@ -41,12 +47,19 @@ class TaskStore:
def create(self, record: dict[str, Any]) -> None:
self._table.insert({**record, **self._scope})
logger.info("Task created uid=%s owner=%s/%s", record.get("uid"), self._owner_kind, self._owner_id)
logger.info(
"Task created uid=%s owner=%s/%s",
record.get("uid"),
self._owner_kind,
self._owner_id,
)
def get(self, uid: str) -> dict[str, Any] | None:
return self._table.find_one(uid=uid, **self._scope)
def list(self, enabled_only: bool = False, status: str | None = None) -> list[dict[str, Any]]:
def list(
self, enabled_only: bool = False, status: str | None = None
) -> list[dict[str, Any]]:
criteria: dict[str, Any] = dict(self._scope)
if enabled_only:
criteria["enabled"] = True
@@ -69,12 +82,18 @@ class TaskStore:
return 0
stuck = list(self._table.find(status="running", **self._scope))
for row in stuck:
self._table.update({"uid": row["uid"], "status": "pending", **self._scope},
["uid", "owner_kind", "owner_id"])
self._table.update(
{"uid": row["uid"], "status": "pending", **self._scope},
["uid", "owner_kind", "owner_id"],
)
if stuck:
logger.info("Recovered %d task(s) stuck in running", len(stuck))
return len(stuck)
def due(self, now_iso: str) -> list[dict[str, Any]]:
rows = self._table.find(enabled=True, status="pending", **self._scope)
return [row for row in rows if row.get("next_run_at") and row["next_run_at"] <= now_iso]
return [
row
for row in rows
if row.get("next_run_at") and row["next_run_at"] <= now_iso
]
+13 -7
View File
@@ -19,12 +19,14 @@ TRAILING_SPACE = re.compile(r"[ \t]+\n")
SKIP_HREF_PREFIXES = ("#", "javascript:")
HIDDEN = "[hidden]"
REDACT_FIELD_KEYS = frozenset({
"gateway_upstream_url",
"gateway_model",
"gateway_vision_url",
"gateway_vision_model",
})
REDACT_FIELD_KEYS = frozenset(
{
"gateway_upstream_url",
"gateway_model",
"gateway_vision_url",
"gateway_vision_model",
}
)
REDACT_STAT_LABELS = frozenset({"Model"})
@@ -80,7 +82,11 @@ def format_response(response: httpx.Response) -> str:
summary["body"] = response.text
return json.dumps(summary, ensure_ascii=False)
if content_type.startswith("text/") or "html" in content_type or "xml" in content_type:
if (
content_type.startswith("text/")
or "html" in content_type
or "xml" in content_type
):
summary["body"] = html_to_text(response.text)
return json.dumps(summary, ensure_ascii=False)
@@ -5,8 +5,16 @@ 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)
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, ...] = (
@@ -23,10 +31,25 @@ VIRTUAL_TOOL_ACTIONS: tuple[Action, ...] = (
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."),
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(
@@ -58,7 +81,11 @@ VIRTUAL_TOOL_ACTIONS: tuple[Action, ...] = (
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"),
arg(
"enabled",
"Enable (true) or disable (false) the tool without deleting it.",
kind="boolean",
),
),
),
Action(
@@ -35,7 +35,9 @@ def _serialize(row: dict[str, Any], full: bool = False) -> dict[str, Any]:
class VirtualToolController:
def __init__(self, store: VirtualToolStore, evaluator: Evaluator, builtin_names: set[str]) -> None:
def __init__(
self, store: VirtualToolStore, evaluator: Evaluator, builtin_names: set[str]
) -> None:
self._store = store
self._evaluator = evaluator
self._builtin_names = builtin_names
@@ -65,7 +67,9 @@ class VirtualToolController:
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)
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()
@@ -80,15 +84,23 @@ class VirtualToolController:
"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.")
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.")
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.")
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.")
raise ToolInputError(
"A 'prompt' is required: the instruction the tool runs when called."
)
record = {
"uid": uuid.uuid4().hex,
"name": name,
@@ -98,12 +110,15 @@ class VirtualToolController:
"enabled": 1,
}
stored = self._store.create(record)
return json.dumps({"status": "created", "tool": _serialize(stored)}, ensure_ascii=False)
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
{"count": len(rows), "tools": [_serialize(row) for row in rows]},
ensure_ascii=False,
)
def _get(self, arguments: dict[str, Any]) -> str:
@@ -126,11 +141,21 @@ class VirtualToolController:
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
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).")
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)
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)
@@ -71,7 +71,12 @@ class VirtualToolStore:
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)
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: