|
This file documents the Devii assistant subsystem (devplacepy/services/devii/ and all its subdirectories: virtual_tools, behavior, email, telegram, container, client, customization, tasks, rsearch, agentic, actions). Claude Code loads it automatically whenever any file under devplacepy/services/devii/ is read or edited.
|
|
|
|
## Overview and account scope
|
|
|
|
`DeviiService` is the in-platform agentic assistant (a relocated standalone agent), exposed as a WebSocket terminal at `/devii/ws` (router `routers/devii.py`, prefix `/devii`) and reachable from the **Devii** user-dropdown item (`data-devii-open`) and auto-opened on `/docs` (`data-devii-autoopen`). Opt-in (`default_enabled=False`). Frontend is `static/js/DeviiTerminal.js` (an `Application.js`-instantiated controller, `app.devii`) plus the `devii-terminal`/`devii-avatar` custom elements and ported renderers under `static/js/devii/`, the vendored md-clippy avatar under `static/vendor/md-clippy/`, and `static/css/devii.css`.
|
|
|
|
A signed-in user's Devii drives **their own** account: the agent's `PlatformClient` uses this instance as base URL and the user's `api_key` as Bearer auth, so admins get admin-level tools. The **LLM** calls use the same `api_key` too: the hub threads `owner_kind` into `_build_settings` -> `build_settings(cfg, base_url, api_key, owner_kind)`, which sets `Settings.ai_key = api_key` for `owner_kind == "user"` (else the configured/internal gateway key). So a user's Devii spend is attributed to them at the gateway (`user:<uid>`) and counts toward any per-user gateway limit, while guests fall back to the internal key. Guests get an unauthenticated sandbox client. Every turn is audited in `devii_turns`.
|
|
|
|
## Sessions, hub, and channels
|
|
|
|
`DeviiHub` keeps one `DeviiSession` per owner-and-**channel** `(owner_kind, owner_id, channel)` - `user`/uid or `guest`/`devii_guest` cookie, channel `main` (default, the floating terminal) or `docs` (the in-page docs chat). A session holds a `set` of WebSockets and **broadcasts** every frame to all of that owner-channel's tabs/devices. After a turn it persists `agent._messages` to `devii_conversations` (users only; rehydrated on reconnect -> survives restarts), appends a `devii_usage_ledger` row, and a `devii_turns` audit row. On `attach` it sends a `{"type":"history"}` snapshot so a new tab renders the prior conversation. Tasks persist in `devii_tasks` (owner-scoped; guests use an in-memory db via `tasks.store.memory_db()`).
|
|
|
|
**Channels are an independent conversation thread only.** A `channel` separates the floating terminal (`main`) from the in-page docs chat (`docs`) so each has its own conversation thread for the same owner. The WS reads `?channel=` (`routers/devii.py`, allowlisted to `{"main","docs"}`, falls back to `main`) and threads it `get_or_create(..., channel=)` -> `DeviiSession(channel=)`. **Only** `devii_conversations` is keyed `(owner_kind, owner_id, channel)` (column added + NULL->`main` backfilled + index extended in `init_db`); lessons, behavior, virtual tools, tasks, and the `devii_usage_ledger`/`devii_turns` 24h quota stay owner-scoped (deliberately shared across channels). `find(...)`/`get_or_create(...)` default `channel="main"` so existing callers (e.g. `/devii/adopt`) are unchanged. The `docs` channel is driven only by `<dp-docs-chat>` (`static/js/components/AppDocsChat.js`): a light-DOM component that calls `/devii/session` (guest cookie), opens `DeviiSocket(handlers, "docs")`, renders user/agent bubbles in docs style (`static/css/docs-chat.css`) via the devii markdown renderer, stubs any `avatar`/`client` request so the agent never hangs, and seeds its first question from the `seed` attribute (the sidebar search box, intercepted). It is mounted on `/docs/search.html` (`templates/docs/_chat.html`); the docs pages no longer carry `data-devii-autoopen`. The docs page no longer auto-opens the floating terminal.
|
|
|
|
## The Docii docs channel
|
|
|
|
The `docs` channel is branded **Docii**, a documentation-only assistant, built by specialising the same `DeviiSession` on `self.channel == "docs"` (no separate service):
|
|
|
|
- **Prompt.** `session.py` sets `self._system_prompt = DOCS_SYSTEM_PROMPT` (instead of `_system_prompt_for`) - it forces the agent to call `search_docs` first for every question, to recursively refine-and-search (reading the returned section `content`, then searching again with new keywords) until grounded, to answer ONLY from retrieved docs, and to do no platform actions. `_compose_system_prompt()` returns it verbatim for docs (the owner `BEHAVIOR_HEADER` is NOT appended, so a user's general Devii behavior rules can't derail it).
|
|
- **Tools.** `_builtin_tools()` filters `CATALOG.tool_schemas_for(...)` to the `DOCS_TOOLS` allowlist (`{"search_docs"}`); `_refresh_tools()` for docs sets `self.tools[:] = _builtin_tools()` with NO virtual tools. So the docs agent has exactly one tool and cannot wander.
|
|
- **Greeting.** `bootstrap_greeting()` returns `DOCS_GREETING` for docs.
|
|
- **search_docs.** `services/devii/docs/controller.py` delegates to the in-process page index `docs_search.search_pages(...)` (no HTTP), returning per result `title`, `url` (`/docs/{slug}.html`), `score` (BM25), and `content` (page text truncated to `CONTENT_CHARS`), admin-filtered by the dispatcher's `is_admin`. So "visiting the search results" is repeated `search_docs` calls; the default 40 `max_tool_iterations` leaves ample room to recurse.
|
|
- **References and inline linking.** Because results carry real `url`s and `score`s, the prompt REQUIRES the answer to (a) link inline to documented pages with markdown links to their `url` and (b) end with a `## References` list of the pages used as clickable links with their score. The devii markdown renderer makes `/docs/...` links clickable client-side.
|
|
- **Topic gate (follow-up vs new).** `_run_turn` runs `_docs_topic_gate(text)` before the main loop (docs channel only). When there is prior history it calls `_classify_topic` (a no-tools `LLMClient.complete_text` with `TOPIC_CLASSIFIER_PROMPT`, parsed by `_parse_topic`, defaulting to `follow_up` on any failure). On `new` it resets `agent._messages` to `[system]`, clears the persisted docs conversation, and emits `{type:"topic", decision, reason}`; the frontend clears the log, re-shows the current question, and prints a reasoning note. On `follow_up` it keeps context and shows the note.
|
|
- **Isolation from Devii (critical).** The docs channel is built with an EPHEMERAL `owned_db` (`hub.get_or_create`: `db if owner_kind=="user" and channel=="main" else memory_db()`), so its task/lesson/behavior/virtual-tool stores are private and empty - a Docii reflection never pollutes the user's Devii memory and vice-versa. The **Scheduler only starts in the `main` channel** (`ensure_scheduler_started`: `if not self._started and self.channel == "main"`), so Devii's scheduled tasks never fire inside a docs session. (Regression fixed: previously the docs session's scheduler ran owner tasks over the shared `devii_tasks` table, and the search-only Docii agent tried to fulfil a `run_js` task by spamming `search_docs` - tasks are a `main`-only feature.)
|
|
- The `main` channel is untouched (full 90-tool catalog + behavior header + Devii greeting).
|
|
|
|
## Docs search mode and BM25 fallback
|
|
|
|
The docs search surface is admin-configurable via the `docs_search_mode` site setting (`agent` | `bm25`, default `agent`, on `/admin/settings`, seeded in `init_db` `operational_defaults`, field in `AdminSettingsForm`). `routers/docs/views.py` `docs_page` (slug `search`) resolves the effective mode: `_agent_search_state(request, user, is_admin)` returns `disabled` (Devii off, or guests-disabled for a guest), `quota` (viewer over their `daily_limit_for`/`spent_24h`), or `ok`. Agent renders **only** when `mode=="agent"` and state `ok`; otherwise it runs the classic `docs_search.search(...)` (BM25, `docs_search.py`) and renders `templates/docs/_search.html`. `docs_base.html` branches the `kind=="search"` include on `search_mode`; on a quota fallback (`quota_fallback`) the BM25 page shows a "reached your daily AI assistant limit" hint. So `docs_search.py`/`_search.html` are live again (the bm25 path), not orphaned.
|
|
|
|
## Conversation, lesson, and task persistence
|
|
|
|
Conversation history persists to `devii_conversations` (rehydrated on reconnect, surviving restarts); per-turn cost goes to `devii_usage_ledger` (the authoritative 24h-spend source - the in-memory `CostTracker` is display-only) and an audit row to `devii_turns`; tasks live in `devii_tasks` (guests use an in-memory store).
|
|
|
|
**Per-owner self-learning memory is privacy-critical.** The `LessonStore` (reflect/recall) is **owner-scoped, never shared**: `LessonStore(db, owner_kind, owner_id)` filters every read/write by owner. The hub builds one per session over the same `owned_db` as the task store - the main `db` (table `devii_lessons`) for signed-in users (persistent, isolated, survives restarts) and a fresh `memory_db()` for guests (ephemeral, scoped to that web session). `forget_lessons` (agentic tool -> `LessonStore.clear()`/`delete()`) lets the user purge them; the system prompt forbids storing credentials/secrets in lessons. **Regression to avoid: do NOT share one `LessonStore` across sessions** - that leaked one user's reflected lessons (including credentials) into every other user's recall.
|
|
|
|
**Lesson retention and deduplication.** Every `add()` deduplicates against existing active lessons via Jaccard similarity (threshold 0.70): a near-duplicate bumps the original's `hits` counter and refreshes `created_at` instead of inserting a new row. A per-owner cap (`devii_lessons_max_per_owner`, default 500, configurable on `/admin/services` and `site_settings`) is enforced on every insert: when exceeded, the oldest lessons are soft-deleted (`deleted_by="retention"`). Age-based pruning runs on `DeviiService.run_once()` (every 60s on the lock-owner worker): lessons older than `devii_lessons_max_age_days` (default 90, configurable) are soft-deleted across all owners. Both settings persist in `site_settings` and are seeded in `init_db`. The `devii_lessons` table is in `SOFT_DELETE_TABLES` for admin Trash restore/purge.
|
|
|
|
**Quality signals.** Each lesson has a `rating` column (integer, default 0). The `lesson_rate(uid, value)` agentic tool (value 1 for useful, -1 for unhelpful) lets the agent self-rate lessons. In `_rebuild()`, lessons with `rating <= -3` are excluded from the BM25 index and therefore never returned by `recall()`. The `lesson_count()` tool reports active lesson count.
|
|
|
|
**CLI:** `devplace devii lessons count` reports active/soft-deleted totals; `devplace devii lessons prune --all-owners|--username USER` soft-deletes old lessons; `devplace devii lessons clear --force` hard-deletes all rows. Rate-limiting guard: `run_once` swallows all exceptions so a bad schema never stops housekeeping.
|
|
|
|
## Reminders and scheduled tasks (persistent across reboot)
|
|
|
|
`create_task` queues a self-contained prompt that a fresh agent runs later (`services/devii/tasks/`): `kind=once` (`delay_seconds` for relative, `run_at` UTC for absolute), `interval` (`every_seconds`), or `cron`. The per-session `Scheduler` ticks every 1s and executes due rows through the session's executor, so the result is broadcast to any connected tab and buffered (`type:"task"` frame) when the terminal is closed.
|
|
|
|
**The scheduler is no longer tied to a live WebSocket.** It is started by `session.ensure_scheduler_started()`, called both on `attach` AND by the lock-owner `DeviiService._ensure_task_schedulers()` each `run_once` (and promptly at boot): that pass scans the shared `devii_tasks` for every user owner with an enabled `pending`/`running` task (`tasks.store.pending_owner_ids(db)`), resolves the user (api_key/username/is_admin/timezone), and `hub.get_or_create(...)`s a headless session whose scheduler then runs the task - so a queued reminder survives a server reboot and fires even if the user never reopens Devii. `hub.gc_idle()` skips a session with `has_pending_tasks()` so a task-only session is not reaped between ticks.
|
|
|
|
A task created as a reminder carries `notify=1`: when it finishes, `session._deliver_reminder` sends a `utils.create_notification(owner, "reminder", result, target_url="/devii")` (the `reminder` notification type), so the in-app notification + live toast reach the user regardless of the terminal.
|
|
|
|
**Timezone awareness.** The terminal sends a `clientinfo` frame (`Intl...timeZone` + UTC offset) on connect; `session.set_clientinfo` stores it and persists `users.timezone` (`database.set_user_timezone`). `session._compose_system_prompt()` injects a `# CURRENT TIME` block (UTC now + the user's local time/timezone, falling back to the stored `users.timezone` for a headless run) so the agent converts wall-clock requests to a correct UTC `run_at`; relative requests use `delay_seconds`. The `REMINDERS AND SCHEDULED TASKS` system-prompt rule makes the agent SCHEDULE rather than fire a delayed instruction immediately, and set `notify=true` for reminders.
|
|
|
|
## Cost, quota, and the financial-data-is-admin-only rule
|
|
|
|
**Financial cap.** The rolling-24h `$` limit is read from `devii_usage_ledger` (`UsageLedger.spent_24h`), **not** the in-memory `CostTracker` (which resets on reboot and is display-only). Checked before each turn in the router; a turn already over is blocked (the over-limit WS error states only "Daily AI quota reached (100%)", never a dollar figure). The caps are `devii_user_daily_usd`, `devii_guest_daily_usd`, and `devii_admin_daily_usd` (default `0` = unlimited, admins exempt by default), resolved via `config.effective_daily_limit`. `config.effective_daily_limit(cfg, owner_kind, is_admin)` is the single source of truth, used by both `service.daily_limit_for` and `build_settings`: guests use `devii_guest_daily_usd` (default $0.05), users `devii_user_daily_usd` ($1.00), and **administrators are exempt by default** via `devii_admin_daily_usd` (default `0.0`, where **`0` = unlimited**). All three are editable on the Devii service config (`/admin/services`). The WS gate is `if limit > 0 and spent >= limit` so a `0` cap never blocks.
|
|
|
|
**Every billed turn is ledgered, not just interactive ones:** `session._record_spend` writes the usage delta in the `finally` of both `_run_turn` AND the scheduler executor, and it runs even when a turn is cancelled/superseded, so scheduled-task spend and interrupted-turn spend count against the cap rather than being forgiven. Pricing is admin-configurable - `cost/tracker.py` resolves a `Pricing` object per `CostTracker` rather than import-time env constants.
|
|
|
|
**Quotas are resettable** (clearing the owner's `devii_usage_ledger` rows): per-user via `POST /admin/users/{uid}/reset-ai-quota` (button on the admin Users page), globally via `POST /admin/ai-quota/reset-guests` and `/reset-all` (buttons on `/admin/ai-usage`), and from the CLI via `devplace devii reset-quota <username> | --guests | --all`.
|
|
|
|
**Financial data is admin-only:** any monetary figure (USD cost, pricing, spend, limit) is restricted to administrators; members and guests see only the **percentage** of their 24h quota used. The owner's admin status is resolved server-side (`is_admin(user)`) and threaded WS -> `hub.get_or_create(is_admin=)` -> `DeviiSession(is_admin=)` -> `Dispatcher(is_admin=)`. The `Action` dataclass has a `requires_admin` flag (`cost_stats` is admin-only USD; the member-safe `usage_quota` tool returns **only** `{used_pct, turns_today, limit_reached}` with no money and is available to everyone). Gating is double: `Catalog.tool_schemas_for(authenticated, is_admin)` never hands an admin-only tool's schema to a non-admin, and the dispatcher independently raises `AuthRequiredError` for any `requires_admin` action a non-admin attempts. `usage_quota`'s data comes from `DeviiSession._quota_snapshot` (ledger `spent_24h`/`turns_24h` over `settings.daily_limit_usd`); for non-admin owners the system prompt also appends a hard rule forbidding any cost disclosure (defense in depth). `GET /devii/usage` mirrors this: it always returns `used_pct`/`turns_today` and adds `spent_24h`/`limit` only for admins. The standalone `devii` CLI runs with `is_admin=True` (the local operator owns the process). The catalog's cost/analytics HTTP tools `ai_usage` (GET `/admin/ai-usage/data`, USD breakdown) and `site_analytics` (GET `/admin/analytics`) are also `requires_admin=True`, so a non-admin session is never offered them and the dispatcher blocks them even if named - the platform 403 is no longer the only guard. The profile page is correctly split too (`_ai_quota(include_cost=viewer_is_admin)` emits dollars only for admins, in both its HTML and JSON forms, so a member fetching their own profile with `Accept: application/json` never sees dollars either).
|
|
|
|
## Aggregate analytics (no pagination)
|
|
|
|
Site analytics is a **documented, admin-secured HTTP endpoint** - `GET /admin/analytics` (`routers/admin/` package, returns JSON, `is_admin` check -> 403 otherwise) backed by `database.get_platform_analytics()` (single UNION query over `posts/comments/gists/projects.created_at`, TTL-cached 60s; total members, active users 24h/7d/30d, signed-in-now, signups, content totals, top authors). Devii calls it as a normal `http` catalog action `site_analytics` (GET `/admin/analytics`, `top_n` query param) - the platform enforces admin, so it behaves identically for the web and the `devii` CLI and never touches the DB directly from the agent. It is documented in the Admin API docs group (`docs_api.py`, id `admin-analytics`). The system prompt tells the model to use it for any "how many"/"how active" question instead of paging `admin_list_users` (which caused a pagination storm), and to `search_docs` before guessing a route rather than probing URLs.
|
|
|
|
## Context-window sizing (DeepSeek V4 Flash, 1M tokens)
|
|
|
|
The upstream model `deepseek-v4-flash` (what `deepseek-chat` routes to; default `gateway_model`) has a 1,048,576-token context and 384,000-token max output. All Devii size limits are **characters**, derived in `services/devii/config.py` from one source of truth: `CONTEXT_INPUT_BUDGET_TOKENS = CONTEXT_WINDOW_TOKENS - MAX_OUTPUT_TOKENS - SYSTEM_RESERVE_TOKENS` (600,576 tokens), and `DEFAULT_CONTEXT_COMPACT_THRESHOLD = CONTEXT_INPUT_BUDGET_TOKENS * CHARS_PER_TOKEN` (3 chars/token = ~1.8M chars). At the conservative 3-chars/token estimate the worst-case input at compaction plus the full max output plus the system reserve sums to exactly the 1M window, so it can never overflow. `DEFAULT_MAX_RESPONSE_CHARS` (200,000) is the master chunk knob: every non-`chunks` tool result passes through `wrap_if_large(result, max_response_chars)` in `actions/dispatcher.py`, and `read_more` slices are clamped to it, so a file up to ~200KB returns in **one** read instead of paging in 12KB slices (the old read_more storm). `OUTPUT_CAP_CHARS` (`agentic/loop.py`, 400,000) must stay **above** `max_response_chars` or it would re-truncate a full chunk envelope. `ChunkStore` caches up to `STORE_MAX_CHARS` (8MB) per entry, `STORE_MAX_ENTRIES` (16) entries; `SUMMARY_INPUT_CAP` (`agentic/compaction.py`, 600,000) bounds what the summarizer ingests when compacting. When changing the upstream model, retune from `CONTEXT_WINDOW_TOKENS`/`MAX_OUTPUT_TOKENS` - everything else derives.
|
|
|
|
## Multi-worker service-lock routing
|
|
|
|
Hubs are per-process, so `/devii/ws` is served **only** by the worker that holds the background-service lock (`service_manager.owns_lock()`, set in `main.py` startup); a non-owner worker `close(4013)` (an application code in the private 4000-4999 range, reliably delivered as the browser `CloseEvent.code`). `DeviiSocket.js` recognises 4013 as "wrong worker, not yet settled" and fast-retries in ~200ms **silently** (no "disconnected, reconnecting..." notice), so with 2 prod workers the client converges on the owner in a fraction of a second instead of bouncing every 1500ms. The visible "connected." notice is emitted on the first server frame (`onReady`), never on raw socket open, so an accepted-then-4013-closed handshake on the wrong worker is invisible. The disabled/no-service path keeps the standard `1013` (a real, user-visible disconnect). The cap stays correct regardless because it reads the DB.
|
|
|
|
## Client-side browser-automation tool channel
|
|
|
|
Beyond the avatar, Devii has a `client`/browser channel using the same request/response pattern: `services/devii/client/ClientController` is bound on `attach`, and `actions/client_actions.py` exposes `get_page_context`, `run_js`, `highlight_element`, `clear_highlights`, `show_toast`, `scroll_to_element`, `navigate_to`, `reload_page` (`handler="client"`, all `requires_auth=False`). The session's generic `_browser_request(channel, action, args)` powers both `avatar` and `client`; the router resolves both `avatar_result` and `client_result`. Frontend `static/js/devii/DeviiClient.js` executes the actions in the user's own browser and the terminal routes `type:"client"` frames to it, returning `client_result`. This powers live on-screen tutorials, page-context awareness, and navigation/refresh. `navigate_to`/`reload_page` reply *before* unloading so the turn completes, then the persistent session reconnects.
|
|
|
|
**Target selection (visibility-aware).** Unlike replies/traces, which `_emit` *broadcasts* to every tab, a browser request is sent to one **target** chosen by `_pick_target()`. The terminal reports each connection's `document` visibility and focus via `{"type":"visibility"}` (on connect, `focus`, `blur`, and `visibilitychange`); the session stores it in `_conn_meta` and ranks connections `(focused, visible, attach_seq)`, so the command runs on the tab the user is actually looking at, falling back to the most recently attached when none reports focus. **Regression to avoid: do NOT route to a single "last-attached primary" socket** - with the `/docs` auto-open tab or any second tab, `reload_page`/`navigate_to` ran on the wrong (hidden) tab while still reporting success, so "the reload did not happen" from the user's view. `run_js` is gated by the `devii_allow_eval` config field (default on), checked in `ClientController` before any round-trip. Overlays (`.devii-hl-box`, `.devii-hl-callout`, `.devii-toast`) attach to `document.body`, not the terminal.
|
|
|
|
## Stale API key auto-recovery (401 self-healing)
|
|
|
|
A user session captures the owner's platform `api_key` into its frozen `Settings` at build time (both `Settings.ai_key` for the gateway and `Settings.platform_api_key` for REST). Regenerating that key (profile regenerate, Devii tool, `devplace apikey reset` - possibly from another worker or process) would strand every live session with a permanent `401 Unauthorized` on all LLM and platform calls. Recovery is **reactive at the two HTTP chokepoints**, never proactive session invalidation (which is per-process and cannot reach the hub on the lock-owner worker): `hub.get_or_create` builds a `_user_key_resolver(owner_id)` (a fresh `users.api_key` DB read, user owners only - guests get `None` and keep the internal gateway key) and passes it to `LLMClient(settings, key_resolver=)` and through `DeviiSession(key_resolver=)` into `PlatformClient`. On a 401 (`LLMClient._post`) or a 401/redirect-to-login (`PlatformClient.call` via `_auth_failed`), the client calls `_refresh_key()`: resolve the current key, and only when it is non-empty AND differs from the header already sent, rewrite the auth headers and retry the request **once**. An unchanged or unresolvable key skips the retry so a genuinely invalid credential still fails closed with the original error. The resolver read is cross-worker correct because it goes straight to SQLite, not any per-process cache.
|
|
|
|
## Shared browser session (auth adoption)
|
|
|
|
The terminal and the browser are one session. `/devii/ws` resolves its owner from the browser's `session` cookie (`_resolve_ws_owner`), so the terminal is whoever the browser is logged in as. Agent-initiated auth propagates: the session watches its own trace stream (`_trace`) and, on a successful `login`/`signup`, captures the real session token the `PlatformClient` minted against this instance (`session_cookie()`) and broadcasts `{"type":"auth","action":"adopt"}`; the browser navigates to single-use `GET /devii/adopt` which sets the httpOnly `session` cookie and redirects (reload). On `logout` it broadcasts `{"type":"auth","action":"logout"}` and the browser goes to `/auth/logout`. Browser->terminal: login/logout are navigations that reconnect the WS; a cross-tab change is caught by a focus check against `/devii/session` that reloads. The httpOnly cookie is only ever set/cleared by HTTP endpoints, never JS.
|
|
|
|
## Screen awareness
|
|
|
|
The `SYSTEM_PROMPT` instructs Devii, after a mutation, to `get_page_context` and `reload_page` when the page the user is viewing displays the data just changed, so settings/list/detail views the user is watching update live without a manual refresh.
|
|
|
|
## Project filesystem tools: line-range editing and the overwrite-protection guard
|
|
|
|
Devii's project filesystem tools include surgical **line-range editing** (`project_read_lines`, `project_replace_lines`, `project_insert_lines`, `project_delete_lines`, `project_append_file`) so it edits large text files without resending them, and `Dispatcher._run_http` enforces an **overwrite-protection guard**: `project_write_file` is rejected only when the target `(slug, path)` **already exists** (the guard probes `GET /projects/{slug}/files/raw`) and the agent has not read it this session (call `project_read_file` first); creating a new file needs no prior read. Reads are recorded in the per-session `Dispatcher._read_files` set. This steers the agent to update files rather than blindly overwrite them and is agent-scoped only (the human UI and public HTTP API are unaffected). The `WRITING AND EDITING FILES` system-prompt rule mirrors it.
|
|
|
|
## Web fetch and arbitrary HTTP (`handler="fetch"`)
|
|
|
|
`services/devii/fetch/FetchController` (a standalone `httpx.AsyncClient`, not `PlatformClient`) backs two tools: `fetch_url` (read a page as readable text, `requires_auth=False`) and `http_request` (`requires_auth=True`) - the general external HTTP client. `http_request` takes `method` (GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS), `headers` (object), and one body of `json` (object/array, Content-Type set automatically), `form` (url-encoded), or `body` (raw string), and returns `{http_status, headers, content_type, content}`; it does **not** raise on non-2xx so the model can read API errors. Both run through one `_stream()` (`fetch_url` via `_download` with `raise_5xx=True`; `http_request` with `raise_5xx=False`) sharing the SSRF `_guard` (refuses private/loopback unless `DEVII_FETCH_ALLOW_PRIVATE`), `fetch_max_bytes` cap, timeout, and decoding. The `object`-typed params required adding an `object` branch to `Action.tool_schema()` in `spec.py` (emits a valid open-object schema). The `SYSTEM_PROMPT` "WEB REQUESTS AND EXTERNAL APIS" rule tells the agent to call `http_request` DIRECTLY for an API and never wrap it in a self-invoking user-defined tool (a virtual tool only re-runs the agent and cannot do I/O, so wrapping the call recurses into the `MAX_EVAL_DEPTH` guard - the exact failure this fixed). To attach a remote file see `attach_url` / `store_attachment_from_url` under Attachments and media.
|
|
|
|
## Remote web tools (rsearch, non-platform)
|
|
|
|
`services/devii/rsearch/RsearchController` (`handler="rsearch"`, registered in `registry.py`, specs in `actions/rsearch_actions.py`, all `requires_auth=False`) exposes four tools that hit the EXTERNAL public service `https://rsearch.app.molodetz.nl` over a standalone `httpx.AsyncClient` (like `FetchController`, not the platform-bound `PlatformClient`): `rsearch` (web/image search, optional `content`/`deep`/`type=images`), `rsearch_answer` (AI answer grounded in fresh web search, returns answer + sources), `rsearch_chat` (direct AI chat, no search), `rsearch_describe_image` (vision describe a public image URL). These are NOT platform-specific: the `SYSTEM_PROMPT` "REMOTE WEB TOOLS" section makes the model prefer platform tools always and call `rsearch_*` only when the user explicitly asks to search the web/an outside source, never to answer questions about this instance. When adding another remote service, add a controller + handler literal in `spec.py` + a dispatcher branch rather than routing external calls through `PlatformClient` (which is hardwired to the instance base URL).
|
|
|
|
rsearch has its **own** read timeout (`rsearch_timeout_seconds`, field `devii_rsearch_timeout` / env `DEVII_RSEARCH_TIMEOUT`, default **300s** with a 30s connect cap via `httpx.Timeout`) - it does NOT share `fetch_timeout_seconds`, because web-grounded answers legitimately take minutes.
|
|
|
|
**rsearch is metered into the gateway ledger.** Because these calls never traverse the gateway upstream, `RsearchController` (constructed with `owner_kind`/`owner_id` from the dispatcher) records one `gateway_usage_ledger` row per call inside its single choke point `_request()` via `GatewayUsageLedger.record_external(...)` (backend `rsearch`, zero tokens, the flat admin-set `gateway_rsearch_cost_per_call`, success and failure both logged). The DeepSearch worker's `crawl.search_queries` emits a `{"type":"rsearch"}` NDJSON frame per search that `DeepsearchService._run_worker` ledgers in-process under the job owner. This keeps `gateway_usage_ledger` (and `/admin/ai-usage`) a complete record of platform AI spend - external AI calls included. `record_external` is the helper to reuse for any future off-gateway AI call.
|
|
|
|
## Email tools (IMAP/SMTP, non-platform)
|
|
|
|
A signed-in user's Devii can connect to their OWN external mailbox via `services/devii/email/EmailController` (`handler="email"`, specs in `actions/email_actions.py`). See `devplacepy/services/email/CLAUDE.md` for the full protocol engine, credential storage, and tool list.
|
|
|
|
## Telegram bot
|
|
|
|
Devii is reachable over Telegram via `channel="telegram"` sessions driven in-process by `services/telegram/bridge.py`. See `devplacepy/services/telegram/CLAUDE.md` for the worker/bridge architecture, pairing flow, and message-update behavior.
|
|
|
|
## Database API tools (`db_*`, primary-administrator only)
|
|
|
|
Devii exposes read-only `db_list_tables`, `db_table_schema`, `db_list_rows`, `db_get_row`, `db_query`, and `db_design_query` tools gated `requires_primary_admin=True`. See `devplacepy/services/dbapi/CLAUDE.md` for the full auth boundary, validation pipeline, and async query service - these tools are added to the LLM tool list only for the primary administrator; every other session is unaware they exist.
|
|
|
|
## All Devii/gateway network timeouts are admin-configurable with a five-minute (300s) floor
|
|
|
|
The Devii LLM-client and `PlatformClient` read timeout is `timeout_seconds` (field `devii_timeout`), the fetch/docs tool timeout is `fetch_timeout_seconds` (field `devii_fetch_timeout`), web search is `rsearch_timeout_seconds` (`devii_rsearch_timeout`); each defaults to 300s with `minimum=MIN_TIMEOUT_SECONDS` (300). The gateway upstream timeout is `gateway_timeout` (`minimum=TIMEOUT_MIN`=300), which also bounds the vision describe-image call (it shares the gateway's httpx client, no per-call override). A stored value below the floor fails `ConfigField.coerce()` and `read()` falls back to the default, so old sub-300 values upgrade automatically. Devii's LLM timeout was previously a hardcoded 45s while the gateway waited up to 180s x retries - large prompts aborted as `[model error] Could not reach the model endpoint:` (an httpx timeout, which stringifies to empty). `build_settings()` now reads these from config instead of hardcoding them.
|
|
|
|
## Config
|
|
|
|
Config is all `config_fields` (AI url/model/key, base url, plan/verify toggles, max iterations, JS-execution toggle, user/guest 24h caps, guests toggle, pricing). `effective_config()` falls back the AI key to `DEVII_AI_KEY` and the base url to the instance origin.
|
|
|
|
## Backend confidentiality
|
|
|
|
Devii must never disclose the underlying model, provider, or any upstream URL - enforced in two layers. The `SYSTEM_PROMPT` (`agent.py`) forbids it even when such values appear inside a tool result. Structurally, `text.redact_backend()` runs on every JSON HTTP tool response in `format_response()` and scrubs the values of `REDACT_FIELD_KEYS` (`gateway_upstream_url`/`gateway_model`/`gateway_vision_url`/`gateway_vision_model`) and any stat labelled in `REDACT_STAT_LABELS` (`Model`) to `[hidden]`, so the admin-services tools cannot leak the gateway's upstream even though the admin settings UI still shows the real values. Add a config key to `REDACT_FIELD_KEYS` if a new field would expose backend infrastructure to the agent.
|
|
|
|
## CLI
|
|
|
|
`pyproject.toml [project.scripts]` ships `devii = "devplacepy.services.devii.cli:main"`. No new dependency (`httpx`/`dataset` already required). The md-clippy avatar is vendored under `static/vendor/md-clippy/`; its `index.js` must not set `globalThis.app` (it would clobber the DevPlace `app`) and its AI proxy is `/devii/clippy/ai/chat`. The standalone `devii` CLI runs with `is_admin=True` (the local operator owns the process) and both `is_admin`/`is_primary_admin` `True` for the trusted local operator.
|
|
|
|
## Devii user-defined ("virtual") tools
|
|
|
|
Users invent new Devii tools in natural language ("when I say woeii, do Y"); each is stored per-owner and added to Devii's live LLM tool list, and when called its handler **re-prompts Devii itself** (a self-eval sub-agent) with the stored prompt plus the user's single free-form `input`. Full CRUD is Devii-only (`tool_create`/`tool_list`/`tool_get`/`tool_update`/`tool_delete`, `handler="virtual_tool"`).
|
|
|
|
- **Dynamic tool list (the crux).** The session tool list is normally frozen (`session.py` `self.tools = CATALOG.tool_schemas_for(...)`, handed by reference to `Agent`, `AgenticController.bind`, and read live by `react_loop` each `llm.complete`). `DeviiSession._refresh_tools()` runs at the top of every `_run_turn` (inside `self._lock`, before `agent.respond`) and rewrites that list **in place**: `self.tools[:] = CATALOG.tool_schemas_for(self.client.authenticated, self.is_admin) + self._virtual_tool_store.tool_schemas()`. Because every holder shares the same list object, a tool created/edited/deleted (and a mid-session login) takes effect on the **next** turn with no Agent rebuild. **Never reassign `self.tools`, always mutate in place.**
|
|
- **Self-eval engine** (`agentic/controller.py`). `_delegate` was refactored onto `_spawn(prompt, tools, system_prompt)`, which runs `react_loop` with a fresh `messages`/`AgentState` under a **depth guard** (`agentic/state.py` `get/set/reset_eval_depth`, `MAX_EVAL_DEPTH=2`) so delegate/eval/virtual tools cannot self-call infinitely. `run_subagent(prompt)` (tools minus `delegate`) is the public engine; the `eval` agentic tool wraps it; `_delegate` keeps its richer JSON report.
|
|
- **Store** (`services/devii/virtual_tools/store.py`). `VirtualToolStore(db, owner_kind, owner_id)` over `devii_virtual_tools` (`uid, owner_kind, owner_id, name, description, prompt, input_description, enabled, created_at, updated_at`; indexes `idx_devii_vtools_owner` / `idx_devii_vtools_name`). `tool_schemas()` builds, for each **enabled** row, an LLM schema with a single free-form `input` string. Persistent for users, `memory_db()` for guests (built in `hub.get_or_create` from the shared `owned_db`).
|
|
- **Controller** (`virtual_tools/controller.py`). `VirtualToolController(store, evaluator, builtin_names)`, evaluator = `agentic.run_subagent`, `builtin_names = set(CATALOG.by_name())`. CRUD `tool_create/list/get/update/delete` (`handler="virtual_tool"`); `tool_create` validates name `^[a-zA-Z][a-zA-Z0-9_]{1,40}$`, rejects built-in collisions and duplicates, requires `description`+`prompt`. `has(name)` / `run(name, args)` compose `f"{prompt}\n\nUser input: {input}"` and call the evaluator.
|
|
- **Dispatch** (`actions/dispatcher.py`). Constructed with `virtual_tools=...`; `_run` routes `handler="virtual_tool"` to CRUD; the **unknown-name branch** (`if action is None`) resolves a virtual tool via `self._virtual_tools.has(name)` -> `run(name, args)` before erroring. So virtual tools live only in the store (not the static catalog) and are resolved on demand; the static `_actions` stays built-in only.
|
|
- Registered via `VIRTUAL_TOOL_ACTIONS` (`registry.py`); the `eval` tool via `AGENTIC_ACTIONS`. System-prompt **USER-DEFINED TOOLS (VIBE TOOLS)** section steers creation and warns against deep self-calls.
|
|
|
|
## Devii self-configured behavior (`services/devii/behavior/`)
|
|
|
|
Devii can partially configure its OWN system message: every system prompt ends with a `# TRUTH RULES AND BEHAVIOR` section (the `BEHAVIOR_HEADER` constant in `session.py`) whose body is an owner-scoped, persistent set of behavior rules. When the user tells Devii to behave differently, says they expect different behavior, or Devii upsets them, Devii calls the **`update_behavior`** tool (`handler="behavior"`, `requires_auth=False`, single `behavior` arg = the FULL new section content) to persist the change. It is **self-prompted only**: the sole way to change the section is Devii calling that tool - there is no HTTP route or UI, like `LessonStore`/customization. Because Devii already SEES the current section in its own system message, it merges (copy current rules, apply the change, send the whole result) so prior rules are not lost; this is why the tool replaces rather than appends.
|
|
|
|
- **Store** (`behavior/store.py`). `BehaviorStore(db, owner_kind, owner_id)` over `devii_behavior` (one upserted row per owner keyed on `owner_kind`/`owner_id`; `text()` reads, `set()` upserts; index `idx_devii_behavior_owner`). Persistent for users, `memory_db()` for guests (built in `hub.get_or_create` from the shared `owned_db`, like the other owner stores).
|
|
- **Controller** (`behavior/controller.py`). `BehaviorController(store)`, `dispatch("update_behavior", args)` -> `store.set(behavior)`. Built in `DeviiSession` and passed to `Dispatcher(behavior=...)`; the dispatcher routes `handler="behavior"` to it and degrades gracefully ("not available in this context") when unwired (e.g. the standalone CLI, same as `virtual_tools`/`avatar`).
|
|
- **Injection and refresh** (`session.py`). `_compose_system_prompt()` = base prompt (`_system_prompt_for(is_admin)`) + CA-IWP fragment + live `CHANNEL` block + `\n\n` + `BEHAVIOR_HEADER` (+ `\n` + body when non-empty; just the header when empty). Used to seed the `Agent` and the scheduler executor worker, and `_refresh_system_prompt()` rewrites `agent._messages[0]["content"]` **at the top of every `_run_turn`** (next to `_refresh_tools`), so a mid-conversation `update_behavior` takes effect on the following turn and the live system message is never overridden by the stale base.
|
|
- Registered via `BEHAVIOR_ACTIONS` (`registry.py`); the system-prompt **SELF-CONFIGURED BEHAVIOR (TRUTH RULES)** section in `agent.py` steers when/how to call it.
|
|
|
|
## Channel-Aware Interactive Widget Protocol (CA-IWP / Speak With Buttons)
|
|
|
|
Devii can ask the user for decisions through a gated tool surface instead of inventing HTML or channel-specific prose. Spec lives as the CA-IWP document; implementation is Devii-only under `services/devii/interaction/`.
|
|
|
|
### Layers
|
|
|
|
| Piece | Role |
|
|
|-------|------|
|
|
| `interaction/capabilities.py` | Maps session channel (`main`/`docs` -> `site-chat`, `telegram`, `cli`) to capability flags + limits; builds the compact `CHANNEL` / `CAPS` / `LIMITS` / `TOOLS` / `INTERACTION` fragment injected every turn. |
|
|
| `interaction/schema.py` | Pydantic validation for `ui_prompt` args (widget catalog: confirm, choice, choice_multi, text, number, date, select, `//`, group). Untrusted model input is sanitized and capped. |
|
|
| `interaction/broker.py` | Validates, assigns `interaction_id`, single-flight open interactions, routes to site / telegram / plain adapters, returns structured `{status, values, meta}`. |
|
|
| `interaction/controller.py` | Dispatches `ui_prompt` / `ui_cancel` / `ui_notify` (`handler="interaction"`). |
|
|
| `interaction/actions.py` | Catalog entries registered in `registry.py` as `INTERACTION_ACTIONS`. |
|
|
| `interaction/markdown.py` + `parse.py` | Dual-surface markdown projection and plain/CLI reply parsers. |
|
|
|
|
### Tool gating and preferences (admin default + user override)
|
|
|
|
`DeviiSession._builtin_tools()` filters UI tools through `channel_context().tools`. Docs channel stays search-only (no UI tools). Open interaction (single-flight) exposes only `ui_cancel` plus preference tools.
|
|
|
|
**Admin default:** Devii service ConfigField `devii_interactions_default` (bool, default on) on `/admin/services`. Guests always use this default.
|
|
|
|
**User override:** column `users.interactions_enabled` (`-1` = inherit default, `0` = off, `1` = on). New signups insert `-1`. Resolution: `interaction/prefs.py` `effective_for(owner_kind, owner_id)`.
|
|
|
|
**Surfaces (same fan-out as AI correction):**
|
|
- Devii tools `interactions_get` / `interactions_set` (`requires_auth=True`; set accepts `enabled` or `reset=true` to inherit again). Always offered to signed-in users even when widgets are off, so they can re-enable.
|
|
- HTTP `POST /profile/{username}/interactions` (owner-or-admin, form `InteractionsForm`), profile card + `InteractionsPref.js`, `docs_api` endpoint, audit `profile.interactions`, profile JSON keys on `ProfileOut`.
|
|
- When effective is off, `ui_prompt` / `ui_notify` are absent from the tool list and the controller refuses them; the model must use degraded markdown menus.
|
|
|
|
### Site-chat path
|
|
|
|
`ui_prompt` blocks like client tools: session `_interaction_wait` emits `{type:"interaction", id, args}` on the WebSocket; `devii-terminal.js` mounts an `<ai-interaction>` tree via `createElement` (never model HTML), lazy-loads widget modules through `AiAutoload` (`static/js/autoload/AiAutoload.js`, allowlisted tag -> module map only), and replies with `{type:"interaction_result"}`. Router resolves via `session.resolve_interaction`. Custom elements live under `static/js/components/Ai*.js` with per-component CSS under `static/css/components/ai-*.css`. Light DOM only; `Application` boots the shell (`AiInteraction`, `AiStatus`, `AiActions`, `AiHelp`, `AiOption`) and starts the autoloader.
|
|
|
|
### Telegram path
|
|
|
|
`TelegramConnection` handles `type:"interaction"`: sends dual-surface plain text plus inline keyboard for confirm / single choice (`callback_data` short tokens). `TelegramBridge` registers a pending future **before** the chat lock so the next message or `callback_query` can resolve mid-turn. Worker `allowed_updates` includes `callback_query`; service routes `type:"callback"` to the bridge.
|
|
|
|
### Plain / CLI path
|
|
|
|
Broker uses `present_plain` or emits a plain menu and waits; `parse_plain_reply` accepts y/n, indices, value tokens, cancel.
|
|
|
|
### System prompt
|
|
|
|
Every non-docs turn appends `CA_IWP_SYSTEM_FRAGMENT` plus the live channel fragment from `_compose_system_prompt()`. Model rules: prefer `ui_prompt` when gated on; never invent HTML/CE tags; branch on `status` (submitted / cancelled / timeout / superseded / error).
|
|
|
|
### Tests
|
|
|
|
Unit coverage under `tests/unit/services/devii/interaction/` (capabilities, schema, markdown, parse, broker, controller) plus session tool-gating assertions and telegram callback emission. Mirror this layout for any extension.
|
|
|
|
## Related Devii tool wrappers (customization, container)
|
|
|
|
Two more Devii-side controllers live under `services/devii/` but their full mechanism is documented in their owning subsystem's file, not here:
|
|
|
|
- **Per-user customization tools** (`services/devii/customization/`, `handler="customization"`): `customize_list`/`get`/`set_css`/`set_js`/`reset` (all `requires_auth=False`) plus `customize_set_enabled` (`requires_auth=True`, the suppression toggles). `CustomizationController(owner_kind, owner_id)` is built in `Dispatcher.__init__`; set/reset are in `CONFIRM_REQUIRED` so `confirmation_error` forces Devii to ask **page-type vs global** before `confirm=true`. Devii previews live with `run_js` (ids `devii-preview-css`/`devii-preview-js`) and `reload_page` after saving. For the storage model, injection pipeline, and per-user suppression flags, see `devplacepy/customization.py` and its own documentation.
|
|
- **Container tools** (`services/devii/container/ContainerController`, `handler="container"`): wraps `services/containers/api.py`, the same operations shared by `routers/containers.py`. For the reconciler, backend ABC, ingress proxy, and workspace sync mechanism, see `devplacepy/services/containers/CLAUDE.md`.
|