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@@ -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 路径不受影响)
Expand Down
2 changes: 1 addition & 1 deletion README.cn.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 即失败

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand Down
37 changes: 36 additions & 1 deletion emrg/server/llm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,6 +14,7 @@
from __future__ import annotations

import asyncio
import gzip
import json
import logging
from typing import AsyncIterator, Optional
Expand DownExpand Up@@ -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."""

Expand DownExpand Up@@ -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", {})

Expand Down
97 changes: 97 additions & 0 deletions tests/test_llm.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Loading