# 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, SYSTEM_PROMPT
from .agentic import AgenticController, LessonStore
from .avatar import AvatarController
from .chunks import ChunkStore
from .client import ClientController
from .config import Settings
from .cost import CostTracker
from .cost.tracker import Pricing
from .errors import AuthRequiredError
from .http_client import PlatformClient
from .llm import LLMClient
from .registry import CATALOG
from .tasks import Scheduler, TaskController, TaskStore
from .virtual_tools import VirtualToolController, VirtualToolStore
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")
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."
)
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,
stores: dict[str, Any],
is_admin: bool = False,
) -> None:
self.owner_kind = owner_kind
self.owner_id = owner_id
self.username = username
self.settings = settings
self.is_admin = is_admin
self.persist_conversation = owner_kind == "user"
self._llm = llm
self._lessons = lessons
self.client = PlatformClient(settings.base_url, settings.timeout_seconds, settings.platform_api_key)
self.avatar = AvatarController()
self.browser = ClientController(allow_eval=settings.allow_eval)
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.dispatcher = Dispatcher(
CATALOG, self.client, settings, self.task_controller, self.agentic,
avatar=self.avatar, browser=self.browser,
is_admin=is_admin, quota_provider=self._quota_snapshot,
owner_kind=owner_kind, owner_id=owner_id,
virtual_tools=self.virtual_tools,
)
self.tools = CATALOG.tool_schemas_for(self.client.authenticated, is_admin)
self._system_prompt = _system_prompt_for(is_admin)
self.agentic.bind(
llm=llm, dispatcher=self.dispatcher, tools=self.tools,
on_trace=self._trace, cost_tracker=self.cost, chunk_store=self.chunks,
)
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._system_prompt,
)
self.scheduler = Scheduler(
self.store, self._make_executor(), self._task_event, settings.scheduler_tick_seconds
)
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._started = False
self._buffer: list[dict[str, Any]] = []
self._turn_tool_calls = 0
self._pending_session: str | None = None
self._turns: set[asyncio.Task] = set()
def restore_history(self, messages: list[dict[str, Any]]) -> None:
if messages:
self.agent._messages = messages
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 role == "user" and isinstance(content, str) and content.startswith(INTERNAL_PREFIXES):
continue
visible.append({"role": role, "content": content})
return visible
def _make_executor(self) -> Any:
async def execute(prompt: str) -> str:
async with self._lock:
worker = Agent(
self.settings, self._llm, self.dispatcher, self.tools, lessons=self._lessons,
on_trace=self._trace, cost_tracker=self.cost, chunk_store=self.chunks,
system_prompt=self._system_prompt,
)
return await worker.respond(prompt)
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)
if not self._started:
self.scheduler.start()
self._started = True
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()
logger.info("Session %s/%s detached (%d conns)", self.owner_kind, self.owner_id, len(self._conns))
def set_visibility(self, ws: Any, visible: bool, focused: bool) -> None:
meta = self._conn_meta.get(ws)
if meta is not None:
meta["visible"] = visible
meta["focused"] = focused
def _pick_target(self) -> Any:
best = None
best_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 best_rank is None or rank > best_rank:
best_rank = rank
best = ws
return best
@property
def connection_count(self) -> int:
return len(self._conns)
async def aclose(self) -> None:
await self.scheduler.stop()
await self.client.aclose()
await self._llm.aclose()
async def bootstrap_greeting(self) -> str:
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)
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, text: str) -> None:
task = asyncio.create_task(self._run_turn(text))
self._turns.add(task)
task.add_done_callback(self._turns.discard)
async def cancel_turns(self) -> None:
turns = [task for task in self._turns if not task.done()]
if not turns:
return
for task in turns:
task.cancel()
await asyncio.gather(*turns, return_exceptions=True)
async def reset(self) -> None:
await self.cancel_turns()
await self.reset_conversation()
async def _run_turn(self, text: str) -> None:
turn_id = uuid_utils.uuid7().hex
started_at = _now_iso()
before = self._cost_snapshot()
self._turn_tool_calls = 0
reply = ""
error = ""
try:
async with self._lock:
self._refresh_tools()
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
error = str(exc)
logger.exception("Turn failed for %s/%s", self.owner_kind, self.owner_id)
await self._emit({"type": "error", "text": error}, buffer=True)
finally:
self._record_turn(turn_id, started_at, text, reply, error, before)
def _refresh_tools(self) -> None:
builtin = CATALOG.tool_schemas_for(self.client.authenticated, self.is_admin)
virtual = self._virtual_tool_store.tool_schemas()
self.tools[:] = builtin + virtual
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 _record_turn(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)
try:
if self.persist_conversation:
self._conv.save(self.owner_kind, self.owner_id, self.agent._messages)
self._ledger.record(self.owner_kind, self.owner_id, turn_id, usage, cost_delta, self.settings.ai_model)
self._audit.record({
"turn_id": turn_id,
"owner_kind": self.owner_kind,
"owner_id": self.owner_id,
"username": self.username,
"started_at": started_at,
"ended_at": _now_iso(),
"prompt": prompt,
"reply": reply,
"iterations": after["requests"] - before["requests"],
"tool_calls": self._turn_tool_calls,
"cost_usd": cost_delta,
"error": error,
})
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:
# When the agent changes who is logged in, propagate it to the browser so the terminal and
# the browser share one session. Login mints a real platform session in the client's cookie
# jar; hand that token to the browser via /devii/adopt. Logout reuses /auth/logout.
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 _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()
# future done -> loop exits and returns; disconnected -> re-send after
# reconnect; otherwise the deadline check at the top raises.
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))
NON_ADMIN_COST_RULE = (
"\n\nThe current user is NOT an administrator. Never reveal any monetary cost, dollar "
"amount, pricing, or spend figure to them under any circumstance. When asked about "
"usage or resources, call usage_quota and report only the percentage of their 24h "
"quota used and the turn count."
)
def _system_prompt_for(is_admin: bool) -> str:
if is_admin:
return SYSTEM_PROMPT
return SYSTEM_PROMPT + NON_ADMIN_COST_RULE
def _now_iso() -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat()