refactor(agent): surface-switch note lives in its own sibling; skip it where no sidecar exists
Move the six surface-switch helpers out of the conversation_loop facade into agent/surface_switch.py (AGENTS.md: new behaviour goes in a topical sibling), and fold the review findings on #104494: - MoA and codex_app_server turns never stamp the api_content sidecar, so the staged note could not be read back from the transcript and was re-sent on every turn after a switch. Those modes now skip the note (stored prompt still reused). - The announced surface was parsed with split(".") — a plugin platform with a dot in its name would never compare equal and re-stage the note every turn. The note now closes the name with a fixed terminator. - One identity-line parser (identity_line_value) shared by _stored_prompt_matches_runtime and the switch detector instead of two copies of the runtime-boundary/rpartition logic; tool names via the existing tools.mcp_tool_agent._def_name; the transcript scan is bounded to the last 200 rows (it ran every turn over the whole history). - consume_surface_switch_note reduced to a plain pop; developer-guide prompt-assembly.md updated (Platform is no longer an identity field); 17 new tests trimmed to 10 (same-shape pin/retire variants folded). Restoring Platform as an identity field still turns 5 tests red.
This commit is contained in:
+11
-150
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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 ""
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user