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:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user