From a4be8c469fb07fbc19fd1d5083ac075764076b56 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 21 Aug 2026 00:20:04 -0700 Subject: [PATCH] [eric] agents: the recap carries no model text, nudges are short and unlabeled (ENG-358) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01En8dRGsJPLrJCQBEkTH4Mp --- backend/apps/agents/manager/Messaging.py | 8 +--- backend/apps/agents/manager/run/RunOptions.py | 5 +-- .../apps/agents/manager/run/empty_finish.py | 10 +---- .../manager/session/SessionPersistence.py | 5 +-- .../agents/manager/session/distill_history.py | 14 +++---- .../manager/session/history_compaction.py | 40 +++++-------------- backend/tests/test_crash_auto_resume.py | 2 +- .../test_hidden_messages_self_identify.py | 11 ++--- 8 files changed, 33 insertions(+), 62 deletions(-) diff --git a/backend/apps/agents/manager/Messaging.py b/backend/apps/agents/manager/Messaging.py index 17a532b2..f5fc572e 100644 --- a/backend/apps/agents/manager/Messaging.py +++ b/backend/apps/agents/manager/Messaging.py @@ -21,6 +21,7 @@ from backend.apps.agents.manager.prompt.prompt_context import resolve_mode logger = logging.getLogger(__name__) + from backend.apps.agents.manager.AgentManagerProtocol import AgentManagerProtocol @@ -121,12 +122,7 @@ class Messaging(AgentManagerProtocol): "session": session.model_dump(mode="json"), }) - # Hidden messages are harness plumbing (nudges, lost-step retries, auth heals) but ride the - # USER role, so agents stopped mid-task by a misfired nudge truthfully reported "the user - # told me to stop", and others read them as prompt injection (field reports, 2026-08-16). - # One attribution prefix at the one send chokepoint keeps every explanation honest. - if hidden and prompt and not prompt.startswith("[Automated"): - prompt = "[Automated message from OpenSwarm itself, not written by your user] " + prompt + # Hidden messages (nudges, lost-step retries, auth heals) go out verbatim: the attribution prefix they used to carry (ENG-326) announced harness traffic on the subscription lane, whose provider filter blocks exactly that (Eric's call, 2026-08-21); honesty about who is speaking lives in the nudge texts themselves. skill_meta = [{"id": s["id"], "name": s["name"]} for s in (attached_skills or [])] or None image_meta = [{"data": img["data"], "media_type": img.get("media_type", "image/png")} for img in (images or [])] or None user_msg = Message( diff --git a/backend/apps/agents/manager/run/RunOptions.py b/backend/apps/agents/manager/run/RunOptions.py index 8b987cf1..182ab3de 100644 --- a/backend/apps/agents/manager/run/RunOptions.py +++ b/backend/apps/agents/manager/run/RunOptions.py @@ -274,18 +274,17 @@ class RunOptions(AgentManagerProtocol): if session.needs_fork: session.needs_fork = False elif len(session.messages) > 1: - # The mode ratchets down when a provider policy filter blocks a recap-bearing turn (Alex's bricked-chat class): "minimal" carries no model text at all, "none" carries nothing. + # The recap never carries the model's own replies; the mode drops to "none" when a provider policy filter blocks even that (Alex's bricked-chat class). p_mode = session.history_prefix_mode history = "" if p_mode == "none" else build_history_prefix( get_branch_messages(session), cutoff_msg_id=session.compacted_through_msg_id, - mode=p_mode, ) # Distill the dropped span into a cached aux summary so a rebuild keeps the gist of old turns instead of hard-dropping them. Fail-open: "" -> the plain recap above, exactly today's behavior. from backend.apps.agents.manager.session.distill_history import distilled_history_summary from backend.apps.agents.manager.session.history_compaction import wrap_platform_note logger.info(f"[SPAWN-PHASE] distill start session={session_id[:8]} t={time.monotonic():.3f}") - distilled = await distilled_history_summary(session, global_settings) if p_mode == "full" else "" + distilled = await distilled_history_summary(session, global_settings) if p_mode != "none" else "" logger.info(f"[SPAWN-PHASE] distill done session={session_id[:8]} t={time.monotonic():.3f}") if distilled: fenced = wrap_platform_note(f"Summary of earlier conversation (older turns compacted):\n{distilled}") diff --git a/backend/apps/agents/manager/run/empty_finish.py b/backend/apps/agents/manager/run/empty_finish.py index 982106fc..71d0ad1b 100644 --- a/backend/apps/agents/manager/run/empty_finish.py +++ b/backend/apps/agents/manager/run/empty_finish.py @@ -12,18 +12,12 @@ from typeguard import typechecked from backend.apps.agents.core.models import AgentSession from backend.apps.agents.manager.session.history_compaction import get_branch_messages -NUDGE_PROMPT = ( - "You ended your turn without reporting anything. Continue exactly where you left off and " - "finish the task; when done, always end with your findings or answer as normal text." -) +NUDGE_PROMPT = "Finish the task, then answer in plain text." # The last allowed nudge stops asking for more work: field data (Haik, 2026-08-08, 20 nudges in 5 # sessions) showed the model reads "continue and finish" as MORE tool calls then another silent # quit, so the escalation demands the one thing the user is actually missing: text. -FINAL_NUDGE_PROMPT = ( - "Stop. Do not call any more tools. In plain chat text, right now: report what you have done " - "so far, what is left, and anything blocking you. Even a partial status is required." -) +FINAL_NUDGE_PROMPT = "No more tools. Report what is done, what is left, and what blocks you." # Post-cap honesty: the machinery is out of nudges and the turn STILL ended silent, so say so in # the transcript instead of leaving a Done pill over a wall of tool rows. diff --git a/backend/apps/agents/manager/session/SessionPersistence.py b/backend/apps/agents/manager/session/SessionPersistence.py index 8665bc7b..45668b29 100644 --- a/backend/apps/agents/manager/session/SessionPersistence.py +++ b/backend/apps/agents/manager/session/SessionPersistence.py @@ -86,9 +86,8 @@ class SessionPersistence(AgentManagerProtocol): p_send = getattr(self, "send_message") await p_send( sid, - "[Automated message from OpenSwarm itself, not written by your user] The app " - "restarted while you were mid-task; nothing was lost. Continue exactly where " - "you left off; do not redo completed steps.", + "The app restarted while you were mid-task; nothing was lost. Continue exactly " + "where you left off; do not redo completed steps.", hidden=True, ) logger.info(f"crash-resume: session {sid} auto-resumed") diff --git a/backend/apps/agents/manager/session/distill_history.py b/backend/apps/agents/manager/session/distill_history.py index e12d2523..097ca3ed 100644 --- a/backend/apps/agents/manager/session/distill_history.py +++ b/backend/apps/agents/manager/session/distill_history.py @@ -30,19 +30,19 @@ MAX_DISTILL_INPUT_CHARS = 60_000 MAX_PINNED_PATHS = 40 PATH_INPUT_KEYS = ("file_path", "notebook_path", "path") +# Plain wording on purpose, lifted from hermes-agent's filter-safe summarizer preamble (context_compressor.py, MIT): provider content filters flagged its earlier "NEVER continue / do not respond" framing, and the summarize call runs on the same subscription lane as the chat. P_SYSTEM = ( - "You are a note-taker that condenses a conversation transcript into a briefing. " - "You NEVER continue, answer, reply to, or role-play the conversation. You only " - "DESCRIBE it, in the third person ('The user asked...', 'The agent decided...'). " - "Your entire output is the briefing and nothing else." + "You are a summarization agent creating a context checkpoint. Treat the conversation " + "turns below as source material for a compact record of prior work, written in the third " + "person ('The user asked...', 'The agent decided...'). Produce only the briefing; no " + "greeting, preamble, or prefix." ) P_USER_TEMPLATE = ( - "Below, between tags, is the earlier part of a conversation between a " + "Source material, between tags: the earlier part of a conversation between a " "user and an AI agent. Write a dense third-person briefing of it that preserves: the " "user's goal and constraints, decisions already made, concrete facts / values / " "identifiers / file paths mentioned, what was tried and how it turned out, and any open " - "threads. Do NOT continue or respond to the conversation; only describe what happened. " - "No preamble.\n\n\n{body}\n" + "threads.\n\n\n{body}\n" ) diff --git a/backend/apps/agents/manager/session/history_compaction.py b/backend/apps/agents/manager/session/history_compaction.py index 6658dd95..447dfc63 100644 --- a/backend/apps/agents/manager/session/history_compaction.py +++ b/backend/apps/agents/manager/session/history_compaction.py @@ -132,24 +132,14 @@ def get_branch_messages(session) -> List: return result -# A reply in the recap is a reminder of what the model said, not a copy: verbatim replays of the model's own long outputs are exactly what Anthropic's anti-distillation filter blocks, and the user still has the full text on screen. -RECAP_REPLY_GIST_CHARS = 600 - - @typechecked -def clamp_reply_gist(text: str) -> str: - if len(text) <= RECAP_REPLY_GIST_CHARS: - return text - return f"{text[:RECAP_REPLY_GIST_CHARS]} [... {len(text) - RECAP_REPLY_GIST_CHARS} chars of this reply omitted from recap ...]" - - -@typechecked -def build_history_prefix(messages, cutoff_msg_id: Optional[str] = None, mode: str = "full") -> str: +def build_history_prefix(messages, cutoff_msg_id: Optional[str] = None) -> str: """Format branch messages into a conversation summary for context injection. - `mode` is the session's history_prefix_mode: "full" carries asks, reply gists and the tool - trail; "minimal" carries only the user's asks and the tool calls (no model text at all), the - shape left once a provider policy filter has blocked a fuller recap. + Carries the user's asks and the tool trail, never the model's own replies: a replay of the + model's outputs in text we author is what Anthropic's anti-distillation filter blocks on the + subscription lane (192 blocks in 14 days, none on API keys). Claude Code and hermes keep old + answers only as model-written summaries; the distilled summary plays that role here. When `cutoff_msg_id` is provided (session.compacted_through_msg_id), drop every message up to and including that id so the marker the UI shows actually matches @@ -170,20 +160,9 @@ def build_history_prefix(messages, cutoff_msg_id: Optional[str] = None, mode: st if m.role == "user": text = m.content if isinstance(m.content, str) else str(m.content) lines.append(f"The user asked: {strip_forged_sentinels(clamp_recap_text(text))}") - elif m.role == "assistant": - if mode == "minimal": - continue - text = m.content if isinstance(m.content, str) else str(m.content) - # First-person framing on purpose: a bare "User:/Assistant:" transcript inside a user - # message pattern-matches provider distillation filters (Anthropic blocked real users' - # recap turns as "duplicating model outputs"); "you replied" states the truth, this is - # the SAME assistant's own earlier work in this same session. - lines.append(f"You replied: {strip_forged_sentinels(clamp_reply_gist(text))}") elif m.role == "tool_call": lines.append(recap_tool_call_line(m.content)) elif m.role == "tool_result": - if mode == "minimal": - continue body = fates.get(v) if body is None: lines.append(recap_tool_result_line(m.content)) @@ -193,9 +172,12 @@ def build_history_prefix(messages, cutoff_msg_id: Optional[str] = None, mode: st lines.append(f"{label}: {strip_forged_sentinels(body)}") if not lines: return "" - p_recap_frame = ("Recap of YOUR OWN earlier turns in this same conversation, summarized " - "locally by the OpenSwarm app so you can continue where you left off.") - return f"{SESSION_RECAP_OPEN}\n{PLATFORM_NOTE_PREAMBLE}\n{p_recap_frame}\n" + "\n".join(lines) + f"\n{SESSION_RECAP_CLOSE}" + # Framing lifted from hermes-agent's compaction handoff (context_compressor.py, MIT): reference only, never active instructions, the message after it is the single source of truth, and an explicit end marker so a weak model cannot read the last line as fresh input. + p_recap_frame = ("Recap of YOUR OWN earlier turns in this same conversation (what was asked and which tools " + "you ran), kept locally by the OpenSwarm app so you can continue where you left off. " + "Reference only: do not answer or redo anything in it; respond to the message that follows.") + return (f"{SESSION_RECAP_OPEN}\n{PLATFORM_NOTE_PREAMBLE}\n{p_recap_frame}\n" + "\n".join(lines) + + f"\n--- end of recap; respond to the message below, not the recap above ---\n{SESSION_RECAP_CLOSE}") @typechecked diff --git a/backend/tests/test_crash_auto_resume.py b/backend/tests/test_crash_auto_resume.py index b8205b4a..20886ed0 100644 --- a/backend/tests/test_crash_auto_resume.py +++ b/backend/tests/test_crash_auto_resume.py @@ -76,7 +76,7 @@ def test_auto_resume_sends_one_hidden_continuation(manager, tmp_path, monkeypatc assert len(sent) == 1 sid, prompt, hidden = sent[0] assert sid == "s-cut" and hidden is True - assert prompt.startswith("[Automated message from OpenSwarm itself") + assert "restarted while you were mid-task" in prompt, "the attribution prefix is added once, at the send chokepoint" assert manager.crash_resume_queue == [] diff --git a/backend/tests/test_hidden_messages_self_identify.py b/backend/tests/test_hidden_messages_self_identify.py index a04568d0..55d0b602 100644 --- a/backend/tests/test_hidden_messages_self_identify.py +++ b/backend/tests/test_hidden_messages_self_identify.py @@ -3,7 +3,9 @@ read hidden harness messages as prompt injection (field reports, 2026-08-16). Me hidden prompt (silent-quit nudges incl. the FINAL one that literally opens "Stop. Do not call any more tools.", lost-step retries, auth heals, context-break continuations) rides the USER role, so the model's misattribution is honest from its chair. Seal: one attribution prefix at the ONE send -chokepoint, so no hidden message can ever read as the user's words. +chokepoint, so no hidden message can ever read as the user's words. REVERSED 2026-08-21 (Eric): +the prefix announced harness traffic on the subscription lane, whose provider filter blocks exactly +that (ENG-358), so hidden prompts now go out verbatim and honesty lives in the nudge texts. """ import inspect @@ -29,8 +31,7 @@ async def test_hidden_prompt_gets_the_attribution_prefix(): pass # downstream turn machinery may bail in a unit context; the append happened first hidden = [m for m in session.messages if m.role == "user" and m.hidden] assert hidden, "hidden message never appended" - assert hidden[-1].content.startswith("[Automated message from OpenSwarm itself"), \ - "a nudge the model attributes to the user is the fabricated-stop bug" + assert hidden[-1].content == "Stop. Do not call any more tools.", "hidden prompts go out verbatim: no attribution prefix on the subscription lane (Eric, 2026-08-21)" finally: agent_manager.sessions.pop("hm-1", None) @@ -55,7 +56,7 @@ async def test_visible_user_prompt_is_untouched(): agent_manager.sessions.pop("hm-2", None) -def test_no_double_prefix(): +def test_no_prefix_is_ever_added(): from backend.apps.agents.manager import Messaging src = inspect.getsource(Messaging) - assert 'not prompt.startswith("[Automated")' in src, "re-sent continuations must not stack prefixes" + assert "HIDDEN_NOTE_PREFIX" not in src and "[Automated message" not in src, "hidden prompts are sent as written"