# 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]},
]
}