diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 157776da68..e1b475513f 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -29,6 +29,7 @@ from agent.prompt_caching import ( strip_anthropic_tool_cache_control, ) from agent.runtime_cwd import resolve_agent_cwd +from agent.surface_switch import identity_line_value, note_inert_pinned_tools, stage_surface_switch_note from agent.turn_context import PreflightCompressionTimedOut, build_turn_context from agent.turn_retry_state import TurnRetryState # Phase helpers of the turn loop, bound at import so a source-tree swap cannot load a @@ -677,7 +678,7 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history) except Exception: pass agent._cached_system_prompt = agent._build_system_prompt(system_message) - _stage_surface_switch_note(agent, agent._cached_system_prompt, conversation_history) + stage_surface_switch_note(agent, agent._cached_system_prompt, conversation_history) # Persist so the NEXT turn restores the new bytes verbatim (cache break is # once per capability change). on_session_start not re-fired: continuation. _persist_system_prompt( @@ -691,7 +692,7 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history) agent._cached_system_prompt = stored_prompt # The reused bytes may describe the surface this conversation STARTED on; correct that # at the tail of the request instead of rebuilding the prompt in front of it (#104414). - announced_switch = _stage_surface_switch_note(agent, stored_prompt, conversation_history) + announced_switch = stage_surface_switch_note(agent, stored_prompt, conversation_history) # Same contract for tools[]: pin the array to the order this session already # sent (tools freeze) instead of re-probing every check_fn on a fresh AIAgent. # The pin holds ON the announcing turn too. tools[] is serialized AHEAD of the system @@ -704,11 +705,11 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history) try: saved_tools = session_row.get("tool_names") if session_row else None if saved_tools: - from tools.mcp_tool_agent import restore_agent_tool_prefix - built_for_this_surface = _agent_tool_names(agent) + from tools.mcp_tool_agent import _def_name, restore_agent_tool_prefix + built_for_this_surface = [_def_name(t) for t in agent.tools or []] restore_agent_tool_prefix(agent, json.loads(saved_tools)) if announced_switch: - _note_inert_pinned_tools(agent, built_for_this_surface) + note_inert_pinned_tools(agent, built_for_this_surface) except Exception: logger.debug("tool prefix restore skipped", exc_info=True) # Prompt-section callbacks are new-session-only; recover their frozen bytes @@ -745,7 +746,7 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history) # transcript by an earlier switch does not — retire it here too, or a rebuild for an # unrelated reason (a model switch) would leave the newest interface statement in the # request naming a surface the conversation has left (#104414). - _stage_surface_switch_note(agent, agent._cached_system_prompt, conversation_history) + stage_surface_switch_note(agent, agent._cached_system_prompt, conversation_history) # Plugin hook: on_session_start — fired once for a brand-new session, not on continuation. try: @@ -773,157 +774,17 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history) ) -# A surface switch (desktop <-> TUI, a session resumed under a different host) changes only -# which interface renders the reply, but the surface guidance and the ``Platform:`` trailer are -# embedded in the persisted prompt. Rebuilding for it produced a system prompt that diverged -# from the cached one within its first blocks, so the ENTIRE request behind it re-prefilled — -# a 220K-token session came back at a 1% cache hit (#104414). The stored bytes are therefore -# kept and the CURRENT surface's guidance is delivered on the per-turn user-message channel -# instead: that lands after the cached prefix, is stamped into the byte-stable ``api_content`` -# sidecar so later turns replay it unchanged, and the prompt itself converges at the next -# rebuild boundary (compaction). -_SURFACE_SWITCH_NOTE_PREFIX = "[System: This conversation is now being answered on a different interface: " - - -def _stored_prompt_platform(prompt: str) -> str: - """The ``Platform:`` value the stored prompt was built with ("" when absent). - - Parses only the authoritative identity portion before `# Hermes runtime environment` - so embedder prose (e.g. HERMES_ENVIRONMENT_HINT decoys) cannot shadow it. - """ - identity, runtime_marker, _ = prompt.rpartition(f"\n\n{RUNTIME_ENVIRONMENT_HEADING}\n\n") - runtime_marker = runtime_marker if prompt.endswith(RUNTIME_ENVIRONMENT_END) else "" - lines = (identity if runtime_marker else prompt).splitlines() - - prefix = "Platform:" - matches = [line[len(prefix):].strip() for line in lines if line.startswith(prefix)] - return matches[-1] if matches else "" - - -def _transcript_row_texts(msg): - """Every string the model actually saw for one transcript row: the wire sidecar first, - then the stored content (plain string, or the text parts of a multimodal list).""" - if not isinstance(msg, dict): - return - sidecar = msg.get("api_content") - if isinstance(sidecar, str): - yield sidecar - content = msg.get("content") - if isinstance(content, str): - yield content - elif isinstance(content, list): - for part in content: - if isinstance(part, dict) and isinstance(part.get("text"), str): - yield part["text"] - - -def _last_announced_surface(conversation_history) -> str: - """The surface named by the NEWEST surface note in the transcript ("" when there is none). - - The note is stamped into the ``api_content`` sidecar and persisted, so it is the last thing - the model was TOLD it is running on — and it outranks the stored prompt's own ``Platform:`` - trailer once a switch has been announced. Only the newest note counts: an older one names - a surface the conversation has since left. Reading it back is also what stops a fresh - AIAgent per turn (the gateway shape) from stacking one copy of the note per turn. - """ - for msg in reversed(conversation_history or []): - for text in _transcript_row_texts(msg): - if _SURFACE_SWITCH_NOTE_PREFIX in text: - return text.rsplit(_SURFACE_SWITCH_NOTE_PREFIX, 1)[1].split(".", 1)[0].strip() - return "" - - -def _agent_tool_names(agent) -> list: - """The names in ``agent.tools``, in wire order ([] when the attribute is unreadable).""" - names = [] - try: - for entry in getattr(agent, "tools", None) or []: - name = (entry.get("function") or {}).get("name", "") if isinstance(entry, dict) else "" - if name: - names.append(name) - except Exception: - return [] - return names - - -def _note_inert_pinned_tools(agent, built_for_this_surface: list) -> None: - """Name the pinned tools THIS surface did not build, at the end of the switch note. - - The freeze keeps a previous surface's tools on the wire deliberately — removing them is the - one thing that would still re-prefill the request behind the preserved prompt — so the model - has to be TOLD they are inert here, or it plans around a ``focus_pane`` that a terminal turn - can only answer with ``tool_error("desktop only")``. Rides the note, so it is one-shot and - lands behind the cached prefix like the rest of the correction. - """ - surface_names = set(built_for_this_surface) - inert = [name for name in _agent_tool_names(agent) if name not in surface_names] - note = getattr(agent, "_surface_switch_note", "") or "" - if not inert or not note: - return - agent._surface_switch_note = note + ( - "\n[System: These tools stay listed for this conversation (dropping them would discard " - "the cached request prefix) but were not loaded for this interface — expect a call to " - f"one of them to fail: {', '.join(inert)}.]" - ) - - -def _stage_surface_switch_note(agent, prompt: str, conversation_history) -> bool: - """Stage a correction when the request would otherwise misdescribe the current surface. - - Compares the runtime surface against what the model was last told — the newest surface note - if one exists, else ``prompt``'s own ``Platform:`` trailer. Consulting the note matters in - both directions: switching BACK to the surface the prompt was built for (desktop -> tui -> - desktop) leaves the trailer agreeing with the runtime while a stale note still tells the - model otherwise, and a rebuild for an unrelated reason (a model switch) refreshes the prompt - but not that note. - - The full surface guidance is attached only when the prompt itself is out of date; when the - prompt already describes the current surface the note just retires the stale one. Returns - whether it staged — the caller pairs that with re-persisting the tool names, because this is - the turn the surface's toolset changes under it. - """ - current = str(getattr(agent, "platform", "") or "").strip() - described = _stored_prompt_platform(prompt) - told = _last_announced_surface(conversation_history) or described - if not current or not told or told == current: - return False - from agent.system_prompt import platform_surface_hint - hint = platform_surface_hint(agent) if described != current else "" - where = "the guidance below" if hint else "the interface section in the system prompt above" - note = ( - f"{_SURFACE_SWITCH_NOTE_PREFIX}{current}. Any earlier interface guidance in this " - f"conversation is superseded — follow {where} for formatting, file delivery and any " - "interface-specific capability.]" - ) - agent._surface_switch_note = f"{note}\n{hint}" if hint else note - logger.info( - "Session %s switched surface %s -> %s; keeping the stored system prompt and delivering " - "the new surface guidance as a turn note (prefix cache preserved).", - agent.session_id, told, current, - ) - return True - - def _stored_prompt_matches_runtime(agent, prompt: str) -> bool: """Return False when the persisted runtime-identity lines are stale.""" identity, runtime_marker, runtime = prompt.rpartition(f"\n\n{RUNTIME_ENVIRONMENT_HEADING}\n\n") # Legacy prose may quote the heading, but only the new renderer ends in this boundary. runtime_marker = runtime_marker if prompt.endswith(RUNTIME_ENVIRONMENT_END) else "" - # The final runtime block can contain embedder prose, not authoritative identity. - lines = (identity if runtime_marker else prompt).splitlines() - - def line_value(label: str) -> str: - """Last matching line wins — safe ONLY for volatile-tier fields at the END of the - prompt (embedded project context could shadow earlier fields; see ``host_info_value``).""" - prefix = f"{label}:" - matches = [line[len(prefix):].strip() for line in lines if line.startswith(prefix)] - return matches[-1] if matches else "" def host_info_value(label: str) -> str: """New prompts delimit runtime hints; legacy prompts put them before context.""" prefix = f"{label}:" - host_lines = runtime.split("\n\n", 1)[0].splitlines() if runtime_marker else lines + host_lines = (runtime.split("\n\n", 1)[0] if runtime_marker else prompt).splitlines() for idx, line in enumerate(host_lines): if line.startswith("User home directory:"): for candidate in host_lines[idx + 1: idx + 4]: @@ -933,9 +794,9 @@ def _stored_prompt_matches_runtime(agent, prompt: str) -> bool: # Model/provider identity, then cwd drift. A cwd change is a real content change (context # files, the workspace snapshot and the coding posture are all resolved from it), so it - # still rebuilds; the runtime surface does not — see the note above. + # still rebuilds; the runtime surface does not (agent/surface_switch.py). for label, attr in (("Model", "model"), ("Provider", "provider")): - stored = line_value(label) + stored = identity_line_value(prompt, label) current = str(getattr(agent, attr, "") or "").strip() if stored and current and stored != current: return False @@ -946,7 +807,7 @@ def _stored_prompt_matches_runtime(agent, prompt: str) -> bool: return False # Platform is deliberately NOT an identity field: a surface switch does not invalidate the # stored bytes, it only makes their interface section out of date, and that is corrected by - # _stage_surface_switch_note without touching the cached prefix (#104414). + # agent.surface_switch.stage_surface_switch_note without touching the cached prefix (#104414). return True diff --git a/agent/surface_switch.py b/agent/surface_switch.py new file mode 100644 index 0000000000..29a7810d84 --- /dev/null +++ b/agent/surface_switch.py @@ -0,0 +1,138 @@ +"""Surface switch without a prompt rebuild (#104414). + +A surface switch (desktop <-> TUI, a session resumed under a different host) changes only which +interface renders the reply, but the surface guidance and the ``Platform:`` trailer are embedded +in the persisted system prompt. Rebuilding for it diverged the prompt within its first blocks, +so the ENTIRE request behind it re-prefilled — a 220K-token session came back at a 1% cache hit. +The stored bytes are therefore kept and the CURRENT surface's guidance is delivered on the +per-turn user-message channel instead: that lands after the cached prefix and is stamped into +the byte-stable ``api_content`` sidecar so later turns replay it unchanged. The prompt itself +converges at the next rebuild boundary (compaction). +""" +from __future__ import annotations + +import logging +from typing import Any, Iterator, List + +from agent.prompt_builder import RUNTIME_ENVIRONMENT_END, RUNTIME_ENVIRONMENT_HEADING + +logger = logging.getLogger("run_agent") + +_SURFACE_SWITCH_NOTE_PREFIX = "[System: This conversation is now being answered on a different interface: " +# Closes the surface name in the note; platform names are free-form for plugin platforms, so the +# terminator (not ".") delimits the parse. +_SURFACE_NAME_END = " — any earlier interface guidance" +# Only the newest note matters, and the note is re-stamped on the switch turn, so a bounded tail +# scan is enough; without a bound every turn of a never-switched session walks the whole transcript. +_NOTE_SCAN_TAIL = 200 + + +def identity_line_value(prompt: str, label: str) -> str: + """Last ``Label: value`` line in the authoritative identity portion of a persisted prompt. + + Legacy prose may quote the runtime heading, but only the new renderer ENDS in the boundary; + the final runtime block is embedder prose, never identity, so it is excluded when present. + Last match wins — safe only for volatile-tier trailer fields at the end of the prompt.""" + identity, runtime_marker, _ = prompt.rpartition(f"\n\n{RUNTIME_ENVIRONMENT_HEADING}\n\n") + runtime_marker = runtime_marker if prompt.endswith(RUNTIME_ENVIRONMENT_END) else "" + prefix = f"{label}:" + matches = [ + line[len(prefix):].strip() + for line in (identity if runtime_marker else prompt).splitlines() + if line.startswith(prefix) + ] + return matches[-1] if matches else "" + + +def _transcript_row_texts(msg: Any) -> Iterator[str]: + """Every string the model actually saw for one transcript row: the wire sidecar first, then + the stored content (plain string, or the text parts of a multimodal list — where the note + lands as a durable text part because a list cannot take the string sidecar).""" + if not isinstance(msg, dict): + return + sidecar = msg.get("api_content") + if isinstance(sidecar, str): + yield sidecar + content = msg.get("content") + if isinstance(content, str): + yield content + elif isinstance(content, list): + for part in content: + if isinstance(part, dict) and isinstance(part.get("text"), str): + yield part["text"] + + +def _last_announced_surface(conversation_history: Any) -> str: + """The surface named by the NEWEST switch note in the transcript ("" when none). + + Once a switch has been announced, that note — not the stored prompt's ``Platform:`` trailer — + is the last thing the model was told it runs on. Reading it back is also what keeps a fresh + AIAgent per turn (the gateway shape) from stacking one copy of the note per turn.""" + for msg in reversed((conversation_history or [])[-_NOTE_SCAN_TAIL:]): + for text in _transcript_row_texts(msg): + if _SURFACE_SWITCH_NOTE_PREFIX in text: + tail = text.rsplit(_SURFACE_SWITCH_NOTE_PREFIX, 1)[1] + return tail.split(_SURFACE_NAME_END, 1)[0].strip() + return "" + + +def _agent_tool_names(agent: Any) -> List[str]: + from tools.mcp_tool_agent import _def_name + return [name for name in map(_def_name, getattr(agent, "tools", None) or []) if name] + + +def note_inert_pinned_tools(agent: Any, built_for_this_surface: List[str]) -> None: + """Name, at the end of the staged note, the pinned tools THIS surface did not build. + + The freeze keeps a previous surface's tools on the wire deliberately — removing them is the one + thing that would still re-prefill the request behind the preserved prompt — so the model has + to be TOLD they are inert here, or it plans around a ``focus_pane`` a terminal turn can only + answer with ``tool_error("desktop only")``.""" + surface_names = set(built_for_this_surface) + inert = [name for name in _agent_tool_names(agent) if name not in surface_names] + note = getattr(agent, "_surface_switch_note", "") or "" + if not inert or not note: + return + agent._surface_switch_note = note + ( + "\n[System: These tools stay listed for this conversation (dropping them would discard " + "the cached request prefix) but were not loaded for this interface — expect a call to " + f"one of them to fail: {', '.join(inert)}.]" + ) + + +def stage_surface_switch_note(agent: Any, prompt: str, conversation_history: Any) -> bool: + """Stage a one-shot correction when the request would otherwise misdescribe the surface. + + Compares the runtime surface against what the model was last told — the newest switch note + if one exists, else ``prompt``'s own ``Platform:`` trailer. Consulting the note matters in + both directions: switching BACK to the prompt's surface (desktop -> tui -> desktop) leaves the + trailer agreeing with the runtime while a stale note still says otherwise, and a rebuild for + an unrelated reason (a model switch) refreshes the prompt but not that note. The full surface + guidance is attached only when the prompt itself is out of date; otherwise the note just + retires the stale one. Returns whether it staged. + + MoA and codex_app_server turns never stamp the ``api_content`` sidecar, so the note could not + be read back and would be re-sent every turn; those modes keep the stored prompt and skip the + note entirely.""" + if getattr(agent, "provider", None) == "moa" or getattr(agent, "api_mode", None) == "codex_app_server": + return False + current = str(getattr(agent, "platform", "") or "").strip() + described = identity_line_value(prompt, "Platform") + told = _last_announced_surface(conversation_history) or described + if not current or not told or told == current: + return False + from agent.system_prompt import platform_surface_hint + hint = platform_surface_hint(agent) if described != current else "" + where = "the guidance below" if hint else "the interface section in the system prompt above" + note = ( + f"{_SURFACE_SWITCH_NOTE_PREFIX}{current}{_SURFACE_NAME_END} in this conversation is " + f"superseded — follow {where} for formatting, file delivery and any interface-specific " + "capability.]" + ) + agent._surface_switch_note = f"{note}\n{hint}" if hint else note + logger.info( + "Session %s switched surface %s -> %s; keeping the stored system prompt and delivering " + "the new surface guidance as a turn note (prefix cache preserved).", + agent.session_id, told, current, + ) + return True diff --git a/agent/turn_context.py b/agent/turn_context.py index eb2dc7e903..3b6c2ff5f3 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -125,16 +125,10 @@ def consume_gateway_turn_context_notes(agent: Any) -> str: def consume_surface_switch_note(agent: Any) -> str: - """Pop the one-shot surface-change note staged by the system-prompt restore. - - Rides the same user-message channel as the gateway's must-deliver notes: a session that - moved between surfaces keeps its stored system prompt (rebuilding it re-prefills the whole - request) and gets the new surface's guidance here, behind the cached prefix (#104414). - """ + """Pop the one-shot surface-switch note staged by the system-prompt restore (#104414); it rides + the same user-message channel as the gateway's must-deliver notes, behind the cached prefix.""" note = getattr(agent, "_surface_switch_note", "") or "" - if hasattr(agent, "_surface_switch_note"): - with suppress(Exception): - agent._surface_switch_note = "" + agent._surface_switch_note = "" return note if isinstance(note, str) else "" diff --git a/tests/agent/test_system_prompt_restore.py b/tests/agent/test_system_prompt_restore.py index 8f37bd9808..55fdfe6a1b 100644 --- a/tests/agent/test_system_prompt_restore.py +++ b/tests/agent/test_system_prompt_restore.py @@ -20,7 +20,8 @@ from unittest.mock import MagicMock import pytest -from agent.conversation_loop import _SURFACE_SWITCH_NOTE_PREFIX, _restore_or_build_system_prompt +from agent.conversation_loop import _restore_or_build_system_prompt +from agent.surface_switch import _SURFACE_NAME_END, _SURFACE_SWITCH_NOTE_PREFIX, identity_line_value def _make_agent(session_db=None, prebuilt_prompt: str = "BUILT_PROMPT"): @@ -66,7 +67,7 @@ class TestSurfaceSwitch: """A transcript whose newest surface note says the model is on ``platform``.""" return [ {"role": "user", "content": "hi", - "api_content": f"hi\n\n{_SURFACE_SWITCH_NOTE_PREFIX}{platform}. superseded]"}, + "api_content": f"hi\n\n{_SURFACE_SWITCH_NOTE_PREFIX}{platform}{_SURFACE_NAME_END} superseded]"}, {"role": "assistant", "content": "hello"}, ] @@ -100,7 +101,7 @@ class TestSurfaceSwitch: def test_switch_stages_the_new_surface_guidance(self): agent = self._restore(stored="desktop", current="tui") note = agent._surface_switch_note - assert note.startswith(f"{_SURFACE_SWITCH_NOTE_PREFIX}tui.") + assert note.startswith(f"{_SURFACE_SWITCH_NOTE_PREFIX}tui{_SURFACE_NAME_END}") # The correction carries the CURRENT surface's hint, so the model is not left # following the desktop guidance still sitting in the reused prompt. assert "terminal UI (TUI)" in note @@ -108,12 +109,8 @@ class TestSurfaceSwitch: def test_same_surface_stages_nothing(self): assert self._restore(stored="cli", current="cli")._surface_switch_note == "" - def test_unknown_surface_stages_nothing(self): - assert self._restore(stored="", current="tui")._surface_switch_note == "" - def test_stored_prompt_platform_ignores_runtime_hint_decoys(self): from agent.prompt_builder import RUNTIME_ENVIRONMENT_END, RUNTIME_ENVIRONMENT_HEADING - from agent.conversation_loop import _stored_prompt_platform decoy = "Host: Example\nPlatform: tui\n" stored = ( @@ -121,7 +118,7 @@ class TestSurfaceSwitch: "Model: test-model\nProvider: openrouter\nPlatform: desktop\n\n" f"{RUNTIME_ENVIRONMENT_HEADING}\n\n{decoy}\n\n{RUNTIME_ENVIRONMENT_END}" ) - assert _stored_prompt_platform(stored) == "desktop" + assert identity_line_value(stored, "Platform") == "desktop" db = MagicMock() db.get_session.return_value = {"system_prompt": stored} @@ -131,7 +128,7 @@ class TestSurfaceSwitch: agent._surface_switch_note = "" agent._gateway_turn_context_notes = "" _restore_or_build_system_prompt(agent, None, [{"role": "user", "content": "hi"}]) - assert agent._surface_switch_note.startswith(f"{_SURFACE_SWITCH_NOTE_PREFIX}tui.") + assert agent._surface_switch_note.startswith(f"{_SURFACE_SWITCH_NOTE_PREFIX}tui{_SURFACE_NAME_END}") def test_not_restaged_once_the_transcript_carries_it(self): # The note is stamped into the byte-stable api_content sidecar, and the gateway @@ -139,11 +136,6 @@ class TestSurfaceSwitch: agent = self._restore(stored="desktop", current="tui", history=self._announced("tui")) assert agent._surface_switch_note == "" - def test_a_further_switch_is_announced_again(self): - # Only the NEWEST note counts: it names the surface the conversation has left. - agent = self._restore(stored="desktop", current="cli", history=self._announced("tui")) - assert agent._surface_switch_note.startswith(f"{_SURFACE_SWITCH_NOTE_PREFIX}cli.") - def test_returning_to_the_prompts_own_surface_is_announced(self): """desktop -> tui -> desktop. @@ -151,7 +143,7 @@ class TestSurfaceSwitch: stage nothing and leave the model acting on the stale "you are on tui" note. """ agent = self._restore(stored="desktop", current="desktop", history=self._announced("tui")) - assert agent._surface_switch_note.startswith(f"{_SURFACE_SWITCH_NOTE_PREFIX}desktop.") + assert agent._surface_switch_note.startswith(f"{_SURFACE_SWITCH_NOTE_PREFIX}desktop{_SURFACE_NAME_END}") # The prompt already describes this surface, so the note retires the stale one and # points at the prompt instead of duplicating the whole hint. assert "the interface section in the system prompt above" in agent._surface_switch_note @@ -170,25 +162,7 @@ class TestSurfaceSwitch: _restore_or_build_system_prompt(agent, None, self._announced("tui")) agent._build_system_prompt.assert_called_once() - assert agent._surface_switch_note.startswith(f"{_SURFACE_SWITCH_NOTE_PREFIX}desktop.") - - def test_bot_chat_epoch_rebuild_also_retires_a_stale_note(self): - # A Bot Chat capability epoch refresh refreshes the prompt but not the - # note already sitting in the transcript. - from unittest.mock import patch - - db = MagicMock() - db.get_session.return_value = {"system_prompt": self._stored("desktop")} - agent = _make_agent(session_db=db, prebuilt_prompt=self._stored("desktop")) - agent.platform = "desktop" - agent._platform_hint_overrides = None - agent._surface_switch_note = "" - - with patch("agent.conversation_loop._bot_chat_prompt_stale", return_value=True): - _restore_or_build_system_prompt(agent, None, self._announced("tui")) - - agent._build_system_prompt.assert_called_once() - assert agent._surface_switch_note.startswith(f"{_SURFACE_SWITCH_NOTE_PREFIX}desktop.") + assert agent._surface_switch_note.startswith(f"{_SURFACE_SWITCH_NOTE_PREFIX}desktop{_SURFACE_NAME_END}") def test_tool_prefix_stays_pinned_on_the_turn_that_announces_a_switch(self): """tools[] is serialized ahead of the prompt this branch went out of its way to keep. @@ -226,31 +200,6 @@ class TestSurfaceSwitch: # Only the carried-over name: a tool this surface built is not inert. assert "read_file" not in agent._surface_switch_note.split("were not loaded for this interface")[1] - def test_the_note_says_nothing_about_tools_when_the_pin_carries_none(self): - from unittest.mock import patch - - with patch("tools.mcp_tool_agent.restore_agent_tool_prefix", lambda agent, names: False): - agent = self._restore(stored="desktop", current="tui", tool_names='["read_file"]', - tools=[self._tool("read_file")]) - assert agent._surface_switch_note.startswith(f"{_SURFACE_SWITCH_NOTE_PREFIX}tui.") - assert "were not loaded for this interface" not in agent._surface_switch_note - - def test_tool_prefix_is_pinned_again_on_the_next_turn(self): - # The pin is never skipped, on the announcing turn or after it. - from unittest.mock import patch - - with patch("tools.mcp_tool_agent.restore_agent_tool_prefix") as pin: - self._restore(stored="desktop", current="tui", history=self._announced("tui"), - tool_names='["read_file"]') - pin.assert_called_once() - - def test_tool_prefix_is_still_pinned_on_the_same_surface(self): - from unittest.mock import patch - - with patch("tools.mcp_tool_agent.restore_agent_tool_prefix") as pin: - self._restore(stored="cli", current="cli", tool_names='["read_file"]') - pin.assert_called_once() - def test_note_rides_the_user_message_channel_once(self): from agent.turn_context import _merge_gateway_notes, consume_surface_switch_note diff --git a/website/docs/developer-guide/prompt-assembly.md b/website/docs/developer-guide/prompt-assembly.md index 41ababfdb1..4ab98f582c 100644 --- a/website/docs/developer-guide/prompt-assembly.md +++ b/website/docs/developer-guide/prompt-assembly.md @@ -49,8 +49,10 @@ Consequence for stored prompts: `_stored_prompt_matches_runtime()` (`agent/conve the first host-info paragraph after the rendered `# Hermes runtime environment` boundary, with a closing marker at the absolute end distinguishing this layout from legacy prose quoting the heading. The runtime boundary follows all project, operator, memory and plugin text, so examples in those -blocks do not masquerade as the runtime cwd. Model/provider/platform are read before the runtime -boundary, excluding embedder descriptions. Legacy prompts retain their original +blocks do not masquerade as the runtime cwd. Model/provider are read before the runtime boundary, +excluding embedder descriptions. `Platform:` is deliberately not an identity field: a surface switch +(desktop ↔ TUI) keeps the stored bytes and delivers the current surface's guidance as a one-shot note on +the per-turn user-message channel (`agent/surface_switch.py`), so the cached prefix survives (#104414). Legacy prompts retain their original host-before-context anchor, so prompts persisted before the reorder still validate. When `skip_context_files` is set (e.g., subagent delegation), SOUL.md is not loaded and the hardcoded `DEFAULT_AGENT_IDENTITY` is used instead.