From 1c534a1bda3ee36348d7bdbf8a7523922ad9f9a6 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Mon, 17 Aug 2026 12:06:12 +0800 Subject: [PATCH 1/3] emrg: submit_rant tool + rant in normal conversation (rant 2026-08-17T11:51:59) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host: a rant is not a special mode — it is a normal part of conversation. The user may complain/suggest in plain speech; the agent should detect rant intent, clarify/polish, get explicit consent, then write it. - New emrg/server/rants.py: append_rant() shared write logic (field order timestamp → project → status → progress → completed → message, tz-aware daemon-authoritative timestamp, sorted rewrite) — extracted from the daemon rant handler, single source of truth. - New emrg/tools/submit_rant_tool.py: SubmitRantTool registered in the daemon tool registry (available in every session); description mandates explicit user consent before calling; project optional (default = emrg). - daemon.py rant handler now delegates to append_rant (identical behavior). - system.j2: new 'Rant Handling' section — recognition (no /rant prefix needed), confirm → clarify → polish → show → call submit_rant; never call without explicit agreement; /rant and GUI panel are explicit. - TUI /rant / /rant @proj no longer write directly: routes through the agent as a normal task with a '[Host wants to submit this rant…]' hint (agent confirms + calls submit_rant). GUI panel keeps the direct write (explicit form submit = confirmed). - +7 tests (append_rant sort/field-order/corrupt-skip, tool write/count, empty-message error, definition consent contract, system.j2 section, tool registered). Agent.md 834→841. --- Agent.md | 2 +- emrg/client/app.py | 49 ++++++++++++----- emrg/server/daemon.py | 46 +++------------- emrg/server/prompts/system.j2 | 16 ++++++ emrg/server/rants.py | 65 +++++++++++++++++++++++ emrg/tools/submit_rant_tool.py | 79 ++++++++++++++++++++++++++++ tests/test_daemon.py | 19 +++++++ tests/test_submit_rant_tool.py | 96 ++++++++++++++++++++++++++++++++++ 8 files changed, 320 insertions(+), 52 deletions(-) create mode 100644 emrg/server/rants.py create mode 100644 emrg/tools/submit_rant_tool.py create mode 100644 tests/test_submit_rant_tool.py diff --git a/Agent.md b/Agent.md index ca181732..56bbcb8f 100644 --- a/Agent.md +++ b/Agent.md @@ -118,7 +118,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` (834) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (841) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (257: 45 daemon_client + 19 conn-manager + 22 app-commands + 129 renderer smoke + 16 i18n + 7 integration + 3 commands + 7 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` 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 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/client/app.py b/emrg/client/app.py index 21f92cdd..c5f11754 100644 --- a/emrg/client/app.py +++ b/emrg/client/app.py @@ -1554,11 +1554,24 @@ async def handle_key(data: bytes) -> bool: if text.lower() in ("quit", "exit"): return False # If a rant project was selected, use this message as the rant + # (rant 2026-08-17T11:51:59: routes through the agent for + # polish/confirm, then the submit_rant tool records it) if _rant_project: - await conn.send_command("rant", message=text, project=_rant_project, - timestamp=datetime.now().isoformat()) - - chat.add("system", f"Rant recorded (@{_rant_project}). The evolution system will review it.") + hint = ( + f"[Host wants to submit this rant — polish it, ask for " + f"confirmation if needed, then call submit_rant " + f"(project: {_rant_project})]\n{text}" + ) + chat.add("user", f"/rant @{_rant_project} {text}") + chat.add("assistant", "") + msg_count += 1; _update_left_extra() + _last_center = "thinking..." + status.update(center=_last_center) + term.render() + rid = await conn.send_task(session_id=session_id, cwd=cwd, + prompt=hint) + if was_busy: + _queued_sends.append({"id": rid, "prompt": hint, "images": None}) _rant_project = None status.update(center=server_id or "emrg") inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render() @@ -1829,6 +1842,10 @@ def _is_image_token(s, i): return True # Handle /rant command + # Rant 2026-08-17T11:51:59: /rant is no longer a direct write — + # it is a hint that the user wants to submit a rant. The text + # goes through the normal conversation so the agent can + # clarify / polish / confirm, then call the submit_rant tool. if text.lower().startswith("/rant"): parts = text.split(None, 2) message = parts[1].strip() if len(parts) > 1 else "" @@ -1850,16 +1867,22 @@ def _is_image_token(s, i): status.update(center="loading projects...") inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render() return True - payload = { - "message": message, - "timestamp": datetime.now().isoformat(), - } - if project: - payload["project"] = project - await conn.send_command("rant", **payload) - target = f" (@{project})" if project else "" - chat.add("system", f"Rant recorded{target}. The evolution system will review it.") + hint = ( + f"[Host wants to submit this rant — polish it, ask for " + f"confirmation if needed, then call submit_rant " + f"(project: {project if project else 'emrg'})]\n{message}" + ) + chat.add("user", f"/rant{target} {message}") + chat.add("assistant", "") + msg_count += 1; _update_left_extra() + _last_center = "thinking..." + status.update(center=_last_center) + term.render() + rid = await conn.send_task(session_id=session_id, cwd=cwd, + prompt=hint) + if was_busy: + _queued_sends.append({"id": rid, "prompt": hint, "images": None}) inp.text = ""; inp.cursor = 0; inp.dirty = True; term.render() return True diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 82677700..65b41870 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -113,8 +113,10 @@ def _redact(value): from emrg.tools.edit_tool import EditTool from emrg.tools.glob_tool import GlobTool from emrg.tools.grep_tool import GrepTool +from emrg.tools.submit_rant_tool import SubmitRantTool from emrg.skills.loader import load_skills from emrg.skills.registry import ensure_catalog_file, load_catalog_skills, skill_is_managed +from emrg.server.rants import append_rant from emrg.server.scheduler import TaskScheduler logger = logging.getLogger(__name__) @@ -196,6 +198,7 @@ def __init__(self, llm_config: LlmConfig) -> None: self.tools.register(EditTool()) self.tools.register(GlobTool()) self.tools.register(GrepTool()) + self.tools.register(SubmitRantTool()) logger.info("tools registered: %s", self.tools.names) # Load skills @@ -1469,44 +1472,11 @@ async def _process_message( # Optional project targeting (multi-project support) project = msg.get("project", "").strip() - # Field order: timestamp → project → status → progress → completed → message - # (project right after timestamp per user feedback; message last) - # Timestamp is daemon-authoritative local time (rant 2026-08-07T13:34Z): - # clients previously supplied timestamps — GUI sent new Date().toISOString() - # (UTC, 8h behind on UTC+8 hosts), TUI sent naive local time. A tz-aware - # local ISO timestamp (+08:00) is self-describing, sorts correctly, and is - # consistent regardless of which client submitted the rant. - entry = { - "timestamp": datetime.now().astimezone().isoformat(), - "project": project, - "status": "pending", - "progress": None, - "completed": None, - } - # message last, so status fields stay visible when scanning the file - entry["message"] = rant_message - - self._rants_log.parent.mkdir(parents=True, exist_ok=True) - - # Read existing rants, append new, sort by timestamp, rewrite sorted - rants: list[dict] = [] - if self._rants_log.exists(): - with open(self._rants_log, encoding="utf-8") as f: - for line in f: - line = line.strip() - if line: - try: - rants.append(json.loads(line)) - except json.JSONDecodeError: - pass - rants.append(entry) - rants.sort(key=lambda r: r.get("timestamp", "")) - - with open(self._rants_log, "w", encoding="utf-8") as f: - for r in rants: - f.write(json.dumps(r, ensure_ascii=False) + "\n") - - count = len(rants) + # Shared write logic (rant 2026-08-17T11:51:59): daemon ``rant`` + # command and the submit_rant tool use the same append_rant, so + # the file format / sort / daemon-authoritative timestamp stay + # consistent no matter which path recorded the rant. + count = append_rant(self._rants_log, rant_message, project) logger.info("rant recorded (%d total)%s: %s", count, f" project={project}" if project else "", _redact_string(rant_message[:100])) diff --git a/emrg/server/prompts/system.j2 b/emrg/server/prompts/system.j2 index 8b56aa4c..452182d2 100644 --- a/emrg/server/prompts/system.j2 +++ b/emrg/server/prompts/system.j2 @@ -137,3 +137,19 @@ When modifying or consolidating memories, check the timestamps to gauge how sett - If a body explicitly says "temporary" / "for now" / "placeholder", it's safe to replace or remove when circumstances change Session-scope memories that have lasting value can be promoted to project scope by moving the file to `.emrg/memory/` and updating both MEMORY.md indexes. + +## Rant Handling + +A rant (吐槽) is feedback from the host — a complaint, bug report, feature request, or improvement suggestion about EMRG itself or any registered project. Rants are not a special mode: they appear naturally in normal conversation ("this feature is bad", "there's a bug", "it should…", "why not…"). + +**Recognition** — do not wait for a `/rant` prefix. Detect rant intent from ordinary messages: complaints, criticism, "should / why not", dissatisfaction with behavior or output. + +**Flow**: +1. Detect rant intent → confirm with the host: "Is this feedback you'd like to submit?" (skip the question only when the intent is unmistakable). +2. If information is incomplete (target project? concrete suggestion / expected behavior?) → ask clarifying questions. +3. Polish/structure the raw speech into a clear, actionable rant description. +4. **Show the polished result and get explicit consent** → then call the `submit_rant` tool. +5. If the host says "don't submit / never mind" → do not call the tool. + +Calling the tool IS the confirmed signal. Never call it without explicit user agreement. For an explicit `/rant ` or a GUI rant-panel submission the host has already expressed intent — treat that as confirmed and keep the direct path. + diff --git a/emrg/server/rants.py b/emrg/server/rants.py new file mode 100644 index 00000000..0d0794cc --- /dev/null +++ b/emrg/server/rants.py @@ -0,0 +1,65 @@ +"""Shared rant-write logic — single source of truth for rants.jsonl. + +Extracted from the daemon's ``rant`` handler (rant 2026-08-17T11:51:59: rant +submission moves from a command-only path to "Agent auto-detects in normal +conversation, confirms with the user, then calls the submit_rant tool"). +Both the daemon ``rant`` command and the ``submit_rant`` tool call +:func:`append_rant`, so behavior stays identical. +""" + +from __future__ import annotations + +import json +from datetime import datetime +from pathlib import Path + + +def append_rant(rants_log: Path, message: str, project: str = "") -> int: + """Append a rant entry to ``rants_log``, sorted by timestamp. + + Args: + rants_log: Path to rants.jsonl (e.g. ``~/.emrg/rants.jsonl``). + message: The rant body (already user-confirmed / polished). + project: Optional target project name (empty = EMRG itself). + + Returns: + The new total rant count. + """ + # Field order: timestamp → project → status → progress → completed → message + # (project right after timestamp per user feedback; message last) + # Timestamp is daemon-authoritative local time (rant 2026-08-07T13:34Z): + # clients previously supplied timestamps — GUI sent new Date().toISOString() + # (UTC, 8h behind on UTC+8 hosts), TUI sent naive local time. A tz-aware + # local ISO timestamp (+08:00) is self-describing, sorts correctly, and is + # consistent regardless of which client submitted the rant. + entry: dict = { + "timestamp": datetime.now().astimezone().isoformat(), + "project": project, + "status": "pending", + "progress": None, + "completed": None, + } + # message last, so status fields stay visible when scanning the file + entry["message"] = message + + rants_log.parent.mkdir(parents=True, exist_ok=True) + + # Read existing rants, append new, sort by timestamp, rewrite sorted + rants: list[dict] = [] + if rants_log.exists(): + with open(rants_log, encoding="utf-8") as f: + for line in f: + line = line.strip() + if line: + try: + rants.append(json.loads(line)) + except json.JSONDecodeError: + pass + rants.append(entry) + rants.sort(key=lambda r: r.get("timestamp", "")) + + with open(rants_log, "w", encoding="utf-8") as f: + for r in rants: + f.write(json.dumps(r, ensure_ascii=False) + "\n") + + return len(rants) diff --git a/emrg/tools/submit_rant_tool.py b/emrg/tools/submit_rant_tool.py new file mode 100644 index 00000000..d6681121 --- /dev/null +++ b/emrg/tools/submit_rant_tool.py @@ -0,0 +1,79 @@ +"""SubmitRantTool — write a user rant/feedback into rants.jsonl. + +Rant 2026-08-17T11:51:59: rants are not a special mode — they are part of +normal conversation. The agent auto-detects rant intent, clarifies/polishes +with the user, and only calls this tool after the user has explicitly agreed. +The daemon ``rant`` command (TUI /rant, GUI rant panel) and this tool share +:func:`emrg.server.rants.append_rant`, so behavior stays identical. +""" + +from __future__ import annotations + +from emrg.server.rants import append_rant +from emrg.server.tool_types import ToolDefinition, ToolResult +from emrg.tools.base import ToolExecutor + + +class SubmitRantTool(ToolExecutor): + """Submit a user-confirmed rant/feedback into rants.jsonl for evolution. + + IMPORTANT: only call after the user has explicitly agreed to submit + (show the polished text first, ask for confirmation, then call). Never + call on an unconfirmed complaint. + """ + + def definition(self) -> ToolDefinition: + return ToolDefinition( + name="submit_rant", + description=( + "Write a user rant/feedback/improvement suggestion into " + "rants.jsonl so the evolution system can act on it. " + "**Must obtain explicit user consent before calling**: first " + "clarify the target and polish the text, show the user the " + "result, and only then call this tool." + ), + parameters={ + "type": "object", + "properties": { + "project": { + "type": "string", + "description": ( + "Target project name, e.g. 'argszero/aitokenpool'. " + "Omit (empty) for EMRG itself." + ), + }, + "message": { + "type": "string", + "description": ( + "The rant body — polished, complete description of " + "the feedback/suggestion/bug report." + ), + }, + }, + "required": ["message"], + }, + ) + + async def execute(self, arguments: dict) -> ToolResult: + message = str(arguments.get("message", "")).strip() + if not message: + return ToolResult( + name="submit_rant", + content="Error: submit_rant requires a message", + error=True, + ) + project = str(arguments.get("project", "") or "").strip() + try: + from emrg.config import config_dir + count = append_rant(config_dir() / "rants.jsonl", message, project) + except Exception as e: # noqa: BLE001 — tool errors must never crash the loop + return ToolResult( + name="submit_rant", + content=f"Error: failed to record rant: {e}", + error=True, + ) + target = f" ({project})" if project else "" + return ToolResult( + name="submit_rant", + content=f"Rant recorded{target}. Total rants: {count}.", + ) diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 0a20d7c3..cec976d7 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -268,6 +268,25 @@ def test_system_prompt_environment_without_session(tmp_path): assert "**Working directory**" not in rendered +def test_system_prompt_rant_handling_section(tmp_path): + """Rant Handling section renders (rant 2026-08-17T11:51:59): agent must + know to detect rants in normal conversation, confirm with the user, and + only then call submit_rant.""" + server = _make_server() + rendered = server._build_system_prompt() + assert "## Rant Handling" in rendered + assert "submit_rant" in rendered + assert "explicit" in rendered.lower() # consent-before-call contract + assert "/rant" in rendered + + +def test_submit_rant_tool_registered(): + """submit_rant is in the daemon tool registry (available in all sessions).""" + server = _make_server() + assert "submit_rant" in server.tools.names + assert server.tools.get("submit_rant") is not None + + def test_context_section_single_file(tmp_path): """When CLAUDE.md exists, it's returned as a dict entry.""" server = _make_server() diff --git a/tests/test_submit_rant_tool.py b/tests/test_submit_rant_tool.py new file mode 100644 index 00000000..6c85b7b2 --- /dev/null +++ b/tests/test_submit_rant_tool.py @@ -0,0 +1,96 @@ +"""Tests for the submit_rant tool + shared append_rant (rant 2026-08-17T11:51:59). + +Rants are not a special mode: the agent detects rant intent in normal +conversation, confirms with the user, then calls submit_rant. The daemon's +``rant`` command and the tool share emrg.server.rants.append_rant, so the +file format / sort / daemon-authoritative timestamp stay identical. +""" + +import json + +import pytest + +from emrg.server.rants import append_rant +from emrg.tools.submit_rant_tool import SubmitRantTool + + +def test_append_rant_writes_sorted_entry(tmp_path): + """New rant appended, sorted by timestamp, field order message last.""" + # pre-existing rant (older timestamp) must stay before the new one + rants_file = tmp_path / "rants.jsonl" + rants_file.write_text( + json.dumps({ + "timestamp": "2026-08-17T08:00:00+08:00", + "project": "emrg", + "status": "completed", + "progress": None, + "completed": "2026-08-17T08:30:00+08:00", + "message": "older rant", + }, ensure_ascii=False) + "\n", + encoding="utf-8", + ) + count = append_rant(rants_file, "new feedback", project="argszero/aitokenpool") + assert count == 2 + + lines = rants_file.read_text(encoding="utf-8").strip().splitlines() + assert len(lines) == 2 + entries = [json.loads(l) for l in lines] + # sorted by timestamp: older first, new last + assert entries[0]["message"] == "older rant" + assert entries[1]["message"] == "new feedback" + # field order: timestamp → project → status → progress → completed → message + assert list(entries[1].keys()) == [ + "timestamp", "project", "status", "progress", "completed", "message", + ] + assert entries[1]["project"] == "argszero/aitokenpool" + assert entries[1]["status"] == "pending" + assert entries[1]["completed"] is None + # daemon-authoritative tz-aware timestamp + import datetime as _dt + ts = _dt.datetime.fromisoformat(entries[1]["timestamp"]) + assert ts.tzinfo is not None + assert abs((_dt.datetime.now(ts.tzinfo) - ts).total_seconds()) < 60 + + +def test_append_rant_missing_file_and_corrupt_lines(tmp_path): + """Missing file → created; corrupt lines skipped without crashing.""" + rants_file = tmp_path / "rants.jsonl" + rants_file.write_text("not-json\n", encoding="utf-8") + count = append_rant(rants_file, "hello") + assert count == 1 + entries = [json.loads(l) for l in + rants_file.read_text(encoding="utf-8").strip().splitlines()] + assert entries[0]["message"] == "hello" + assert entries[0]["project"] == "" + + +def test_submit_rant_tool_writes_and_reports_count(tmp_path, monkeypatch): + """Tool execute writes via append_rant and returns the new count.""" + monkeypatch.setattr("emrg.config.config_dir", lambda: tmp_path) + tool = SubmitRantTool() + + result = __import__("asyncio").run(tool.execute( + {"message": "this feature is broken", "project": "argszero/aitokenpool"} + )) + assert result.error is False + assert "Total rants: 1" in result.content + lines = (tmp_path / "rants.jsonl").read_text(encoding="utf-8").strip().splitlines() + assert len(lines) == 1 + assert json.loads(lines[0])["message"] == "this feature is broken" + + +def test_submit_rant_tool_requires_message(): + """Empty message → error result, nothing written.""" + tool = SubmitRantTool() + result = __import__("asyncio").run(tool.execute({"message": " "})) + assert result.error is True + assert "requires a message" in result.content + + +def test_submit_rant_definition_exposes_consent_contract(): + """The tool description must require explicit user consent before calling.""" + tool = SubmitRantTool() + d = tool.definition() + assert d.name == "submit_rant" + assert "consent" in d.description.lower() or "confirm" in d.description.lower() + assert "message" in d.parameters["required"] From 906e72126fc5794d8521c8afadc864fd0926afa8 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Mon, 17 Aug 2026 12:13:32 +0800 Subject: [PATCH 2/3] emrg: tool purpose field in logs + submit_rant project required (rant 2026-08-17T12:03:13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host: every agent tool log should output 'tool name + human-readable purpose' so background calls (memory reflection / consolidation) are understandable without context. Also submit_rant's project becomes REQUIRED (host 12:00 demand folded into this rant). - ToolDefinition gains purpose: human-friendly one-line purpose (logs/UI); description stays for LLM routing. - All 7 tools (bash/read/write/edit/glob/grep/submit_rant) get a purpose. - Log sites now print 'name — purpose (args)': * main loop: 'tool call: bash — 执行 shell 命令并返回输出 ({command: ...})' * memory reflection: 'memory reflection: id= round= tool name — purpose → out…' * consolidation: 'consolidation tool: name — purpose → out…' (unknown tool → purpose 'unknown tool'; tool mocks in tests updated) - submit_rant: parameters.required now ['project','message']; execute rejects empty project with 'ask the user which project this rant targets'. - system.j2 Rant Handling: tool requires project — ask user if unknown. Also covers rant 2026-08-17T12:00:35 (memory reflection log id/round/ truncation marker) — same log line, superset, #815 closed. +2 tests (project-required, all-tools-have-purpose), Agent.md 841→843. --- Agent.md | 2 +- emrg/server/daemon.py | 25 ++++++++++++++++++++++--- emrg/server/prompts/system.j2 | 4 ++-- emrg/server/tool_types.py | 3 +++ emrg/tools/bash_tool.py | 1 + emrg/tools/edit_tool.py | 1 + emrg/tools/glob_tool.py | 1 + emrg/tools/grep_tool.py | 1 + emrg/tools/read_tool.py | 1 + emrg/tools/submit_rant_tool.py | 18 +++++++++++++++--- emrg/tools/write_tool.py | 1 + tests/test_submit_rant_tool.py | 27 +++++++++++++++++++++++++++ tests/test_ws_e2e.py | 8 ++++++++ 13 files changed, 84 insertions(+), 9 deletions(-) diff --git a/Agent.md b/Agent.md index 56bbcb8f..0bb0bc8a 100644 --- a/Agent.md +++ b/Agent.md @@ -118,7 +118,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` (841) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (843) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (257: 45 daemon_client + 19 conn-manager + 22 app-commands + 129 renderer smoke + 16 i18n + 7 integration + 3 commands + 7 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` 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 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 65b41870..a6115d7d 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -2316,7 +2316,13 @@ async def _run_tool_loop( except json.JSONDecodeError: args = {} - logger.info("tool call: %s(%s)", tc_name, + # Rant 2026-08-17T12:03:13: log the human-readable purpose + # alongside the tool name so background/reflection calls + # (memory reflection / consolidation) are understandable + # without context. + tool_obj = self.tools.get(tc_name) + purpose = tool_obj.definition().purpose if tool_obj else "unknown tool" + logger.info("tool call: %s — %s (%s)", tc_name, purpose, json.dumps(_redact(args), ensure_ascii=False)[:200]) # Notify client (broadcast to all session subscribers) @@ -3415,7 +3421,14 @@ async def _reflect(): "tool_call_id": tc_id, "content": result_text, }) - logger.debug("memory reflection tool: %s → %s", tc_name, _redact_string(result_text[:100])) + # Rant 2026-08-17T12:03:13: include the human-readable purpose + purpose = tool.definition().purpose if tool else "unknown tool" + logger.debug( + "memory reflection: id=%s round=%d tool %s — %s → %s%s", + session.session_id, _round + 1, tc_name, purpose, + _redact_string(result_text[:100]), + "…" if len(result_text) > 100 else "", + ) except Exception: logger.debug("memory reflection failed", exc_info=True) @@ -3519,7 +3532,13 @@ async def _consolidate_session_memories( "tool_call_id": tc_id, "content": result_text, }) - logger.debug("consolidation tool: %s → %s", tc_name, _redact_string(result_text[:100])) + # Rant 2026-08-17T12:03:13: include the human-readable purpose + purpose = tool.definition().purpose if tool else "unknown tool" + logger.debug( + "consolidation tool: %s — %s → %s%s", + tc_name, purpose, _redact_string(result_text[:100]), + "…" if len(result_text) > 100 else "", + ) except Exception: logger.debug("memory consolidation failed", exc_info=True) diff --git a/emrg/server/prompts/system.j2 b/emrg/server/prompts/system.j2 index 452182d2..2b17cd0e 100644 --- a/emrg/server/prompts/system.j2 +++ b/emrg/server/prompts/system.j2 @@ -146,9 +146,9 @@ A rant (吐槽) is feedback from the host — a complaint, bug report, feature r **Flow**: 1. Detect rant intent → confirm with the host: "Is this feedback you'd like to submit?" (skip the question only when the intent is unmistakable). -2. If information is incomplete (target project? concrete suggestion / expected behavior?) → ask clarifying questions. +2. If information is incomplete (target project? concrete suggestion / expected behavior?) → ask clarifying questions. The `submit_rant` tool requires a `project` — if you don't know which project the rant targets, ask the user first. 3. Polish/structure the raw speech into a clear, actionable rant description. -4. **Show the polished result and get explicit consent** → then call the `submit_rant` tool. +4. **Show the polished result and get explicit consent** → then call the `submit_rant` tool (with the confirmed `project`). 5. If the host says "don't submit / never mind" → do not call the tool. Calling the tool IS the confirmed signal. Never call it without explicit user agreement. For an explicit `/rant ` or a GUI rant-panel submission the host has already expressed intent — treat that as confirmed and keep the direct path. diff --git a/emrg/server/tool_types.py b/emrg/server/tool_types.py index 20502254..87b8b2aa 100644 --- a/emrg/server/tool_types.py +++ b/emrg/server/tool_types.py @@ -16,11 +16,14 @@ class ToolDefinition: name: tool name exposed to the model description: what the tool does (used by the model for routing) + purpose: human-friendly one-line purpose (used in logs/UI — what is + this tool for, in plain words; rant 2026-08-17T12:03:13) parameters: JSON Schema dict for the tool's arguments """ name: str = "" description: str = "" + purpose: str = "" parameters: dict = field(default_factory=dict) diff --git a/emrg/tools/bash_tool.py b/emrg/tools/bash_tool.py index 1aac9b7b..a2b89045 100644 --- a/emrg/tools/bash_tool.py +++ b/emrg/tools/bash_tool.py @@ -128,6 +128,7 @@ class BashTool(ToolExecutor): def definition(self) -> ToolDefinition: return ToolDefinition( name="bash", + purpose="Execute a shell command and return its output (run tests, inspect files, git operations, etc.)", description=( "Execute a shell command and return stdout and stderr. " "Use for running tests, git commands, listing files, " diff --git a/emrg/tools/edit_tool.py b/emrg/tools/edit_tool.py index 25c63582..e93dfbe9 100644 --- a/emrg/tools/edit_tool.py +++ b/emrg/tools/edit_tool.py @@ -22,6 +22,7 @@ class EditTool(ToolExecutor): def definition(self) -> ToolDefinition: return ToolDefinition( name="edit", + purpose="Precisely replace a text fragment in an existing file (shows diff)", description=( "Replace old_string with new_string in an existing file. " "old_string must appear exactly once in the file — use the " diff --git a/emrg/tools/glob_tool.py b/emrg/tools/glob_tool.py index 50130c17..40b5c52d 100644 --- a/emrg/tools/glob_tool.py +++ b/emrg/tools/glob_tool.py @@ -23,6 +23,7 @@ class GlobTool(ToolExecutor): def definition(self) -> ToolDefinition: return ToolDefinition( name="glob", + purpose="Find files by name pattern (e.g. '**/*.py')", description=( "Find files matching a glob pattern. " "Supports standard glob patterns: *, ?, [seq], ** for recursive. " diff --git a/emrg/tools/grep_tool.py b/emrg/tools/grep_tool.py index ce74d992..1e11a79a 100644 --- a/emrg/tools/grep_tool.py +++ b/emrg/tools/grep_tool.py @@ -29,6 +29,7 @@ class GrepTool(ToolExecutor): def definition(self) -> ToolDefinition: return ToolDefinition( name="grep", + purpose="Search file contents with a regex pattern", description=( "Search file contents for a regex pattern. " "Returns matching lines prefixed with filename:line_number. " diff --git a/emrg/tools/read_tool.py b/emrg/tools/read_tool.py index 1d241347..2f86ffd7 100644 --- a/emrg/tools/read_tool.py +++ b/emrg/tools/read_tool.py @@ -29,6 +29,7 @@ class ReadTool(ToolExecutor): def definition(self) -> ToolDefinition: return ToolDefinition( name="read", + purpose="Read file content (line-numbered, chunked)", description=( "Read a file from the filesystem. Returns content with " "line numbers prefixing each line (format: ' LINE_NUMBER\\tCONTENT'). " diff --git a/emrg/tools/submit_rant_tool.py b/emrg/tools/submit_rant_tool.py index d6681121..3e57f1bc 100644 --- a/emrg/tools/submit_rant_tool.py +++ b/emrg/tools/submit_rant_tool.py @@ -25,6 +25,7 @@ class SubmitRantTool(ToolExecutor): def definition(self) -> ToolDefinition: return ToolDefinition( name="submit_rant", + purpose="Write a user-confirmed rant/feedback into rants.jsonl for evolution", description=( "Write a user rant/feedback/improvement suggestion into " "rants.jsonl so the evolution system can act on it. " @@ -38,8 +39,10 @@ def definition(self) -> ToolDefinition: "project": { "type": "string", "description": ( - "Target project name, e.g. 'argszero/aitokenpool'. " - "Omit (empty) for EMRG itself." + "REQUIRED — target project name (e.g. 'emrg', " + "'argszero/aitokenpool'). If you cannot determine " + "which project the rant targets, ask the user " + "before calling." ), }, "message": { @@ -50,7 +53,7 @@ def definition(self) -> ToolDefinition: ), }, }, - "required": ["message"], + "required": ["project", "message"], }, ) @@ -63,6 +66,15 @@ async def execute(self, arguments: dict) -> ToolResult: error=True, ) project = str(arguments.get("project", "") or "").strip() + if not project: + return ToolResult( + name="submit_rant", + content=( + "Error: project is required — ask the user which project " + "this rant targets before submitting" + ), + error=True, + ) try: from emrg.config import config_dir count = append_rant(config_dir() / "rants.jsonl", message, project) diff --git a/emrg/tools/write_tool.py b/emrg/tools/write_tool.py index 51271736..5c36ed7d 100644 --- a/emrg/tools/write_tool.py +++ b/emrg/tools/write_tool.py @@ -19,6 +19,7 @@ class WriteTool(ToolExecutor): def definition(self) -> ToolDefinition: return ToolDefinition( name="write", + purpose="Write or create a file (new file or full overwrite)", description=( "Write content to a file. Creates the file if it doesn't exist, " "or overwrites it if it does. Parent directories are created " diff --git a/tests/test_submit_rant_tool.py b/tests/test_submit_rant_tool.py index 6c85b7b2..9007e29f 100644 --- a/tests/test_submit_rant_tool.py +++ b/tests/test_submit_rant_tool.py @@ -87,6 +87,15 @@ def test_submit_rant_tool_requires_message(): assert "requires a message" in result.content +def test_submit_rant_tool_requires_project(): + """Missing project → error telling the agent to ask the user (rant 12:03:13).""" + tool = SubmitRantTool() + result = __import__("asyncio").run(tool.execute({"message": "some complaint"})) + assert result.error is True + assert "project is required" in result.content + assert "ask the user" in result.content + + def test_submit_rant_definition_exposes_consent_contract(): """The tool description must require explicit user consent before calling.""" tool = SubmitRantTool() @@ -94,3 +103,21 @@ def test_submit_rant_definition_exposes_consent_contract(): assert d.name == "submit_rant" assert "consent" in d.description.lower() or "confirm" in d.description.lower() assert "message" in d.parameters["required"] + assert "project" in d.parameters["required"] # required since rant 12:03:13 + assert d.purpose # human-readable purpose (rant 12:03:13) + + +def test_all_tools_have_purpose(): + """Every registered tool carries a non-empty human-readable purpose.""" + from emrg.tools.bash_tool import BashTool + from emrg.tools.read_tool import ReadTool + from emrg.tools.write_tool import WriteTool + from emrg.tools.edit_tool import EditTool + from emrg.tools.glob_tool import GlobTool + from emrg.tools.grep_tool import GrepTool + + for tool in (BashTool(), ReadTool(), WriteTool(), EditTool(), + GlobTool(), GrepTool(), SubmitRantTool()): + d = tool.definition() + assert d.name, "tool name missing" + assert d.purpose, f"{d.name} has no purpose" diff --git a/tests/test_ws_e2e.py b/tests/test_ws_e2e.py index 3e7f8571..13880c70 100644 --- a/tests/test_ws_e2e.py +++ b/tests/test_ws_e2e.py @@ -692,6 +692,10 @@ async def execute(self, args): return ToolResult(tool_call_id="call_1", name="bash", content="hi", error=False) + def definition(self): + from emrg.server.tool_types import ToolDefinition + return ToolDefinition(name="bash", purpose="slow bash for tests") + orig_get = server.tools.get server.tools.get = lambda name: _SlowBash() if name == "bash" else orig_get(name) @@ -774,6 +778,10 @@ async def execute(self, args): return ToolResult(tool_call_id="call_1", name="bash", content="hi", error=False) + def definition(self): + from emrg.server.tool_types import ToolDefinition + return ToolDefinition(name="bash", purpose="slow bash for tests") + orig_get = server.tools.get server.tools.get = lambda name: _SlowBash() if name == "bash" else orig_get(name) From 2c6035c66b9c19c2c7026d5f52d4c5816eb5eeaf Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Mon, 17 Aug 2026 12:15:52 +0800 Subject: [PATCH 3/3] test: append_rant sort test uses host-local offset (UTC CI fix) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The older-rant fixture hardcoded a +08:00 timestamp; on UTC CI hosts the daemon writes +00:00, so the lexicographic string sort puts the new entry first and the order assertion failed. Production is unaffected (all rants share the host offset), but the test must too — compute the older timestamp as now - 1h in the host's own tz. Verified under TZ=UTC and TZ=Asia/Shanghai. --- tests/test_submit_rant_tool.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_submit_rant_tool.py b/tests/test_submit_rant_tool.py index 9007e29f..46a82122 100644 --- a/tests/test_submit_rant_tool.py +++ b/tests/test_submit_rant_tool.py @@ -16,11 +16,16 @@ def test_append_rant_writes_sorted_entry(tmp_path): """New rant appended, sorted by timestamp, field order message last.""" - # pre-existing rant (older timestamp) must stay before the new one + import datetime as _dt + # pre-existing rant (older timestamp) must stay before the new one — + # use the same tz-aware local offset as the daemon so the lexicographic + # string sort matches chronological order (all rants share the host + # timezone in production; mixing offsets would mis-sort on UTC hosts) + older_ts = (_dt.datetime.now().astimezone() - _dt.timedelta(hours=1)).isoformat() rants_file = tmp_path / "rants.jsonl" rants_file.write_text( json.dumps({ - "timestamp": "2026-08-17T08:00:00+08:00", + "timestamp": older_ts, "project": "emrg", "status": "completed", "progress": None, @@ -46,7 +51,6 @@ def test_append_rant_writes_sorted_entry(tmp_path): assert entries[1]["status"] == "pending" assert entries[1]["completed"] is None # daemon-authoritative tz-aware timestamp - import datetime as _dt ts = _dt.datetime.fromisoformat(entries[1]["timestamp"]) assert ts.tzinfo is not None assert abs((_dt.datetime.now(ts.tzinfo) - ts).total_seconds()) < 60