forked from retoor/devplacepy
AI gateway: - Add a generic, admin-selectable `client_profile` field on gateway_providers (e.g. "opencode") so a provider needing special request headers (OpenCode Zen's client-identity spoofing) is configured like any other provider, not hardcoded by name. - Track per-(provider, model) reliability/speed/latency health in memory, seeded from the existing gateway_usage_ledger at startup - purely observational, never influences routing. - New Stats tab on /admin/gateway: request volume, latency, per-model breakdowns, and reliability weight, charted with a vendored Chart.js and devplace's own theme tokens. - Record which model a failed request actually fell back to (fallback_used_route), surfaced in the Recent Failures table. - Stop excluding context_length errors from fallback, and skip a primary attempt outright when its known context window is already too small for the estimated request size, going straight to the fallback. - gateway_usage_ledger's provider/fallback_used_route columns and indexes are ensured centrally in database/schema.py's init_db(), the single point of truth for this table's schema. - Non-OpenAI upstream routing and client-model passthrough; trust only the upstream's own X-Gateway-Model header for served-model attribution. Devii agent: - Fix a real lockup: plan/verify tools could be individually disabled via the admin tool toggles while still being required by the protocol gate, permanently bricking any task that needed tools. They can no longer be disabled, and the gate now also checks the tool is actually offered. - Fix compaction being silently calibrated for a 1M-token model while running a much smaller one: context budget is now percentage-based and the summarizer's own request is sized to fit the real model. - Give a specific, actionable retry message when plan()'s own arguments get cut off by the output limit, and tighten its schema to discourage overlong plans. Other: - Backup service: offload completed backups to a remote Hetzner Storage Box. - Container manager: fix orphan blob leaks from sync races, add a two-phase plan/execute `system prune` CLI command. - Admin gateway UI: replace the JS-rendered model/provider tables with server-rendered forms and pages. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DhmEkvutuwtzFVcLbTrhdo
1053 lines
39 KiB
Python
1053 lines
39 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from typing import Any
|
|
|
|
import uuid_utils
|
|
|
|
from ..actions import Dispatcher
|
|
from ..agent import Agent
|
|
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
|
|
from ..cost import CostTracker
|
|
from ..cost.tracker import Pricing
|
|
from ..http_client import PlatformClient
|
|
from ..interaction import InteractionController
|
|
from ..interaction.broker import InteractionBroker
|
|
from ..interaction.capabilities import (
|
|
CA_IWP_SYSTEM_FRAGMENT,
|
|
UI_TOOL_NAMES,
|
|
fragment_for,
|
|
)
|
|
from ..llm import LLMClient
|
|
from ..registry import CATALOG
|
|
from ..tasks import TaskController, TaskStore
|
|
from ..tasks.context import in_task_run
|
|
from ..virtual_tools import VirtualToolController, VirtualToolStore
|
|
from ..text import normalize_newlines
|
|
from ._helpers import _format_offset, _now_iso, _repair_history
|
|
from .prompts import (
|
|
BEHAVIOR_HEADER,
|
|
DOCS_GREETING,
|
|
DOCS_SYSTEM_PROMPT,
|
|
DOCS_TOOLS,
|
|
LOGIN_REQUEST,
|
|
TASK_RUN_ADMIN,
|
|
TASK_RUN_HEADER,
|
|
TASK_RUN_MEMBER,
|
|
TOPIC_CLASSIFIER_PROMPT,
|
|
_parse_topic,
|
|
_system_prompt_for,
|
|
)
|
|
|
|
logger = logging.getLogger("devii.session")
|
|
|
|
AVATAR_QUERY_TIMEOUT_SECONDS = 30.0
|
|
BROWSER_REQUEST_DEADLINE_SECONDS = 60.0
|
|
RESULT_NOTICE_CHARS = 20000
|
|
INTERNAL_PREFIXES = (
|
|
"[memory]",
|
|
"[reflection-trigger]",
|
|
"[verification-gate]",
|
|
"[stopped]",
|
|
"Protocol violation:",
|
|
)
|
|
BUFFERED_TYPES = ("task", "error", "reply", "status")
|
|
|
|
|
|
class DeviiSession:
|
|
def __init__(
|
|
self,
|
|
owner_kind: str,
|
|
owner_id: str,
|
|
username: str,
|
|
settings: Settings,
|
|
llm: LLMClient,
|
|
lessons: LessonStore,
|
|
pricing: Pricing,
|
|
task_store: TaskStore,
|
|
virtual_tool_store: VirtualToolStore,
|
|
behavior_store: Any,
|
|
stores: dict[str, Any],
|
|
is_admin: bool = False,
|
|
is_primary_admin: bool = False,
|
|
channel: str = "main",
|
|
key_resolver: Any = None,
|
|
) -> None:
|
|
self.owner_kind = owner_kind
|
|
self.owner_id = owner_id
|
|
self.channel = channel
|
|
self.username = username
|
|
self.settings = settings
|
|
self._is_admin = is_admin
|
|
self._is_primary_admin = is_primary_admin
|
|
self.persist_conversation = owner_kind == "user"
|
|
self._timezone: str = ""
|
|
self._tz_offset_minutes: int | None = None
|
|
self._llm = llm
|
|
self._lessons = lessons
|
|
self.client = PlatformClient(
|
|
settings.base_url,
|
|
settings.timeout_seconds,
|
|
settings.platform_api_key,
|
|
key_resolver=key_resolver,
|
|
)
|
|
self.avatar = AvatarController()
|
|
self.browser = ClientController(
|
|
allow_eval=settings.allow_eval, allow_control=settings.browser_control
|
|
)
|
|
self.store = task_store
|
|
self.task_controller = TaskController(self.store)
|
|
self.agentic = AgenticController(lessons, settings)
|
|
self.cost = CostTracker(pricing=pricing)
|
|
self.chunks = ChunkStore()
|
|
self._virtual_tool_store = virtual_tool_store
|
|
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.interaction_broker = InteractionBroker(
|
|
session_channel=channel,
|
|
owner_kind=owner_kind,
|
|
owner_id=owner_id,
|
|
)
|
|
self.interaction = InteractionController(
|
|
self.interaction_broker, owner_kind=owner_kind, owner_id=owner_id
|
|
)
|
|
self.dispatcher = Dispatcher(
|
|
CATALOG,
|
|
self.client,
|
|
settings,
|
|
self.task_controller,
|
|
self.agentic,
|
|
avatar=self.avatar,
|
|
browser=self.browser,
|
|
is_admin=is_admin,
|
|
is_primary_admin=is_primary_admin,
|
|
quota_provider=self._quota_snapshot,
|
|
owner_kind=owner_kind,
|
|
owner_id=owner_id,
|
|
virtual_tools=self.virtual_tools,
|
|
behavior=self.behavior,
|
|
interaction=self.interaction,
|
|
)
|
|
self.tools = self._builtin_tools()
|
|
self._system_prompt = (
|
|
DOCS_SYSTEM_PROMPT if channel == "docs" else _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,
|
|
)
|
|
self.agent = Agent(
|
|
settings,
|
|
llm,
|
|
self.dispatcher,
|
|
self.tools,
|
|
lessons=lessons,
|
|
on_trace=self._trace,
|
|
cost_tracker=self.cost,
|
|
chunk_store=self.chunks,
|
|
system_prompt=self._compose_system_prompt(),
|
|
)
|
|
self._scheduled_executor = self._make_executor()
|
|
self._conv = stores["conversations"]
|
|
self._ledger = stores["ledger"]
|
|
self._audit = stores["turns"]
|
|
self._conns: set[Any] = set()
|
|
self._conn_meta: dict[Any, dict[str, Any]] = {}
|
|
self._attach_seq = 0
|
|
self._lock = asyncio.Lock()
|
|
self._send_lock = asyncio.Lock()
|
|
self._pending: dict[str, asyncio.Future] = {}
|
|
self._query_seq = 0
|
|
self._connected = asyncio.Event()
|
|
self._disconnected = asyncio.Event()
|
|
self._disconnected.set()
|
|
self._buffer: list[dict[str, Any]] = []
|
|
self._turn_tool_calls = 0
|
|
self._pending_session: str | None = None
|
|
self._turns: set[asyncio.Task] = set()
|
|
self._turn_epoch = 0
|
|
|
|
@property
|
|
def is_admin(self) -> bool:
|
|
if self.owner_kind != "user" or not self.owner_id:
|
|
return False
|
|
from devplacepy.database import get_admin_uids
|
|
|
|
return self.owner_id in get_admin_uids()
|
|
|
|
@property
|
|
def is_primary_admin(self) -> bool:
|
|
if self.owner_kind != "user" or not self.owner_id:
|
|
return False
|
|
from devplacepy.database import get_primary_admin_uid
|
|
|
|
return self.owner_id == get_primary_admin_uid()
|
|
|
|
def _refresh_privileges(self) -> None:
|
|
admin = self.is_admin
|
|
if admin != self._is_admin:
|
|
self._is_admin = admin
|
|
if self.channel != "docs":
|
|
self._system_prompt = _system_prompt_for(admin)
|
|
self._is_primary_admin = self.is_primary_admin
|
|
self.dispatcher.set_admin(admin, self._is_primary_admin)
|
|
|
|
def restore_history(self, messages: list[dict[str, Any]]) -> None:
|
|
if not messages:
|
|
return
|
|
repaired: list[dict[str, Any]] = []
|
|
for message in messages:
|
|
item = dict(message)
|
|
content = item.get("content")
|
|
if isinstance(content, str):
|
|
item["content"] = normalize_newlines(content)
|
|
repaired.append(item)
|
|
self.agent._messages = repaired
|
|
|
|
def history(self) -> list[dict[str, Any]]:
|
|
visible: list[dict[str, Any]] = []
|
|
for message in self.agent._messages:
|
|
role = message.get("role")
|
|
content = message.get("content")
|
|
if role not in ("user", "assistant") or not content:
|
|
continue
|
|
if not isinstance(content, str):
|
|
continue
|
|
content = normalize_newlines(content).strip()
|
|
if not content:
|
|
continue
|
|
if role == "user" and content.startswith(INTERNAL_PREFIXES):
|
|
continue
|
|
visible.append({"role": role, "content": content})
|
|
return visible
|
|
|
|
def scheduler_binding(self) -> tuple[TaskStore, Any, Any]:
|
|
return self.store, self._scheduled_executor, self._task_event
|
|
|
|
def _make_executor(self) -> Any:
|
|
async def execute(prompt: str) -> str:
|
|
async with self._lock:
|
|
self._refresh_privileges()
|
|
self._refresh_tools()
|
|
turn_id = uuid_utils.uuid7().hex
|
|
started_at = _now_iso()
|
|
self._turn_tool_calls = 0
|
|
before = self._cost_snapshot()
|
|
reply = ""
|
|
error = ""
|
|
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,
|
|
system_prompt=self._compose_system_prompt(),
|
|
)
|
|
try:
|
|
reply = await worker.respond(prompt)
|
|
return reply
|
|
except Exception as exc: # noqa: BLE001 - recorded, then re-raised to the scheduler
|
|
error = str(exc)
|
|
raise
|
|
finally:
|
|
self._record_spend(
|
|
turn_id, started_at, prompt, reply, error, before
|
|
)
|
|
|
|
return execute
|
|
|
|
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._connected.set()
|
|
self._disconnected.clear()
|
|
self.avatar.bind(self._avatar_request)
|
|
self.browser.bind(self._client_request)
|
|
self.interaction_broker.bind(
|
|
emit=self._emit_interaction,
|
|
wait_site=self._interaction_wait,
|
|
)
|
|
if self._buffer:
|
|
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),
|
|
)
|
|
|
|
def detach(self, ws: Any) -> None:
|
|
self._conns.discard(ws)
|
|
self._conn_meta.pop(ws, None)
|
|
if not self._conns:
|
|
self._connected.clear()
|
|
self._disconnected.set()
|
|
self.avatar.unbind()
|
|
self.browser.unbind()
|
|
open_id = self.interaction_broker.open_id()
|
|
if open_id:
|
|
self.interaction_broker.resolve(
|
|
open_id,
|
|
status="cancelled",
|
|
values={},
|
|
error="Browser disconnected.",
|
|
)
|
|
logger.info(
|
|
"Session %s/%s detached (%d conns)",
|
|
self.owner_kind,
|
|
self.owner_id,
|
|
len(self._conns),
|
|
)
|
|
|
|
def is_busy(self) -> bool:
|
|
return self._lock.locked() or bool(self._turns)
|
|
|
|
def set_clientinfo(self, timezone_name: str, offset_minutes: int | None) -> None:
|
|
if timezone_name:
|
|
self._timezone = timezone_name
|
|
if offset_minutes is not None:
|
|
self._tz_offset_minutes = int(offset_minutes)
|
|
if self.owner_kind == "user" and timezone_name:
|
|
from devplacepy.database import set_user_timezone
|
|
|
|
try:
|
|
set_user_timezone(self.owner_id, timezone_name)
|
|
except Exception: # noqa: BLE001 - persistence must not break the socket
|
|
logger.exception(
|
|
"Failed to persist timezone for %s/%s",
|
|
self.owner_kind,
|
|
self.owner_id,
|
|
)
|
|
|
|
def set_timezone(self, timezone_name: str) -> None:
|
|
if timezone_name:
|
|
self._timezone = timezone_name
|
|
|
|
def set_visibility(self, ws: Any, visible: bool, focused: bool) -> None:
|
|
meta = self._conn_meta.get(ws)
|
|
if meta is not None:
|
|
meta["visible"] = visible
|
|
meta["focused"] = focused
|
|
|
|
def _pick_target(self) -> Any:
|
|
best = None
|
|
highest_rank = None
|
|
for ws in self._conns:
|
|
meta = self._conn_meta.get(ws)
|
|
if meta is None:
|
|
continue
|
|
rank = (meta["focused"], meta["visible"], meta["seq"])
|
|
if highest_rank is None or rank > highest_rank:
|
|
highest_rank = rank
|
|
best = ws
|
|
return best
|
|
|
|
@property
|
|
def connection_count(self) -> int:
|
|
return len(self._conns)
|
|
|
|
async def aclose(self) -> None:
|
|
await self.client.aclose()
|
|
await self._llm.aclose()
|
|
|
|
async def bootstrap_greeting(self) -> str:
|
|
if self.channel == "docs":
|
|
return DOCS_GREETING
|
|
if self.client.authenticated:
|
|
return f"Signed in as {self.username}. Devii is operating your DevPlace account. How can I help?"
|
|
return LOGIN_REQUEST
|
|
|
|
async def send_bootstrap(self, ws: Any) -> None:
|
|
await self._send_to(ws, {"type": "status", "text": "Devii ready"})
|
|
history = self.history()
|
|
if history:
|
|
await self._send_to(ws, {"type": "history", "messages": history})
|
|
else:
|
|
await self._send_to(
|
|
ws, {"type": "reply", "text": await self.bootstrap_greeting()}
|
|
)
|
|
|
|
async def reset_conversation(self) -> None:
|
|
async with self._lock:
|
|
messages = self.agent._messages
|
|
if messages and messages[0].get("role") == "system":
|
|
system = messages[0]
|
|
else:
|
|
system = {"role": "system", "content": self._system_prompt}
|
|
self.agent._messages = [system]
|
|
self._buffer = []
|
|
if self.persist_conversation:
|
|
try:
|
|
self._conv.clear(self.owner_kind, self.owner_id, self.channel)
|
|
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,
|
|
)
|
|
await self._emit({"type": "clear"}, buffer=False)
|
|
|
|
def spawn_turn(self, content: Any, audit_text: str | None = None) -> None:
|
|
epoch = self._turn_epoch
|
|
task = asyncio.create_task(self._run_turn(content, epoch, audit_text))
|
|
self._turns.add(task)
|
|
task.add_done_callback(self._turns.discard)
|
|
|
|
async def cancel_turns(self) -> None:
|
|
self._turn_epoch += 1
|
|
turns = [task for task in self._turns if not task.done()]
|
|
if not turns:
|
|
return
|
|
for task in turns:
|
|
task.cancel()
|
|
try:
|
|
await asyncio.wait_for(
|
|
asyncio.gather(*turns, return_exceptions=True), timeout=10
|
|
)
|
|
except asyncio.TimeoutError:
|
|
logger.warning(
|
|
"cancel_turns: a turn did not unwind within 10s for %s/%s (epoch-guarded)",
|
|
self.owner_kind,
|
|
self.owner_id,
|
|
)
|
|
|
|
async def reset(self) -> None:
|
|
await self.cancel_turns()
|
|
await self.reset_conversation()
|
|
await self._emit(
|
|
{"type": "status", "text": "Reset. Conversation cleared."}, buffer=False
|
|
)
|
|
|
|
async def stop(self) -> None:
|
|
await self.cancel_turns()
|
|
async with self._lock:
|
|
_repair_history(self.agent._messages)
|
|
if self.persist_conversation:
|
|
try:
|
|
self._conv.save(
|
|
self.owner_kind,
|
|
self.owner_id,
|
|
self.agent._messages,
|
|
self.channel,
|
|
)
|
|
except Exception: # noqa: BLE001 - persistence must not break the socket
|
|
logger.exception(
|
|
"Failed to persist history after stop for %s/%s",
|
|
self.owner_kind,
|
|
self.owner_id,
|
|
)
|
|
await self._emit({"type": "status", "text": "Stopped."}, buffer=False)
|
|
|
|
async def _run_turn(
|
|
self, content: Any, epoch: int, audit_text: str | None = None
|
|
) -> None:
|
|
turn_id = uuid_utils.uuid7().hex
|
|
started_at = _now_iso()
|
|
before = self._cost_snapshot()
|
|
self._turn_tool_calls = 0
|
|
reply = ""
|
|
error = ""
|
|
cancelled = False
|
|
prompt_text = audit_text if audit_text is not None else (
|
|
content if isinstance(content, str) else "[image]"
|
|
)
|
|
try:
|
|
async with self._lock:
|
|
self._refresh_privileges()
|
|
self._refresh_tools()
|
|
self._refresh_system_prompt()
|
|
if self.channel == "docs":
|
|
await self._docs_topic_gate(prompt_text)
|
|
before = self._cost_snapshot()
|
|
reply = await self.agent.respond(content)
|
|
if not isinstance(content, str):
|
|
self._redact_last_user_message(prompt_text)
|
|
if epoch == self._turn_epoch:
|
|
await self._emit({"type": "reply", "text": reply}, buffer=True)
|
|
except asyncio.CancelledError:
|
|
cancelled = True
|
|
raise
|
|
except Exception as exc: # noqa: BLE001 - reported to the browser, recorded for audit
|
|
error = str(exc)
|
|
logger.exception("Turn failed for %s/%s", self.owner_kind, self.owner_id)
|
|
if epoch == self._turn_epoch:
|
|
await self._emit({"type": "error", "text": error}, buffer=True)
|
|
finally:
|
|
self._record_spend(turn_id, started_at, prompt_text, reply, error, before)
|
|
if not cancelled and epoch == self._turn_epoch:
|
|
self._persist_history()
|
|
if self.owner_kind == "user" and self.channel != "docs" and not error:
|
|
from devplacepy.utils import track_action
|
|
|
|
track_action(self.owner_id, "devii")
|
|
|
|
def _builtin_tools(self) -> list[dict[str, Any]]:
|
|
from ..tool_prefs import filter_disabled
|
|
|
|
schemas = filter_disabled(
|
|
CATALOG.tool_schemas_for(
|
|
self.client.authenticated, self.is_admin, self.is_primary_admin
|
|
)
|
|
)
|
|
if self.channel == "docs":
|
|
return [
|
|
s
|
|
for s in schemas
|
|
if s.get("function", {}).get("name") in DOCS_TOOLS
|
|
]
|
|
allowed = set(self.interaction_broker.channel_context().tools)
|
|
return [
|
|
s
|
|
for s in schemas
|
|
if s.get("function", {}).get("name") not in UI_TOOL_NAMES
|
|
or s.get("function", {}).get("name") in allowed
|
|
]
|
|
|
|
def _refresh_tools(self) -> None:
|
|
if self.channel == "docs":
|
|
self.tools[:] = self._builtin_tools()
|
|
return
|
|
virtual = self._virtual_tool_store.tool_schemas()
|
|
self.tools[:] = self._builtin_tools() + virtual
|
|
|
|
def _compose_system_prompt(self) -> str:
|
|
if self.channel == "docs":
|
|
return self._system_prompt
|
|
body = self._behavior_store.text().strip()
|
|
section = BEHAVIOR_HEADER if not body else f"{BEHAVIOR_HEADER}\n{body}"
|
|
channel_block = fragment_for(self.interaction_broker.channel_context())
|
|
return (
|
|
f"{self._system_prompt}\n\n"
|
|
f"{CA_IWP_SYSTEM_FRAGMENT}\n\n"
|
|
f"{channel_block}\n\n"
|
|
f"{self._task_run_block()}"
|
|
f"{section}\n\n"
|
|
f"{self._clock_line()}"
|
|
)
|
|
|
|
def _task_run_block(self) -> str:
|
|
if not in_task_run():
|
|
return ""
|
|
body = TASK_RUN_ADMIN if self.is_admin else TASK_RUN_MEMBER
|
|
return f"{TASK_RUN_HEADER}\n{body}\n\n"
|
|
|
|
def _clock_line(self) -> str:
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
now = datetime.now(timezone.utc).replace(microsecond=0)
|
|
tz_name = self._timezone or self._stored_timezone()
|
|
local = ""
|
|
if tz_name:
|
|
try:
|
|
from zoneinfo import ZoneInfo
|
|
|
|
local_now = now.astimezone(ZoneInfo(tz_name))
|
|
offset = local_now.utcoffset()
|
|
offset_text = _format_offset(offset)
|
|
local = (
|
|
f" The user's local time is {local_now.strftime('%Y-%m-%d %Hh')} "
|
|
f"({tz_name}, UTC{offset_text})."
|
|
)
|
|
except Exception: # noqa: BLE001 - unknown tz name: fall back to UTC only
|
|
local = ""
|
|
if not local and self._tz_offset_minutes is not None:
|
|
offset = timedelta(minutes=self._tz_offset_minutes)
|
|
local = (
|
|
f" The user's local time is "
|
|
f"{(now + offset).strftime('%Y-%m-%d %Hh')} "
|
|
f"(UTC{_format_offset(offset)})."
|
|
)
|
|
return (
|
|
f"# CURRENT TIME\n"
|
|
f"The current UTC time is approximately {now.strftime('%Y-%m-%d %Hh')} UTC "
|
|
f"(rounded to the hour for situational awareness only).{local} "
|
|
"When the user gives a wall-clock time (for example '3pm' or 'tomorrow at 09:00'), "
|
|
"interpret it in the user's local timezone and convert it to UTC for the run_at field. "
|
|
"For a relative request (for example 'in 40 seconds' or 'in 2 hours'), use delay_seconds "
|
|
"instead and do not compute an absolute time - it is applied against the exact time on "
|
|
"the server when the task is created, regardless of the rounding above. If you ever need "
|
|
"the exact current time to the second - for example to compute an absolute run_at from a "
|
|
"phrase you cannot express as delay_seconds - call the current_time tool first; never "
|
|
"derive an absolute run_at from the rounded line above."
|
|
)
|
|
|
|
def _stored_timezone(self) -> str:
|
|
if self.owner_kind != "user":
|
|
return ""
|
|
try:
|
|
from devplacepy.database import db
|
|
|
|
if "users" not in db.tables:
|
|
return ""
|
|
row = db["users"].find_one(uid=self.owner_id)
|
|
return (row or {}).get("timezone") or ""
|
|
except Exception: # noqa: BLE001 - clock line is best-effort
|
|
return ""
|
|
|
|
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()
|
|
|
|
async def _docs_topic_gate(self, text: str) -> None:
|
|
prior = [
|
|
m
|
|
for m in self.agent._messages
|
|
if m.get("role") in ("user", "assistant") and m.get("content")
|
|
]
|
|
if not prior:
|
|
return
|
|
decision, reason = await self._classify_topic(prior, text)
|
|
if decision == "new":
|
|
messages = self.agent._messages
|
|
if messages and messages[0].get("role") == "system":
|
|
system = messages[0]
|
|
else:
|
|
system = {"role": "system", "content": self._compose_system_prompt()}
|
|
self.agent._messages = [system]
|
|
self._buffer = []
|
|
if self.persist_conversation:
|
|
try:
|
|
self._conv.clear(self.owner_kind, self.owner_id, self.channel)
|
|
except Exception: # noqa: BLE001 - clearing storage must not break the turn
|
|
logger.exception(
|
|
"Failed to clear docs conversation for %s/%s",
|
|
self.owner_kind,
|
|
self.owner_id,
|
|
)
|
|
await self._emit(
|
|
{"type": "topic", "decision": decision, "reason": reason}, buffer=False
|
|
)
|
|
|
|
async def _classify_topic(
|
|
self, prior: list[dict[str, Any]], text: str
|
|
) -> tuple[str, str]:
|
|
recent = prior[-6:]
|
|
convo = "\n".join(
|
|
f"{m['role']}: {str(m['content'])[:300]}" for m in recent
|
|
)
|
|
messages = [
|
|
{"role": "system", "content": TOPIC_CLASSIFIER_PROMPT},
|
|
{
|
|
"role": "user",
|
|
"content": f"Prior conversation:\n{convo}\n\nNew message:\n{text}\n\nClassify.",
|
|
},
|
|
]
|
|
try:
|
|
raw = await self._llm.complete_text(messages)
|
|
except Exception: # noqa: BLE001 - on any failure, keep context (treat as follow-up)
|
|
return "follow_up", ""
|
|
return _parse_topic(raw)
|
|
|
|
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)
|
|
limit = self.settings.daily_limit_usd
|
|
used_pct = round(min(100.0, spent / limit * 100), 1) if limit > 0 else 0.0
|
|
return {
|
|
"used_pct": used_pct,
|
|
"turns_today": turns,
|
|
"limit_reached": limit > 0 and spent >= limit,
|
|
}
|
|
|
|
def _cost_snapshot(self) -> dict[str, Any]:
|
|
return {
|
|
"requests": self.cost.requests,
|
|
"prompt_tokens": self.cost.prompt_tokens,
|
|
"completion_tokens": self.cost.completion_tokens,
|
|
"cache_hit_tokens": self.cost.cache_hit_tokens,
|
|
"cache_miss_tokens": self.cost.cache_miss_tokens,
|
|
"cost_usd": self.cost.cost_usd()["total"],
|
|
}
|
|
|
|
def _persist_history(self) -> None:
|
|
if not self.persist_conversation:
|
|
return
|
|
try:
|
|
self._conv.save(
|
|
self.owner_kind,
|
|
self.owner_id,
|
|
self.agent._messages,
|
|
self.channel,
|
|
)
|
|
except Exception: # noqa: BLE001 - persistence must never break a turn
|
|
logger.exception(
|
|
"Failed to persist conversation for %s/%s",
|
|
self.owner_kind,
|
|
self.owner_id,
|
|
)
|
|
|
|
def _redact_last_user_message(self, text: str) -> None:
|
|
messages = self.agent._messages
|
|
for message in reversed(messages):
|
|
if message.get("role") == "user":
|
|
message["content"] = text
|
|
return
|
|
|
|
def _record_spend(self, turn_id, started_at, prompt, reply, error, before) -> None:
|
|
after = self._cost_snapshot()
|
|
usage = {
|
|
"prompt_tokens": after["prompt_tokens"] - before["prompt_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"],
|
|
}
|
|
cost_delta = round(after["cost_usd"] - before["cost_usd"], 8)
|
|
if (
|
|
cost_delta <= 0
|
|
and usage["prompt_tokens"] <= 0
|
|
and usage["completion_tokens"] <= 0
|
|
):
|
|
return
|
|
try:
|
|
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,
|
|
}
|
|
)
|
|
from devplacepy.services.audit import record as audit
|
|
|
|
audit.record_system(
|
|
"devii.turn",
|
|
actor_kind="user" if self.owner_kind == "user" else self.owner_kind,
|
|
actor_uid=self.owner_id if self.owner_kind == "user" else None,
|
|
actor_username=self.username,
|
|
actor_role="user" if self.owner_kind == "user" else self.owner_kind,
|
|
origin="devii",
|
|
via_agent=1,
|
|
result="failure" if error else "success",
|
|
summary=f"Devii processed a turn for {self.username or self.owner_id}",
|
|
metadata={
|
|
"iterations": after["requests"] - before["requests"],
|
|
"tool_calls": self._turn_tool_calls,
|
|
"cost_usd": cost_delta,
|
|
"prompt_tokens": usage["prompt_tokens"],
|
|
"completion_tokens": usage["completion_tokens"],
|
|
},
|
|
)
|
|
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
|
|
)
|
|
|
|
def resolve_query(self, query_id: str, payload: Any) -> None:
|
|
future = self._pending.pop(query_id, None)
|
|
if future is not None and not future.done():
|
|
future.set_result(payload)
|
|
|
|
async def _flush(self, frames: list[dict[str, Any]]) -> None:
|
|
for frame in frames:
|
|
await self._emit(frame, buffer=True)
|
|
|
|
def _trace(self, event: str, name: str = "", detail: str = "") -> None:
|
|
if event == "call":
|
|
self._turn_tool_calls += 1
|
|
elif event == "ok":
|
|
self._sync_auth(name)
|
|
payload = {"type": "trace", "event": event, "name": name, "detail": detail}
|
|
asyncio.create_task(self._emit(payload, buffer=False))
|
|
|
|
def _sync_auth(self, name: str) -> None:
|
|
if name in ("login", "signup"):
|
|
token = self.client.session_cookie()
|
|
if token:
|
|
self._pending_session = token
|
|
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)
|
|
)
|
|
|
|
def take_pending_session(self) -> str | None:
|
|
token = self._pending_session
|
|
self._pending_session = None
|
|
return token
|
|
|
|
async def _send_to(self, ws: Any, payload: dict[str, Any]) -> None:
|
|
async with self._send_lock:
|
|
await ws.send_json(payload)
|
|
|
|
async def _emit(self, payload: dict[str, Any], buffer: bool = True) -> None:
|
|
delivered = False
|
|
for ws in list(self._conns):
|
|
try:
|
|
async with self._send_lock:
|
|
await ws.send_json(payload)
|
|
delivered = True
|
|
except Exception: # noqa: BLE001 - drop dead sockets, keep serving the rest
|
|
self._conns.discard(ws)
|
|
if not delivered and buffer and payload.get("type") in BUFFERED_TYPES:
|
|
self._buffer.append(payload)
|
|
if len(self._buffer) > 100:
|
|
self._buffer = self._buffer[-100:]
|
|
|
|
async def _emit_interaction(self, payload: dict[str, Any]) -> None:
|
|
await self._emit(payload, buffer=False)
|
|
|
|
async def _interaction_wait(
|
|
self, interaction_id: str, frame: dict[str, Any], timeout: float
|
|
) -> Any:
|
|
loop = asyncio.get_event_loop()
|
|
deadline_seconds = timeout if timeout and timeout > 0 else BROWSER_REQUEST_DEADLINE_SECONDS
|
|
if timeout and timeout > 0:
|
|
deadline_seconds = max(timeout, 1.0)
|
|
else:
|
|
deadline_seconds = max(BROWSER_REQUEST_DEADLINE_SECONDS, 300.0)
|
|
deadline = loop.time() + deadline_seconds
|
|
request_id = f"ix:{interaction_id}"
|
|
future: asyncio.Future = loop.create_future()
|
|
self._pending[request_id] = future
|
|
wire = {
|
|
"type": "interaction",
|
|
"id": request_id,
|
|
"interaction_id": interaction_id,
|
|
"args": frame,
|
|
}
|
|
try:
|
|
while not future.done():
|
|
remaining = deadline - loop.time()
|
|
if remaining <= 0:
|
|
raise asyncio.TimeoutError()
|
|
try:
|
|
await asyncio.wait_for(self._connected.wait(), timeout=remaining)
|
|
except asyncio.TimeoutError as exc:
|
|
raise asyncio.TimeoutError() from exc
|
|
ws = self._pick_target()
|
|
if ws is None:
|
|
continue
|
|
try:
|
|
await self._send_to(ws, wire)
|
|
except Exception:
|
|
await asyncio.sleep(0.1)
|
|
continue
|
|
drop = asyncio.create_task(self._disconnected.wait())
|
|
try:
|
|
await asyncio.wait(
|
|
{future, drop},
|
|
timeout=max(0.0, deadline - loop.time()),
|
|
return_when=asyncio.FIRST_COMPLETED,
|
|
)
|
|
finally:
|
|
drop.cancel()
|
|
return future.result()
|
|
finally:
|
|
self._pending.pop(request_id, None)
|
|
|
|
def resolve_interaction(self, interaction_id: str, payload: Any) -> None:
|
|
request_id = str(interaction_id or "")
|
|
if not request_id.startswith("ix:"):
|
|
request_id = f"ix:{request_id}"
|
|
future = self._pending.get(request_id)
|
|
if future is not None and not future.done():
|
|
future.set_result(payload)
|
|
self._pending.pop(request_id, None)
|
|
bare = request_id[3:]
|
|
if isinstance(payload, dict):
|
|
self.interaction_broker.resolve(
|
|
bare,
|
|
status=str(payload.get("status") or "submitted"),
|
|
values=payload.get("values")
|
|
if isinstance(payload.get("values"), dict)
|
|
else {},
|
|
error=payload.get("error"),
|
|
meta=payload.get("meta")
|
|
if isinstance(payload.get("meta"), dict)
|
|
else None,
|
|
)
|
|
return
|
|
bare = request_id[3:] if request_id.startswith("ix:") else request_id
|
|
self.interaction_broker.resolve(
|
|
bare,
|
|
status=str((payload or {}).get("status") or "submitted")
|
|
if isinstance(payload, dict)
|
|
else "error",
|
|
values=(payload or {}).get("values")
|
|
if isinstance(payload, dict)
|
|
else {},
|
|
error=(payload or {}).get("error") if isinstance(payload, dict) else None,
|
|
meta=(payload or {}).get("meta") if isinstance(payload, dict) else None,
|
|
)
|
|
|
|
async def try_answer_interaction(self, text: str) -> bool:
|
|
open_id = self.interaction_broker.open_id()
|
|
if not open_id:
|
|
return False
|
|
pending = self._pending.get(f"ix:{open_id}")
|
|
if pending is not None and pending.done():
|
|
return False
|
|
outcome = self.interaction_broker.answer_text(text)
|
|
if not outcome or not outcome.get("handled"):
|
|
return False
|
|
if outcome.get("status") == "error":
|
|
result = {
|
|
"status": "cancelled",
|
|
"interaction_id": open_id,
|
|
"values": {},
|
|
"meta": {
|
|
"adapter": "site-chat",
|
|
"via": "text_non_answer",
|
|
"degraded": True,
|
|
},
|
|
"error": None,
|
|
}
|
|
self.resolve_interaction(open_id, result)
|
|
await self._emit(
|
|
{
|
|
"type": "interaction_closed",
|
|
"id": f"ix:{open_id}",
|
|
"interaction_id": open_id,
|
|
"result": result,
|
|
},
|
|
buffer=False,
|
|
)
|
|
return False
|
|
result = outcome.get("result") or {
|
|
"status": outcome.get("status") or "submitted",
|
|
"interaction_id": open_id,
|
|
"values": {},
|
|
"meta": {"adapter": "site-chat", "via": "text", "degraded": True},
|
|
}
|
|
self.resolve_interaction(open_id, result)
|
|
await self._emit(
|
|
{
|
|
"type": "interaction_closed",
|
|
"id": f"ix:{open_id}",
|
|
"interaction_id": open_id,
|
|
"result": result,
|
|
},
|
|
buffer=False,
|
|
)
|
|
return True
|
|
|
|
async def _avatar_request(self, action: str, args: dict[str, Any]) -> Any:
|
|
return await self._browser_request("avatar", action, args)
|
|
|
|
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:
|
|
loop = asyncio.get_event_loop()
|
|
deadline = loop.time() + BROWSER_REQUEST_DEADLINE_SECONDS
|
|
self._query_seq += 1
|
|
request_id = f"{self.owner_id}:{self._query_seq}"
|
|
future: asyncio.Future = loop.create_future()
|
|
self._pending[request_id] = future
|
|
frame = {"type": channel, "id": request_id, "action": action, "args": args}
|
|
try:
|
|
while not future.done():
|
|
remaining = deadline - loop.time()
|
|
if remaining <= 0:
|
|
raise RuntimeError(f"Browser did not respond to '{action}' in time")
|
|
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
|
|
ws = self._pick_target()
|
|
if ws is None:
|
|
continue
|
|
try:
|
|
await self._send_to(ws, frame)
|
|
except Exception: # noqa: BLE001 - socket dying mid-send; retry on reconnect
|
|
await asyncio.sleep(0.1)
|
|
continue
|
|
drop = asyncio.create_task(self._disconnected.wait())
|
|
try:
|
|
await asyncio.wait(
|
|
{future, drop},
|
|
timeout=max(0.0, deadline - loop.time()),
|
|
return_when=asyncio.FIRST_COMPLETED,
|
|
)
|
|
finally:
|
|
drop.cancel()
|
|
return future.result()
|
|
finally:
|
|
self._pending.pop(request_id, None)
|
|
|
|
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],
|
|
}
|
|
asyncio.create_task(self._emit(message, buffer=True))
|
|
if kind in ("done", "finished") and row.get("notify"):
|
|
self._deliver_reminder(row, payload)
|
|
|
|
def _deliver_reminder(self, row: dict[str, Any], payload: str) -> None:
|
|
if self.owner_kind != "user" or not self.owner_id:
|
|
return
|
|
text = (payload or "").strip() or (
|
|
row.get("label") or "Your scheduled reminder fired."
|
|
)
|
|
try:
|
|
from devplacepy.utils import create_notification
|
|
|
|
create_notification(
|
|
self.owner_id,
|
|
"reminder",
|
|
text[:500],
|
|
row.get("uid", ""),
|
|
target_url="/devii",
|
|
)
|
|
except Exception: # noqa: BLE001 - a reminder notification must never crash the scheduler
|
|
logger.exception(
|
|
"Failed to deliver reminder notification for %s/%s",
|
|
self.owner_kind,
|
|
self.owner_id,
|
|
)
|