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` (1106) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (1107) — 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: `cd emrg/gui/renderer && npm run typecheck && npm test` (262: 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 + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 8 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog) + `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 上下文)
Expand Down
38 changes: 35 additions & 3 deletions emrg/server/daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,6 +28,7 @@
from datetime import datetime
from pathlib import Path
from typing import Optional
from urllib.parse import urlparse

import yaml
from websockets.asyncio.server import serve
Expand DownExpand Up@@ -259,7 +260,7 @@ def __init__(self, llm_config: LlmConfig) -> None:
# (estimated_tokens, iso_ts) at anchor-loss warning time, so the
# estimator drift of the anchor-less window can be measured when
# the provider re-anchors (countable metric, not just a log line).
self._missing_anchor_est: dict[str, tuple[int, str]] = {}
self._missing_anchor_est: dict[str, tuple[int, str, str, str]] = {}
# Rant 2026-08-23T13:54:14: per-session dynamic-context snapshot
# (session_id → (text, injected_ms)) so unchanged context is not
# re-sent (context_refresh_interval_ms gating).
Expand DownExpand Up@@ -3023,13 +3024,23 @@ def _warn_missing_usage_anchor(
return # already warned once for this session
self._warned_missing_usage_anchor.add(session.session_id)
loss_ts = datetime.now().astimezone().isoformat()
self._missing_anchor_est[session.session_id] = (estimated, loss_ts)
loss_model = self.llm.config.model
loss_provider = _provider_slug(self.llm.config.base_url)
# Issue #1011 (heinrichneb): a loss window without provider identity is
# half a counter — after a mid-session model switch the drift file must
# say WHICH provider went silent. Store the loss-time identity so a
# later anchor_drift can attribute the window.
self._missing_anchor_est[session.session_id] = (
estimated, loss_ts, loss_model, loss_provider,
)
try:
_append_usage_anchor_event({
"type": "anchor_loss",
"timestamp": loss_ts,
"session": session.session_id,
"est": estimated,
"model": loss_model,
"provider": loss_provider,
})
except OSError as exc:
logger.warning("usage-anchor stats append failed: %s", exc)
Expand All@@ -3055,7 +3066,7 @@ def _record_anchor_drift(self, session, real_pt: int) -> None:
stored = self._missing_anchor_est.pop(session.session_id, None)
if not stored:
return
est_before, loss_ts = stored
est_before, loss_ts, loss_model, loss_provider = stored
try:
_append_usage_anchor_event({
"type": "anchor_drift",
Expand All@@ -3064,6 +3075,12 @@ def _record_anchor_drift(self, session, real_pt: int) -> None:
"real_after": real_pt,
"delta": real_pt - est_before,
"loss_ts": loss_ts,
# Issue #1011: attribute the window — who went silent
# (loss identity) vs who re-anchored (current identity).
"loss_model": loss_model,
"loss_provider": loss_provider,
"model": self.llm.config.model,
"provider": _provider_slug(self.llm.config.base_url),
})
except OSError as exc:
logger.warning("usage-anchor stats append failed: %s", exc)
Expand DownExpand Up@@ -4268,6 +4285,21 @@ def _write_exit_record(reason: str, exit_code: int, traceback_text: str | None)
_USAGE_ANCHOR_STATS_PATH = Path.home() / ".emrg" / "logs" / "usage-anchor.jsonl"


def _provider_slug(base_url: str) -> str:
"""Derive a provider identity from the LLM base_url (issue #1011).

Deterministic hostname extraction — 'https://api.openai.com/v1' →
'api.openai.com'; a local endpoint ('http://localhost:11434/v1') →
'localhost'. Empty/unparsable input falls back to the raw string so the
event still carries something attributable. No heuristics, no DNS.
"""
try:
host = urlparse(base_url).hostname
return host or base_url
except Exception:
return base_url


def _append_usage_anchor_event(record: dict, path: Path | None = None) -> int:
"""Append a countable usage-anchor event (one JSON line) and return the
cumulative total (community feedback 2026-08-26T07:18:29 — heinrichneb:
Expand Down
57 changes: 57 additions & 0 deletions tests/test_daemon.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -710,6 +710,9 @@ def test_usage_anchor_loss_event_is_countable(tmp_path, monkeypatch):
e1, e2 = json.loads(lines[0]), json.loads(lines[1])
assert e1["type"] == "anchor_loss" and e1["session"] == "s1"
assert e1["total"] == 1 and "timestamp" in e1
# Issue #1011: the loss event must say WHICH provider went silent
assert e1["model"] == "gpt-4o-mini" # default LlmConfig model in _make_server
assert e1["provider"] == "localhost" # base_url host of _make_server
assert e2["session"] == "s2" and e2["est"] == 60_000 and e2["total"] == 2


Expand DownExpand Up@@ -759,12 +762,66 @@ def test_usage_anchor_drift_measured_on_reanchor(tmp_path, monkeypatch):
assert drift["real_after"] == 180_000
assert drift["delta"] == 80_000
assert drift["loss_ts"] == loss["timestamp"]
# Issue #1011: drift window is attributable — loss identity (who went
# silent) + re-anchor identity (who finally reported real tokens)
assert drift["loss_model"] == loss["model"] == "gpt-4o-mini"
assert drift["loss_provider"] == loss["provider"] == "localhost"
assert drift["model"] == "gpt-4o-mini"
assert drift["provider"] == "localhost"

# Re-anchor measured once — a second call (no pending loss) is a no-op
server._record_anchor_drift(_SidSession(sid), 200_000)
assert len(stats.read_text(encoding="utf-8").strip().splitlines()) == 2


def test_usage_anchor_cross_provider_window_attributable(tmp_path, monkeypatch):
"""Issue #1011 — heinrichneb: 'a counter that can't say WHICH provider
went silent is half a counter'. The loss window's identity must survive
from anchor_loss to anchor_drift: a mid-session model switch produces a
drift window whose loss side names the silent provider and whose re-anchor
side names the new provider."""
stats = tmp_path / "usage-anchor.jsonl"
monkeypatch.setattr(daemon_mod, "_USAGE_ANCHOR_STATS_PATH", stats)
server = _make_server()
sid = "x-provider"
messages = [
{"role": "assistant", "content": "x"},
{"role": "user", "content": "y"},
]
# Loss detected under provider A (deepseek)
server.llm.config.model = "deepseek-chat"
server.llm.config.base_url = "https://api.deepseek.com/v1"
server._warn_missing_usage_anchor(_SidSession(sid), messages, 50_000)

# Model switch to provider B (openai) — #1003 invalidates anchors; the
# pending drift window is dropped, so no cross-provider drift is measured.
# But the anchor_loss event itself must still name provider A.
server.llm.config.model = "gpt-4o"
server.llm.config.base_url = "https://api.openai.com/v1"
server._invalidate_usage_anchors_on_switch()

lines = stats.read_text(encoding="utf-8").strip().splitlines()
assert len(lines) == 1
loss = json.loads(lines[0])
assert loss["type"] == "anchor_loss"
assert loss["model"] == "deepseek-chat"
assert loss["provider"] == "api.deepseek.com"

# Same-provider window: loss + re-anchor both under provider B (fresh
# session — warn-once is per session lifetime, so a new window needs a
# new session id to emit a second anchor_loss)
server._warn_missing_usage_anchor(_SidSession("x-provider-2"), messages, 60_000)
server._record_anchor_drift(_SidSession("x-provider-2"), 150_000)
lines = stats.read_text(encoding="utf-8").strip().splitlines()
assert len(lines) == 3
loss_b, drift_b = json.loads(lines[1]), json.loads(lines[2])
assert loss_b["provider"] == "api.openai.com"
assert drift_b["loss_model"] == "gpt-4o"
assert drift_b["loss_provider"] == "api.openai.com"
assert drift_b["model"] == "gpt-4o"
assert drift_b["provider"] == "api.openai.com"


def test_context_message_injection_format(tmp_path):
"""Context message carries tz-aware time and lands before the user prompt.

Expand Down
Loading