From 26e32e9bb35aeafdde39dd3bff6154c9e72564e4 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Tue, 3 Nov 2026 20:39:51 +0800 Subject: [PATCH] emrg: the batch-call reminder is a system turn, not the host's voice A round that dispatched exactly one tool call is reminded at the tail of the next request, and that reminder was appended as a user message. A user turn is the host's voice: the role a client renders as the host's bubble, the role a transcript attributes to the host, and the role a compaction summarises as something the host said. The reminder is the daemon's instruction, so every one of those readers was told the host said something it never typed. It is now a system message. Measured against the configured provider: a {"role": "system"} entry after the user turn is accepted and answers normally. Closes #2693 --- emrg/server/daemon.py | 12 +++++++++++- tests/test_batch_call_nudge.py | 11 +++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/emrg/server/daemon.py b/emrg/server/daemon.py index 4a0afc56..4dc76a4c 100644 --- a/emrg/server/daemon.py +++ b/emrg/server/daemon.py @@ -714,6 +714,16 @@ def _with_batch_call_nudge( states it at the tail of the next request, where the next round's calls are actually decided. + It is a **system** message, not a user one. The content is what the model + reads; the role is what every *other* reader reads. A user turn is the + host's voice: it is the role a client renders as the host's bubble, the role + a transcript attributes to the host, and the role a compaction summarises as + something the host said. This reminder is the daemon's instruction to the + model, so a user role tells every one of those readers that the host said + something the host never typed (reported 2026-11-03). A trailing system + message is accepted where it is needed: measured against the configured + provider, `{"role": "system"}` after the user turn answers normally. + A **new list** is returned and `messages` is never touched, and that is the whole of "the reminder exists only in this request": the loop's own list is what `_log_llm_exchange` writes and what a rebuild is rebuilt from, so a @@ -727,7 +737,7 @@ def _with_batch_call_nudge( """ request = [dict(m) for m in messages] if previous_round_tool_calls == 1: - request.append({"role": "user", "content": BATCH_CALL_NUDGE}) + request.append({"role": "system", "content": BATCH_CALL_NUDGE}) return request diff --git a/tests/test_batch_call_nudge.py b/tests/test_batch_call_nudge.py index 08884bdc..828c5caf 100644 --- a/tests/test_batch_call_nudge.py +++ b/tests/test_batch_call_nudge.py @@ -6,8 +6,11 @@ requirement stated in the prompt sits too far from the point where the next round's calls are decided. -So the daemon appends `{"role": "user", "content": "batch call"}` to the -**request** that follows a round dispatching exactly one call. The readings here +So the daemon appends `{"role": "system", "content": "batch call"}` to the +**request** that follows a round dispatching exactly one call. A **system** turn +and not a user one: the reminder is the daemon's instruction, while a user turn +is the host's voice — the role a client renders as the host's bubble and a +transcript attributes to the host. The readings here are the `messages` lists the provider was actually handed — captured where the stream is consumed — so nothing asserts the shape of the code that built them: removing the append, inverting its condition, or moving it ahead of the tool @@ -36,7 +39,7 @@ from emrg.session import Session SESSION_ID = "batch-call-nudge" -NUDGE = {"role": "user", "content": BATCH_CALL_NUDGE} +NUDGE = {"role": "system", "content": BATCH_CALL_NUDGE} class _StubTool: @@ -124,7 +127,7 @@ async def fake_summary(session, records, source=None): def _nudges(messages: list[dict]) -> int: - return sum(1 for m in messages if m.get("role") == "user" + return sum(1 for m in messages if m.get("role") == "system" and m.get("content") == BATCH_CALL_NUDGE)