The headline: an unsupported character now degrades to a missing glyph,
never to silence. The reply validator is still a fail-closed whitelist —
that is a token-leak defence and it stays — but "reject" used to mean "drop
the whole reply", and a whitelist narrow enough to catch a chat template is
also narrow enough to catch ok ❤️ sure. Measured against the old
validator, ordinary replies with emoji, curly quotes, an ellipsis or a
katakana word produced "": the user saw nothing on the turn they cared
about.
Added
- A three-tier reply character policy: STRIP / MAP / ALLOW. Emoji,
variation selectors, ZWJ sequences and decorative symbol blocks are
stripped (the reply survives minus the glyph); curly quotes, dashes and
no-break spaces are mapped to their ASCII spelling; and named letter
ranges join the whitelist itself, each with a written reason. A code point
named in no tier still drops the reply — adding a script stays a
deliberate act. The policy ships with its own suite
(tests/test_textproc.py): a leak corpus that must stay silenced under
the widest style a persona can express, plus a test-of-the-test that
fails if the corpus could no longer detect an over-broad widening. - Six more scripts on the default path. Kana, Hangul, Cyrillic, Greek,
Arabic and Latin-with-diacritics are how languages are spelled, not
registers a persona opts into:café later,нет проблемandなるほど
are content now. Thai, Hebrew, Devanagari and friends still fail closed
until someone names them. - Per-persona character opt-ins (
ReplyStyle), with a card to carry
them. A new optionalPERSONA_CARD_FILE(defaultpersona.card.json)
may declare{"reply_style": {"emoji": true, "charsets": ["music"], "max_chars": 320}}. Optional charsets are ellipsis, music and arrows —
registers, not languages. Every malformed value fails toward the narrow
default, and the arrows opt-in buys narration (s1 → s2), not a frame:
an arrow hugging a bare token (←persona→) is rejected by shape, so the
opt-in cannot be used to smuggle a template past the whitelist. - A persona
[style]declaration block. A persona document may end
with a[style]block declaring six register knobs (length,vent,
recs,good_news,particles,fatigue). The block is parsed against
a single knob table and stripped from the prose, so raw configuration
never reaches the model as persona text; prose-shaped lines, unclosed or
repeated blocks and orphan closers all resolve toward keeping the
persona's sentences. current_tz_offset_h— a per-turn timezone contextvar for gateway
embedders whose users are not all in the deployment'sTZ_OFFSET_HOURS.
Changed
- The per-turn reply ceiling rose from 500 to 800 characters, and
truncation got a visible seam. The ceiling is also the per-turn
exfiltration bound, so it moved deliberately: the widest length band's
English reading did not fit under 500, which turned the band into a
truncation machine. A cut reply now ends in a visible...rather than
pretending it was whole. - The trusted trailer, and honesty about being an AI. The system prompt
now ends with<trusted_directives>— application-authored text a persona
document cannot displace — and the persona sits in an unforgeable
<persona>region above it. The directives carry the safety exceptions
and an affirmative honesty clause; the engine no longer instructs any
persona to deny being an AI, in either chat path. - Gateway transport hardening. The gateway-conversation LRU warns once
instead of per message, skips evicting a conversation whose lock is
currently held, and releases waiters on eviction; private DM history is
capped instead of growing without bound.
Fixed
- Pacing survives CRLF, and no bubble is a wall.
\r\nbreaks are
honored by the splitter instead of leaking\rinto bubbles; a run of
punctuation can no longer produce a zero-length bubble, and discarding an
all-whitespace chunk no longer discards the hard break it carried. - Emoji modifiers no longer drop the whole reply. U+FE0F, U+200D,
keycaps, flags and skin-tone modifiers survived the old emoji strip,
reached the whitelist, and silenced the turn.
The engine work below was ported back from the maintainer's private fork of
this engine; the entries above are that sync. What follows was already on
main awaiting release.
Fixed
-
The benchmark's blind judging is now actually blind — and actually judges.
Four measurement defects, found by running the thing: the "blind" inbox spelled
the arm out in everyitem_id; a run the judge scored 5-across-the-board (zero
variance) was plotted as a tidy curve instead of being refused; the model's
PASSsentinel was graded as if someone had typed the word (polluting both
the learning material and the judged sample); and a reply-only judge rated a
drafted apology letter 5/5 "like a friend offering a script" because without
the chat context, over-formality is invisible. Item ids are opaque digests
now;ingestnames void runs (zero variance, silent-rate imbalance,
no-feedback on-arm,--style fullceilings) and exits 2; PASS collapses to
silence and silence is counted per arm instead of judged; the judge sees the
scenario context (identical for both arms) and rates against the persona
register, not mere human-plausibility. -
An empty reply with
finish_reason=lengthis retried once at 4x the
budget. A reasoning model can spend the whole token budget on hidden
chain-of-thought and emit nothing visible; every turn came back empty with
only a terse warning. The retry recovers the turn and the log now names the
likely cause (model choice) and the fix. -
The self-evaluator scores register, not "quality" — and the learning
trigger moved to match. Measured three times: a "Here you go: [drafted
apology]" reply got 4/5 ("slightly formal") from the quality-framed prompt,
a bolted-on "blatant tells cap at 2" anchor was talked around ("AI-like,
though not blatant" -> 4), and the same model that rated the same letter
5/5 as a quality-evaluator rated it 3 as a register-judge -- the frame, not
the model, was the problem. The eval prompt now defines the persona register
and scores against it (5 = the register, 3 = drifting into
helpful-assistant, 1 = broke character). Validated on 8 known-label
replies: every known tell scored exactly 3, every casual line 5. Because 3
now means "assistant drift",EVOLVE_THRESHOLDdefaults to 3 -- with the
old default of 2 the loop would still collect nothing. -
Thinking-mode models no longer silently skip the JSON reply protocol.
Measured on the real reply path: with thinking on, the model treats its
hidden reasoning channel as having satisfied the protocol'sreasoning
field and emits only the bare chat line -- 9 of 17 @-directed turns were
dropped whole by the fail-closed parser. JSON-protocol call sites now send
response_format={"type":"json_object"}(0 drops in 52 measured turns and
a 10-turn live check), and the budget-starvation retry also fires on a
truncated-but-non-empty JSON, which used to vanish without even a length
warning. Bare text is still never accepted by the parser: the protocol
boundary stays fail-closed. -
Token budgets raised for reasoning models, and
disable_thinkingis now
real. Hidden thinking tokens bill againstmax_tokens, so the old
budgets starved: the reply path truncated about 1 turn in 10, and the
web-search decision at 150 tokens could not even fit its tool call -- with
thinking on that endpoint rarely emits tool calls at any budget, so the
search gate now disables thinking outright. Reply 1200->3000, gate
600->1500, search decision 150->800, evolve draft 600->2000, self-eval
800->1500, reaction adjudication 400->1000, sticker tagging 200->600 and
40->300 (thinking off), default cap 2048->4096. Thedisable_thinking
parameter, previously documented as ignored, now maps to the endpoint's
thinking switch.
Added
- Six assistant-bait scenario families (
rec-request,tech-help,
explain-bait,decision-bait,task-bait,plan-bait, 18 train + 12
holdout). The original families are all easy social chatter; none exercised
the style rules the loop is supposed to re-derive. Each new family baits the
weak-styled model into a register the persona forbids. tools/scenario_probe.py— calibrates candidate scenarios against the
real model before they earn a place in the benchmark: reports each
scenario's self-eval and blind-judge score so curation is evidence, not
intuition.--judge openai— routes blind judging to any OpenAI-compatible
endpoint (BENCH_JUDGE_BASE_URL/BENCH_JUDGE_API_KEY, falling back to
theDEEPSEEK_*vars). A failed judge call is dropped, never backfilled
with a neutral 3 — a fabricated middle score manufactures the "no
difference" verdict the benchmark exists to test for. Theanthropic
backend now behaves the same way.
Recording something and being changed by it are now separate acts. A reaction is
evidence. An adjudication creates a candidate. Promotion grants a
candidate authority over future replies. Rollback or supersession takes that
authority away without erasing the history.
Changed
- No automatic signal writes a retrieval pool any more. An accepted
correction, an accepted retry and a positive reaction previously landed in
runtime/feedback.<lang>.jsonlorruntime/examples.<lang>.jsonl— the
correction and retry paths on the strength of one signal each. All four
automatic channels (reaction correction, reaction rejection, retry-completion,
self-eval) now record immutable evidence and propose a versioned candidate.
Only a promoted candidate reaches few-shot retrieval. EVOLVE_AUTOproposes instead of applying. The unattended loop still
diagnoses its own low-scoring replies and drafts a BAD → OK rewrite, but a
self-diagnosis is one automatic signal that nobody witnessed: it now waits for
a real user event to corroborate it, or fortools/candidates_admin.py.- Promotion requires corroboration. At least two distinct compatible events,
at least one of them strong — an explicit correction from the person the reply
was aimed at, or a retry that person then accepted. Evidence combines only
within one persona, persona version, language, conversation and mode. Weak
evidence (laughter, banter, the agent's own score) never promotes anything at
any quantity, so positive examples are now promoted by a human, not by the
loop. Contradictory evidence blocks automatic promotion and leaves the
candidates for review. Owner status no longer substitutes for being the
affected recipient. - Retrieval reads promoted candidates from their own view files
(runtime/promoted.{examples,feedback}.<lang>.jsonl), rebuilt atomically from
the ledger and fully derivable from it. The learned pools and thedata/
seeds are no longer written by the agent at all, so a rollback or a rebuild
can never disturb a row you approved yourself. EXAMPLES_MAX_AUTO/FEEDBACK_MAX_AUTOnow size the promoted views (the
offline tools still apply them to what they write). Same names, same reason:
material promoted under an older prompt should not outvote recent material.
Added
persona_agent/evidence.py— append-only, content-addressed evidence log.
Every directed reaction, correction, rejection, retry result and positive
response is recorded with its scope (language, platform, conversation,
persona and persona hash), speaker and recipient, the reply and its context,
the reaction text and how it was directed, the structured verdict, the
adjudicator model and prompt version, and a parent link for retries and
elicited corrections. Chain of thought is never stored — only the verdict
and the one-sentence reason. Duplicate events are idempotent.persona_agent/candidates.py— versioned candidates (preference_pair/
positive_example) and the append-only ledger that owns their lifecycle
(proposed→promoted→rolled_back/superseded, plusrejected).
Current state is a replay projection, so a restart cannot disagree with the
process that wrote it.tools/candidates_admin.py— list pending candidates, show one with the
evidence behind it, promote, reject, roll back, supersede, and rebuild the
retrieval views. Every action appends a lifecycle event; nothing is edited or
deleted, and the running agent picks the change up on its next turn.- Promotion policy configuration with conservative defaults:
PROMOTE_AUTO,
PROMOTE_MIN_EVENTS,PROMOTE_MIN_STRONG,PROMOTE_EVIDENCE_MAX_AGE_DAYS,
PROMOTE_REQUIRE_SAME_CONVERSATION, andPERSONA_VERSION. tests/test_ledger.py(102 checks) covering the fourteen behaviours that
matter: one positive promotes nothing, repeated weak engagement promotes
nothing, one correction proposes without promoting, two compatible events
including a strong one promote, incompatible scopes never combine,
contradictions block promotion, an accepted retry corroborates, duplicates are
idempotent, replay reproduces state exactly, rollback removes a preference
from retrieval, supersession replaces the active one, both logs stay
append-only, legacy manual feedback still loads, and no test touches real
runtime state.
Compatibility
- Hand-written
data/seeds are untouched and still read-only. - Rows you approved through
prompt_lab.pystay trusted and are still
retrieved. - Pre-ledger automatic rows are left exactly as they are — not deleted, not
reclassified, not migrated into the ledger, and still retrieved. They are
still retractable: an accepted rejection or correction removes the matching
row, because deletion is the only revocation the pre-ledger design had.
promotion.CandidatePoolandpromotion.retract_exampleremain public. tools/auto_reviewer.py --yesis refused. Unattended direct writes cannot
stand in for a human decision; use interactive--applyor promote a
candidate explicitly with the admin CLI.tools/evolution_benchmark.pystubs the human gate (actor="benchmark"in
the ledger) so the arm still measures something. No new benchmark numbers are
claimed for this change.
Naming and dependencies
The agent talks to one thing: the provider's OpenAI-compatible
/v1/chat/completions endpoint, over plain httpx. Several names still claimed
otherwise, and one dependency was installed for a vendor SDK the bot never
imports.
ANTHROPIC_PRIVATE_MODELis nowPRIVATE_MODEL. It was only ever an
alternate model name on the primary endpoint — no Anthropic endpoint was
involved. The old name is still read as a fallback, so existing.envfiles
keep working; greppre-0.1.2to find every shim when dropping them.- Internals renamed to match what they do:
_call_anthropic→_call_llm,
anthropic_caller→llm_caller,check_anthropic_chat→check_private_chat.
Stale comments about the Anthropic SDK, its exception shape and its prompt
caching were corrected to describe the httpx/OpenAI-compatible path actually
in use. anthropicis no longer a runtime dependency. Nothing under
persona_agent/imports it. It is now the optional[judge]extra, needed
only bytools/prompt_lab.pyandevolution_benchmark.py --judge anthropic,
which both fail with an install hint instead of a traceback. Install with
pip install -e ".[judge]".start.sh/start.ps1no longer probe foranthropic. The preflight
import gates the "installing dependencies…" reinstall, so a complete
environment without the unused SDK triggered a pointlesspip installon
every launch. It now checksPILandddgs, which the bot does use.