fix(agent): run memory prefetch on multimodal turns by flattening the query (#71998)

The delivery half of this PR lets a multimodal (list) turn carry the memory /
pre_llm_call context via a durable text part. But the execution half never ran
on those turns: `_memory_turn_start_and_prefetch` keyed the query off
`isinstance(str)`, so a list turn collapsed to `_query = ""` — `on_turn_start`
saw an empty turn and `is_trivial_prompt("")` skipped `prefetch_all` entirely.
So on an image+text turn recall never fired and the sidecar had nothing to
deliver, silently (no `recall` audit rows, no `prefetch failed` warning).

Flatten str/list content to its text via `flatten_message_text` before building
the query. An image-only turn still flattens to "" and is correctly treated as
trivial (no semantic text to query on); a text+image turn now runs prefetch on
its text. Adds unit coverage for the flattening and the trivial-prompt gate.

Reported by @albert748 on #72026.
This commit is contained in:
PRATHAMESH75
2026-09-10 17:32:27 +05:30
committed by Teknium
parent b8b519c398
commit d1267d8045
2 changed files with 60 additions and 1 deletions

View File

@@ -21,6 +21,7 @@ from agent.conversation_compression import recover_rotated_compression_session
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.model_metadata import estimate_messages_tokens_rough, estimate_request_tokens_rough
from agent.image_token_cost import bind_image_token_cost
@@ -883,6 +884,22 @@ def _bind_interrupt_scope(agent: Any, ra) -> None:
agent._interrupt_thread_signal_pending = False
def _memory_query_text(original_user_message: Any) -> str:
"""The semantic text of a turn's user content for memory queries.
A multimodal (list) turn carries its text in content parts, so keying the
query off ``isinstance(str)`` alone collapses it to ``""`` — ``on_turn_start``
sees an empty turn and ``is_trivial_prompt("")`` skips ``prefetch_all``
entirely, so memory recall never even runs on an image+text turn. That is the
execution-side twin of the delivery gap this PR (#71998) closes: the sidecar
can now carry recall on a multimodal turn, but only if prefetch produced any.
Flatten str/list to text; an image-only turn still yields ``""`` and is
correctly treated as trivial (no semantic text to query on)."""
if isinstance(original_user_message, (str, list)):
return flatten_message_text(original_user_message)
return ""
def _memory_turn_start_and_prefetch(
agent: Any, original_user_message: Any, turn_author: Optional[Dict[str, Any]] = None,
) -> str:
@@ -891,7 +908,7 @@ def _memory_turn_start_and_prefetch(
Returns the prefetch text (``""`` when nothing was injected)."""
if not agent._memory_manager:
return ""
_query = original_user_message if isinstance(original_user_message, str) else ""
_query = _memory_query_text(original_user_message)
# The author rides along so a provider can attribute THIS turn, not whoever opened the session.
_author = turn_author if isinstance(turn_author, dict) else {}
with suppress(Exception):

View File

@@ -31,6 +31,7 @@ import pytest
from agent.memory_manager import build_memory_context_block
from agent.turn_context import (
_memory_query_text,
build_turn_context,
compose_multimodal_context_part,
compose_user_api_content,
@@ -78,6 +79,47 @@ class TestComposeMultimodalContextPart:
assert sidecar == "hello\n\n" + part
class TestMemoryQueryText:
"""#71998 execution side: the memory query must flatten multimodal (list)
content to its text, or prefetch/on_turn_start never run on an image+text
turn and the delivery sidecar has nothing to carry."""
def test_str_passthrough(self):
assert _memory_query_text("what is my address") == "what is my address"
def test_text_plus_image_list_yields_text(self):
content = [
{"type": "text", "text": "what is in this photo of my house"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
]
assert _memory_query_text(content) == "what is in this photo of my house"
def test_image_only_list_yields_empty(self):
content = [
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
]
assert _memory_query_text(content) == ""
def test_non_text_types_yield_empty(self):
assert _memory_query_text(None) == ""
assert _memory_query_text(12345) == ""
def test_drives_trivial_prompt_gate_correctly(self):
from agent.memory_provider import is_trivial_prompt
text_plus_image = [
{"type": "text", "text": "remind me what my dog's name is"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
]
image_only = [
{"type": "image_url", "image_url": {"url": "data:image/png;base64,AAAA"}},
]
# A text+image turn is NOT trivial once flattened, so prefetch runs...
assert is_trivial_prompt(_memory_query_text(text_plus_image)) is False
# ...while an image-only turn stays trivial (no semantic text to query).
assert is_trivial_prompt(_memory_query_text(image_only)) is True
# ---------------------------------------------------------------------------
# SessionDB: schema, round-trip, verbatim replay
# ---------------------------------------------------------------------------