Skip to content
Closed
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` (1103) — 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` (151 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) + `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
30 changes: 27 additions & 3 deletions emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -2983,9 +2983,10 @@ def _warn_missing_usage_anchor(
# First round: no assistant turn yet — nothing anchored, normal.
if not any(m.get("role") == "assistant" for m in messages):
return
# Post-compact re-anchor round: anchor deliberately dropped; consume
# the marker so a SECOND consecutive anchor-less round (provider
# still silent) warns on the next round.
# Deliberate drop (post-compact, manual compact, or mid-session model
# switch — issue #1000): anchor intentionally invalidated; consume the
# marker so a SECOND consecutive anchor-less round (provider still
# silent) warns on the next round.
if session.session_id in self._usage_anchor_dropped_by_compact:
self._usage_anchor_dropped_by_compact.discard(session.session_id)
return
Expand DownExpand Up@@ -3521,6 +3522,29 @@ async def _handle_set_model(
if new_vision is not None:
self.llm.config.vision = new_vision

# Issue #1000 (Dev.to community finding): a usage anchor's base is the
# OLD model's real prompt_tokens for the historical context. Different
# models tokenize the same context differently (the 148K-est-vs-222K-
# real tokenizer variance is model-specific), so after a mid-session
# model/provider switch a stale base drifts the projection and can
# mis-fire the auto-compact gate (undercount → overflow risk, or
# overcount → premature compact). Drop ALL anchors (model is global
# daemon state) and mark the drops as deliberate — same pattern as the
# post-compact drop — so the next round re-anchors from the NEW
# model's real usage and the fail-LOUD missing-anchor warning does not
# false-fire.
if api_model != old_model:
dropped = len(self._usage_anchors)
for sid in list(self._usage_anchors.keys()):
self._usage_anchors.pop(sid, None)
self._usage_anchor_dropped_by_compact.add(sid)
if dropped:
logger.info(
"model switch %s → %s: dropped %d stale usage anchor(s) "
"(issue #1000 — tokenizer base is model-specific)",
old_model, api_model, dropped,
)

logger.info(
"model switched: %s → %s (api=%s, context_window: %d → %d)",
old_model, model_name, api_model, old_ctx, self.llm.config.context_window,
Expand Down
40 changes: 40 additions & 0 deletions tests/test_daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -602,6 +602,46 @@ def test_manual_compact_drop_marks_anchor(caplog):
assert sid not in server._usage_anchor_dropped_by_compact # consumed


def test_model_switch_drops_stale_usage_anchors(caplog):
"""Issue #1000 (Dev.to community finding): a usage anchor's base is the
OLD model's real prompt_tokens — the tokenizer base is model-specific
(148K est vs 222K real variance differs per model). A mid-session model
switch must drop ALL anchors + mark the deliberate drop so the next round
re-anchors from the NEW model's real usage: no stale-base projection
drift, no false-positive fail-LOUD missing-anchor warning."""
import asyncio

server = _make_server()
sid = "model-switch-test"
# Stale anchor from the OLD model's real prompt_tokens
server._usage_anchors[sid] = (222_000, 148_000)
server.llm.config.model = "gpt-4o"
server.llm.config.models = [
{"name": "deepseek", "model": "deepseek-chat", "context_window": 128000}
]
writer = _FakeWriter()

# Actual model switch → anchors dropped + deliberate-drop marked
asyncio.run(server._handle_set_model("deepseek", writer))
assert sid not in server._usage_anchors
assert sid in server._usage_anchor_dropped_by_compact

# Next round: legitimate re-anchor round, no false-positive warning
messages = [
{"role": "assistant", "content": "x"},
{"role": "user", "content": "y"},
]
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_compact # consumed

# No-op switch (same api model) must NOT drop anchors
server._usage_anchors[sid] = (222_000, 148_000)
asyncio.run(server._handle_set_model("deepseek", writer))
assert sid in server._usage_anchors # untouched


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


Expand Down
Loading