diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index d1b4c6e4e4..9a1f83e7d7 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -39,7 +39,7 @@ from agent.gemini_native_adapter import is_native_gemini_base_url # misidentify and, without an api_key, return 401 on every leg (issue #89863). from agent.model_metadata import is_local_endpoint from agent.message_content import flatten_message_text -from agent.message_metadata import append_message, stamp_message_timestamp +from agent.message_metadata import PERSISTENCE_ONLY_MESSAGE_FIELDS, append_message, stamp_message_timestamp from agent.message_sanitization import ( _sanitize_surrogates, _repair_tool_call_arguments, normalize_finish_reason as _normalize_finish_reason, sanitize_outbound_kwargs, strip_images_for_rejecting_model, @@ -2129,8 +2129,8 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None, reset_a # Keys outside the Chat Completions schema that strict gateways (Fireworks-backed OpenCode # Go, Mistral, Moonshot/Kimi) reject with 422. The transport's convert_messages() drops them # in the main loop; the summary path calls chat.completions.create() directly, so mirror it. -_SUMMARY_FOREIGN_MESSAGE_KEYS = ("reasoning", "finish_reason", "tool_name", "codex_reasoning_items", - "codex_message_items", "timestamp", "platform_message_id") +_SUMMARY_FOREIGN_MESSAGE_KEYS = PERSISTENCE_ONLY_MESSAGE_FIELDS | {"reasoning", "finish_reason", "tool_name", + "codex_reasoning_items", "codex_message_items", "platform_message_id"} _EMPTY_SUMMARY_RESPONSE = "I reached the iteration limit and couldn't generate a summary." diff --git a/agent/message_metadata.py b/agent/message_metadata.py index e13acb663f..687942156a 100644 --- a/agent/message_metadata.py +++ b/agent/message_metadata.py @@ -6,9 +6,12 @@ from time import time as wall_time from typing import Any, MutableMapping, Optional, TypeVar -# These fields describe Hermes' durable record, not provider-visible message -# content. They must not influence context-pressure decisions. -PERSISTENCE_ONLY_MESSAGE_FIELDS = frozenset({"timestamp"}) +# These fields describe Hermes' durable record and timeline display, not +# provider-visible message content. The request builder strips them from every +# outgoing copy and the token estimator ignores them: one set, so an estimate +# never prices bytes the provider never receives (an edit's inline_diff in +# display_metadata is ~9KB and would trigger premature compaction). +PERSISTENCE_ONLY_MESSAGE_FIELDS = frozenset({"timestamp", "display_kind", "display_metadata", "_row_id"}) _Message = TypeVar("_Message", bound=MutableMapping[str, Any]) diff --git a/agent/turn_context.py b/agent/turn_context.py index 43f8ae03b4..113527c540 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -22,7 +22,7 @@ from agent.iteration_budget import IterationBudget from agent.memory_manager import build_memory_context_block from agent.memory_provider import is_trivial_prompt from agent.message_content import flatten_message_text -from agent.message_metadata import append_message, stamp_message_timestamp +from agent.message_metadata import PERSISTENCE_ONLY_MESSAGE_FIELDS, append_message, stamp_message_timestamp from agent.model_metadata import estimate_messages_tokens_rough, estimate_request_tokens_rough from agent.image_token_cost import bind_image_token_cost from agent.usage_anchor import anchored_context_tokens, restore_usage_anchor @@ -1220,11 +1220,12 @@ def build_api_messages( # persisted history via nested containers; see _clone_message_for_send. api_msg = _clone_message_for_send(msg) # api_content is bookkeeping (exact bytes sent), never a provider field — pop - # it from EVERY outgoing copy. display_* is display-only timeline metadata - # (strict OpenAI backends reject unknown keys); _row_id is the durable row id - # from _rows_to_conversation and only chat-completions strips underscore keys. + # it from EVERY outgoing copy. Persistence/display fields (display_*, _row_id, + # timestamp) are local bookkeeping: strict OpenAI backends reject unknown keys + # and only chat-completions strips underscore keys. The token estimator drops + # the same set, so it never prices bytes the provider never receives. _api_content = api_msg.pop("api_content", None) - for key in ("display_kind", "display_metadata", "_row_id"): + for key in PERSISTENCE_ONLY_MESSAGE_FIELDS: api_msg.pop(key, None) # Inject ephemeral context (memory prefetch + pre_llm_call user hooks) diff --git a/tests/agent/test_model_metadata.py b/tests/agent/test_model_metadata.py index 3d8736f3c7..76088df438 100644 --- a/tests/agent/test_model_metadata.py +++ b/tests/agent/test_model_metadata.py @@ -23,6 +23,7 @@ from agent.model_metadata import ( _strip_provider_prefix, estimate_tokens_rough, estimate_messages_tokens_rough, + estimate_request_tokens_rough, get_model_context_length, get_next_probe_tier, get_cached_context_length, @@ -70,6 +71,17 @@ class TestEstimateMessagesTokensRough: estimate_messages_tokens_rough([msg]) ) + def test_display_only_fields_do_not_change_estimate(self): + """An edit row's inline_diff rides display_metadata, which the request builder strips; + pricing it would compact early and break the prompt cache.""" + wire = {"role": "tool", "tool_call_id": "call-1", "name": "write_file", + "content": '{"bytes_written": 18000}'} + rich = {**wire, "_row_id": 42, "display_kind": "tool_result", + "display_metadata": {"tool_result_metadata": {"inline_diff": "\x1b[32m+ line\x1b[0m\n" * 600}}} + + assert estimate_messages_tokens_rough([rich]) == estimate_messages_tokens_rough([wire]) + assert estimate_request_tokens_rough([rich]) == estimate_request_tokens_rough([wire]) + def test_message_with_list_content(self): """Vision messages with multimodal content arrays.