Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
250 changes: 250 additions & 0 deletions gateway/auto_continue_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
"""Auto-continue noise sanitization and history-media path collection helpers.

Extracted verbatim from ``gateway/run.py`` (slice 8 of #54962): a pure move of
the auto-continue noise / history-media cluster into its own module, with zero
behavior change. ``gateway.run`` re-exports these names via a module-attribute
import so existing ``gateway.run.<name>`` references stay green.
"""

import json
import re
from typing import Any, Dict, List, Optional

from gateway.platforms.base import BasePlatformAdapter


def _last_transcript_timestamp(history: Optional[List[Dict[str, Any]]]) -> Any:
"""Return the ``timestamp`` of the last usable transcript row, if any.

Skips metadata-only rows (``session_meta``, system injections) that are
dropped before being handed to the agent. Returns ``None`` when no
usable row carries a timestamp — callers should treat that as "fresh"
for backward compatibility.
"""
if not history:
return None
for msg in reversed(history):
if not isinstance(msg, dict):
continue
role = msg.get("role")
if not role or role in {"session_meta", "system"}:
continue
ts = msg.get("timestamp")
if ts is not None:
return ts
# First non-meta row without a timestamp — legacy transcript row.
# Returning None lets the caller fall through to the legacy-fresh path.
return None
return None


# Tool results can contain literal MEDIA: examples in docs, logs, or other
# ordinary outputs. Only tools that intentionally create deliverable media
# artifacts should be eligible for automatic append when the model omits them
# from the final gateway reply.
_AUTO_APPEND_MEDIA_TOOL_NAMES = {
"text_to_speech",
"text_to_speech_tool",
"image_generate",
"bfl_flux3_get_result",
}


_AUTO_CONTINUE_NOTE_PREFIX = "[System note: Your previous turn"
_AUTO_CONTINUE_FALLBACK_PREFIX = "[System note: A new message"


def _is_auto_continue_noise(content: Any) -> bool:
"""Return True if this user-message content is a gateway-injected
auto-continue note that should NOT be replayed as a real user turn."""
if not isinstance(content, str):
return False
return (
content.startswith(_AUTO_CONTINUE_NOTE_PREFIX)
or content.startswith(_AUTO_CONTINUE_FALLBACK_PREFIX)
)


def _strip_auto_continue_noise(content: Any) -> Any:
"""Remove persisted gateway auto-continue note prefix from user text.

Older gateway builds prepended the recovery note directly to the user
message, so the transcript row can contain both the synthetic note and
the user's real question. Strip one or more leading synthetic notes while
preserving any real text that follows.
"""
if not _is_auto_continue_noise(content):
return content
text = str(content)
while _is_auto_continue_noise(text):
end = text.find("]")
if end < 0:
return ""
text = text[end + 1 :].lstrip()
return text


# Tools in this set return their deliverable artifact as a JSON payload with a
# local-file path field rather than a literal ``MEDIA:`` tag (e.g. image_generate
# returns ``{"success": true, "image": "/abs/path.png"}``). The auto-append path
# extracts the path from these fields so delivery is deterministic and does not
# depend on the model restating the path in its final reply.
_JSON_MEDIA_TOOL_PATH_FIELDS = ("host_image", "image", "agent_visible_image")


# Extension-anchored MEDIA: matcher for tool results. Mirrors the dispatch-site
# pattern so a bare ``MEDIA:`` token in prose (no deliverable extension) is never
# auto-appended. Kept local to the auto-append path; the producer-tool allowlist
# below is the primary guard, this is the secondary precision guard.
_TOOL_MEDIA_RE = re.compile(
r'MEDIA:((?:[A-Za-z]:[/\\]|/|~\/)\S+\.(?:png|jpe?g|gif|webp|'
r'mp4|mov|avi|mkv|webm|ogg|opus|mp3|wav|m4a|'
r'flac|epub|pdf|zip|rar|7z|docx?|xlsx?|pptx?|'
r'txt|csv|apk|ipa))',
re.IGNORECASE,
)


def _collect_auto_append_media_tags(
messages: List[Dict[str, Any]],
history_offset: int = 0,
history_media_paths: Optional[set] = None,
) -> tuple[List[str], bool]:
"""Collect real media tags from current-turn producer-tool results only.

Two layered guards keep stale/example MEDIA: strings out of the reply:

1. Producer-tool allowlist: only tools that intentionally emit deliverable
artifacts (TTS) are eligible. Documentation, logs, and search results can
contain example strings such as MEDIA:/absolute/path/to/file, which must
never be delivered as attachments. (Fixes the original report behind #16721.)
2. Current-turn isolation: only messages produced this turn are scanned, so a
tool result from an earlier turn (still present in the full message list)
cannot leak onto a later text-only reply (#34608).

Mid-run context compression can rewrite/shrink the message list below the
original history length. When that happens the slice boundary is no longer
trustworthy, so fall back to scanning every message and rely on
``history_media_paths`` for dedup, preserving the compression-safe behaviour
of #160. The producer-tool allowlist still applies on the fallback path.
"""
history_media_paths = history_media_paths or set()
# Only trust the slice boundary when the message list still contains the
# full history prefix. Otherwise scan everything (compression-safe fallback).
if history_offset and len(messages) >= history_offset:
new_messages = messages[history_offset:]
else:
new_messages = messages

