Skip to content

fix(agent): persist inbound user message to state.db on receipt - #48578

Closed
Kewe63 wants to merge 1 commit into
NousResearch:mainfrom
Kewe63:fix/45110-prompt-persist-on-receipt
Closed

Kewe63 wants to merge 1 commit into
NousResearch:mainfrom
Kewe63:fix/45110-prompt-persist-on-receipt

Conversation

@Kewe63

@Kewe63 Kewe63 commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Summary

When the first assistant response never completes — a stall, a streaming client
disconnect on a gateway turn, or a process crash mid-generation — nothing from
the turn reaches state.db. The user prompt vanishes as if never asked, and
follow-up turns hit an agent with a hole in its own history. session_search
then fills the gap by guessing from older sessions, and the model confidently
conflates prior context (#45110).

Two observed failure modes:

  1. Silent data loss on mobile — ask a question, background the browser
    while the agent is thinking, return: no answer, and no record the question
    was ever asked.
  2. Confabulation in later turns — the model fills the missing turn from
    older sessions, producing wrong answers downstream.

The _persist_session call in the prologue already covers the user message via
identity tracking, but the _flush_messages_to_session_db path it flows
through runs _apply_persist_user_message_override and
_drop_trailing_empty_response_scaffolding first, both of which can mutate or
silently no-op the message. The fix adds a focused, explicit persist that runs
immediately after the user message is appended to the live messages list —
before any model call — and writes only the user message directly to SQLite.


Changes

  • run_agent.py (L1578-1641) — new AIAgent._persist_inbound_user_message(user_msg):
    • Writes the user message to state.db via self._session_db.append_message.
    • Mirrors the existing _flush_messages_to_session_db guard pattern
      (if not self._session_db: return).
    • Transforms multimodal content: base64 image parts become [screenshot]
      placeholders to avoid bloating the session DB.
    • Silent logger.debug() on exception — the existing _persist_session
      call remains the production-grade safety net.
  • agent/turn_context.py (L226-243) — calls the new method right after
    messages.append(user_msg), wrapped in try/except so an audit-log bug
    can never break the agent loop. The existing _persist_session call stays
    in place as belt-and-suspenders.
  • tests/agent/test_turn_context.py (L188-337) — 5 new regression tests:
    • test_persist_inbound_user_message_writes_user_row_on_receipt — headline
      test; drives build_turn_context end-to-end and asserts the user message
      landed in SessionDB before any model call.
    • test_persist_inbound_user_message_handles_no_db_session_db is None
      is a no-op.
    • test_persist_inbound_user_message_persists_only_user_not_assistant
      scope check; targeted call never writes assistant/tool/system messages.
    • test_persist_inbound_user_message_skips_non_user_dicts — defensive
      guard against accidental misuse.
    • test_persist_inbound_user_message_strips_multimodal_to_text_summary
      multimodal content lands as "what is in this image?\n[screenshot]",
      not a base64 blob.

How to Test

# New tests
pytest tests/agent/test_turn_context.py -v
# ✅ 11/11 passed (6 existing + 5 new)

# Regression filter
pytest tests/run_agent/test_run_agent.py \
  -k "persist_user_message or append_message or persist_session or state.db"
# ✅ 3/3 passed

# Lint
ruff check agent/turn_context.py run_agent.py
# ✅ All checks passed

Checklist

  • Tests pass — 11/11 in test_turn_context.py, 3/3 regression filter
  • ruff check — PASS, 0 warnings
  • Follows Conventional Commits
  • Changes scoped to this fix only — 3 files

Risk & Impact

Low. Additive — the existing _persist_session crash-resilience call is
unchanged. The new _persist_inbound_user_message method is a focused SQLite
write that runs once per turn and silently no-ops if _session_db is not
configured. The try/except wrapper in turn_context.py guarantees the agent
loop is never broken by an audit-log bug. Public method signatures are unchanged.

Type: 🐛 Bug fix (data-loss prevention)
Closes: #45110

…Research#45110)

When the first assistant response never completes — a stall, a
streaming client disconnect on a gateway turn, or a process crash
mid-generation — nothing from the turn reaches state.db. The user
prompt vanishes as if it were never asked, and follow-up turns
hit an agent with a hole in its own history. session_search then
fills the gap by guessing from older sessions, and the model
confidently conflates prior context (NousResearch#45110).

The crash-resilience ``_persist_session`` call in the prologue
already covers the user message via identity tracking, but the
``_flush_messages_to_session_db`` path it flows through runs
``_apply_persist_user_message_override`` and ``_drop_trailing_empty_response_scaffolding``
first, both of which can mutate the message or no-op silently. The
fix adds a focused, explicit persist that runs immediately after
the user message is appended to the live ``messages`` list — BEFORE
any model call — and writes ONLY the user message directly to
SQLite.

Fix:

- ``AIAgent._persist_inbound_user_message(user_msg)`` (new
  method) writes the user message to state.db via
  ``self._session_db.append_message``. Mirrors the existing
  ``_flush_messages_to_session_db`` guard pattern (``if not
  self._session_db: return``) and the multimodal-text-summary
  transformation (base64 image parts become ``[screenshot]``
  placeholders; full base64 would bloat the session DB and isn't
  useful for cross-session replay). Silent ``logger.debug()`` on
  exception — the existing ``_persist_session`` crash-resilience
  call later in the prologue is the production-grade safety net.

- ``agent/turn_context.py`` calls the new method right after
  ``messages.append(user_msg)``, wrapped in ``try/except`` so an
  audit-log bug can never break the agent loop. The existing
  ``_persist_session`` call stays in place as belt-and-suspenders.

Five new regression tests in ``tests/agent/test_turn_context.py``:

- ``test_persist_inbound_user_message_writes_user_row_on_receipt``:
  after ``build_turn_context`` returns, the in-memory SessionDB
  recorded the user message — exactly the contract the streaming
  / stalled-first-response failure mode violates without the fix.
- ``test_persist_inbound_user_message_handles_no_db``:
  ``_session_db is None`` is a no-op (test stubs, ephemeral flows).
- ``test_persist_inbound_user_message_persists_only_user_not_assistant``:
  the targeted call never writes assistant / tool / system messages
  — that's the full ``_persist_session`` job.
- ``test_persist_inbound_user_message_skips_non_user_dicts``:
  defensive guard against accidental misuse.
- ``test_persist_inbound_user_message_strips_multimodal_to_text_summary``:
  multimodal user content (text + image_url parts) lands in
  state.db as ``"what is in this image?\n[screenshot]"``, not as a
  base64 blob.

11/11 tests pass in ``test_turn_context.py``; the existing
crash-resilience ``_persist_session`` call is unchanged and still
fires once per turn.

Closes NousResearch#45110
@alt-glitch alt-glitch added type/bug Something isn't working comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P1 High — major feature broken, no workaround labels Jun 18, 2026
@alt-glitch

Copy link
Copy Markdown
Contributor

Related: #45110 (the issue this addresses — prompt lost when a turn's first response never completes), #45887 (harden early-turn persistence to survive session-DB failures, #45110), #46471 (gateway persists transcript itself), #43853 (backfill missing assistant transcript rows).

Part of the SessionDB persistence family (merged anchor #45260 handles the flush-cursor / identity-tracking facet). This PR's mechanism is distinct: an explicit persist of the inbound user message immediately on receipt, before _apply_persist_user_message_override / _drop_trailing_empty_response_scaffolding can mutate or no-op it — so it complements rather than duplicates the open competitors. Verified on main that no such early explicit persist exists yet.

@teknium1 teknium1 added sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit labels Jun 21, 2026
@teknium1

Copy link
Copy Markdown
Collaborator

Thanks @Kewe63 — and apologies for the redundancy here.

The core of #45110 is already fixed on current main: the inbound user message is persisted to state.db on receipt, before any model call, in agent/turn_context.py via _persist_session(...)_flush_messages_to_session_db (identity-tracked append_message). A stall or client disconnect during the first response can't erase it.

I verified the disconnect-before-response scenario end-to-end against a real SessionDB — user message → no assistant response → prompt survives in state.db. The data loss no longer reproduces.

On the PR's premise: _apply_persist_user_message_override and _drop_trailing_empty_response_scaffolding operate on the message tail (trailing empty-response scaffolding / a persist override), not the user row that was just appended — and identity tracking in _flush_messages_to_session_db already protects that row from being skipped. So the focused direct-persist this PR adds duplicates a write that already happens.

Closing as implemented-on-main. Thanks for the contribution.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint P1 High — major feature broken, no workaround sweeper:blast-broad Sweeper blast radius: broad — a core path most sessions hit sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-session-state Sweeper risk: may lose/corrupt/mis-associate session or context state type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants