forked from retoor/devplacepy
fix: normalize unicode escape sequences and reformat multi-line expressions across codebase
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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]},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user