Files
discord_bot/specs/SPEC-001-responder-envelope.md

186 lines
8.2 KiB
Markdown

# SPEC-001 — Responder + response envelope
Characterization of the current (v2) responder contract in
`fjerkroa_bot/ai_responder.py`. The model answers with a JSON
envelope: `answer`, `answer_needed`, `channel`, `staff`, `picture`,
`picture_edit`, `hack`. FDB-005 will replace the transport of this
contract (structured outputs); the *behavioral* requirements below
survive that change unless marked otherwise.
### ENV-01 — Model answer reaches the user (coverage: feature)
When the model envelope carries a non-empty `answer` and
`answer_needed` is true, `AIResponder.send()` returns an `AIResponse`
with that answer text and `answer_needed == True`. This is the core
loop: user talks, bot answers.
### ENV-02 — Suppressed answer stays silent (coverage: feature)
When the model envelope sets `answer_needed` to false (and the
message is not direct, mentions nobody, and no staff note is set),
the returned `AIResponse` has `answer_needed == False`. The bot may
observe without butting in.
### ENV-03 — Staff note forces delivery (coverage: feature)
When the envelope carries a non-null `staff` text and a non-null
`answer`, the returned response preserves the staff text and has
`answer_needed == True`. Staff alerts must never be silently
dropped.
### ENV-04 — Direct messages are always answered (coverage: feature)
When the incoming `AIMessage` is marked `direct` and the model
returns a non-null answer, `answer_needed` is forced to `True`
regardless of the model's own `answer_needed`. A user addressing the
bot directly gets a reply.
### ENV-05 — Short-path rules skip the model (coverage: feature)
When a configured `short-path` `[channel-regex, user-regex]` pair
matches the message, `send()` appends the message to history, trims
history to the limit, persists it, and returns an empty response
(answer `None`, `answer_needed False`) **without calling the model**.
Cheap archival of noisy channels.
### ENV-06 — Malformed model output is repaired (coverage: withdrawn — successor ENV-18)
Withdrawn 2026-07-13 with FDB-005: strict structured outputs make the
repair model obsolete. Malformed output now counts as a failed
attempt — see ENV-18.
### ENV-07 — History is trimmed to the limit (coverage: feature)
After a completed exchange, `len(history) <= history-limit` holds.
Trimming happens both before the model call and after appending the
new question/answer pair.
### ENV-08 — Markdown links are unwrapped (coverage: feature)
In the final answer text, `[label](url)` becomes `url` and
`@[label](url)` becomes `label`. Discord renders raw URLs; markdown
link syntax from the model reads as noise.
### ENV-09 — Missing channel falls back to the message channel (coverage: feature)
When the envelope `channel` is null/none/empty, the response channel
is the channel the message came from.
### ENV-10 — Dynamic context reaches the system prompt (coverage: test)
The system message carries the current date, time, news (when the
configured file exists) and the memory block (legacy string while
structured memory is inactive, see MEM-10). Since FDB-008 these live
in a context suffix, not inline — see ENV-20; legacy `{date}`,
`{time}`, `{news}`, `{memory}` placeholders in operator templates are
stripped.
### ENV-21 — Tool calls disable reasoning effort (coverage: test)
When function tools are attached to a chat call, the call carries
`reasoning_effort` (config `reasoning-effort`, default `"none"`) —
gpt-5.6 models reject tools + reasoning on chat/completions with a
400 otherwise (found live on ggg 2026-07-13: IGDB tools made Luma
mute after the Luna cutover). Tool-less calls stay untouched.
### ENV-20 — Persona prefix is byte-stable (coverage: test)
`message()` renders the system message as: static persona text
(config template with all dynamic placeholders removed) followed by a
`## Context` suffix holding date, time, news and memory. Two calls in
the same channel produce byte-identical persona prefixes — the prompt
cache can actually hit (the old inline `{date}`/`{time}` substitution
invalidated it every minute).
### ENV-11 — Per-channel history shrink prefers busy channels (coverage: test)
`shrink_history_by_one()` removes the oldest entry whose channel has
more than `history-per-channel` (default 3) entries; when no channel
exceeds the cap, the oldest entry overall is removed.
### ENV-12 — Exhausted retries raise, attempts are spaced (coverage: test)
When the model returns no usable answer three times in a row,
`send()` raises `RuntimeError`. Consecutive attempts are separated by
an exponential-backoff sleep — failures never hammer the API
back-to-back (D1).
### ENV-13 — Reaction-clear events are recorded (coverage: test)
`on_reaction_clear(message, reactions)` — the discord.py signature —
records the clearing in the channel's memory. (D7: the previous
handler declared `(reaction, user)` and crashed on dispatch.)
### ENV-14 — update_memory persists its argument (coverage: withdrawn — successor SPEC-002)
Withdrawn 2026-07-13 with FDB-007: the per-answer memory rewrite
(`update_memory`/`memoize`) is deleted; structured memory (MEM-01+)
replaces it. The D12 defect died with the code.
### ENV-15 — retry-model is used after a rate limit (coverage: test)
After a rate-limited attempt, the next `chat()` attempt uses the
configured `retry-model` instead of `model`; a successful attempt
switches back. (D2: the fallback was assigned to a local and never
took effect.)
### ENV-16 — Model calls are never served from a disk cache (coverage: test)
`openai_chat`/`openai_image` call the client every time. The pickle
response cache (`openai_chat.dat`) is a test-era artifact and must
not exist in the production path (D3/D4).
### ENV-17 — IGDB tool execution runs in a worker thread (coverage: test)
`_execute_igdb_function` executes the synchronous IGDB library off
the event loop (worker thread), returning identical results. The
event loop keeps serving Discord events during lookups (D5).
### ENV-18 — Malformed or refused output is a failed attempt (coverage: test)
Model output is requested as schema-validated JSON (strict structured
outputs). Output that still fails to parse, or a model refusal,
counts as a failed attempt (backoff + retry per ENV-12) — there is no
repair model, no `fix()` path, no relaxed-JSON fallback. Replaces
ENV-06.
### ENV-19 — The envelope schema is pinned (coverage: test)
Every chat call carries `response_format` = strict JSON schema named
`envelope` with exactly the fields `answer`, `answer_needed`,
`channel`, `staff`, `picture`, `picture_count` (since FDB-009,
IMG-02), `picture_edit`, `hack` — all required,
`additionalProperties: false`, nullable where the protocol allows
null. Tool-followup calls carry the same format.
### ENV-22 — Responses API path behind a flag (coverage: test)
With `use-responses-api = true`, responder chat calls go to
`/v1/responses` instead of chat/completions: same model selection
(default / vision / factual / retry), the same strict envelope schema
(as `text.format`), tools in the flat Responses shape, and
`reasoning` = config `reasoning-effort` — tools + reasoning are
allowed here (the chat/completions 400 from ENV-21 does not apply).
Flag off (default) = the ENV-21 path, byte-identical behavior.
Classifier, consolidation and task-proposal calls stay on
chat/completions.
### ENV-23 — Responses tool loop is stateless and keeps reasoning (coverage: test)
The Responses path runs with `store=false` and
`include=["reasoning.encrypted_content"]` (nothing retained
server-side). On a function call, the reasoning and function_call
output items are passed back as input — reduced to their input-shape
fields, since response-only fields like `status` are rejected as
unknown parameters (live 400, 2026-07-17) — together with one
`function_call_output` per call (matched by `call_id`, result
sanitized per SAF-03), so the model continues one chain of thought
across tool rounds. Up to `responses-tool-rounds` (default 4) rounds
may call tools; an exhausted loop forces a final tool-less answer.
### ENV-24 — Responses refusals are failed attempts (coverage: test)
A refusal content part in the Responses output yields no answer
(backoff + retry per ENV-12/ENV-18), exactly like the
chat/completions path.