diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index e1b475513f..bb1afe163a 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -29,7 +29,9 @@ 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.surface_switch import ( + identity_line_value, note_inert_pinned_tools, split_runtime_boundary, 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 @@ -705,8 +707,9 @@ 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 _def_name, restore_agent_tool_prefix - built_for_this_surface = [_def_name(t) for t in agent.tools or []] + from tools.mcp_tool_agent import agent_tool_names, restore_agent_tool_prefix + # Captured BEFORE the pin merges the previous surface's tools back in. + built_for_this_surface = agent_tool_names(agent) if announced_switch else [] restore_agent_tool_prefix(agent, json.loads(saved_tools)) if announced_switch: note_inert_pinned_tools(agent, built_for_this_surface) @@ -777,9 +780,7 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history) 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 "" + _identity, runtime_marker, runtime = split_runtime_boundary(prompt) def host_info_value(label: str) -> str: """New prompts delimit runtime hints; legacy prompts put them before context.""" diff --git a/agent/surface_switch.py b/agent/surface_switch.py index 29a7810d84..5b642ecd0b 100644 --- a/agent/surface_switch.py +++ b/agent/surface_switch.py @@ -12,8 +12,9 @@ converges at the next rebuild boundary (compaction). from __future__ import annotations import logging -from typing import Any, Iterator, List +from typing import Any, List +from agent.message_content import flatten_message_text from agent.prompt_builder import RUNTIME_ENVIRONMENT_END, RUNTIME_ENVIRONMENT_HEADING logger = logging.getLogger("run_agent") @@ -27,41 +28,23 @@ _SURFACE_NAME_END = " — any earlier interface guidance" _NOTE_SCAN_TAIL = 200 +def split_runtime_boundary(prompt: str) -> tuple: + """``(identity, runtime_marker, runtime)`` of a persisted prompt. Legacy prose may quote + the runtime heading, but only the new renderer ENDS in the boundary; when the marker is + empty the whole prompt is identity.""" + identity, runtime_marker, runtime = prompt.rpartition(f"\n\n{RUNTIME_ENVIRONMENT_HEADING}\n\n") + return (identity, runtime_marker, runtime) if prompt.endswith(RUNTIME_ENVIRONMENT_END) else (prompt, "", "") + + 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 "" + """Last ``Label: value`` line in the identity portion (the final runtime block is embedder + prose, never identity). Last match wins — safe only for the volatile-tier trailer fields.""" prefix = f"{label}:" - matches = [ - line[len(prefix):].strip() - for line in (identity if runtime_marker else prompt).splitlines() - if line.startswith(prefix) - ] + matches = [line[len(prefix):].strip() for line in split_runtime_boundary(prompt)[0].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). @@ -69,18 +52,18 @@ def _last_announced_surface(conversation_history: Any) -> str: 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() + # The note only ever lands on a user row: in its api_content sidecar, or as a text part + # when the content is a multimodal list (which cannot take the string sidecar). + if not isinstance(msg, dict) or msg.get("role") != "user": + continue + sidecar = msg.get("api_content") + text = (sidecar if isinstance(sidecar, str) else "") + "\n" + flatten_message_text(msg.get("content")) + 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. @@ -88,8 +71,9 @@ def note_inert_pinned_tools(agent: Any, built_for_this_surface: List[str]) -> No 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")``.""" + from tools.mcp_tool_agent import agent_tool_names surface_names = set(built_for_this_surface) - inert = [name for name in _agent_tool_names(agent) if name not in surface_names] + 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 @@ -117,12 +101,14 @@ def stage_surface_switch_note(agent: Any, prompt: str, conversation_history: Any if getattr(agent, "provider", None) == "moa" or getattr(agent, "api_mode", None) == "codex_app_server": return False current = str(getattr(agent, "platform", "") or "").strip() + if not current: + return False described = identity_line_value(prompt, "Platform") told = _last_announced_surface(conversation_history) or described - if not current or not told or told == current: + if not told or told == current: return False - from agent.system_prompt import platform_surface_hint - hint = platform_surface_hint(agent) if described != current else "" + from agent.system_prompt import platform_hint + hint = platform_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 " diff --git a/agent/system_prompt.py b/agent/system_prompt.py index 34284bc534..3778515924 100644 --- a/agent/system_prompt.py +++ b/agent/system_prompt.py @@ -381,7 +381,7 @@ def _active_profile_line(agent: Any) -> str: ) -def _platform_hint(agent: Any) -> str: +def platform_hint(agent: Any) -> str: """Built-in/plugin platform hint + Telegram rich-messages opt-in + config override + desktop TUI clarifier.""" platform_key = (agent.platform or "").lower().strip() @@ -457,16 +457,6 @@ def _timestamp_line(agent: Any) -> str: return timestamp_line + "".join(f"\n{label}: {value}" for label, value in trailer if value) -def platform_surface_hint(agent: Any) -> str: - """The rendering-surface guidance for ``agent.platform`` ("" when the surface has none). - - Public because a session that changed surface mid-conversation keeps its stored prompt - (rebuilding it re-prefills the whole request) and delivers the CURRENT surface's guidance - through the per-turn user-message channel instead — see ``_SURFACE_SWITCH_NOTE_PREFIX`` - in ``agent.conversation_loop`` (#104414).""" - return _platform_hint(agent) or "" - - def _memory_parts(agent: Any) -> List[str]: """Built-in memory/USER.md blocks plus the external provider block (gated on the same check ``inject_memory_provider_tools`` uses, so we never advertise @@ -589,7 +579,7 @@ def _post_workspace_parts(agent: Any) -> List[str]: pass # Probe failure must never block prompt build. if getattr(agent, "_bot_mode_protocol", True): parts.extend(_bot_mode_parts(agent)) - parts += [_active_profile_line(agent), _platform_hint(agent)] + parts += [_active_profile_line(agent), platform_hint(agent)] return parts @@ -746,7 +736,7 @@ def format_tools_for_system_message(agent: Any) -> str: __all__ = ["build_system_prompt_parts", "build_system_prompt", "invalidate_system_prompt", - "platform_surface_hint", "restore_plugin_prompt_sections", "format_tools_for_system_message"] + "platform_hint", "restore_plugin_prompt_sections", "format_tools_for_system_message"] # ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ---- diff --git a/agent/turn_context.py b/agent/turn_context.py index 3b6c2ff5f3..ef54a0c1e4 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -114,22 +114,25 @@ def extract_api_content_sidecar(msg: Mapping[str, Any]) -> Optional[str]: return v if isinstance(v, str) else None -def consume_gateway_turn_context_notes(agent: Any) -> str: - """Pop the gateway's per-turn must-deliver notes off the agent (one-shot, so the - system prompt stays byte-stable and a cached agent never replays a stale note).""" - notes = getattr(agent, "_gateway_turn_context_notes", "") or "" - if hasattr(agent, "_gateway_turn_context_notes"): +def _pop_turn_note(agent: Any, attr: str) -> str: + """One-shot per-turn note: read and clear, so the system prompt stays byte-stable and a + cached agent never replays a stale note.""" + note = getattr(agent, attr, "") or "" + if hasattr(agent, attr): with suppress(Exception): - agent._gateway_turn_context_notes = "" - return notes if isinstance(notes, str) else "" + setattr(agent, attr, "") + return note if isinstance(note, str) else "" + + +def consume_gateway_turn_context_notes(agent: Any) -> str: + """Pop the gateway's per-turn must-deliver notes.""" + return _pop_turn_note(agent, "_gateway_turn_context_notes") def consume_surface_switch_note(agent: Any) -> str: - """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 "" - agent._surface_switch_note = "" - return note if isinstance(note, str) else "" + """Pop the surface-switch note staged by the system-prompt restore (#104414); rides the same + user-message channel as the gateway notes, behind the cached prefix.""" + return _pop_turn_note(agent, "_surface_switch_note") def append_notes_to_multimodal_content(content: Any, notes: str) -> bool: diff --git a/tools/mcp_tool_agent.py b/tools/mcp_tool_agent.py index b355ce99bf..6942ee9e57 100644 --- a/tools/mcp_tool_agent.py +++ b/tools/mcp_tool_agent.py @@ -24,6 +24,11 @@ def _agent_tool_defs(agent) -> list: return list(getattr(agent, "tools", None) or []) +def agent_tool_names(agent) -> list: + """Names of ``agent.tools`` in wire order (unnamed entries skipped).""" + return [name for name in map(_def_name, _agent_tool_defs(agent)) if name] + + def _resolve_refresh_toolsets(agent, enabled_override, disabled_override): """Explicit reloads pass freshly-resolved toolsets (so a server just ENABLED in config is picked up) and the agent's selection is updated to match; automatic paths pass nothing