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` (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 上下文)

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

Expand Down
43 changes: 34 additions & 9 deletions emrg/server/scheduler.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -549,6 +549,7 @@ async def _run_evolution_cycle(self) -> None:

tool_count = 0
error = None
truncated = False

try:
await ws.send(task_msg)
Expand All@@ -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:
Expand All@@ -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(
Expand All@@ -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]}")

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