From 8d24a1329b53677bb9844303e12b2bdcbf36e019 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Fri, 7 Aug 2026 03:54:02 +0800 Subject: [PATCH] emrg: flag truncated evolution cycles as truncated, not complete/empty --- Agent.md | 2 +- README.md | 2 +- emrg/server/scheduler.py | 43 ++++++++++++++++++----- tests/test_scheduler.py | 74 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 110 insertions(+), 11 deletions(-) diff --git a/Agent.md b/Agent.md index a7b1cd29..a68aea23 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` (493) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (495) — 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 上下文) diff --git a/README.md b/README.md index 334722f2..f4f71842 100644 --- a/README.md +++ b/README.md @@ -276,7 +276,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 493 items) +uv run pytest tests/ -v # run tests (currently 495 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/scheduler.py b/emrg/server/scheduler.py index 41ae5722..fd2f0641 100644 --- a/emrg/server/scheduler.py +++ b/emrg/server/scheduler.py @@ -549,6 +549,7 @@ async def _run_evolution_cycle(self) -> None: tool_count = 0 error = None + truncated = False try: await ws.send(task_msg) @@ -560,10 +561,25 @@ async def _run_evolution_cycle(self) -> None: break if resp.get("done"): duration = int((datetime.now() - cycle_time).total_seconds()) - logger.info( - "EvolutionHandler[%s] complete (tools=%d, duration=%ds)", - self.name, tool_count, duration, - ) + # Distinguish truncation from successful completion: the + # daemon's max-tool-rounds frame (daemon.py "Exceeded + # maximum tool call rounds") is a done frame too — without + # this check a truncated cycle is misreported as complete + # and (when HEAD is unchanged) counted as an *empty* cycle, + # wrongly advancing the idle-halt backoff (mem repo lesson: + # truncation must be flagged, not silently treated as done). + content = resp.get("content") or "" + truncated = "exceeded" in content.lower() + if truncated: + logger.warning( + "EvolutionHandler[%s] truncated (max tool rounds, tools=%d, duration=%ds)", + self.name, tool_count, duration, + ) + else: + logger.info( + "EvolutionHandler[%s] complete (tools=%d, duration=%ds)", + self.name, tool_count, duration, + ) break if "tool_name" in resp: @@ -586,9 +602,15 @@ async def _run_evolution_cycle(self) -> None: except Exception: pass - # Detect empty cycles: git HEAD unchanged → no work was done + # Detect empty cycles: git HEAD unchanged → no work was done. + # A truncated cycle is NOT empty — the agent wanted to work but hit + # the tool-round cap; counting it would wrongly back off the handler. git_head_after = self._get_git_head() - if git_head_before and git_head_after and git_head_before == git_head_after: + if ( + not truncated + and git_head_before and git_head_after + and git_head_before == git_head_after + ): self._empty_cycles += 1 self._save_saturation_state() logger.debug( @@ -597,18 +619,21 @@ async def _run_evolution_cycle(self) -> None: ) else: if self._empty_cycles > 0: + reason = "truncated cycle" if truncated else "git HEAD changed" logger.info( - "EvolutionHandler[%s]: git HEAD changed, resetting empty streak", - self.name, + "EvolutionHandler[%s]: %s, resetting empty streak", + self.name, reason, ) self._empty_cycles = 0 self._save_saturation_state() cycle_ts = cycle_time.isoformat() impact = [ - f"evolution-cycle-{cycle_ts}-complete", + f"evolution-cycle-{cycle_ts}-{'truncated' if truncated else 'complete'}", f"tools-executed={tool_count}", ] + if truncated: + impact.append("truncated=max-tool-rounds") if error: impact.append(f"error={error[:200]}") diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index db1f3988..41635853 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -718,3 +718,77 @@ def test_ensure_evolution_workspace_clone_failure_skips(tmp_path): assert ok is False assert handler._source_dir != str(mod.EVOLUTION_CWD / "emrg") + + +# ── EvolutionHandler cycle truncation detection ────────────────── +# mem-repo lesson (tool-call truncation must be flagged, not silently +# treated as a successful/empty cycle — #523 applied it to the chat UI; +# this covers EMRG's own evolution task loop). + +def _make_cycle_handler(tmp_path, frames): + """Build a fully-scripted handler for _run_evolution_cycle tests.""" + import json as _json + + from websockets.exceptions import ConnectionClosed as _Closed + + from emrg.server import scheduler as mod + + class _FakeWS: + def __init__(self, frm): + self._frames = list(frm) + self.sent = [] + + async def send(self, msg): + self.sent.append(msg) + + async def recv(self): + if self._frames: + return _json.dumps(self._frames.pop(0), ensure_ascii=False) + raise _Closed() + + async def close(self): + pass + + async def _fake_connect(): + return _FakeWS(frames) + + handler = _make_handler(tmp_path, project="", path=str(tmp_path)) + handler._ensure_evolution_workspace = lambda: True + handler._build_evolution_prompt = lambda: "test prompt" + handler._get_git_head = lambda: "abc123" # HEAD unchanged + captured = {} + async def _fake_write_log(log): + captured["log"] = log + handler._write_evolution_log = _fake_write_log + mod.connect_to_server = _fake_connect + return handler, captured + + +def test_evolution_cycle_truncated_not_empty_not_complete(tmp_path): + """Truncated done frame → flagged truncated, NOT an empty cycle, impact reflects it.""" + handler, captured = _make_cycle_handler(tmp_path, frames=[ + {"tool_name": "bash"}, + {"request_id": "r1", "content": "Exceeded maximum tool call rounds (270).", + "done": True, "delta": False, "session_id": "s"}, + ]) + asyncio.run(handler._run_evolution_cycle()) + assert handler._empty_cycles == 0, \ + "truncated cycle must not advance the idle-halt backoff" + impact = captured["log"].impact + assert any("truncated" in i for i in impact), impact + assert "truncated=max-tool-rounds" in impact, impact + assert not any(i.endswith("-complete") for i in impact), impact + + +def test_evolution_cycle_complete_unchanged_head_still_empty(tmp_path): + """Normal completion with unchanged HEAD keeps the existing empty-cycle semantics.""" + handler, captured = _make_cycle_handler(tmp_path, frames=[ + {"request_id": "r1", "content": "Done", "done": True, + "delta": False, "session_id": "s"}, + ]) + asyncio.run(handler._run_evolution_cycle()) + assert handler._empty_cycles == 1, \ + "unchanged-HEAD complete cycle is still counted as empty (existing behavior)" + impact = captured["log"].impact + assert any(i.endswith("-complete") for i in impact), impact + assert "truncated=max-tool-rounds" not in impact, impact