forked from retoor/devplacepy
feat: add remote URL attachment support and project editing endpoint
This commit is contained in:
@@ -227,6 +227,25 @@ ACTIONS: tuple[Action, ...] = (
|
||||
body("attachment_uids", ATTACHMENTS),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="edit_project",
|
||||
method="POST",
|
||||
path="/projects/edit/{project_slug}",
|
||||
summary="Edit an existing project",
|
||||
params=(
|
||||
path(
|
||||
"project_slug",
|
||||
"Exact project slug copied from a /projects/... link in a listing response; do not build it from the title.",
|
||||
),
|
||||
body("title", "Updated project title.", required=True),
|
||||
body("description", "Updated project description.", required=True),
|
||||
body("release_date", "Updated release date."),
|
||||
body("demo_date", "Updated demo date."),
|
||||
body("project_type", "Updated project type."),
|
||||
body("platforms", "Updated supported platforms."),
|
||||
body("status", "Updated project status."),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="delete_project",
|
||||
method="POST",
|
||||
@@ -828,6 +847,27 @@ ACTIONS: tuple[Action, ...] = (
|
||||
summary="Upload a local file and obtain its attachment uid",
|
||||
params=(upload("file", "Local filesystem path of the file to upload."),),
|
||||
),
|
||||
Action(
|
||||
name="attach_url",
|
||||
method="POST",
|
||||
path="/uploads/upload-url",
|
||||
summary="Download a file from a public URL and store it as an attachment, returning its uid",
|
||||
description=(
|
||||
"Fetches the file at the given URL on the server (size-capped, SSRF-guarded) and stores it "
|
||||
"as an attachment exactly like an upload, returning {uid, url, mime_type, ...}. Pass the "
|
||||
"returned uid in attachment_uids when you create_post, create_project, create_gist, "
|
||||
"create_comment, create_bug, or send_message to attach it. The file type is taken from the "
|
||||
"URL or its Content-Type; supply 'filename' (with an allowed extension) when the URL has no "
|
||||
"usable name. Use this to attach an image or file straight from the internet."
|
||||
),
|
||||
params=(
|
||||
body("url", "Public http(s) URL of the file to download and attach.", required=True),
|
||||
body(
|
||||
"filename",
|
||||
"Optional filename with an allowed extension, used when the URL has no clear name.",
|
||||
),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="delete_attachment",
|
||||
method="DELETE",
|
||||
@@ -847,10 +887,13 @@ ACTIONS: tuple[Action, ...] = (
|
||||
path="/admin/analytics",
|
||||
summary="Site-wide aggregate analytics in one call (admin only)",
|
||||
description=(
|
||||
"Returns JSON: total members, active users in the last 24h/7d/30d, users signed in "
|
||||
"now, new signups (24h/7d/30d), content totals (posts, comments, gists, projects, "
|
||||
"Returns JSON: total members, active users in the last 24h/7d/30d, signed_in_now, "
|
||||
"new signups (24h/7d/30d), content totals (posts, comments, gists, projects, "
|
||||
"news), and top authors. Use this for any 'how many'/'how active'/count question "
|
||||
"instead of paging through admin_list_users."
|
||||
"instead of paging through admin_list_users. signed_in_now counts members holding an "
|
||||
"unexpired session (logged in within the session lifetime, default 7-30 days), NOT a "
|
||||
"real-time online count - see signed_in_definition in the response and never report it "
|
||||
"as 'currently online' or 'logged in right now'."
|
||||
),
|
||||
params=(query("top_n", "How many top authors to include (1-50)."),),
|
||||
requires_admin=True,
|
||||
|
||||
@@ -103,11 +103,15 @@ CONTAINER_ACTIONS: tuple[Action, ...] = (
|
||||
path="",
|
||||
handler="container",
|
||||
requires_admin=True,
|
||||
summary="Run a one-shot command inside a running instance and return its output",
|
||||
summary="Run a one-shot command inside a running instance and return its output. The command runs in /app (the project workspace) by default, so never prefix it with 'cd /app'",
|
||||
params=(
|
||||
SLUG,
|
||||
arg("instance", "Instance name, slug, or uid.", required=True),
|
||||
arg("command", "Command to run, e.g. 'pip list'.", required=True),
|
||||
arg(
|
||||
"command",
|
||||
"Command to run, e.g. 'git clone ... && ls'. Runs in /app already; do not prepend 'cd /app'.",
|
||||
required=True,
|
||||
),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
@@ -35,8 +36,20 @@ CONFIRM_REQUIRED = {
|
||||
"customize_set_css",
|
||||
"customize_set_js",
|
||||
"customize_reset",
|
||||
"project_delete_file",
|
||||
"delete_project",
|
||||
}
|
||||
|
||||
DESTRUCTIVE_COMMAND = re.compile(
|
||||
r"(?:^|[\s;&|`(])(?:sudo\s+)?(rm|rmdir|unlink|shred|truncate|wipefs|mkfs\S*|dd)(?:\s|$)"
|
||||
r"|>\s*/"
|
||||
r"|\bdrop\s+(?:table|database)\b"
|
||||
r"|\bdelete\s+from\b"
|
||||
r"|\bgit\s+clean\b"
|
||||
r"|\s-delete\b",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("devii.dispatch")
|
||||
|
||||
|
||||
@@ -49,8 +62,12 @@ def _is_confirmed(arguments: dict[str, Any]) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _is_destructive_command(arguments: dict[str, Any]) -> bool:
|
||||
return bool(DESTRUCTIVE_COMMAND.search(str(arguments.get("command", ""))))
|
||||
|
||||
|
||||
def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError | None:
|
||||
if name not in CONFIRM_REQUIRED or _is_confirmed(arguments):
|
||||
if _is_confirmed(arguments):
|
||||
return None
|
||||
if name in ("customize_set_css", "customize_set_js"):
|
||||
scope = str(arguments.get("scope", "")).strip() or "(unspecified)"
|
||||
@@ -64,11 +81,39 @@ def confirmation_error(name: str, arguments: dict[str, Any]) -> ToolInputError |
|
||||
"Deleting customizations cannot be undone. Ask the user to confirm, then call again with "
|
||||
"confirm=true."
|
||||
)
|
||||
return ToolInputError(
|
||||
"Setting a project read-only makes every file immutable and blocks all further "
|
||||
"edits. Ask the user to confirm this explicitly first, then call again with "
|
||||
"confirm=true."
|
||||
)
|
||||
if name == "project_set_readonly":
|
||||
return ToolInputError(
|
||||
"Setting a project read-only makes every file immutable and blocks all further "
|
||||
"edits. Ask the user to confirm this explicitly first, then call again with "
|
||||
"confirm=true."
|
||||
)
|
||||
if name == "project_delete_file":
|
||||
path = str(arguments.get("path", "")).strip() or "(unspecified)"
|
||||
return ToolInputError(
|
||||
f"Deleting '{path}' is permanent and cannot be undone. Show the user the exact path, "
|
||||
"get explicit confirmation, then call again with confirm=true."
|
||||
)
|
||||
if name == "delete_project":
|
||||
return ToolInputError(
|
||||
"Deleting a project permanently removes the project and ALL of its files. Ask the user "
|
||||
"to confirm this explicitly first, then call again with confirm=true."
|
||||
)
|
||||
if (
|
||||
name == "container_instance_action"
|
||||
and str(arguments.get("action", "")).strip().lower() == "delete"
|
||||
):
|
||||
return ToolInputError(
|
||||
"Deleting a container instance is irreversible and destroys its state. Ask the user to "
|
||||
"confirm explicitly, then call again with confirm=true."
|
||||
)
|
||||
if name == "container_exec" and _is_destructive_command(arguments):
|
||||
command = str(arguments.get("command", "")).strip()
|
||||
return ToolInputError(
|
||||
"This command can permanently delete files or data (it contains a destructive operation "
|
||||
f"such as rm, dd, truncate, or drop): {command!r}. Show the user the exact command, get "
|
||||
"explicit confirmation, then call again with confirm=true."
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
class Dispatcher:
|
||||
@@ -86,6 +131,7 @@ class Dispatcher:
|
||||
owner_kind: str = "guest",
|
||||
owner_id: str = "",
|
||||
virtual_tools: Any = None,
|
||||
behavior: Any = None,
|
||||
) -> None:
|
||||
self._actions = catalog.by_name()
|
||||
self._client = client
|
||||
@@ -107,6 +153,7 @@ class Dispatcher:
|
||||
|
||||
self._customization = CustomizationController(owner_kind, owner_id)
|
||||
self._virtual_tools = virtual_tools
|
||||
self._behavior = behavior
|
||||
self._read_files: set[tuple[str, str]] = set()
|
||||
|
||||
@staticmethod
|
||||
@@ -226,6 +273,15 @@ class Dispatcher:
|
||||
if action.handler == "customization":
|
||||
return await self._customization.dispatch(action.name, arguments)
|
||||
|
||||
if action.handler == "behavior":
|
||||
if self._behavior is None:
|
||||
return error_result(
|
||||
ToolInputError(
|
||||
"Behavior configuration is not available in this context."
|
||||
)
|
||||
)
|
||||
return await self._behavior.dispatch(action.name, arguments)
|
||||
|
||||
if action.handler == "virtual_tool":
|
||||
if self._virtual_tools is None:
|
||||
return error_result(
|
||||
|
||||
@@ -34,4 +34,64 @@ FETCH_ACTIONS: tuple[Action, ...] = (
|
||||
),
|
||||
),
|
||||
),
|
||||
Action(
|
||||
name="http_request",
|
||||
method="LOCAL",
|
||||
path="",
|
||||
summary="Make an arbitrary HTTP request (GET/POST/PUT/PATCH/DELETE) to any external API",
|
||||
description=(
|
||||
"The general HTTP client for external services and REST/JSON APIs. Send any method with "
|
||||
"custom headers and a request body, and read the full response (status, headers, content). "
|
||||
"Use the 'json' argument to send a JSON body (Content-Type is set automatically), 'form' to "
|
||||
"send url-encoded form fields, or 'body' to send a raw string. The response body is returned "
|
||||
"verbatim for JSON, or as readable text for HTML, capped to fit the context window; non-2xx "
|
||||
"responses are returned (not raised) so you can inspect API errors. Private and loopback "
|
||||
"addresses are refused. Call this DIRECTLY to talk to an API; never wrap it in a user-defined "
|
||||
"tool or eval that calls itself. Prefer fetch_url for simply reading a web page."
|
||||
),
|
||||
handler="fetch",
|
||||
requires_auth=True,
|
||||
params=(
|
||||
Param(
|
||||
name="url",
|
||||
location="body",
|
||||
description="The request URL (https is assumed if no scheme is given).",
|
||||
required=True,
|
||||
),
|
||||
Param(
|
||||
name="method",
|
||||
location="body",
|
||||
description="HTTP method: GET, POST, PUT, PATCH, DELETE, HEAD, or OPTIONS. Defaults to GET.",
|
||||
),
|
||||
Param(
|
||||
name="headers",
|
||||
location="body",
|
||||
description="Optional request headers as a JSON object of string key/value pairs.",
|
||||
type="object",
|
||||
),
|
||||
Param(
|
||||
name="json",
|
||||
location="body",
|
||||
description="Optional JSON request body as an object or array; sets Content-Type to application/json.",
|
||||
type="object",
|
||||
),
|
||||
Param(
|
||||
name="form",
|
||||
location="body",
|
||||
description="Optional url-encoded form body as a JSON object of string key/value pairs.",
|
||||
type="object",
|
||||
),
|
||||
Param(
|
||||
name="body",
|
||||
location="body",
|
||||
description="Optional raw string request body; set a matching Content-Type header yourself.",
|
||||
),
|
||||
Param(
|
||||
name="max_chars",
|
||||
location="body",
|
||||
description="Optional cap on returned response characters; clamped to a context-safe maximum.",
|
||||
type="integer",
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -61,6 +61,12 @@ class Action:
|
||||
"items": {"type": "object"},
|
||||
"description": param.description,
|
||||
}
|
||||
elif param.type == "object":
|
||||
properties[param.name] = {
|
||||
"type": "object",
|
||||
"description": param.description,
|
||||
"additionalProperties": True,
|
||||
}
|
||||
else:
|
||||
properties[param.name] = {
|
||||
"type": param.type,
|
||||
|
||||
@@ -82,6 +82,15 @@ SYSTEM_PROMPT = (
|
||||
"response, or their combined content overflows the completion and the last file is truncated. If a "
|
||||
"tool result has error 'tool_input_truncated', your output was cut off - resend that single write or "
|
||||
"line edit on its own.\n\n"
|
||||
"DELETING IS ALWAYS CONFIRMED\n"
|
||||
"Deleting is irreversible, so you MUST get explicit user confirmation before ANY deletion and only "
|
||||
"then pass confirm=true. This covers deleting a file (project_delete_file), deleting a whole project "
|
||||
"(delete_project), deleting a container instance (container_instance_action with action 'delete'), "
|
||||
"and running a destructive shell command via container_exec (anything that removes or wipes data: "
|
||||
"rm, rmdir, unlink, shred, truncate, dd, mkfs, 'find -delete', redirecting over a file, or SQL "
|
||||
"'drop'/'delete from'). Never delete a file, database, container, or any data on your own initiative, "
|
||||
"even while testing or fixing something - show the user exactly what will be removed, wait for a clear "
|
||||
"yes, and only then retry with confirm=true. If in doubt, ask first.\n\n"
|
||||
"PROJECT PRIVACY AND READ-ONLY\n"
|
||||
"A project owner can mark a project private (project_set_private) so only the owner and "
|
||||
"administrators can see it, and read-only (project_set_readonly) so every file becomes immutable. "
|
||||
@@ -98,7 +107,9 @@ SYSTEM_PROMPT = (
|
||||
"inspect stats, and schedule starts/stops. There is no image building: every instance runs the "
|
||||
"shared prebuilt 'ppy' image (Python + Playwright + common libraries) with the project's files "
|
||||
"mounted at /app, so creating an instance is instant. If a project needs an extra Python package, "
|
||||
"install it at runtime via container_exec ('pip install ...') - no sudo is needed. Instances run on "
|
||||
"install it at runtime via container_exec ('pip install ...') - no sudo is needed. Every "
|
||||
"container_exec command already runs in /app (the project workspace), so NEVER prefix a command "
|
||||
"with 'cd /app' - just run it directly. Instances run on "
|
||||
"the host docker daemon, so confirm destructive actions and never expose backend or infrastructure "
|
||||
"detail. These tools are unavailable to non-administrators. "
|
||||
"When the user asks to attach, open, show, reopen, or get back a terminal/shell for a container, "
|
||||
@@ -111,6 +122,27 @@ SYSTEM_PROMPT = (
|
||||
"'ingress_url'. To reach a published service, use that 'ingress_url' (the configured public URL, "
|
||||
"e.g. https://host/p/<slug>) - never localhost or 127.0.0.1, which web tools refuse. If ingress_url "
|
||||
"is a relative /p/<slug>, the admin has not set the public Site URL in admin settings yet.\n\n"
|
||||
"WEB REQUESTS AND EXTERNAL APIS\n"
|
||||
"To read a web page, call fetch_url. To call an external HTTP or REST/JSON API with any method, "
|
||||
"call http_request: it takes a url, a method (GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS), "
|
||||
"optional headers, and a body sent as 'json' (a JSON object/array, Content-Type set for you), "
|
||||
"'form' (url-encoded fields), or 'body' (a raw string). It returns the response status, headers, "
|
||||
"and content, and non-2xx responses are returned rather than raised so you can read API errors. "
|
||||
"When a user asks you to integrate with an external service (for example an image-generation, "
|
||||
"payment, or data API), read its OpenAPI/docs if given, then call http_request DIRECTLY with the "
|
||||
"right method and JSON body. Do NOT build a user-defined tool whose job is to make the HTTP call - "
|
||||
"user-defined tools only re-run you and cannot perform network I/O themselves, so a tool that calls "
|
||||
"the API by re-invoking you will recurse and fail the self-evaluation depth limit. Private and "
|
||||
"loopback addresses are refused.\n\n"
|
||||
"ATTACHING FILES FROM THE INTERNET\n"
|
||||
"To attach an image or file that lives at a public URL to something you create (a post, project, "
|
||||
"gist, comment, bug report, or direct message), call attach_url with that URL. It downloads the "
|
||||
"file on the server and stores it as a real attachment, returning a uid. Then pass that uid in the "
|
||||
"attachment_uids field when you call create_post, create_project, create_gist, create_comment, "
|
||||
"create_bug, or send_message, and the file is attached. Collect several uids to attach more than "
|
||||
"one. Do not paste the raw URL into the body when the user wants it attached - use attach_url so it "
|
||||
"becomes a proper hosted attachment with a thumbnail. The file type comes from the URL or its "
|
||||
"Content-Type; if a URL has no clear name, pass a filename with an allowed extension.\n\n"
|
||||
"REMOTE WEB TOOLS (rsearch)\n"
|
||||
"The rsearch_* tools (rsearch, rsearch_answer, rsearch_chat, rsearch_describe_image) reach an "
|
||||
"EXTERNAL public web/AI service, not this platform. They are not platform-specific, so platform "
|
||||
@@ -148,8 +180,23 @@ SYSTEM_PROMPT = (
|
||||
"re-runs you on its stored prompt plus the user's input and returns the result. Manage these with "
|
||||
"tool_list, tool_get, tool_update (including enabling/disabling), and tool_delete. Use eval(prompt) "
|
||||
"to run yourself on an arbitrary prompt and get the result. A user-defined tool covers all of that "
|
||||
"user's sessions (for a guest, only the current session). Nesting is bounded: do not design a tool "
|
||||
"or eval that keeps calling itself, as the self-evaluation depth is limited.\n\n"
|
||||
"user's sessions (for a guest, only the current session). A user-defined tool is a stored prompt "
|
||||
"that re-runs you; the real work is still done by the built-in tools it tells you to call (for "
|
||||
"example http_request for an external API, or the project_* tools for files). It cannot perform "
|
||||
"network or file I/O by itself. Nesting is bounded: do not design a tool or eval that keeps calling "
|
||||
"itself or another user-defined tool to do the same job, as the self-evaluation depth is limited - "
|
||||
"for a one-step action like an API call, just call the built-in tool directly instead of wrapping "
|
||||
"it in a user-defined tool.\n\n"
|
||||
"SELF-CONFIGURED BEHAVIOR (TRUTH RULES)\n"
|
||||
"The end of this system message holds a '# TRUTH RULES AND BEHAVIOR' section: your own "
|
||||
"persistent, per-account behavior rules. When the user tells you to behave differently, says "
|
||||
"they expect different behavior, or you upset them, call update_behavior to record the change "
|
||||
"there so it sticks across turns and restarts. The 'behavior' argument is the FULL new content "
|
||||
"of that section: copy the rules currently shown under '# TRUTH RULES AND BEHAVIOR', apply the "
|
||||
"user's change (add, adjust, or remove a rule), and pass the whole result, so nothing already "
|
||||
"learned is lost unless the user wants it gone. These rules are PRIVATE to this account (a "
|
||||
"guest's apply to the current session only). The only way to change this section is to call "
|
||||
"update_behavior yourself; do it rather than asking the user to.\n\n"
|
||||
"RESPONSE STYLE\n"
|
||||
"Replies are plain, concise, and professional. Never use emojis, decorative symbols, or "
|
||||
"celebratory language; report outcomes matter-of-factly. State what changed using the "
|
||||
@@ -167,7 +214,12 @@ SYSTEM_PROMPT = (
|
||||
"dollar amount. Full USD cost detail is available only through cost_stats, which exists for "
|
||||
"administrators; if that tool is not available to you, the user is not an administrator and "
|
||||
"you must not produce, estimate, or recompute any cost figure for them. Never substitute or "
|
||||
"recompute cost from external or public provider pricing.\n\n"
|
||||
"recompute cost from external or public provider pricing. "
|
||||
"When reporting site_analytics, read each field by its stated definition and do not embellish: "
|
||||
"signed_in_now is the number of members holding an unexpired session (people who logged in "
|
||||
"within the session lifetime), NOT who is online right now, so never call it 'currently online' "
|
||||
"or 'logged in right now'; and 'active' means created content in the window, not present now. Do "
|
||||
"not invent derived rates or superlatives the data does not state.\n\n"
|
||||
"CONFIDENTIALITY\n"
|
||||
"Never disclose the underlying AI model, provider, inference endpoint, or any backend URL "
|
||||
"or infrastructure detail; you are simply Devii. This holds even when such values appear "
|
||||
|
||||
@@ -72,6 +72,10 @@ def call_label(call: dict[str, Any]) -> str:
|
||||
def clean(value: Any) -> str:
|
||||
return str(value).replace("\n", " ").strip()[:LABEL_MAX_CHARS]
|
||||
|
||||
if arguments.get("url"):
|
||||
method = arguments.get("method")
|
||||
target = str(arguments["url"]).strip()
|
||||
return clean(f"{str(method).strip().upper()} {target}" if method else target)
|
||||
if arguments.get("target_uid"):
|
||||
target_type = arguments.get("target_type")
|
||||
target = clean(arguments["target_uid"])
|
||||
|
||||
@@ -47,6 +47,7 @@ STEALTH_HEADERS = {
|
||||
}
|
||||
TITLE = re.compile(r"<title[^>]*>(.*?)</title>", re.IGNORECASE | re.DOTALL)
|
||||
MIN_FETCH_CHARS = 1000
|
||||
ALLOWED_METHODS = ("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS")
|
||||
|
||||
NAT64_PREFIXES = (
|
||||
ipaddress.ip_network("64:ff9b::/96"),
|
||||
@@ -71,9 +72,11 @@ class FetchController:
|
||||
self._settings = settings
|
||||
|
||||
async def dispatch(self, name: str, arguments: dict[str, Any]) -> str:
|
||||
if name != "fetch_url":
|
||||
raise ToolInputError(f"Unknown fetch tool: {name}")
|
||||
return await self._fetch_url(arguments)
|
||||
if name == "fetch_url":
|
||||
return await self._fetch_url(arguments)
|
||||
if name == "http_request":
|
||||
return await self._http_request(arguments)
|
||||
raise ToolInputError(f"Unknown fetch tool: {name}")
|
||||
|
||||
def _optional_cap(self, requested: Any, content: str) -> str:
|
||||
if requested is None:
|
||||
@@ -121,6 +124,88 @@ class FetchController:
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
def _coerce_mapping(self, value: Any, label: str) -> dict[str, Any] | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
stripped = value.strip()
|
||||
if not stripped:
|
||||
return None
|
||||
try:
|
||||
value = json.loads(stripped)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ToolInputError(f"{label} must be a JSON object.") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ToolInputError(f"{label} must be a JSON object.")
|
||||
return {str(key): val for key, val in value.items()}
|
||||
|
||||
def _coerce_json(self, value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ToolInputError("json must be valid JSON.") from exc
|
||||
return value
|
||||
|
||||
async def _http_request(self, arguments: dict[str, Any]) -> str:
|
||||
url = str(arguments.get("url", "")).strip()
|
||||
if not url:
|
||||
raise ToolInputError("http_request requires a url.")
|
||||
if "://" not in url:
|
||||
url = "https://" + url
|
||||
method = (str(arguments.get("method", "")).strip() or "GET").upper()
|
||||
if method not in ALLOWED_METHODS:
|
||||
raise ToolInputError(
|
||||
f"Unsupported HTTP method '{method}'. Use one of {', '.join(ALLOWED_METHODS)}."
|
||||
)
|
||||
await self._guard(url)
|
||||
|
||||
headers = self._coerce_mapping(arguments.get("headers"), "headers")
|
||||
json_body = (
|
||||
self._coerce_json(arguments["json"])
|
||||
if arguments.get("json") is not None
|
||||
else None
|
||||
)
|
||||
form = (
|
||||
self._coerce_mapping(arguments.get("form"), "form")
|
||||
if arguments.get("form") is not None
|
||||
else None
|
||||
)
|
||||
raw_body = arguments.get("body")
|
||||
content = None if raw_body is None else str(raw_body)
|
||||
|
||||
body, final_url, content_type, status, response_headers = await self._stream(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
json_body=json_body,
|
||||
form=form,
|
||||
content=content,
|
||||
raise_5xx=False,
|
||||
)
|
||||
|
||||
if "json" in content_type or content_type.startswith("text/"):
|
||||
content_text = body
|
||||
elif "html" in content_type or "xml" in content_type:
|
||||
content_text = html_to_text(body)
|
||||
elif not content_type:
|
||||
content_text = body
|
||||
else:
|
||||
content_text = f"(non-text response: {content_type})"
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"status": "success",
|
||||
"url": final_url,
|
||||
"method": method,
|
||||
"http_status": status,
|
||||
"content_type": content_type,
|
||||
"headers": response_headers,
|
||||
"content": self._optional_cap(arguments.get("max_chars"), content_text),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
async def _guard(self, url: str) -> None:
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in ("http", "https"):
|
||||
@@ -150,15 +235,41 @@ class FetchController:
|
||||
)
|
||||
|
||||
async def _download(self, url: str) -> tuple[str, str, str, int]:
|
||||
body, final_url, content_type, status, _ = await self._stream(
|
||||
"GET", url, raise_5xx=True
|
||||
)
|
||||
return body, final_url, content_type, status
|
||||
|
||||
async def _stream(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
headers: dict[str, Any] | None = None,
|
||||
json_body: Any = None,
|
||||
form: dict[str, Any] | None = None,
|
||||
content: str | None = None,
|
||||
raise_5xx: bool = False,
|
||||
) -> tuple[str, str, str, int, dict[str, str]]:
|
||||
limit = self._settings.fetch_max_bytes
|
||||
merged_headers = dict(STEALTH_HEADERS)
|
||||
if headers:
|
||||
merged_headers.update({str(k): str(v) for k, v in headers.items()})
|
||||
request_kwargs: dict[str, Any] = {}
|
||||
if json_body is not None:
|
||||
request_kwargs["json"] = json_body
|
||||
elif form is not None:
|
||||
request_kwargs["data"] = form
|
||||
elif content is not None:
|
||||
request_kwargs["content"] = content
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
headers=STEALTH_HEADERS,
|
||||
headers=merged_headers,
|
||||
follow_redirects=True,
|
||||
timeout=self._settings.fetch_timeout_seconds,
|
||||
) as client:
|
||||
async with client.stream("GET", url) as response:
|
||||
if response.status_code >= 500:
|
||||
async with client.stream(method, url, **request_kwargs) as response:
|
||||
if raise_5xx and response.status_code >= 500:
|
||||
raise UpstreamError(
|
||||
f"Server returned {response.status_code}.",
|
||||
status=response.status_code,
|
||||
@@ -178,7 +289,16 @@ class FetchController:
|
||||
except LookupError:
|
||||
body = raw.decode("utf-8", errors="replace")
|
||||
content_type = response.headers.get("content-type", "").lower()
|
||||
return body, str(response.url), content_type, response.status_code
|
||||
response_headers = {
|
||||
key: value for key, value in response.headers.items()
|
||||
}
|
||||
return (
|
||||
body,
|
||||
str(response.url),
|
||||
content_type,
|
||||
response.status_code,
|
||||
response_headers,
|
||||
)
|
||||
except httpx.TimeoutException as exc:
|
||||
raise NetworkError(f"Request timed out fetching {url}", url=url) from exc
|
||||
except httpx.HTTPError as exc:
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any, Callable
|
||||
from devplacepy.database import db
|
||||
|
||||
from .agentic import LessonStore
|
||||
from .behavior import BehaviorStore
|
||||
from .config import Settings
|
||||
from .cost.tracker import Pricing
|
||||
from .llm import LLMClient
|
||||
@@ -55,6 +56,7 @@ class DeviiHub:
|
||||
task_store = TaskStore(owned_db, owner_kind, owner_id)
|
||||
lessons = LessonStore(owned_db, owner_kind, owner_id)
|
||||
virtual_tool_store = VirtualToolStore(owned_db, owner_kind, owner_id)
|
||||
behavior_store = BehaviorStore(owned_db, owner_kind, owner_id)
|
||||
session = DeviiSession(
|
||||
owner_kind,
|
||||
owner_id,
|
||||
@@ -65,6 +67,7 @@ class DeviiHub:
|
||||
pricing,
|
||||
task_store,
|
||||
virtual_tool_store,
|
||||
behavior_store,
|
||||
self._stores,
|
||||
is_admin=is_admin,
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .actions.avatar_actions import AVATAR_ACTIONS
|
||||
from .actions.behavior_actions import BEHAVIOR_ACTIONS
|
||||
from .actions.catalog import ACTIONS
|
||||
from .actions.chunk_actions import CHUNK_ACTIONS
|
||||
from .actions.client_actions import CLIENT_ACTIONS
|
||||
@@ -30,5 +31,6 @@ CATALOG = Catalog(
|
||||
+ RSEARCH_ACTIONS
|
||||
+ CONTAINER_ACTIONS
|
||||
+ CUSTOMIZATION_ACTIONS
|
||||
+ BEHAVIOR_ACTIONS
|
||||
+ VIRTUAL_TOOL_ACTIONS
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ from .actions import Dispatcher
|
||||
from .agent import Agent, SYSTEM_PROMPT
|
||||
from .agentic import AgenticController, LessonStore
|
||||
from .avatar import AvatarController
|
||||
from .behavior import BehaviorController
|
||||
from .chunks import ChunkStore
|
||||
from .client import ClientController
|
||||
from .config import Settings
|
||||
@@ -36,6 +37,7 @@ INTERNAL_PREFIXES = (
|
||||
"Protocol violation:",
|
||||
)
|
||||
BUFFERED_TYPES = ("task", "error", "reply", "status")
|
||||
BEHAVIOR_HEADER = "# TRUTH RULES AND BEHAVIOR"
|
||||
LOGIN_REQUEST = (
|
||||
"Welcome to Devii. Ask me anything - I can generate clients and bots, search the docs, "
|
||||
"fetch pages, and more. Sign in to DevPlace whenever you want me to work on your own account."
|
||||
@@ -54,6 +56,7 @@ class DeviiSession:
|
||||
pricing: Pricing,
|
||||
task_store: TaskStore,
|
||||
virtual_tool_store: VirtualToolStore,
|
||||
behavior_store: Any,
|
||||
stores: dict[str, Any],
|
||||
is_admin: bool = False,
|
||||
) -> None:
|
||||
@@ -79,6 +82,8 @@ class DeviiSession:
|
||||
self.virtual_tools = VirtualToolController(
|
||||
virtual_tool_store, self.agentic.run_subagent, set(CATALOG.by_name())
|
||||
)
|
||||
self._behavior_store = behavior_store
|
||||
self.behavior = BehaviorController(behavior_store)
|
||||
self.dispatcher = Dispatcher(
|
||||
CATALOG,
|
||||
self.client,
|
||||
@@ -92,6 +97,7 @@ class DeviiSession:
|
||||
owner_kind=owner_kind,
|
||||
owner_id=owner_id,
|
||||
virtual_tools=self.virtual_tools,
|
||||
behavior=self.behavior,
|
||||
)
|
||||
self.tools = CATALOG.tool_schemas_for(self.client.authenticated, is_admin)
|
||||
self._system_prompt = _system_prompt_for(is_admin)
|
||||
@@ -112,7 +118,7 @@ class DeviiSession:
|
||||
on_trace=self._trace,
|
||||
cost_tracker=self.cost,
|
||||
chunk_store=self.chunks,
|
||||
system_prompt=self._system_prompt,
|
||||
system_prompt=self._compose_system_prompt(),
|
||||
)
|
||||
self.scheduler = Scheduler(
|
||||
self.store,
|
||||
@@ -171,7 +177,7 @@ class DeviiSession:
|
||||
on_trace=self._trace,
|
||||
cost_tracker=self.cost,
|
||||
chunk_store=self.chunks,
|
||||
system_prompt=self._system_prompt,
|
||||
system_prompt=self._compose_system_prompt(),
|
||||
)
|
||||
return await worker.respond(prompt)
|
||||
|
||||
@@ -308,6 +314,7 @@ class DeviiSession:
|
||||
try:
|
||||
async with self._lock:
|
||||
self._refresh_tools()
|
||||
self._refresh_system_prompt()
|
||||
reply = await self.agent.respond(text)
|
||||
await self._emit({"type": "reply", "text": reply}, buffer=True)
|
||||
except Exception as exc: # noqa: BLE001 - reported to the browser, recorded for audit
|
||||
@@ -322,6 +329,16 @@ class DeviiSession:
|
||||
virtual = self._virtual_tool_store.tool_schemas()
|
||||
self.tools[:] = builtin + virtual
|
||||
|
||||
def _compose_system_prompt(self) -> str:
|
||||
body = self._behavior_store.text().strip()
|
||||
section = BEHAVIOR_HEADER if not body else f"{BEHAVIOR_HEADER}\n{body}"
|
||||
return f"{self._system_prompt}\n\n{section}"
|
||||
|
||||
def _refresh_system_prompt(self) -> None:
|
||||
messages = self.agent._messages
|
||||
if messages and messages[0].get("role") == "system":
|
||||
messages[0]["content"] = self._compose_system_prompt()
|
||||
|
||||
def _quota_snapshot(self) -> dict[str, Any]:
|
||||
spent = self._ledger.spent_24h(self.owner_kind, self.owner_id)
|
||||
turns = self._ledger.turns_24h(self.owner_kind, self.owner_id)
|
||||
|
||||
Reference in New Issue
Block a user