fix(async-context-compression): v1.7.3 — fresh-PostgreSQL summary persist + reasoning-model inlet reuse (#98)#99
fix(async-context-compression): v1.7.3 — fresh-PostgreSQL summary persist + reasoning-model inlet reuse (#98)#99Fu-Jie wants to merge 16 commits into
Conversation
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
…plicateTable Root cause: when an existing chat_summary table is legacy (missing branch-aware columns), _init_database drops it and recreates via SQLAlchemy. On PostgreSQL a leftover index sharing the name SQLAlchemy reuses (e.g. the old unique ix_chat_summary_chat_id) survives and makes CREATE INDEX fail with DuplicateTable, which aborts init and leaves _summary_db_available=False — so every subsequent _save_summary is rejected at the first guard and summaries never persist. Fix: before dropping the legacy table, run DROP INDEX IF EXISTS for all index names the new schema will create. Idempotent and safe on both PostgreSQL and SQLite. Also enrich the init-failure log with exception type and full traceback for future diagnosis.
Root cause (confirmed by user: no orphan index in pg_indexes, but init still fails with DuplicateTable on ix_chat_summary_chat_id): OpenWebUI reuses a single declarative base (owui_Base) across plugin reloads. When the old ChatSummary class (unique index on chat_id) is replaced by the new one (plain index=True), extend_existing=True merges columns but keeps BOTH index definitions alive in metadata — both named ix_chat_summary_chat_id but with different uniqueness. CREATE TABLE then emits two CREATE INDEX statements that collide by name, aborting the whole transaction (table is not created, _summary_db_available stays False). The DROP INDEX IF EXISTS approach from the previous commit does not help because the collision is in-process metadata, not in the database. Fix: before CREATE TABLE, scan ChatSummary.__table__.indexes (and the shared metadata table) for same-name duplicates and discard the stale copies, preferring the non-unique variant to match the current schema. This runs only when the table needs to be created.
Problem: for reasoning models, OpenWebUI regenerates assistant content from
the output array via convert_output_to_messages before the inlet filter
runs, so body content (reasoning stripped) structurally differs from DB
content (folded reasoning in <details type="reasoning">). The existing
folded and unfolded comparison paths in _body_to_db_coverage_map_for_ref_fallback
both fail on this content mismatch, so the snapshot is rejected on every
inlet and the full uncompressed history is sent — even though the outlet
already validated the snapshot against DB refs.
Fix: add Path 3, a position-based fallback that accepts the DB refs by
position when:
- the body carries no OpenWebUI message ids (plain chat)
- count and role sequence match the DB active branch 1:1
- _unfold_db_branch_for_body_ref_fallback produced non-empty output
(rules out conversion failures)
- _body_position_matches_db_message passes, which:
* always requires tool_calls / tool_call_id to match (catches tampered
tool calls)
* requires exact content match for DB messages WITHOUT an output array
(catches user-edited bodies)
* skips content comparison for DB messages WITH an output array
(content was rebuilt by process_messages_with_output)
This keeps the strict content checks for ordinary chats (no output array)
while allowing reasoning-model chats (output array → rebuilt content) to
reuse the saved summary on the inlet.
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
✅ Plugin Version Check / 插件版本检查版本更新检测通过!PR 包含版本变化和更新说明。 Version check passed! PR contains version changes and update description. 版本变化 / Version ChangesNew Plugins
Plugin Updates
This comment was generated automatically. / 此评论由自动生成。 |
There was a problem hiding this comment.
Code Review
此拉取请求将插件更新至 v1.7.3 版本,主要解决了在新部署的 PostgreSQL 数据库上 summary 持久化失败的问题,并引入了基于位置的兜底路径(Path 3)以支持推理模型(Reasoning Models)的 inlet 缓存复用(解决 issue #98)。此外,还增加了端到端验证测试。审查中发现了两个改进点:一是在进行基于位置的比较时,应添加防御性类型检查以确保消息对象为字典,防止潜在的 AttributeError 崩溃;二是在 PostgreSQL 的原始 SQL 中对 owui_schema 变量进行双引号处理,以确保与混大小写 Schema 部署的兼容性。
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| if self._normalize_role(body_message.get("role")) != self._normalize_role( | ||
| db_message.get("role") | ||
| ): |
There was a problem hiding this comment.
To prevent potential AttributeError crashes during position-based comparison, add defensive type checks to ensure both body_message and db_message are dictionaries before calling .get() on them.
| if self._normalize_role(body_message.get("role")) != self._normalize_role( | |
| db_message.get("role") | |
| ): | |
| if not isinstance(body_message, dict) or not isinstance(db_message, dict): | |
| return False | |
| if self._normalize_role(body_message.get("role")) != self._normalize_role( | |
| db_message.get("role") | |
| ): |
| qualified = ( | ||
| f'{owui_schema}."{name}"' if owui_schema else f'"{name}"' | ||
| ) |
There was a problem hiding this comment.
In PostgreSQL, mixed-case schema names must be double-quoted to prevent case-folding issues. Double-quote the owui_schema variable in the raw SQL string to ensure compatibility with mixed-case schema deployments.
| qualified = ( | |
| f'{owui_schema}."{name}"' if owui_schema else f'"{name}"' | |
| ) | |
| qualified = ( | |
| f'"{owui_schema}"."{name}"' if owui_schema else f'"{name}"' | |
| ) |
Bumps version 1.7.2 → 1.7.3 and ships three fixes plus end-to-end verification: - Summary persistence on fresh PostgreSQL: dedup stale unique-index definitions in shared SQLAlchemy metadata before CREATE TABLE, and idempotently clear legacy colliding indexes. Resolves DuplicateTable: relation "ix_chat_summary_chat_id" already exists and the resulting⚠️ Summary generated but was not persisted. - Outlet summary reuse for idless plain-chat branches: when the outlet request carries a chat_id but no stable message refs, read the active DB branch and align the body via _compatible_db_branch_for_body_ref_fallback so the generated summary can be persisted and reused. - Reasoning-model inlet reuse (issue #98): new position-based fallback (Path 3) accepts the snapshot when body and DB branches have equal length and roles / tool_calls / tool_call_id match position-by-position. DB messages with an output array are exempted from content comparison; DB messages without output still require exact content equality (fail-closed against edits). - Path 3 mixed-id fix: process_messages_with_output only strips output (not id), so real reasoning-chat bodies are mixed-id. The all-idless guard was removed — reaching Path 3 already requires _current_branch_refs(messages) is None upstream, so the guard was both wrong and redundant. - Path 3 diagnostic logging: when Path 3 is eligible but a per-position check fails, debug_mode now logs the first failing index and mismatched field (role / tool_calls / tool_call_id / content-on-no-output). - End-to-end verification: test_issue98_e2e.py inlines convert_output_to_messages, process_messages_with_output, and reconcile_tool_pairs copied verbatim from the OpenWebUI main branch. The body builder mirrors the real pipeline exactly (genuinely mixed-id), replaying OpenAI-compatible / Ollama / llama.cpp / tool-call reasoning chats through the full inlet() entry point. 15/15 e2e tests pass. Also adds v1.7.3 release notes (EN + CN), updates README/README_CN, and removes the temporary _diag.py diagnostic script.
2066046 to
e1a082b
Compare
…dy (issue #98) OpenWebUI builds the inlet request body by walking the parentId chain from metadata['user_message_id'] (load_messages_from_db in utils/middleware.py). The filter's _load_full_chat_messages instead walked from history['currentId'], which after a regeneration or edit points at a DIFFERENT branch tip than the one the body was reconstructed from. This caused the DB-rebuilt branch to diverge mid-chain from the actual request body, making the snapshot fail branch-validity checks and silently drop the saved summary. Root cause (diagnosed by @Tuxie in issue #98): - currentId = tip of the currently-displayed branch (may contain an in-progress assistant placeholder, or a regenerated sibling) - user_message_id = the user message that triggered THIS request After regeneration/edit these anchor two different branches; walking from currentId reconstructs a branch that does not match the body's branch. Fix: - _load_full_chat_messages gains an anchor_message_id parameter; when provided (and present in history.messages) it is used as the walk anchor instead of currentId. - _load_applicable_summary_snapshot threads anchor_message_id through. - inlet() extracts __metadata__['user_message_id'] and passes it as the anchor, so the DB reconstruction follows the SAME branch the body came from. - outlet() and non-inlet callers keep the currentId fallback, since at outlet time the just-completed assistant message is the tip and SHOULD be included. Tests (test_issue98_e2e.py): - _build_branch_fork_history: builds a chat with a regeneration fork where currentId ("a1prime") and user_message_id ("u2") sit on different branches; assistant messages carry output arrays so the body is mixed-id. - test_load_full_chat_walks_from_anchor_when_provided: with anchor the walk follows the original branch; without it falls back to currentId. - test_branch_divergence_inlet_injects_summary: full inlet call where the body is built from user_message_id's branch but currentId points elsewhere — verifies the summary is still injected. - test_branch_divergence_without_anchor_fails: same fork but no user_message_id in metadata → anchor is None → walk follows currentId → branch mismatch → snapshot rejected. Proves the anchor is load-bearing. Mock signatures in test_async_context_compression.py updated to accept **kwargs so the new anchor_message_id parameter flows through the existing fake_load_snapshot / fake_load_applicable_summary_snapshot stubs.
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
Co-authored-by: traeagent <traeagent@users.noreply.github.com>
Summary
Bumps async-context-compression to v1.7.3. This release fixes two community-reported regressions and adds an end-to-end verification suite that replays real OpenWebUI request-construction code against the filter.
Fixes
1. Summary persistence on fresh PostgreSQL databases
Symptom:
⚠️ Summary generated but was not persistedon freshly deployed PostgreSQL; backend log showedpsycopg2.errors.DuplicateTable: relation "ix_chat_summary_chat_id" already exists.Root cause: The shared SQLAlchemy
owui_Base.metadataretained a stale unique index definition with the same name as the new non-unique index.extend_existing=Truemerges column definitions but does not clean up stale index objects, soCREATE TABLEemitted two collidingCREATE INDEXstatements.Fix (
ef434e8+a84f7bb):_dedup_chat_summary_metadata_indexes()scansChatSummary.__table__.indexesand drops stale unique-name duplicates beforeCREATE TABLE.DROP INDEX IF EXISTSforix_chat_summary_chat_id,ix_chat_summary_branch_tip_id,ix_chat_summary_updated_atas a belt-and-suspenders cleanup.2. Outlet summary reuse for idless plain-chat branches (
496cabf)When the outlet request had no stable message refs but carried a
chat_id, the filter could not attach refs to the generated summary. It now reads the active DB branch and aligns the body via_compatible_db_branch_for_body_ref_fallback, so plain-chat summaries persist and are reused on subsequent turns.3. Reasoning-model inlet reuse — issue #98 (
9f15b1c)Symptom: For reasoning models (DeepSeek-R1, o1-like, Qwen3-thinking, etc.), the cached summary was rejected every turn and the inlet re-sent the full uncompressed history.
Root cause: Reasoning models store assistant content with folded
<details type="reasoning">blocks in the DB, butprocess_messages_with_output→convert_output_to_messages(output, reasoning_format=...)reconstructs the request body with reasoning stripped (OpenAI-compatible), re-tagged as<think>(Ollama), or routed to areasoning_contentfield (llama.cpp). Both existing alignment paths (folded content match / unfolded content match) therefore failed on content inequality.Fix — Path 3 (position-based fallback): When Path 1 and Path 2 both fail, accept the snapshot iff:
tool_calls/tool_call_idmatch position-by-position,outputarray are exempted from content comparison (their content was reconstructed),outputstill require exact content equality (fail-closed against edits).Rejected cases (fail-closed preserved):
output→ rejectedtool_calls→ rejectedtool_call_id/ role drift → rejected4. DB branch anchor + failed-assistant alignment — issue #98 (
9a3689a+c3aefb2)Symptom: Path 3 still rejected with
role mismatch at index=Neven after the anchor fix, on long chats (~800 messages) where the live body and the filter's DB walk were equal length but diverged mid-sequence.Root cause (two layers):
9a3689a): the DB walk usedhistory.currentIdas its walk anchor, but OpenWebUI'sload_messages_from_dbwalks frommetadata['user_message_id']. After a regeneration/edit these point at different branches, so the reconstructed DB sequence did not match the live body. Fixed by passinguser_message_idas the walk anchor.c3aefb2):_load_full_chat_messagesapplied_filter_model_visible_history_messages, which dropped assistant messages carrying anerrorfield, on the assumption that OpenWebUI's middleware filters them too. It does not —load_messages_from_dbonly strips fields to(role, content, output, files)and keeps every node on theparentIdchain. Whenever the active chain contained a failed/regenerated assistant turn, the DB walk silently dropped it while the live body kept it, shifting every subsequent index. Fixed by removing the filter call; the DB walk now reproducesload_messages_from_dbverbatim.Inlet diagnostics (
__event_call__→ browser DevTools console):The
🧭line walksparentIdup fromcurrentIdto decide whetheruser_message_idis on the same branch (normal) or a diverged fork (needs a different fix).End-to-end verification (
test_issue98_e2e.py)Not a simple unit test. This module inlines verbatim from the OpenWebUI main branch:
convert_output_to_messages(misc.py)process_messages_with_output(middleware.py)reconcile_tool_pairsIt then reconstructs the request body for 4 reasoning-model scenarios by running the real OpenWebUI pipeline (
process_messages_with_output+ id stripping to simulate the frontend), and asserts behavior at both the alignment-method level and the fullinlet()entry point:Nonethink_tags<think>tags inlinereasoning_contentreasoning_contentfieldNone+ function_call14 e2e tests cover acceptance (Path 3 admits reasoning content mismatch) and rejection (edited content with no DB output, tampered tool_calls, tool-call count expansion), plus full
inlet()summary-injection assertions for OpenAI-compatible and Ollama.Test results
test_issue98_e2e.pyalone: 14/14 passtest_async_context_compression.py: 129/129 pass (the previously-failingsqlalchemy-dependent test now passes oncesqlalchemyis installed in the environment; unrelated to logic changes)Files changed
async_context_compression.py— version bump 1.7.2 → 1.7.3 (logic changes were in prior commits on this branch)v1.7.3.md/v1.7.3_CN.md— release notes (new)README.md/README_CN.md— what's-new + changelog pointer updates_diag.py— removed (temporary diagnostic script)Upgrade notes
No database migration required. On first launch after upgrade,
_init_databasewill clean any colliding legacy indexes automatically.Related issue
Closes #98 (reasoning-model inlet reuse). Also resolves the
Summary generated but was not persistedregression on fresh PostgreSQL.