tool_name_by_call_id: Dict[str, str] = {}
for msg in new_messages:
if msg.get("role") != "assistant":
continue
for call in msg.get("tool_calls") or []:
call_id = call.get("id") or call.get("call_id")
fn = call.get("function") or {}
name = str(fn.get("name") or call.get("name") or "")
if call_id and name:
tool_name_by_call_id[str(call_id)] = name

media_tags: List[str] = []
has_voice_directive = False
for msg in new_messages:
if msg.get("role") not in ("tool", "function"):
continue
call_id = str(msg.get("tool_call_id") or msg.get("call_id") or "")
if tool_name_by_call_id.get(call_id) not in _AUTO_APPEND_MEDIA_TOOL_NAMES:
continue
content = str(msg.get("content") or "")
tool_name = tool_name_by_call_id.get(call_id)
# JSON-payload tools (image_generate) return a local-file path in a
# known field rather than a MEDIA: tag. Extract it so delivery is
# deterministic even when the model omits the path from its reply.
if tool_name == "image_generate" and "MEDIA:" not in content:
try:
payload = json.loads(content)
except Exception:
payload = None
if isinstance(payload, dict) and payload.get("success"):
for field in _JSON_MEDIA_TOOL_PATH_FIELDS:
path = payload.get(field)
if (isinstance(path, str)
and _TOOL_MEDIA_RE.fullmatch(f"MEDIA:{path}")
and path not in history_media_paths):
media_tags.append(f"MEDIA:{path}")
break
continue
if "MEDIA:" not in content:
continue
for match in _TOOL_MEDIA_RE.finditer(content):
path = match.group(1).strip().rstrip('",}')
if path and path not in history_media_paths:
media_tags.append(f"MEDIA:{path}")
if "[[audio_as_voice]]" in content:
has_voice_directive = True

return media_tags, has_voice_directive


def _collect_history_media_paths(agent_history: List[Dict[str, Any]]) -> set:
"""Collect every media path already delivered in prior assistant/tool output.

Used to dedup auto-appended and model-emitted MEDIA tags so the same file
is not re-sent on later turns. Covers three delivery shapes:
* ``MEDIA:<path>`` text tags in tool results,
* ``MEDIA:<path>`` text tags in assistant messages (model-generated tags),
* ``image_generate`` JSON-payload paths (``host_image`` / ``image`` /
``agent_visible_image``), which carry no MEDIA: tag.

Missing the JSON-payload shape caused #46627; missing the assistant-message
shape caused repeated delivery when the model echoed a previous MEDIA tag.
"""
paths: set = set()
tool_name_by_call_id: Dict[str, str] = {}

def _add_text_media_paths(content: str) -> None:
for match in _TOOL_MEDIA_RE.finditer(content):
path = match.group(1).strip().rstrip('",}')
if path:
paths.add(path)
# The regex alone misses quoted and spaced paths that the delivery
# pipeline's extract_media grammar accepts — collect through the same
# extractor so the dedup set sees every path that could actually have
# been delivered.
media_files, _ = BasePlatformAdapter.extract_media(content)
paths.update(path for path, _is_voice in media_files)

for msg in agent_history:
if msg.get("role") == "assistant":
for call in msg.get("tool_calls") or []:
cid = call.get("id") or call.get("call_id")
fn = call.get("function") or {}
name = str(fn.get("name") or call.get("name") or "")
if cid and name:
tool_name_by_call_id[str(cid)] = name
for msg in agent_history:
role = msg.get("role")
if role == "assistant":
content = str(msg.get("content", "") or "")
if "MEDIA:" in content:
_add_text_media_paths(content)
continue
if role not in {"tool", "function"}:
continue
content = str(msg.get("content", "") or "")
if "MEDIA:" in content:
_add_text_media_paths(content)
continue
cid = str(msg.get("tool_call_id") or msg.get("call_id") or "")
if tool_name_by_call_id.get(cid) == "image_generate":
try:
payload = json.loads(content)
except Exception:
payload = None
if isinstance(payload, dict) and payload.get("success"):
for field in _JSON_MEDIA_TOOL_PATH_FIELDS:
jp = payload.get(field)
if isinstance(jp, str) and jp:
paths.add(jp)
break
return paths
Loading
Loading