feat(rag): pass chat history with prior thinking to the LLM
Phase 74 (TODO.md L4): a follow-up question now reaches the model WITH the conversation so far — every prior user/brain turn and the prior thinking blocks on brain turns (preserve-thinking) — while POST /api/chat stays stateless (A10): the client provides the history in the request body and the server stores nothing new. Server (task 01): - ChatRequest.history: optional list[HistoryTurn] (who: user|brain, text, optional thinking) — absent/empty keeps the request byte-identical to pre-phase-74 (the two-message [system, user] request; the kill-switch semantics are pinned in the integration suite). - app.rag.prompts.history_to_messages: pure mapper — walks the turns newest-first against the settings budgets (history_max_turns=40 / history_max_chars=24000, BOR_HISTORY_MAX_TURNS / BOR_HISTORY_MAX_CHARS); a capped turn is dropped WHOLE (never cut mid-answer); the kept window is returned oldest-first; brain turns carry their thinking as reasoning_content (A4) only when non-empty. - Both branches feed it: the deflected path splices it between the system prompt and the current user message (the phase-71 recovery still rebuilds from messages[1:]), the grounded agent receives run_agent(..., history=hist); llm.py's message params widen to list[dict[str, Any]] (string-only messages stay byte-identical on the wire — the SDK passes message dicts through verbatim). - The per-turn log line (PLAN §9) gains history_msgs=N after kb_chars=N. - Pins: tests/unit/test_history.py (mapper: mapping, reasoning gating, both budgets, drop-whole, ordering, empty default), tests/unit/test_config.py (the two settings + env overrides), tests/unit/test_agent.py (the history splice + the default), tests/integration/test_chat_api.py (deflected AND grounded forward the history incl. reasoning_content, no-history byte-identity, 422 pins, the log field). Client (task 02): - runTurn — the single funnel for fresh send / phase-49 retry / phase-53 stale-regen — sends history = the conversation record minus the current question, with thinking only on brain records that streamed one (undefined drops the key from the JSON, the record's convention); the question is never duplicated into the history. Wire proof (task 03): - The mock's echo my history marker (HISTORY_TRIGGER) answers with the deterministic history echo — history: N prior messages; last answer tail: <last 24 chars>; thinking: yes|no — checked BEFORE the DEFLECT_MODE branch (like TABLE_TRIGGER), so it fires on both turn branches whatever the gate says; the module docstring records the user/assistant-only history invariant that keeps every existing (tool-result-classified) marker flow unaffected. - tests/e2e/test_llm_history.py (isolated): a grounded follow-up and a deflected follow-up both receive history: 2 prior messages + thinking: yes + the byte-exact tail of turn 1's answer (derived from the persisted bor.chat.v1 record — the same array the client maps into the body); a cold start receives history: 0 prior messages / last answer tail: none / thinking: no. - Regressions green in isolation: chat_rag, chat_history (phase 50), agent_document_tools, harness_aligned_tools, stop_generation, retry_answer, response_to_docs.
This commit is contained in:
@@ -228,6 +228,30 @@ Implements just enough of the aipi surface:
|
||||
``SUMMARY_MODE``), so a marker question always gets the table
|
||||
answer; the E2E asks it against an on-topic fixture (HIGH gate) and
|
||||
asserts non-deflection.
|
||||
- user message containing ``echo my history``
|
||||
(``HISTORY_TRIGGER``, phase 74, TODO L4 — chat history with prior
|
||||
thinking reaches the LLM) -> the deterministic HISTORY ECHO, derived
|
||||
statelessly from the request messages and byte-stable:
|
||||
``history: N prior messages; last answer tail: <tail>; thinking:
|
||||
yes|no (Deterministic mock answer for E2E.)`` where N = the count
|
||||
of non-``system`` messages before the LAST ``user`` message
|
||||
(everything the client sent as prior turns — the current question
|
||||
itself is excluded), <tail> = the LAST 24 chars of the most recent
|
||||
prior ``assistant`` message's content (``none`` when there is no
|
||||
prior assistant message), and thinking is ``yes`` iff that prior
|
||||
``assistant`` message carries a non-empty ``reasoning_content``
|
||||
field (the client's phase-74 history mapping of the brain record's
|
||||
``thinking`` — A4). Checked BEFORE the ``DEFLECT_MODE`` branch
|
||||
(like ``TABLE_TRIGGER`` — the marker lives in the user message, a
|
||||
deflection prompt never carries it), so a marker question always
|
||||
gets the echo whatever the honesty gate says; the story E2E
|
||||
(``tests/e2e/test_llm_history.py``) asserts the wire contents
|
||||
byte-exactly against the conversation record the client persisted.
|
||||
Invariant the marker relies on: the client history contains ONLY
|
||||
``user``/``assistant`` messages — never ``tool``-role ones (the
|
||||
client never sends tool calls/results) — so every existing marker
|
||||
flow (which classifies statelessly from TOOL results and the LAST
|
||||
user message) is unaffected by the now-always-present history.
|
||||
|
||||
Failure injection (phase 67, LLM retry, TODO.md L3) — deterministic
|
||||
dead-endpoint behavior for the retry E2E suite (``tests/e2e/
|
||||
@@ -415,6 +439,15 @@ TABLE_TRIGGER = "show me a table"
|
||||
#: E2E asserts the rendered table shape, the escaped ``<img onerror>``
|
||||
#: line (the XSS payload must survive the mock byte-for-byte), and the
|
||||
#: wide table's ``scrollWidth > clientWidth`` inside the 46rem column.
|
||||
#: Phase 74 (chat history, TODO L4): a user message containing this
|
||||
#: substring (case-insensitive) gets the deterministic HISTORY ECHO
|
||||
#: (``_history_echo`` below — see the module docstring): the prior-turn
|
||||
#: count, the last 24 chars of the most recent prior answer, and
|
||||
#: whether that prior answer carried ``reasoning_content``. Verified
|
||||
#: 2026-09-08: no existing E2E question or fixture file contains the
|
||||
#: phrase, so every other suite is unaffected.
|
||||
HISTORY_TRIGGER = "echo my history"
|
||||
|
||||
TABLE_ANSWER = (
|
||||
"Here's the shape, in a table:\n"
|
||||
"\n"
|
||||
@@ -1011,6 +1044,50 @@ def first_kb_bullet(system: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _history_echo(body: dict[str, Any]) -> str:
|
||||
"""The phase-74 history echo (byte-stable, stateless over messages).
|
||||
|
||||
``history: N prior messages`` — N = the count of non-``system``
|
||||
messages before the LAST ``user`` message (the client's phase-74
|
||||
``history`` block: the prior turns only, the current question
|
||||
itself excluded). ``last answer tail: <tail>`` — the LAST 24 chars
|
||||
of the most recent prior ``assistant`` message's content, or
|
||||
``none`` when there is no prior assistant message (the cold-start
|
||||
pin: no phantom history). ``thinking: yes|no`` — ``yes`` iff that
|
||||
prior assistant message carries a non-empty ``reasoning_content``
|
||||
field (A4: the client's prior thinking, mapped by
|
||||
``app.rag.prompts.history_to_messages``), ``no`` otherwise.
|
||||
|
||||
The invariant (see the module docstring): the client history is
|
||||
``user``/``assistant``-only, so the last ``user`` message is always
|
||||
the current question and every earlier non-system message is a
|
||||
client-provided prior turn.
|
||||
"""
|
||||
msgs = _messages(body)
|
||||
last_user = max(
|
||||
(i for i, m in enumerate(msgs) if m.get("role") == "user"),
|
||||
default=-1,
|
||||
)
|
||||
prior = [
|
||||
m
|
||||
for i, m in enumerate(msgs)
|
||||
if i < last_user and m.get("role") != "system"
|
||||
]
|
||||
tail = "none"
|
||||
thinking = "no"
|
||||
for m in reversed(prior):
|
||||
if m.get("role") == "assistant":
|
||||
tail = str(m.get("content") or "")[-24:]
|
||||
thinking = "yes" if str(m.get("reasoning_content") or "") else "no"
|
||||
break
|
||||
return (
|
||||
f"history: {len(prior)} prior messages; "
|
||||
f"last answer tail: {tail}; "
|
||||
f"thinking: {thinking} "
|
||||
"(Deterministic mock answer for E2E.)"
|
||||
)
|
||||
|
||||
|
||||
def compose_answer(body: dict[str, Any]) -> str:
|
||||
system = _system(body)
|
||||
user = _user(body)
|
||||
@@ -1053,6 +1130,15 @@ def compose_answer(body: dict[str, Any]) -> str:
|
||||
# against an on-topic fixture, where the gate is HIGH, and
|
||||
# asserts non-deflection as part of the table test.
|
||||
answer = TABLE_ANSWER
|
||||
elif HISTORY_TRIGGER in user.lower():
|
||||
# Phase 74 (TODO L4, chat history): the deterministic history
|
||||
# echo — proves on the wire that the client's prior turns (and
|
||||
# the prior thinking, as ``reasoning_content`` on the assistant
|
||||
# messages) reached the model. Checked BEFORE the DEFLECT_MODE
|
||||
# branch, like TABLE_TRIGGER: the marker lives in the user
|
||||
# message, a deflection prompt never carries it, so a marker
|
||||
# question always gets the echo whatever the gate says.
|
||||
answer = _history_echo(body)
|
||||
elif "DEFLECT_MODE" in system:
|
||||
answer = (
|
||||
"Ah — I haven't done anything like that, so I don't want to make stuff up! "
|
||||
|
||||
Reference in New Issue
Block a user