|
# retoor <retoor@molodetz.nl>
|
|
|
|
import logging
|
|
import os
|
|
|
|
from devplacepy.services.base import BaseService, ConfigField
|
|
|
|
from . import config
|
|
from .config import (
|
|
DEFAULT_AI_MODEL,
|
|
DEFAULT_AI_URL,
|
|
build_settings,
|
|
)
|
|
from .cost.tracker import Pricing
|
|
from .tasks import limits
|
|
from .tasks.guards import DEFAULT_MAX_PER_OWNER, REASON_OWNER_IDLE
|
|
from .tasks.scheduler import GlobalScheduler, retire
|
|
|
|
logger = logging.getLogger("devii.service")
|
|
|
|
INSTANCE_ORIGIN_DEFAULT = "http://127.0.0.1:10500"
|
|
|
|
|
|
class DeviiService(BaseService):
|
|
default_enabled = False
|
|
min_interval = 10
|
|
title = "Devii"
|
|
description = (
|
|
"An in-platform agentic assistant exposed as a WebSocket terminal at /devii/ws. "
|
|
"Each signed-in user's Devii operates their own DevPlace account; guests get a "
|
|
"sandbox session. Spend is capped per user and per guest over a rolling 24 hours, "
|
|
"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_BROWSER_CONTROL,
|
|
"Allow browser control",
|
|
type="bool",
|
|
default=True,
|
|
help="Let Devii drive the user's own browser with structured tools "
|
|
"(discover elements, click, fill, submit, wait, sequences). Independent of "
|
|
"raw JavaScript execution; read-only tools (context, discover, read) are unaffected.",
|
|
group="Agent",
|
|
),
|
|
ConfigField(
|
|
config.FIELD_INTERACTIONS_DEFAULT,
|
|
"Interactive widgets default",
|
|
type="bool",
|
|
default=True,
|
|
help="Site default for CA-IWP interactive prompts (ui_prompt). Guests always use "
|
|
"this default. Signed-in users inherit it until they override it on their profile "
|
|
"or with the interactions_set tool.",
|
|
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_EMAIL_ENABLED,
|
|
"Enable email tools",
|
|
type="bool",
|
|
default=True,
|
|
help="Allow the email_* tools (configure accounts, list/read/search/flag/move/delete "
|
|
"messages, send mail) for signed-in users. These connect to the user's own external "
|
|
"mailbox over IMAP/SMTP, not this platform. When off, those tools are refused.",
|
|
group="Email",
|
|
),
|
|
ConfigField(
|
|
config.FIELD_EMAIL_TIMEOUT,
|
|
"Email timeout (seconds)",
|
|
type="float",
|
|
default=config.DEFAULT_EMAIL_TIMEOUT_SECONDS,
|
|
minimum=1,
|
|
help="Connection/read timeout for IMAP and SMTP calls.",
|
|
group="Email",
|
|
),
|
|
ConfigField(
|
|
config.FIELD_TASK_MAX_CONCURRENT,
|
|
"Max concurrent scheduled tasks",
|
|
type="int",
|
|
default=config.DEFAULT_TASK_MAX_CONCURRENT,
|
|
minimum=1,
|
|
help="Upper bound on scheduled tasks running at the same time across ALL owners. "
|
|
"Slots are handed out round-robin, one at a time per owner, so a single owner can "
|
|
"never monopolise the scheduler.",
|
|
group="Automation",
|
|
),
|
|
ConfigField(
|
|
config.FIELD_TASK_MAX_PER_OWNER,
|
|
"Max active tasks per owner",
|
|
type="int",
|
|
default=DEFAULT_MAX_PER_OWNER,
|
|
minimum=0,
|
|
help="How many enabled tasks one administrator may hold at once. 0 disables the cap.",
|
|
group="Automation",
|
|
),
|
|
ConfigField(
|
|
limits.FIELD_MEMBER_CREATE,
|
|
"Member tasks created / 24h",
|
|
type="int",
|
|
default=limits.DEFAULT_MEMBER_CREATE,
|
|
minimum=0,
|
|
help="How many tasks a member may create in a rolling 24 hours. Deleting a task does "
|
|
"not give the slot back. 0 = unlimited.",
|
|
group="Automation",
|
|
),
|
|
ConfigField(
|
|
limits.FIELD_MEMBER_RUNS,
|
|
"Member task runs / 24h",
|
|
type="int",
|
|
default=limits.DEFAULT_MEMBER_RUNS,
|
|
minimum=0,
|
|
help="How many scheduled runs a member's tasks may execute in a rolling 24 hours. "
|
|
"A run over the limit is postponed until a slot frees, never dropped. 0 = unlimited.",
|
|
group="Automation",
|
|
),
|
|
ConfigField(
|
|
limits.FIELD_ADMIN_CREATE,
|
|
"Administrator tasks created / 24h",
|
|
type="int",
|
|
default=limits.DEFAULT_ADMIN_CREATE,
|
|
minimum=0,
|
|
help="Same rolling 24-hour creation quota for administrators. 0 = unlimited.",
|
|
group="Automation",
|
|
),
|
|
ConfigField(
|
|
limits.FIELD_ADMIN_RUNS,
|
|
"Administrator task runs / 24h",
|
|
type="int",
|
|
default=limits.DEFAULT_ADMIN_RUNS,
|
|
minimum=0,
|
|
help="Rolling 24-hour run quota for administrators. 0 = unlimited.",
|
|
group="Automation",
|
|
),
|
|
ConfigField(
|
|
config.FIELD_TASK_DAILY_USD,
|
|
"Max USD per owner / 24h for automation",
|
|
type="float",
|
|
default=config.DEFAULT_TASK_DAILY_USD,
|
|
minimum=0,
|
|
help="Rolling 24-hour spend cap for SCHEDULED runs, separate from the interactive "
|
|
"quota (administrators are exempt from that one by default). 0 = unlimited.",
|
|
group="Automation",
|
|
),
|
|
ConfigField(
|
|
config.FIELD_TASK_MAX_FAILURES,
|
|
"Max consecutive task failures",
|
|
type="int",
|
|
default=config.DEFAULT_TASK_MAX_FAILURES,
|
|
minimum=0,
|
|
help="A task that fails this many times in a row disables itself. 0 disables the "
|
|
"check, which lets a broken task retry forever.",
|
|
group="Automation",
|
|
),
|
|
ConfigField(
|
|
config.FIELD_TASK_IDLE_DAYS,
|
|
"Disable tasks after owner idle (days)",
|
|
type="int",
|
|
default=config.DEFAULT_TASK_IDLE_DAYS,
|
|
minimum=0,
|
|
help="Tasks of an owner who has not been seen for this many days are disabled on the "
|
|
"housekeeping pass. 0 disables the check.",
|
|
group="Automation",
|
|
),
|
|
ConfigField(
|
|
"devii_lessons_max_per_owner",
|
|
"Max lessons per owner",
|
|
type="int",
|
|
default=500,
|
|
minimum=0,
|
|
help="Soft cap on active lessons per user/guest. When exceeded, the oldest lessons are pruned. 0 disables the cap.",
|
|
group="Memory",
|
|
),
|
|
ConfigField(
|
|
"devii_lessons_max_age_days",
|
|
"Max lesson age (days)",
|
|
type="int",
|
|
default=90,
|
|
minimum=1,
|
|
help="Lessons older than this are soft-deleted on the periodic housekeeping pass.",
|
|
group="Memory",
|
|
),
|
|
]
|
|
|
|
def __init__(self):
|
|
super().__init__(name="devii", interval_seconds=60)
|
|
self._hub = None
|
|
self._scheduler = None
|
|
|
|
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()
|
|
)
|
|
return cfg
|
|
|
|
def instance_base_url(self) -> str:
|
|
cfg = self.get_config()
|
|
configured = cfg.get("devii_base_url", "")
|
|
if configured:
|
|
return configured
|
|
return os.environ.get("DEVII_BASE_URL", INSTANCE_ORIGIN_DEFAULT)
|
|
|
|
def _pricing(self, cfg: dict) -> Pricing:
|
|
return Pricing(
|
|
model=cfg[config.FIELD_AI_MODEL] or DEFAULT_AI_MODEL,
|
|
cache_hit_per_m=float(cfg[config.FIELD_PRICE_CACHE_HIT]),
|
|
cache_miss_per_m=float(cfg[config.FIELD_PRICE_CACHE_MISS]),
|
|
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,
|
|
):
|
|
cfg = self.effective_config()
|
|
settings = build_settings(cfg, base_url, api_key, owner_kind, is_admin)
|
|
return settings, self._pricing(cfg)
|
|
|
|
def daily_limit_for(self, owner_kind: str, is_admin: bool = False) -> float:
|
|
return config.effective_daily_limit(self.get_config(), owner_kind, is_admin)
|
|
|
|
def guests_enabled(self) -> bool:
|
|
return bool(self.get_config()[config.FIELD_GUESTS_ENABLED])
|
|
|
|
def spent_24h(self, owner_kind: str, owner_id: str) -> float:
|
|
return self.hub().ledger.spent_24h(owner_kind, owner_id)
|
|
|
|
def quota_exceeded(
|
|
self, owner_kind: str, owner_id: str, is_admin: bool = False
|
|
) -> bool:
|
|
limit = self.daily_limit_for(owner_kind, is_admin)
|
|
return limit > 0 and self.spent_24h(owner_kind, owner_id) >= limit
|
|
|
|
def reset_quota(self, owner_kind: str, owner_id: str) -> int:
|
|
return self.hub().ledger.reset(owner_kind, owner_id)
|
|
|
|
def reset_guest_quotas(self) -> int:
|
|
return self.hub().ledger.reset_owner_kind("guest")
|
|
|
|
def reset_all_quotas(self) -> int:
|
|
return self.hub().ledger.reset_all()
|
|
|
|
async def run_once(self) -> None:
|
|
if not self.is_enabled():
|
|
return
|
|
hub = self.hub()
|
|
self._configure_scheduler()
|
|
idle = self._disable_idle_owner_tasks()
|
|
runs_pruned = self._prune_task_runs()
|
|
pruned = hub.ledger.prune(48)
|
|
removed = await hub.gc_idle()
|
|
lessons_pruned = self._prune_old_lessons()
|
|
if pruned or removed or idle or lessons_pruned or runs_pruned:
|
|
self.log(
|
|
f"Housekeeping: disabled {idle} idle-owner task(s), pruned {runs_pruned} task-run "
|
|
f"rows, pruned {pruned} ledger rows, pruned {lessons_pruned} lessons, closed "
|
|
f"{removed} idle sessions"
|
|
)
|
|
|
|
def _prune_task_runs(self) -> int:
|
|
from devplacepy.database import db
|
|
from .tasks.schedule import now_utc
|
|
|
|
try:
|
|
return limits.prune_runs(db, now_utc())
|
|
except Exception: # noqa: BLE001 - a bad read must not stop housekeeping
|
|
logger.exception("Failed to prune Devii task-run rows")
|
|
return 0
|
|
|
|
def _prune_old_lessons(self) -> int:
|
|
try:
|
|
from devplacepy.database import db
|
|
from .agentic.lessons import LessonStore, _read_retention_settings
|
|
|
|
_, max_age = _read_retention_settings(db)
|
|
store = LessonStore(db, "_global", "_global")
|
|
pruned = store.prune_all_owners(max_age)
|
|
if pruned:
|
|
logger.info("Devii lessons housekeeping: pruned %d across all owners", pruned)
|
|
return pruned
|
|
except Exception:
|
|
logger.exception("Devii lessons housekeeping failed")
|
|
return 0
|
|
|
|
def scheduler(self) -> GlobalScheduler:
|
|
if self._scheduler is None:
|
|
from devplacepy.database import db
|
|
|
|
cfg = self.get_config()
|
|
self._scheduler = GlobalScheduler(
|
|
db,
|
|
self._resolve_task_owner,
|
|
tick_seconds=1.0,
|
|
max_concurrent=int(cfg[config.FIELD_TASK_MAX_CONCURRENT]),
|
|
max_failures=int(cfg[config.FIELD_TASK_MAX_FAILURES]),
|
|
budget_exceeded=self.automation_budget_exceeded,
|
|
)
|
|
return self._scheduler
|
|
|
|
def _configure_scheduler(self) -> None:
|
|
cfg = self.get_config()
|
|
self.scheduler().configure(
|
|
int(cfg[config.FIELD_TASK_MAX_CONCURRENT]),
|
|
int(cfg[config.FIELD_TASK_MAX_FAILURES]),
|
|
)
|
|
|
|
def automation_budget_exceeded(self, owner_kind: str, owner_id: str) -> bool:
|
|
limit = float(self.get_config()[config.FIELD_TASK_DAILY_USD] or 0.0)
|
|
return limit > 0 and self.spent_24h(owner_kind, owner_id) >= limit
|
|
|
|
def _resolve_task_owner(self, row: dict):
|
|
from devplacepy.database import db
|
|
from devplacepy.utils import is_admin, is_primary_admin
|
|
|
|
owner_id = str(row.get("owner_id") or "")
|
|
if str(row.get("owner_kind") or "") != "user" or not owner_id:
|
|
return None
|
|
users = db["users"] if "users" in db.tables else None
|
|
user = users.find_one(uid=owner_id, deleted_at=None) if users else None
|
|
if not user:
|
|
return None
|
|
session = self.hub().get_or_create(
|
|
"user",
|
|
owner_id,
|
|
user.get("username", ""),
|
|
user.get("api_key", ""),
|
|
self.instance_base_url(),
|
|
is_admin=is_admin(user),
|
|
is_primary_admin=is_primary_admin(user),
|
|
channel="main",
|
|
)
|
|
session.set_timezone(user.get("timezone") or "")
|
|
return session.scheduler_binding()
|
|
|
|
def _disable_idle_owner_tasks(self) -> int:
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
from devplacepy.database import db
|
|
from .tasks.store import TABLE, TaskStore
|
|
|
|
days = int(self.get_config()[config.FIELD_TASK_IDLE_DAYS] or 0)
|
|
if days <= 0 or TABLE not in db.tables:
|
|
return 0
|
|
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
|
|
users = db["users"] if "users" in db.tables else None
|
|
if users is None:
|
|
return 0
|
|
disabled = 0
|
|
rows = db[TABLE].find(enabled=True, owner_kind="user", deleted_at=None)
|
|
for row in rows:
|
|
user = users.find_one(uid=row.get("owner_id"))
|
|
last_seen = (user or {}).get("last_seen")
|
|
if not last_seen:
|
|
continue
|
|
try:
|
|
seen_at = datetime.fromisoformat(str(last_seen))
|
|
except ValueError:
|
|
continue
|
|
if seen_at.tzinfo is None:
|
|
seen_at = seen_at.replace(tzinfo=timezone.utc)
|
|
if seen_at >= cutoff:
|
|
continue
|
|
store = TaskStore(db, "user", str(row.get("owner_id")))
|
|
retire(store, row, REASON_OWNER_IDLE)
|
|
disabled += 1
|
|
return disabled
|
|
|
|
async def on_enable(self) -> None:
|
|
self.scheduler().start()
|
|
|
|
async def on_disable(self) -> None:
|
|
if self._scheduler is not None:
|
|
await self._scheduler.stop()
|
|
self._scheduler = None
|
|
if self._hub is not None:
|
|
await self._hub.aclose()
|
|
self._hub = None
|
|
|
|
def collect_metrics(self) -> dict:
|
|
cfg = self.get_config()
|
|
hub = self._hub
|
|
active = hub.active_sessions() if hub is not None else 0
|
|
conns = hub.connection_count() if hub is not None else 0
|
|
spent = hub.ledger.total_spent_24h() if hub is not None else 0.0
|
|
return {
|
|
"stats": [
|
|
{"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": "Tasks running",
|
|
"value": (
|
|
f"{self._scheduler.running}/"
|
|
f"{int(cfg[config.FIELD_TASK_MAX_CONCURRENT])}"
|
|
if self._scheduler is not None
|
|
else "0"
|
|
),
|
|
},
|
|
{"label": "Model", "value": cfg[config.FIELD_AI_MODEL]},
|
|
]
|
|
}
|