Skip to content

v0.2.0 — a reply loses a glyph, never its voice

Latest

Choose a tag to compare

@wangkant wangkant released this 11 Aug 06:35
· 3 commits to master since this release

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 optional PERSONA_CARD_FILE (default persona.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's TZ_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\n breaks are
    honored by the splitter instead of leaking \r into 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 every item_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
    PASS sentinel 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; ingest names void runs (zero variance, silent-rate imbalance,
    no-feedback on-arm, --style full ceilings) 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=length is 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_THRESHOLD defaults 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's reasoning
    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_thinking is now
    real.
    Hidden thinking tokens bill against max_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. The disable_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
    the DEEPSEEK_* 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. The anthropic
    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>.jsonl or runtime/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_AUTO proposes 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 for tools/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 the data/
    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_AUTO now 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
    (proposedpromotedrolled_back / superseded, plus rejected).
    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, and PERSONA_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.py stay 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.CandidatePool and promotion.retract_example remain public.
  • tools/auto_reviewer.py --yes is refused. Unattended direct writes cannot
    stand in for a human decision; use interactive --apply or promote a
    candidate explicitly with the admin CLI.
  • tools/evolution_benchmark.py stubs 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_MODEL is now PRIVATE_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 .env files
    keep working; grep pre-0.1.2 to find every shim when dropping them.
  • Internals renamed to match what they do: _call_anthropic_call_llm,
    anthropic_callerllm_caller, check_anthropic_chatcheck_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.
  • anthropic is no longer a runtime dependency. Nothing under
    persona_agent/ imports it. It is now the optional [judge] extra, needed
    only by tools/prompt_lab.py and evolution_benchmark.py --judge anthropic,
    which both fail with an install hint instead of a traceback. Install with
    pip install -e ".[judge]".
  • start.sh / start.ps1 no longer probe for anthropic. The preflight
    import gates the "installing dependencies…" reinstall, so a complete
    environment without the unused SDK triggered a pointless pip install on
    every launch. It now checks PIL and ddgs, which the bot does use.