From 0cc46f40656ff62cdd8c1062643d7dd702245422 Mon Sep 17 00:00:00 2001 From: argszero Date: Sat, 22 Aug 2026 21:14:49 +0800 Subject: [PATCH 1/2] emrg: revert DeepSeek thinking-mode reasoning pass-back (rant 2026-08-22T21:12:46) --- Agent.md | 2 +- emrg/server/daemon.py | 30 +++----------------------- emrg/session.py | 6 ------ tests/test_session.py | 49 ------------------------------------------- 4 files changed, 4 insertions(+), 83 deletions(-) diff --git a/Agent.md b/Agent.md index f63b2027..4665707b 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.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (1011) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (1008) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (259: 45 daemon_client + 20 conn-manager + 22 app-commands + 129 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 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 91045d08..622c9256 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -2539,24 +2539,18 @@ async def _run_tool_loop( reasoning=full_reasoning, ) - # Persist assistant message (reasoning kept for DeepSeek - # thinking-mode pass-back, rant 2026-08-22T17:25:02) + # Persist assistant message session.append_message({ "type": "message", "role": "assistant", "content": full_content, - **({"reasoning": full_reasoning} if full_reasoning else {}), }) # Append the assistant reply to the local messages so the # LLM context stays coherent when queued messages are # injected after this round (mirrors Case 2's assistant # tool_calls message). - messages.append({ - "role": "assistant", - "content": full_content, - **({"reasoning_content": full_reasoning} if full_reasoning else {}), - }) + messages.append({"role": "assistant", "content": full_content}) # P1 (rant 21:55:37): messages queued mid-round (after the # round-top drain) must not end the turn — inject and continue. @@ -2611,10 +2605,6 @@ async def _run_tool_loop( }, }) assistant_msg["tool_calls"] = openai_tool_calls - if full_reasoning: - # DeepSeek thinking mode: reasoning must be passed back - # verbatim on the next round (rant 2026-08-22T17:25:02). - assistant_msg["reasoning_content"] = full_reasoning messages.append(assistant_msg) # Persist assistant message WITH embedded tool_calls @@ -2622,7 +2612,6 @@ async def _run_tool_loop( "type": "message", "role": "assistant", "content": full_content, - **({"reasoning": full_reasoning} if full_reasoning else {}), "tool_calls": [ { "id": tc.get("id", ""), @@ -2745,17 +2734,12 @@ async def _run_tool_loop( "type": "message", "role": "assistant", "content": full_content, - **({"reasoning": full_reasoning} if full_reasoning else {}), }) # Append the assistant reply to the local messages so the LLM # context stays coherent when queued messages are injected # after this round. - messages.append({ - "role": "assistant", - "content": full_content, - **({"reasoning_content": full_reasoning} if full_reasoning else {}), - }) + messages.append({"role": "assistant", "content": full_content}) # P1 (rant 21:55:37): messages queued mid-round must not end the # turn — inject and continue (injection round does not consume @@ -3752,10 +3736,6 @@ async def _reflect(): # IMPORTANT: assistant message with tool_calls must come BEFORE # tool result messages (OpenAI/DeepSeek API requirement). assistant_msg["tool_calls"] = openai_tool_calls - if msg.get("reasoning_content") or msg.get("reasoning"): - assistant_msg["reasoning_content"] = ( - msg.get("reasoning_content") or msg.get("reasoning") - ) messages.append(assistant_msg) for tc in tool_calls: @@ -3868,10 +3848,6 @@ async def _consolidate_session_memories( "function": {"name": fn.get("name", ""), "arguments": fn.get("arguments", "")}, }) assistant_msg["tool_calls"] = openai_tool_calls - if msg.get("reasoning_content") or msg.get("reasoning"): - assistant_msg["reasoning_content"] = ( - msg.get("reasoning_content") or msg.get("reasoning") - ) messages.append(assistant_msg) for tc in tool_calls: diff --git a/emrg/session.py b/emrg/session.py index 16b663f5..0510b25c 100644 --- a/emrg/session.py +++ b/emrg/session.py @@ -323,12 +323,6 @@ def get_messages_for_llm(self) -> list[dict]: if r.get("type") == "message": msg: dict = {"role": r["role"], "content": r.get("content")} - # DeepSeek thinking mode: assistant reasoning must be passed - # back verbatim to the API (rant 2026-08-22T17:25:02). Old - # records without the field are skipped naturally. - if r.get("role") == "assistant" and r.get("reasoning"): - msg["reasoning_content"] = r["reasoning"] - # Check for embedded tool_calls (current format) embedded_tc = r.get("tool_calls") if embedded_tc and r["role"] == "assistant": diff --git a/tests/test_session.py b/tests/test_session.py index 5f26d796..35cabcaf 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -641,55 +641,6 @@ def test_messages_with_embedded_tool_calls(self, tmp_path): assert result[2]["role"] == "tool" assert result[2]["tool_call_id"] == "call_1" - def test_assistant_reasoning_passed_back(self, tmp_path): - """get_messages_for_llm() maps persisted reasoning to reasoning_content - (DeepSeek thinking-mode pass-back, rant 2026-08-22T17:25:02).""" - session = Session.create(tmp_path) - session.append_message({ - "type": "message", - "role": "assistant", - "content": "answer", - "reasoning": "think step by step", - }) - - result = session.get_messages_for_llm() - assert len(result) == 1 - assert result[0]["reasoning_content"] == "think step by step" - - def test_assistant_reasoning_with_tool_calls_passed_back(self, tmp_path): - """get_messages_for_llm() keeps reasoning_content on tool-call rounds.""" - session = Session.create(tmp_path) - session.append_message({ - "type": "message", - "role": "assistant", - "content": None, - "reasoning": "need to read the file", - "tool_calls": [ - {"id": "call_1", "type": "function", "function": {"name": "read", "arguments": "{}"}}, - ], - }) - session.append_message({ - "type": "tool_result", - "role": "tool", - "tool_call_id": "call_1", - "content": "file content", - }) - - result = session.get_messages_for_llm() - assert len(result) == 2 - assert result[0]["reasoning_content"] == "need to read the file" - assert "tool_calls" in result[0] - - def test_old_records_without_reasoning_untouched(self, tmp_path): - """get_messages_for_llm() skips reasoning for old records without the field.""" - session = Session.create(tmp_path) - session.append_message({"type": "message", "role": "assistant", "content": "old answer"}) - - result = session.get_messages_for_llm() - assert len(result) == 1 - assert "reasoning_content" not in result[0] - assert result[0] == {"role": "assistant", "content": "old answer"} - class TestSessionListSessions: """Tests for Session.list_sessions().""" From 3cd02a9c1fe3fe00dcb62e8398ab5979cb1e3893 Mon Sep 17 00:00:00 2001 From: argszero Date: Sat, 22 Aug 2026 21:27:16 +0800 Subject: [PATCH 2/2] emrg: revert tool result sliding window (#936) (rant 2026-08-22T21:23:18) --- Agent.md | 2 +- emrg/config.py | 5 -- emrg/server/daemon.py | 106 ---------------------------- emrg/server/prompts/system.j2 | 6 -- tests/test_daemon.py | 129 +--------------------------------- 5 files changed, 2 insertions(+), 246 deletions(-) diff --git a/Agent.md b/Agent.md index 4665707b..3125fb76 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.token; python -m emrg ``` -Python: `uv run pytest tests/ -v` (1008) — import check: `uv run python -c "from emrg.client.app import run_client"` +Python: `uv run pytest tests/ -v` (1003) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (259: 45 daemon_client + 20 conn-manager + 22 app-commands + 129 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 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/config.py b/emrg/config.py index 7b7f4d9e..3460021d 100644 --- a/emrg/config.py +++ b/emrg/config.py @@ -26,10 +26,6 @@ class LlmConfig: # 30 轮在复杂任务中经常不够用,导致任务被截断。270 轮给予充足空间。 # 演化系统可能会尝试将此值改小——保留此注释以说明意图。 max_tool_rounds: int = 270 - # 工具结果滑动窗口:每次发送 LLM 前仅保留最近 N 轮完整工具结果, - # 更早的原子组折叠为省略占位消息(软节流)。0 = 关闭(全量发送,行为与旧版一致)。 - # 与 auto-compact 互补:窗口折叠后 token 估算骤降,有损压缩的触发概率大幅下降。 - tool_window_rounds: int = 7 context_window: int = 131072 auto_compact_threshold: float = 0.0 models: list[dict] = field(default_factory=list) # [[llm.models]] for /model switching @@ -104,7 +100,6 @@ def load_config() -> EmrgConfig: max_tokens=llm_data.get("max_tokens", 8192), temperature=llm_data.get("temperature", 0.7), max_tool_rounds=llm_data.get("max_tool_rounds", 270), - tool_window_rounds=llm_data.get("tool_window_rounds", 7), context_window=llm_data.get("context_window", 131072), auto_compact_threshold=llm_data.get("auto_compact_threshold", 0.0), models=llm_data.get("models", []), diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 622c9256..53d593df 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -232,10 +232,6 @@ def __init__(self, llm_config: LlmConfig) -> None: # 有差异 = 已装新版本但 daemon 未重启 → 弹"重启生效"横幅。 self._run_version = self._current_installed_version() self._max_tool_rounds = llm_config.max_tool_rounds - # Tool-result sliding window: keep full tool results for the most - # recent N rounds; older groups are folded into a placeholder at - # send time (rant 2026-08-22T11:33:54). - self._tool_window_rounds = llm_config.tool_window_rounds self._projects_log = runtime_dir / "projects.yml" self._rants_log = runtime_dir / "rants.jsonl" @@ -1313,11 +1309,6 @@ def _build_system_prompt(self, session: Session | None = None) -> str: if session: ctx["session"] = self._collect_history_data(session) - # ── Tool Window ── - # Tool-result sliding window (rant 2026-08-22T11:33:54): expose the - # configured window so system.j2 renders the fold notice (hidden when 0). - ctx["tool_window_rounds"] = self._tool_window_rounds - template = _get_jinja_env().get_template("system.j2") rendered = template.render(**ctx) @@ -2209,94 +2200,6 @@ async def _run_tool_loop_locked( "session_id": session_id, }) - def _apply_tool_window( - self, - messages: list[dict], - keep_rounds: int = 7, - history_path: str = "", - ) -> list[dict]: - """Fold tool results older than the recent N rounds (pure function). - - Atomic group = assistant message with tool_calls + its immediately - following tool messages (OpenAI pairing constraint, see - session._validate_tool_messages). The most recent ``keep_rounds`` - groups are kept in full; each older group is replaced in-place by a - single assistant placeholder message carrying tool names/counts, - tool_call_ids and the on-disk history path for backtracking. - - Never folded: system/user messages, assistant plain-text replies, - summary records, and groups inside the window. ``keep_rounds <= 0`` - disables folding (identity). No session/disk access — the history - path is passed in as a string (design doc §4.3/§4.4, rant - 2026-08-22T11:33:54). - """ - if keep_rounds <= 0: - return messages - - # Split messages into segments: (foldable_group, payload) tuples. - # A foldable group is an assistant msg with tool_calls plus all - # consecutive tool messages that follow it. - segments: list[tuple[bool, object]] = [] - i = 0 - n = len(messages) - while i < n: - m = messages[i] - if m.get("role") == "assistant" and m.get("tool_calls"): - group = [m] - j = i + 1 - while j < n and messages[j].get("role") == "tool": - group.append(messages[j]) - j += 1 - segments.append((True, group)) - i = j - else: - segments.append((False, m)) - i += 1 - - # Identify the most recent keep_rounds foldable groups (tail-scan). - foldable_idx = [k for k, (foldable, _) in enumerate(segments) if foldable] - keep_from = max(0, len(foldable_idx) - keep_rounds) - keep_set = set(foldable_idx[keep_from:]) - - out: list[dict] = [] - for k, (foldable, payload) in enumerate(segments): - if foldable and k not in keep_set: - out.append(self._fold_tool_group(payload, keep_rounds, history_path)) - elif foldable: - out.extend(payload) # type: ignore[arg-type] - else: - out.append(payload) # type: ignore[arg-type] - return out - - @staticmethod - def _fold_tool_group( - group: list[dict], - keep_rounds: int, - history_path: str, - ) -> dict: - """Build the placeholder assistant message for one folded group.""" - leader = group[0] - tool_calls = leader.get("tool_calls") or [] - counts: dict[str, int] = {} - ids: list[str] = [] - for tc in tool_calls: - name = (tc.get("function") or {}).get("name") or "?" - counts[name] = counts.get(name, 0) + 1 - ids.append(str(tc.get("id", ""))) - executed = ", ".join(f"{name} ×{cnt}" for name, cnt in counts.items()) - id_list = ", ".join(ids) - - lines = [ - f"[Tool results omitted — older than recent {keep_rounds} rounds]", - f"executed: {executed}", - f"tool_call_ids: {id_list}", - ] - if history_path: - lines.append(f"full results: {history_path}") - anchor = ids[0] if ids else "tool_call_id" - lines.append(f" → grep '<{anchor}>' 定位对应结果;或按时间戳区间回溯") - return {"role": "assistant", "content": "\n".join(lines)} - async def _run_tool_loop( self, req: TaskRequest, ws, session: Session, cancel_event: asyncio.Event | None = None, @@ -2346,15 +2249,6 @@ async def _run_tool_loop( force_ask = False round_num = 1 while True: - # Tool-result sliding window (rant 2026-08-22T11:33:54): fold - # tool results older than the most recent N rounds into a - # placeholder before each LLM request. Pure fold — the on-disk - # history.jsonl keeps full results for backtracking. - messages = self._apply_tool_window( - messages, - keep_rounds=self._tool_window_rounds, - history_path=str(session.dir_path / "history.jsonl"), - ) if round_num > self._max_tool_rounds: # P1 (rant 21:55:37): round budget exhausted but messages # still queued — process them with a fresh round budget diff --git a/emrg/server/prompts/system.j2 b/emrg/server/prompts/system.j2 index 59c2bc4e..5a4fdac2 100644 --- a/emrg/server/prompts/system.j2 +++ b/emrg/server/prompts/system.j2 @@ -22,12 +22,6 @@ You are EMRG, an evolving AI agent running as a micro-kernel daemon (emrgd). You **Working directory**: `{{ working_dir }}` {% endif %} -{% if tool_window_rounds > 0 %} -1. 为控制上下文长度,较早的工具调用结果会在发送时折叠为省略标记(仅保留最近 {{ tool_window_rounds }} 轮的完整结果)。看到 [Tool results omitted] 标记时,可依据标记中的路径与标识回溯查看完整记录(磁盘始终保留全量数据)。 -2. 建议:工具调用结果中的有价值信息(关键数据、发现、决策依据、坑),请在对话过程中及时用 write/edit 工具总结到记忆文件或临时文件——不要依赖它们永远留在上下文里;省略后如需回顾可依据标记回溯。 -3. 记忆优先写入 session/project memory,临时参考写入会话目录临时文件。 -{% endif %} - {% if project_context %} ## Project Context diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 36a84543..cc1228b6 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -21,7 +21,7 @@ from emrg.protocol import InstanceIdentity from emrg.server.daemon import EmrgServer from emrg.server.scheduler import TaskHandler, TaskScheduler -from emrg.session import Session, _validate_tool_messages +from emrg.session import Session # ── TaskHandler._build_evolution_prompt ───────────────────── @@ -1784,130 +1784,3 @@ def test_pong_run_vs_installed_version(tmp_path, monkeypatch): assert frame["current_version"] == "0.2.61" assert frame["installed_version"] == "0.2.62" assert "previous_version" not in frame, "previous-version.txt no longer used (14:38:27)" - - -# ── Tool-result sliding window (rant 2026-08-22T11:33:54) ── - - -def _make_tool_group(group_no: int, tool_names: list[str]) -> list[dict]: - """Build one atomic tool group: assistant(tool_calls) + tool messages.""" - group = [{ - "role": "assistant", - "content": None, - "tool_calls": [ - {"id": f"call_{group_no}_{i}", "type": "function", - "function": {"name": name, "arguments": "{}"}} - for i, name in enumerate(tool_names) - ], - }] - for i, name in enumerate(tool_names): - group.append({ - "role": "tool", - "tool_call_id": f"call_{group_no}_{i}", - "content": f"{name} output {group_no}", - }) - return group - - -def test_tool_window_folds_old_groups_keeps_recent(): - """10 groups, keep 7 → 3 oldest folded to placeholders, 7 complete.""" - server = _make_server() - messages: list[dict] = [] - for n in range(10): - messages.extend(_make_tool_group(n, ["bash"])) - out = server._apply_tool_window( - messages, keep_rounds=7, history_path="/tmp/x/history.jsonl" - ) - placeholders = [m for m in out - if (m.get("content") or "").startswith("[Tool results omitted")] - complete = [m for m in out if m.get("role") == "assistant" and m.get("tool_calls")] - tools = [m for m in out if m.get("role") == "tool"] - assert len(placeholders) == 3 - assert len(complete) == 7 - assert len(tools) == 7 - # Order preserved: the 3 oldest group positions hold placeholders. - for idx in range(3): - assert out[idx]["role"] == "assistant" - assert out[idx].get("content", "").startswith("[Tool results omitted") - # Placeholder carries backtrack info. - ph = placeholders[0]["content"] - assert "[Tool results omitted — older than recent 7 rounds]" in ph - assert "bash ×1" in ph - assert "tool_call_ids: call_0_0" in ph - assert "full results: /tmp/x/history.jsonl" in ph - - -def test_tool_window_zero_disables_folding(): - """keep_rounds=0 → identity (feature off, old behavior).""" - server = _make_server() - messages: list[dict] = [] - for n in range(10): - messages.extend(_make_tool_group(n, ["bash", "read"])) - out = server._apply_tool_window(messages, keep_rounds=0) - assert out == messages - - -def test_tool_window_no_fold_within_window(): - """Fewer groups than keep_rounds → nothing folded.""" - server = _make_server() - messages: list[dict] = [] - for n in range(3): - messages.extend(_make_tool_group(n, ["bash"])) - out = server._apply_tool_window(messages, keep_rounds=7, history_path="x") - assert out == messages - - -def test_tool_window_placeholder_aggregates_tool_counts(): - """executed line aggregates counts per tool name.""" - server = _make_server() - messages = _make_tool_group(0, ["bash", "bash", "read"]) - messages.extend(_make_tool_group(1, ["grep"])) - out = server._apply_tool_window(messages, keep_rounds=1, history_path="/h.jsonl") - placeholders = [m for m in out - if (m.get("content") or "").startswith("[Tool results omitted")] - assert len(placeholders) == 1 - ph = placeholders[0]["content"] - assert "bash ×2" in ph - assert "read ×1" in ph - assert "tool_call_ids: call_0_0, call_0_1, call_0_2" in ph - assert "full results: /h.jsonl" in ph - # The other group remains complete. - assert any(m.get("role") == "tool" for m in out) - - -def test_tool_window_preserves_non_foldable_and_validates(): - """system/user/text messages untouched; folded output stays API-valid.""" - server = _make_server() - messages: list[dict] = [ - {"role": "system", "content": "sys"}, - {"role": "user", "content": "u1"}, - ] - for n in range(10): - messages.extend(_make_tool_group(n, ["bash"])) - messages.append({"role": "assistant", "content": "final text reply"}) - out = server._apply_tool_window( - messages, keep_rounds=7, history_path="/tmp/x/history.jsonl" - ) - assert out[0] == {"role": "system", "content": "sys"} - assert out[1] == {"role": "user", "content": "u1"} - assert out[-1] == {"role": "assistant", "content": "final text reply"} - # No orphaned tool messages after folding (OpenAI pairing holds). - valid = _validate_tool_messages([dict(m) for m in out]) - i = 0 - while i < len(valid): - if valid[i].get("tool_calls"): - j = i + 1 - want = {tc["id"] for tc in valid[i]["tool_calls"]} - got: set[str] = set() - while j < len(valid) and valid[j].get("role") == "tool": - got.add(valid[j]["tool_call_id"]) - j += 1 - assert got == want - i = j - else: - i += 1 - # Every tool message must be preceded by an assistant with tool_calls. - for k, m in enumerate(out): - if m.get("role") == "tool": - prev = out[k - 1] if k > 0 else {} - assert prev.get("tool_calls"), "tool message must follow assistant with tool_calls"