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

8.2 KiB

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.

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.