Skip to content

Releases: wangkant/personagent

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

Choose a tag to compare

@wangkant wangkant released this 11 Aug 06:35

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-e...
Read more