') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ', 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); })(); emrg: daemon — invalidate usage anchor on mid-session model/provider switch by pm25coder · Pull Request #1003 · argszero/emrg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -119,7 +119,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg
```

Python: `uv run pytest tests/ -v` (1102) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (1106) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (265: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group + 3 preload-api) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`; renderer React suite: `cd emrg/gui/renderer && npm run typecheck && npm test` (168 vitest: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 7 TranscriptView + 15 history + 22 composer + 14 Composer + 12 sidebar + 10 Sidebar + 9 fileTree + 8 FileTree) + `npm run build` → `renderer/dist/`
CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文)
Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响)
Expand Down
36 changes: 36 additions & 0 deletions emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -250,6 +250,11 @@ def __init__(self, llm_config: LlmConfig) -> None:
# (warn once per session, no per-round spam).
self._usage_anchor_dropped_by_compact: set[str] = set()
self._warned_missing_usage_anchor: set[str] = set()
# Community feedback 2026-08-26T18:21:30 (Dev.to comment 3dh3g):
# sessions whose usage anchor was deliberately invalidated by a
# mid-session model/provider switch — the next anchor-less round is a
# legitimate re-anchor round, not a silent provider usage-loss.
self._usage_anchor_dropped_by_switch: set[str] = set()
# Community feedback 2026-08-26T07:18:29: session_id ->
# (estimated_tokens, iso_ts) at anchor-loss warning time, so the
# estimator drift of the anchor-less window can be measured when
Expand DownExpand Up@@ -2965,6 +2970,24 @@ def _count_chars_for_tokens(text: str) -> int:
ascii_chars += 1
return (cjk // 2) + (ascii_chars // 4)

def _invalidate_usage_anchors_on_switch(self) -> None:
"""Drop every usage anchor when the active model/provider changes.

Community feedback 2026-08-26T18:21:30 (Dev.to comment 3dh3g): the
anchor is keyed by session_id only, so a mid-session model switch
would project the OLD provider's real prompt_tokens + the NEW
estimate delta — a mixed base that can silently miss the auto-compact
gate across providers (the #946 failure mode). Invalidate anchors and
any pending drift window, and mark sessions so the fail-LOUD
missing-anchor warning treats the switch round as a legitimate
re-anchor round (the next response re-anchors from the new
provider's real prompt_tokens).
"""
switched = set(self._usage_anchors)
self._usage_anchors.clear()
self._missing_anchor_est.clear()
self._usage_anchor_dropped_by_switch.update(switched)

def _warn_missing_usage_anchor(
self, session, messages: list[dict], estimated: int
) -> None:
Expand All@@ -2989,6 +3012,13 @@ def _warn_missing_usage_anchor(
if session.session_id in self._usage_anchor_dropped_by_compact:
self._usage_anchor_dropped_by_compact.discard(session.session_id)
return
# Model/provider switch round: anchor deliberately invalidated by
# _invalidate_usage_anchors_on_switch (Dev.to 3dh3g) — consume the
# marker so the NEXT anchor-less round warns if the new provider is
# also silent.
if session.session_id in self._usage_anchor_dropped_by_switch:
self._usage_anchor_dropped_by_switch.discard(session.session_id)
return
if session.session_id in self._warned_missing_usage_anchor:
return # already warned once for this session
self._warned_missing_usage_anchor.add(session.session_id)
Expand DownExpand Up@@ -3516,6 +3546,12 @@ async def _handle_set_model(
break

self.llm.config.model = api_model
if api_model != old_model:
# Community feedback 2026-08-26T18:21:30 (Dev.to 3dh3g): a real
# API model change invalidates every usage anchor — the base held
# the previous provider's real prompt_tokens, which would mix
# with the new estimate delta on the next auto-compact projection.
self._invalidate_usage_anchors_on_switch()
if new_ctx is not None:
self.llm.config.context_window = new_ctx
if new_vision is not None:
Expand Down
80 changes: 80 additions & 0 deletions tests/test_daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -602,6 +602,86 @@ def test_manual_compact_drop_marks_anchor(caplog):
assert sid not in server._usage_anchor_dropped_by_compact # consumed


# ── model/provider switch invalidates anchors (Dev.to comment 3dh3g) ──


def test_invalidate_usage_anchors_on_switch():
"""A mid-session model switch must drop every usage anchor: the anchor
holds the OLD provider's real prompt_tokens; projecting old base + new
estimate delta mixes bases and can silently miss the auto-compact gate
(community feedback 2026-08-26T18:21:30, Dev.to comment 3dh3g)."""
server = _make_server()
server._usage_anchors["s1"] = (222_000, 148_000)
server._usage_anchors["s2"] = (100_000, 60_000)
server._missing_anchor_est["s1"] = (150_000, "2026-08-26T00:00:00+08:00")

server._invalidate_usage_anchors_on_switch()

assert server._usage_anchors == {}
assert server._missing_anchor_est == {}
# Only sessions that held an anchor are marked for the re-anchor pass
assert server._usage_anchor_dropped_by_switch == {"s1", "s2"}


def test_switch_round_does_not_warn_false_positive(caplog):
"""The anchor-less round right after a model switch is legitimate — the
fail-LOUD warning must stay silent, and the marker is consumed so a
SECOND consecutive anchor-less round (new provider also silent) warns."""
server = _make_server()
sid = "switch-test"
server._usage_anchors[sid] = (222_000, 148_000)
messages = [
{"role": "assistant", "content": "x"},
{"role": "user", "content": "y"},
]
server._invalidate_usage_anchors_on_switch()
assert server._usage_anchors == {}
assert sid in server._usage_anchor_dropped_by_switch

with caplog.at_level("WARNING", logger="emrg.server.daemon"):
server._warn_missing_usage_anchor(_SidSession(sid), messages, 100)
assert "usage anchor missing" not in caplog.text
assert sid not in server._usage_anchor_dropped_by_switch # consumed

# New provider still silent -> the next anchor-less round now warns
caplog.clear()
with caplog.at_level("WARNING", logger="emrg.server.daemon"):
server._warn_missing_usage_anchor(_SidSession(sid), messages, 200)
assert "usage anchor missing for established session" in caplog.text


def test_handle_set_model_invalidates_usage_anchors():
"""set_model with a REAL api-model change must invalidate usage anchors —
a mid-session switch must not project the old provider's base with the
new estimate delta (Dev.to comment 3dh3g)."""
import asyncio
server = _make_server()
server._usage_anchors["s1"] = (222_000, 148_000)
server._usage_anchors["s2"] = (100_000, 60_000)
writer = _FakeWriter()
new_name = server.llm.config.model + "-switched"

asyncio.run(server._handle_set_model(new_name, writer))

assert server._usage_anchors == {}
assert server._usage_anchor_dropped_by_switch == {"s1", "s2"}


def test_handle_set_model_same_api_model_keeps_anchors():
"""Re-selecting the SAME api model (e.g. display alias) must NOT
invalidate anchors — the provider/tokenizer is unchanged."""
import asyncio
server = _make_server()
server._usage_anchors["s1"] = (222_000, 148_000)
writer = _FakeWriter()
same = server.llm.config.model

asyncio.run(server._handle_set_model(same, writer))

assert server._usage_anchors == {"s1": (222_000, 148_000)}
assert server._usage_anchor_dropped_by_switch == set()


# ── usage-anchor countable stats (community feedback 2026-08-26T07:18:29) ──


Expand Down
Loading