diff --git a/Agent.md b/Agent.md index 23b10ab7..6e72d9d8 100644 --- a/Agent.md +++ b/Agent.md @@ -93,7 +93,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design: pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg ``` -Python: `uv run pytest tests/ -v` (502) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (508) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (88: 22 daemon_client + 22 app-commands + 19 renderer smoke + 15 i18n + 7 integration + 3 commands) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` CI: `uv run pytest` + 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 路径不受影响) diff --git a/README.cn.md b/README.cn.md index 88c17ff9..fbe78f10 100644 --- a/README.cn.md +++ b/README.cn.md @@ -274,7 +274,7 @@ EMRG 不只是追赶——它自己追上来。 git clone https://github.com/argszero/emrg.git cd emrg uv sync # 安装依赖 -uv run pytest tests/ -v # 跑测试(当前 480 项) +uv run pytest tests/ -v # 跑测试(当前 508 项) uv run python -m emrg # 启动 TUI # CI 含 actionlint workflow 门禁(#444):workflow 解析错误在 PR 即失败 diff --git a/README.md b/README.md index adf8fe80..804bc7e7 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,7 @@ EMRG doesn't just keep up — it catches up on its own. git clone https://github.com/argszero/emrg.git cd emrg uv sync # install deps -uv run pytest tests/ -v # run tests (currently 502 items) +uv run pytest tests/ -v # run tests (currently 508 items) uv run python -m emrg # launch TUI # CI includes actionlint workflow gate (#444): workflow parse errors fail PR CI diff --git a/emrg/server/llm.py b/emrg/server/llm.py index 8f162f40..651de208 100644 --- a/emrg/server/llm.py +++ b/emrg/server/llm.py @@ -14,6 +14,7 @@ from __future__ import annotations import asyncio +import gzip import json import logging from typing import AsyncIterator, Optional @@ -56,6 +57,19 @@ def _redact_headers(headers: dict) -> dict: RETRY_BASE_DELAY = 1.0 # seconds, doubled each retry +def _parse_json_body(content: bytes) -> dict: + """Parse an LLM response body, transparently decompressing gzip. + + Some gateways/proxies return gzip-compressed bodies without a proper + Content-Encoding header, so httpx does not decompress them and + ``resp.json()`` crashes with UnicodeDecodeError on the gzip magic + bytes (0x1f 0x8b). Detect the magic prefix and decompress first. + """ + if content[:2] == b"\x1f\x8b": + content = gzip.decompress(content) + return json.loads(content) + + class LlmClient: """Async LLM client with tool calling and multi-turn streaming support.""" @@ -131,7 +145,28 @@ async def chat( if resp.status_code == 200: self.last_response_status = resp.status_code self.last_response_headers = dict(resp.headers) - data = resp.json() + try: + data = _parse_json_body(resp.content) + except (json.JSONDecodeError, UnicodeDecodeError, OSError, EOFError) as exc: + # Malformed body (e.g. truncated gzip / proxy error page). + # Treat as transient — retry with backoff instead of crashing + # (20260807: memory reflection died on gzip body without + # Content-Encoding, resp.json() raised UnicodeDecodeError). + if attempt < MAX_RETRIES: + delay = RETRY_BASE_DELAY * (2 ** attempt) + logger.warning( + "LLM response body unparseable (%s), retrying in %.1fs " + "(attempt %d/%d)", + type(exc).__name__, delay, attempt + 1, MAX_RETRIES, + ) + await asyncio.sleep(delay) + last_error = RuntimeError( + f"LLM response body unparseable: {type(exc).__name__}" + ) + continue + raise RuntimeError( + f"LLM response body unparseable: {type(exc).__name__}" + ) from exc choice = data["choices"][0] return choice.get("message", {}) diff --git a/tests/test_llm.py b/tests/test_llm.py index f8b849da..f34f0841 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -171,3 +171,100 @@ def test_redact_text_masks_inline_credentials(): assert "sk-" not in _redact_text("bad key sk-A1b2C3d4A1b2C3d4A1b2C3d4A1b2C3d4 supplied") assert "ghp_" not in _redact_text("token ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890 rejected") assert _redact_text("rate limit exceeded, try later") == "rate limit exceeded, try later" + + +# ── gzip body 容错(20260807-1240:memory reflection UnicodeDecodeError)── + + +def test_parse_json_body_plain(): + """Plain JSON body parses unchanged.""" + from emrg.server.llm import _parse_json_body + data = _parse_json_body(b'{"choices": []}') + assert data == {"choices": []} + + +def test_parse_json_body_gzip_without_content_encoding(): + """Gzip body (no Content-Encoding header → httpx won't decompress) is + transparently decompressed via magic-byte detection.""" + import gzip as gz + from emrg.server.llm import _parse_json_body + raw = '{"choices": [{"message": {"content": "hi"}}]}'.encode() + data = _parse_json_body(gz.compress(raw)) + assert data["choices"][0]["message"]["content"] == "hi" + + +def test_parse_json_body_corrupt_gzip_raises(): + """Gzip magic bytes with corrupt payload raise OSError (BadGzipFile).""" + import gzip as gz + import pytest + from emrg.server.llm import _parse_json_body + with pytest.raises(OSError): + _parse_json_body(b"\x1f\x8bCORRUPTED-NOT-REAL-GZIP") + + +class _FakeResponse: + def __init__(self, status_code: int, content: bytes, headers=None): + self.status_code = status_code + self.content = content + self.headers = headers or {} + + +class _FakeHttpClient: + def __init__(self, responses): + self.responses = list(responses) + self.calls = 0 + + async def post(self, url, headers=None, json=None): + self.calls += 1 + return self.responses.pop(0) + + +def _patch_fast_sleep(monkeypatch): + """Make retry backoff instant in tests.""" + import emrg.server.llm as llm_mod + + async def fast_sleep(_delay): + pass + + monkeypatch.setattr(llm_mod.asyncio, "sleep", fast_sleep) + + +def test_chat_gzip_body_transparent_decompress(monkeypatch, client): + """chat() returns the message when the 200 body is gzip-compressed + without Content-Encoding (the production failure mode).""" + import asyncio + import gzip as gz + body = gz.compress(b'{"choices": [{"message": {"content": "ok"}}]}') + fake = _FakeHttpClient([_FakeResponse(200, body)]) + client._client = fake + msg = asyncio.run(client.chat([{"role": "user", "content": "hi"}])) + assert msg == {"content": "ok"} + assert fake.calls == 1 # no retry needed + + +def test_chat_malformed_body_retries_then_succeeds(monkeypatch, client): + """Unparseable 200 body retries with backoff instead of crashing + (previously: UnicodeDecodeError killed memory reflection outright).""" + import asyncio + _patch_fast_sleep(monkeypatch) + good = b'{"choices": [{"message": {"content": "recovered"}}]}' + fake = _FakeHttpClient([ + _FakeResponse(200, b"\x1f\x8bCORRUPT"), + _FakeResponse(200, good), + ]) + client._client = fake + msg = asyncio.run(client.chat([{"role": "user", "content": "hi"}])) + assert msg == {"content": "recovered"} + assert fake.calls == 2 + + +def test_chat_malformed_body_exhausts_retries(monkeypatch, client): + """Persistently malformed body raises RuntimeError after MAX_RETRIES.""" + import asyncio + import pytest + _patch_fast_sleep(monkeypatch) + fake = _FakeHttpClient([_FakeResponse(200, b"\x1f\x8bBAD")] * 4) + client._client = fake + with pytest.raises(RuntimeError, match="unparseable"): + asyncio.run(client.chat([{"role": "user", "content": "hi"}])) + assert fake.calls == 4 # 1 initial + 3 retries