Interim say
At most 3 per turn, 80 characters each, no line breaks; sent immediately and the typing indicator comes back. After it, the turn is no longer merged and regenerated, and it skips pre-send review.
Pipeline
A message first lands in a durable queue, then deterministic gates and a reply rating decide whether to answer. If it does, Diana assembles context, generates with the model or the agent, and only after pre-send review sends the reply back to its source channel in chunks. Every stop leaves an outcome code and a reason.
Overview
OneBot / Telegram / QQ / DingTalk / Feishu / WeCom, normalised into a MessageEvent.
Backfill after disconnects, gap detection and retries; survives a restart.
Deterministic gates → direct triggers → reply rating.
SOUL.md, history, memory, media; the stable part first so the prefix cache hits.
With the agent on, a tool loop (possibly using a browser); otherwise a single model call.
Silence, dedup, the audit model, group stop.
Send-time guards, idempotency, backoff and retries.
Outcome code and reason in inbound_events, shown in the WebUI event center.
01 Reply triggers
HandleEvent does not generate a reply directly. It writes the message into a SQLite inbound queue and wakes a worker, so a crash, a restart or a stuck model does not lose it: a worker claims an item with a 10-minute lease, and an expired lease is claimed again.
The bot's own messages only go into history; pokes, joins, friend adds and recalls follow their own switches and cooldowns and never enter the queue.
Everyone / owner only / allowlist.
ignored_private_admissionnoteDirectedInbound runs before enqueueing and is later used to interrupt the same person's older reply.
Priority: direct trigger 100, quote reply 80, resolvable link 60, everything else 0; ties by time.
By default 3 items per group and 2 per private chat in flight, under the global reply concurrency limit.
backfill_history_onlyOlder than the replay windowignored_stalerouteMessageEvent. A transient transcription failure retries the item later.
12 checks that do not call a model; see the table below.
ignored_*Private chat, a mention, a quote of the bot, an alias, an owner command, a resolvable link or a plugin command — after passing the reply gate (owner bypass → blocked → allowlist → exempt → active hours → member level).
A message added within 2 minutes is classified by a model as a repeat, a supplement, a correction or an independent question.
merged_into_replyProcessing errors retry from 5 seconds, doubling up to 5 minutes, at most 5 times; after that the item is recorded as dropped_retries_exhausted. A definitive platform rejection is recorded as dropped_send_rejected and not retried.
01 Reply triggers
Messages sent while the bot was offline never arrive as pushes; they can only be pulled from the platform afterwards. runInboundCoordinator does that.
Releases expired leases and reads the last online checkpoint. While online the checkpoint is saved every 30 seconds.
Stops claiming and saves the checkpoint.
Replay cutoff = disconnect time − 30 minutes, looking back at most 24 hours.
After 1 second, then again at +1 and +5 minutes. Walks the group list and recent contacts, 100 per page and at most 200 per session, one session at a time.
Compared with queued and stored messages, so nothing is processed twice.
Only the newest N per session (history_backfill_message_limit, default 3) that would have triggered a reply directly are queued normally.
Coming back after an hour offline and answering a hundred stored group messages one by one would flood the chat. The recovered history still goes into context; it just does not trigger replies.
While online there is also gap detection: the first live message per group after a reconnect, and any jump in seq numbers, triggers verifyGroupSeqGap — a targeted refetch once backfill finishes, rechecked at 15 seconds, 1 minute and 3 minutes. It logs backfill_gap_detected / resolved / unresolved.
01 Reply triggers
Checked in order; any stop ends with a specific reason. None of these calls a model (except checks 12 and 13), so results are stable and reproducible.
| # | Check | Outcome when stopped |
|---|---|---|
| 1 | The bot left the group or the group is unavailable | ignored_unavailable_group |
| 2 | Resolve quotes, forwards and images; plugin observers; store history | — |
| 3 | Group not enabled or not admitted (owner suppression commands excepted). A disabled group is either dormant (default: local history only, no memory extraction, context summaries, semantic indexing or voice transcription) or silently observing (all of those keep running, it just never replies) | ignored_policy; blocked users record ignored_user_blocked |
| 4 | The bot is muted in the group | ignored_bot_muted; with "judge while muted" on, the judgement runs and records ignored_bot_muted_judged |
| 5 | Group model quota exhausted | ignored_model_quota |
| 6 | Event triggers (keyword / regex tasks) | Not a stop; tasks run in the background |
| 7 | The owner's clear-context command | Handled immediately |
| 8 | Backlog hand-over (see "Concurrency") | merged_into_backlog_turn |
| 9 | Member level threshold (OneBot; mentions, owner and admins exempt) | ignored_member_level |
| 10 | Reply suppression is active | ignored_response_suppression |
| 11 | Video only, no text | ignored_video |
| 12 | The sender is a marked bot or a Telegram bot and did not mention or quote this bot: a model decides whether it is talking to us | ignored_bot_message |
| 13 | Reply fatigue is full (many replies to the same person whose messages were long, repetitive and aimless; a fast and a slow trace decaying over 10 minutes and 6 hours, total = fast + 0.2 × slow): after deciding to reply, the accumulated judgement comes first: the purpose scores the send audit gave recent turns are blended into a value that decays over 30 minutes, and a person who keeps asking, correcting or requesting things still gets a reply. Otherwise a model reads the same time-ordered transcript as intent recognition and checks whether this message brings anything new or asks/requests something; only when it does neither is there no reply, even for mentions and quotes. No model call while fatigue is below the limit or the accumulated purpose is high enough | ignored_reply_fatigue |
01 Reply triggers
Ordinary group messages that were not directly triggered first pass the group's random sampling rate (owner exempt), then go to the router model. For each candidate it answers two things with a short reason each: relevance.directed (is this clearly addressed to the bot — yes or no) and chat_in.score (0–1, how natural it would be to chime in).
Addressing info, a time-ordered transcript and the group's extra criteria.
Purpose proactive_reply_router, bound to a chat model or the Jev decision model; waits at most 60 seconds.
Means someone is asking it to stop, or a mechanical loop.
replied_proactiveCooldown defaults to 30 seconds and starts only after a chat reply is actually sent.
ignoredOver 25% of messages in 10 minutes, at least 3, with more than one other person talking.
replied_proactive, into the reply flow.
| Level | Chat score needed |
|---|---|
off | Never chimes in |
minimal | ≥ 0.90 (strictest) |
low | ≥ 0.70 |
medium | ≥ 0.50 |
high | ≥ 0.30 |
extreme | ≥ 0.10 (loosest) |
always | Ignores the score; cooldown and bot share still apply |
"Is this addressed to the bot" is not scored: a 0–1 score makes the model waver between 0.45 and 0.55, and the same kind of message gets an answer one time and not the next. Any model failure means silence — timeouts used to fall back to a keyword rule (a question mark meant "public question"), which erred towards speaking when it should not. Rating details are logged as proactive_reply_route.
01 Reply triggers
When one session gets messages quickly, three mechanisms avoid answering each one separately or answering the wrong thing:
With newer messages behind it in the session; first attempt only, never the owner's.
At most 20 messages, kept 10 minutes.
The newest direct trigger becomes the reply target; without one, eligible candidates go to the rating together.
Admission opens only after choosing a dialogue path that can regenerate. Resolvers, fixed plugin responses and local commands leave new questions to their own turns.
Merged only at or above the confidence threshold (default 75%).
At most 3 generations. Admission closes before the final attempt, before sending, or after an external side effect. A question asked after the answer starts a new reply.
superseded_follow_up; a recall only removes the quote, it never cancels a reply.
Merging creates a durable pending handoff. It is finalized when the complete answer covering those requests is delivered. Silence, generation failure, cancellation and partial delivery requeue unanswered requests. Restart recovery releases pending handoffs without a live owner. An attempted answer with an unconfirmed outcome is not automatically resent; supplements absent from that answer are still recovered. Plugin facts retain independent delivery, while optional model commentary obeys the follow-up send gate.
Mentioning, quoting or naming the bot in a group with "shut up" or "stop" is caught by the pre-send review, which opens a 10-minute window for that group: the rating is skipped and in-flight unprompted replies are dropped. Mentions, quotes and names are still answered — "stop" means "stop butting in", not "ignore me".
02 Reply flow
replyAndRecord runs replyTo up to 3 times: when the same person adds a supplement or correction during generation, the old draft is discarded and regenerated with it; before the third attempt the turn is sealed against further merges.
Current images, history, identity aliasing, request timeout, the typing indicator (Telegram and OneBot private chats).
May start background tasks or answer directly.
Prompt order below. Reply rules can pin this turn's model profile or turn the reply into voice.
Take out control markers; stop tool calls written as prose; convert to plain text where the platform has no rich text; split long replies at natural boundaries, then split code blocks, then compress with a model (30 seconds, at most 2 calls per reply).
ignored_*Next section.
History, message IDs, delivery stages, outcome; chat cooldown starts; relationship evaluation is queued.
System head (SOUL.md, fixed rules, tool rules) → summary checkpoint → stable history → cache breakpoint → per-turn blocks (plugin context, session state, memory, notebook, world book, self-notes, media index, recent sources, recent tool calls) → same-turn candidates → dependency images → system tail (speaker) → clock → decoration rules → current message. Everything before the breakpoint stays the same between turns in a group, so upstream prefix caches hit. Real user IDs and group numbers are replaced by aliases in model requests and restored in replies and tool calls.
When generation comes back empty, markers decide the fallback: a pause sends one in-character line and goes quiet; a refusal sends fixed text; a queued image job turns "started generating" into the reply; if say already spoke this turn it stays silent; otherwise "I didn't produce a usable reply."
02 Reply flow
On [diana-msg] / [diana-line], trailing full stops removed; replies in the same session queue instead of interleaving.
Only the first chunk quotes and mentions; no quote if the trigger was recalled or the reply targets another participant.
From the second chunk: max(300 ms interval, 100 ms per character), capped at 6 seconds, with the typing indicator kept on.
Mask secrets → prepare local images and audio → resolve [diana-reply] / [diana-at] → send-time guards.
Fingerprints are kept in the turn's delivery ledger.
Renewed to at least ten minutes ahead before every retry.
Record Acknowledged, write chat history, keep the message ID, pause the typing indicator.
A chat reply is attempted once per call; platform media-validation rejections retry briefly, at most 3 attempts in all. send_retry_attempts is for non-reply sends such as scheduled pushes. When the queue retries a whole turn, chunks already delivered are skipped via the ledger and never sent twice.
03 Agent run
With the agent on and tools allowed for this relationship, Runner.Run produces the turn: every step the model either calls one tool or finishes with agent_finalize.
20 steps by default (max 32); the owner gets 32 steps and 20,000 characters of final reply, everyone else 8,000. 60 seconds per tool, 20 seconds reserved for finishing, 3 protocol repairs.
Core tools are always callable; the rest appear as one catalog line and need tools_load, then tools_execute. Trimmed by the permission allowlist and bot / group extension switches; a tool without permission answers "no permission" instead of "does not exist". In groups, loaded tools are restored from the group session snapshot on later turns.
Protocol prompt and deferred-tool catalog; Skills injected by trigger words; a trusted clock; the carry-over archive if the last turn was forced to finish.
Planning deadline = request deadline − finish reserve (the configured value or a third of the remaining time, whichever is smaller).
Other calls in the batch get "not executed", so every step sees the previous result.
Deferred tools must be loaded and pass schema checks → unknown or denied → input coercion → extension changes need a confirmation code → identical to the previous call is skipped → quotas (web_search 3; introspection 6 and tools_load 4 without using steps).
Per-tool timeout and output budget; run_command runs in a sandbox as configured; MCP has its own 60-second timeout.
Secrets masked, long output truncated; search and render results go into the evidence ledger; remaining budget attached. Tool errors are not fatal — they become observations for the model.
Chunks must use [diana-msg] / [diana-line].
A forced finish happens when steps or repairs run out, the finish reserve is reached, or evidence was required and not gathered: a finish instruction and the evidence digest are appended, and only agent_finalize is offered, forced. Afterwards a forced finish keeps up to 12 step summaries as a carry-over archive (valid 10 minutes); the last 8 tool calls are kept for 30 minutes and injected into later turns.
At most 3 per turn, 80 characters each, no line breaks; sent immediately and the typing indicator comes back. After it, the turn is no longer merged and regenerated, and it skips pre-send review.
A synchronous model call with no tools and no history: at most 4 per reply, 6,000 characters of material, 90-second timeout.
Summarise the longest text messages (at most 2 calls), replace images with descriptions, downsize images, and finally drop them.
The runner does not retry. The provider stack retries a transient error once after 700 ms, halves an oversized context up to 4 times, then fails over to the next profile in order, keeping completed tool steps.
04 Browsers
The disposable headless renderer browser_render is registered for everyone, always. An interactive browser with login state is owner-only, and only one set is registered per turn, so the model never gets two and guesses.
browser_renderbrowsersource.Pick: built-in first, extension second by default; the first usable one wins.
Built-in: enabled and Chrome found
agent_browser_box_disabled
browser_* point at the external CDP address insteadRegisters browser_*.
Extension: enabled, connected, not taken over
agent_browser_control_enabled, off by default.
Registers browser_ext_*.
None usable
Anything other than the default 127.0.0.1:9222.
Registers browser_*.
The built-in browser counts as usable whenever it is enabled and Chrome is found; during a takeover browser_* calls fail with "the user is controlling the built-in browser". The extension is the opposite: when every connection is taken over it counts as unusable and the next source is tried.
04 Browsers
The plugin renders at most 2 links per message and injects the text as an untrusted block.
http/https only; private, loopback, link-local, localhost and .local addresses are refused.
3 at once by default, queueing at most 20 seconds; queue time does not count against the timeout.
A temporary 0700 profile, HOME / XDG redirected into it, local host names blocked in resolution.
Every main-document request is re-validated; a download aborts the render.
DOM, network and timers quiet; 25 seconds by default, 60 at most.
The process ends and the temporary directory is deleted.
--screenshot (8 MiB output cap, takes a slot); scripted or animated pages use html_capture on a fake diana-render.invalid origin where every request is answered locally or blocked, with a strict CSP and a virtual clock, at most 600 frames and 4096 px — it does not take a slot today.DIANA_HEADLESS_BROWSER_EXECUTABLE → DIANA_AGENT_BROWSER_EXECUTABLE → common install locations (including a downloaded Chrome for Testing) → PATH. Installation tries the system package manager first; on glibc Linux it falls back to downloading Chrome for Testing into DIANA_BROWSER_DIR.04 Browsers
Only checks for http/https or about:blank, plus the built-in browser's denied_hosts.
A tab counts as held by a conversation for 10 minutes after use.
Timed by agent_browser_timeout_ms (15 seconds by default): on timeout stopLoading and an error; a body that never finishes is stopped after 8 seconds.
click / type / key / wait / eval / screenshot…; clicks and keys are real input events, falling back to el.click() when covered; cookie values in browser_eval results are masked.
Screenshots go to .agent-browser/ in the workspace (one file per conversation, cleaned after 7 days) and are attached as images; every call writes a browser_action log.
| Built-in | External CDP | Extension (your Chrome) | |
|---|---|---|---|
| Tools | browser_* | browser_* | browser_ext_* |
| Login state | What you logged in there, per bot | That Chrome's own | Your everyday browser's; Diana never touches cookies |
| Network boundary | Scheme check + denied_hosts; LAN reachable | Scheme check | Site allow / deny lists, read-write level, rate limit |
| Takeover | Clicking the live view takes over; returned after 5 idle minutes | — | Toggled in the extension or WebUI |
| Screenshots | Yes | Yes | No (it would widen extension permissions) |
05 Pre-send review
After a reply is generated and before it is sent, a series of checks can drop, rewrite or delay it. Only one of them is a dedicated audit model call; the rest are program checks and semantic dedup.
ignored_model_silentSide effect / factual plugin result / image queuedsilence refused, empty-reply fallbackignored_no_natural_reply[[DIANA_IGNORE_CURRENT_USER_30M]] / [[DIANA_REFUSE_CURRENT]]
Their inputs are nearly the same, so starting both saves seconds; if dedup rewrites the reply, the audit runs again on the new text.
Runs only if the session replied within 2 minutes; confidence below 0.9, errors and timeouts all pass.
ignored_duplicate_replydrop, direct replysent anyway (being addressed means you answer)rewritereshaped and formatted againNext section.
ignored_* or a public noticeignored_stop_requestedEvery message then passes the send-time guards.
05 Pre-send review
The audit is simply on or off (on by default, overridable per group). One call judges all of the following; if none applies it is skipped entirely. Verdicts apply in this order:
Judged only within 3 minutes of the bot's last private message; only verdicts at 0.90 confidence or higher count.
ignored_stop_requested; non-owners paused 30 minutesGoodbyes past the grace countignored_conversation_closedJudged on group messages that mention, quote or name the bot; opens a 10-minute window, and this reply is still sent.
Bot-to-bot loop detection is on by default. An exchange with no content counts as one loop. When the other side is marked as a bot, a dense exchange with no purpose counts too. Banter from unmarked accounts does not.
ignored_ai_reply_loopDeliberately low: suspected risk still passes; only a near-certain unsafe verdict stops it.
Contradiction, off topic, truncated, harmful advice, or send_confidence below proactive_reply_threshold.
ignored_proactive_reply_qualityA refusal (confidence ≥ 0.90) is still sent; 4 refusals within 30 minutes start a pause.
The order is deliberate: once someone says "stop replying", whether this reply is accurate no longer matters, so closing comes first. The quality gate only applies to unprompted replies — going silent on someone who addressed you because of a score looks like ignoring them. Direct plugin replies go through the same audit but not semantic dedup.
| Guard | Condition | Result |
|---|---|---|
| Secret masking | A registered credential appears in the text | Replaced with a masked value |
| Mention and quote markers | [diana-at] / [diana-reply] malformed or pointing at a missing message | Rewritten or removed |
| Group unavailable | Confirmed left or disabled | ignored_unavailable_group |
| Paused | Within a pause for this person | ignored_response_suppression |
| Supplement | The person added a message that was merged into this turn | This draft is discarded and regenerated |
| Superseded | Same person addressed the bot again in a group; merged into another turn; unprompted batch changed | superseded_follow_up / superseded_reply_turn / superseded_proactive |
| Idempotency | This step was already sent | Skipped, message ID reused |
| Mute / offline / backoff | Muted; connection offline; group backoff | Muted drops; offline retries via the queue; backoff waits, then drops |
Interrupts and supersession only apply before the first chunk is sent and before any external side effect; a reply that has started sending finishes. Identity aliasing is not a send-time check: real IDs are replaced before the request reaches the model.
Troubleshooting
Every message, answered or not, ends with a record: decision is the outcome code and decision_reason the human-readable reason. When troubleshooting, find which stage it stopped at, then go back to that section.
| Stage | Outcome codes |
|---|---|
| Inbound queue | ignored_stale backfill_history_only ignored_private_admission dropped_retries_exhausted |
| Deterministic gates | ignored_policy ignored_user_blocked ignored_bot_muted ignored_model_quota ignored_member_level ignored_response_suppression ignored_video ignored_bot_message ignored_reply_fatigue |
| Reply rating | ignored replied_proactive |
| Concurrency | merged_into_reply merged_into_backlog_turn superseded_follow_up superseded_reply_turn superseded_proactive |
| Pre-send review | ignored_model_silent ignored_no_natural_reply ignored_duplicate_reply ignored_proactive_reply_quality ignored_stop_requested ignored_conversation_closed ignored_ai_reply_loop |
| Sending | ignored_unavailable_group dropped_send_rejected |
| Area | Files |
|---|---|
| Inbound and backfill | model/assistant/runtime_inbound.go inbound_queue.go inbound_gap.go inbound_retry.go inbound_backlog_merge.go |
| Gates and rating | model/assistant/runtime.go (routeMessageEvent, routeProactiveReplyBatch) participation_single_score.go direct_reply_merge.go reply_interrupt.go group_stop.go |
| Reply and sending | model/assistant/runtime.go (replyTo, sendDecorated) reply_compression.go runtime_outbound.go group_send_guard.go outbound_idempotency.go |
| Agent | model/agent/runner.go finalize.go deferred_tools.go claim_evidence.go; model/assistant/agent_scope.go |
| Browsers | model/browsersource/ model/browserbox/ model/browserctl/ model/agent/headless_browser*.go browser_tools.go html_capture.go |
| Pre-send review | model/assistant/proactive_reply_quality.go reply_semantic_gate.go private_closing.go reply_suppression.go outbound_forward_gate.go |
| Outcome records | model/storage/inbound_queue.go event_audit.go |