fix(storage): unify CAS persistence for inline images

- Externalize inline images in messages and run events before persistence
- Keep snapshots, transcripts, and metadata free of inline payloads
- Serialize messages after externalization to reduce peak memory usage
- Preserve live messages, URL-only images, and SDK image retrieval behavior
- Add regression tests for deduplication, redaction, storage failures, and serialization order
This commit is contained in:
BiFangKNT
2026-10-18 00:04:20 +08:00
parent f12490bc73
commit cbc849d4e2
9 changed files with 438 additions and 67 deletions
@@ -794,7 +794,9 @@ async def execute_platform_tool(
bot_name=str(getattr(getattr(bot, 'bot_entity', None), 'name', bot_id)),
pipeline_id=str(authorization.get('processor_id') or ''),
pipeline_name=str(authorization.get('processor_id') or authorization.get('runner_id') or ''),
message_content=json.dumps(content, ensure_ascii=False),
message_content=content
if isinstance(content, (list, dict))
else json.dumps(content, ensure_ascii=False),
session_id=str(authorization.get('conversation_id') or ''),
role='assistant',
status='error' if delivery_error else 'success',
+12 -4
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import typing
from ...core import app
from ...storage.media import MediaCache
from .descriptor import RunnerDescriptor
from .errors import RunnerProtocolError
from .host_models import AgentBinding, AgentEventEnvelope
@@ -44,10 +45,10 @@ class AgentRunJournal:
if not item:
return value
item_type = item.get('type')
if item_type == 'image_base64' and item.get('image_base64'):
item['image_base64'] = None
if MediaCache.has_inline_images(item):
item = MediaCache.redact_inline_images(item)
item['content_redacted'] = True
elif item_type == 'file_base64' and item.get('file_base64'):
if item_type == 'file_base64' and item.get('file_base64'):
item['file_base64'] = None
item['content_redacted'] = True
return item
@@ -77,6 +78,8 @@ class AgentRunJournal:
return [cls._sanitize_event_data(item) for item in value]
if not isinstance(value, dict):
return value
if value.get('type') in ('Image', 'image', 'image_url', 'image_base64'):
value = cls._sanitize_content_item(value)
result = {}
for key, item in value.items():
if key in ('image_base64', 'file_base64', 'base64') and item:
@@ -157,11 +160,16 @@ class AgentRunJournal:
usage = result_dict.get('usage')
if hasattr(usage, 'model_dump'):
usage = usage.model_dump(mode='json')
data = result_dict.get('data') if isinstance(result_dict.get('data'), dict) else {}
if MediaCache.has_inline_images(data):
data = await self.ap.storage_mgr.media_cache.externalize_chain_dump(data)
if MediaCache.has_inline_images(metadata):
metadata = MediaCache.redact_inline_images(metadata)
return await self._get_run_ledger_store().append_event(
run_id=run_id,
sequence=sequence,
event_type=str(result_dict.get('type') or 'unknown'),
data=result_dict.get('data') if isinstance(result_dict.get('data'), dict) else {},
data=data,
usage=usage if isinstance(usage, dict) else None,
source=source,
metadata=metadata,
+20 -18
View File
@@ -15,6 +15,7 @@ from ....entity.persistence import monitoring as persistence_monitoring
from ....entity.persistence import agent_run as persistence_agent_run
from ....entity.persistence import agent_interaction as persistence_agent_interaction
from ....entity.persistence.event_log import EventLog
from ....storage.media import MediaCache
from ..authz import WorkspaceRequiredError
from ..context import ExecutionContext
from .tenant import TenantContext, require_workspace_uuid
@@ -504,30 +505,31 @@ class MonitoringService(ExecutionDetailsMixin):
def _sanitize_message_content(self, content: str) -> str:
"""Strip raw base64 data to protect database storage from unbounded bloating."""
if not content or len(content) < 10000 or (';base64,' not in content and 'data:image/' not in content):
if not content or ('base64' not in content and 'data:image/' not in content):
return content
try:
data = json.loads(content)
def _strip_node(node):
if isinstance(node, list):
return [_strip_node(x) for x in node]
if isinstance(node, dict):
res = dict(node)
if res.get('type') == 'Image' and res.get('base64'):
res['base64'] = None
for k, v in list(res.items()):
if isinstance(v, (list, dict)):
res[k] = _strip_node(v)
return res
return node
return json.dumps(_strip_node(data), ensure_ascii=False)
return json.dumps(MediaCache.redact_inline_images(data), ensure_ascii=False)
except Exception:
return re.sub(
r'data:image/[a-zA-Z0-9.+_-]+;base64,[\sA-Za-z0-9+/=]{1000,}', '[base64 image omitted]', content
)
async def _externalize_message_content(self, content: str | list | dict) -> str:
"""Externalize structured images before allocating their JSON representation."""
if isinstance(content, str):
if not content or ('base64' not in content and 'data:image/' not in content):
return content
try:
payload = json.loads(content)
except (TypeError, ValueError):
return self._sanitize_message_content(content)
else:
payload = content
if MediaCache.has_inline_images(payload):
payload = await self.ap.storage_mgr.media_cache.externalize_chain_dump(payload)
return json.dumps(MediaCache.redact_inline_images(payload), ensure_ascii=False)
@_workspace_transaction
async def record_message(
self,
@@ -536,7 +538,7 @@ class MonitoringService(ExecutionDetailsMixin):
bot_name: str,
pipeline_id: str,
pipeline_name: str,
message_content: str,
message_content: str | list | dict,
session_id: str,
status: str = 'success',
level: str = 'info',
@@ -558,7 +560,7 @@ class MonitoringService(ExecutionDetailsMixin):
event_id = event_id or (trace_state.event_id if trace_state else None)
run_id = run_id or trace.current_run() or None
message_id = str(uuid.uuid4())
message_content = self._sanitize_message_content(message_content)
message_content = await self._externalize_message_content(message_content)
from ....utils.execution_lease import host_execution_lease
message_data = {
+5 -11
View File
@@ -143,12 +143,9 @@ class MonitoringHelper:
sender_name = query.message_event.sender.member_name
# Try to record message
# Use JSON serialization to preserve message chain structure (including image URLs, etc.)
# Let persistence externalize images before serializing the message chain.
if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'):
chain_dump = query.message_chain.model_dump()
if hasattr(ap, 'storage_mgr') and hasattr(ap.storage_mgr, 'media_cache'):
chain_dump = await ap.storage_mgr.media_cache.externalize_chain_dump(chain_dump)
message_content = json.dumps(chain_dump, ensure_ascii=False)
message_content = query.message_chain.model_dump()
else:
message_content = str(query)
@@ -257,10 +254,10 @@ class MonitoringHelper:
# Extract response content from resp_message_chain
if hasattr(query, 'resp_message_chain') and query.resp_message_chain:
# Serialize the last response message chain
# Preserve the structure until persistence has externalized images.
last_resp = query.resp_message_chain[-1]
if hasattr(last_resp, 'model_dump'):
message_content = json.dumps(last_resp.model_dump(), ensure_ascii=False)
message_content = last_resp.model_dump()
else:
message_content = str(last_resp)
elif hasattr(query, 'resp_messages') and query.resp_messages:
@@ -268,10 +265,7 @@ class MonitoringHelper:
if hasattr(last_resp, 'get_content_platform_message_chain'):
chain = last_resp.get_content_platform_message_chain()
if hasattr(chain, 'model_dump'):
chain_dump = chain.model_dump()
if hasattr(ap, 'storage_mgr') and hasattr(ap.storage_mgr, 'media_cache'):
chain_dump = await ap.storage_mgr.media_cache.externalize_chain_dump(chain_dump)
message_content = json.dumps(chain_dump, ensure_ascii=False)
message_content = chain.model_dump()
else:
message_content = str(chain)
else:
+1 -4
View File
@@ -1356,10 +1356,7 @@ class RuntimeBot:
"""Record a discarded message in the monitoring system."""
try:
if hasattr(message_chain, 'model_dump'):
chain_dump = message_chain.model_dump()
if hasattr(self.ap, 'storage_mgr') and hasattr(self.ap.storage_mgr, 'media_cache'):
chain_dump = await self.ap.storage_mgr.media_cache.externalize_chain_dump(chain_dump)
message_content = json.dumps(chain_dump, ensure_ascii=False)
message_content = message_chain.model_dump()
else:
message_content = str(message_chain)
+99 -27
View File
@@ -11,6 +11,8 @@ import xxhash
from pathlib import Path
from typing import TYPE_CHECKING, Any
from ..utils.image import DEFAULT_BASE64_MEDIA_LIMIT
if TYPE_CHECKING:
from ...core import app
from . import mgr as storage_mgr
@@ -49,14 +51,80 @@ class MediaCache:
if split_index != -1:
mime_type = data_url[5:split_index]
b64_data = data_url[split_index + 8 :]
return base64.b64decode(b64_data), mime_type
# Try raw base64 if sufficiently long
if len(data_url) > 20 and not data_url.startswith(('http://', 'https://', '/')):
return base64.b64decode(data_url), 'application/octet-stream'
return MediaCache._decode_base64(b64_data), mime_type
if not data_url.startswith(('http://', 'https://', '/')):
return MediaCache._decode_base64(data_url), 'application/octet-stream'
except Exception:
return None
return None
@staticmethod
def _decode_base64(value: str) -> bytes:
"""Bound decoding before allocating a binary payload for persistence."""
if len(value) > 4 * ((DEFAULT_BASE64_MEDIA_LIMIT + 2) // 3):
raise ValueError('Inline image exceeds the media size limit')
decoded = base64.b64decode(value, validate=True)
if len(decoded) > DEFAULT_BASE64_MEDIA_LIMIT:
raise ValueError('Inline image exceeds the media size limit')
return decoded
@staticmethod
def _inline_image_payload(node: dict) -> str | None:
"""Recognize platform and provider image payloads without fetching URLs."""
node_type = node.get('type')
if node_type == 'Image':
payload = node.get('base64')
url = node.get('url')
elif node_type in ('image', 'image_url', 'image_base64'):
payload = node.get('image_base64')
url = node.get('image_url')
if isinstance(url, dict):
url = url.get('url')
else:
return None
if isinstance(payload, str) and payload:
return payload
if isinstance(url, str) and url.startswith('data:image/'):
return url
return None
@classmethod
def has_inline_images(cls, value: Any) -> bool:
if isinstance(value, list):
return any(cls.has_inline_images(item) for item in value)
if isinstance(value, dict):
return cls._inline_image_payload(value) is not None or any(
cls.has_inline_images(item) for item in value.values() if isinstance(item, (list, dict))
)
return False
@staticmethod
def _clear_inline_image(node: dict) -> None:
field = 'base64' if node.get('type') == 'Image' else 'image_base64'
if node.get(field):
node[field] = None
url_field = 'url' if node.get('type') == 'Image' else 'image_url'
url = node.get(url_field)
if isinstance(url, str) and url.startswith('data:image/'):
node[url_field] = None
elif isinstance(url, dict) and str(url.get('url', '')).startswith('data:image/'):
node[url_field] = {**url, 'url': None}
@classmethod
def redact_inline_images(cls, value: Any) -> Any:
"""Keep metadata-only records binary-free, including short raw base64."""
if isinstance(value, list):
return [cls.redact_inline_images(item) for item in value]
if isinstance(value, dict):
node = dict(value)
if cls._inline_image_payload(node) is not None:
cls._clear_inline_image(node)
return {
key: cls.redact_inline_images(item) if isinstance(item, (list, dict)) else item
for key, item in node.items()
}
return value
@staticmethod
def guess_extension(mime_type: str | None, default: str = '.jpg') -> str:
"""Guess appropriate file extension from MIME type."""
@@ -201,29 +269,33 @@ class MediaCache:
return [await self.externalize_chain_dump(item) for item in chain_dump]
if isinstance(chain_dump, dict):
node = copy.copy(chain_dump)
node_type = node.get('type')
if node_type == 'Image':
b64 = node.get('base64')
if b64 and isinstance(b64, str):
try:
parsed = self.parse_data_url(b64)
if parsed is not None:
raw_bytes, mime_type = parsed
hash_str, storage_key, size = await self.save_media(raw_bytes, mime_type)
filename = os.path.basename(storage_key)
current_url = node.get('url') or ''
if current_url and not current_url.startswith('data:'):
node['original_url'] = current_url
node['url'] = f'/api/v1/files/media/{filename}'
node['base64'] = None
node['hash'] = hash_str
node['storage_key'] = storage_key
node['size'] = size
node['mime_type'] = mime_type
except Exception as e:
if hasattr(self.ap, 'logger') and self.ap.logger:
self.ap.logger.warning(f'Failed to externalize image to media cache: {e}')
node['base64'] = None
payload = self._inline_image_payload(node)
if payload is not None:
try:
parsed = await asyncio.to_thread(self.parse_data_url, payload)
if parsed is None:
raise ValueError('Invalid inline image payload')
raw_bytes, mime_type = parsed
hash_str, storage_key, size = await self.save_media(raw_bytes, mime_type)
url = f'/api/v1/files/media/{os.path.basename(storage_key)}'
if node.get('type') == 'Image':
current_url = node.get('url') or ''
if current_url and not current_url.startswith('data:'):
node['original_url'] = current_url
node['url'] = url
else:
current_url = node.get('image_url')
if isinstance(current_url, dict):
current_url = current_url.get('url')
if current_url and not current_url.startswith('data:'):
node['original_url'] = current_url
image_url = node.get('image_url')
node['image_url'] = {**image_url, 'url': url} if isinstance(image_url, dict) else {'url': url}
node.update(hash=hash_str, storage_key=storage_key, size=size, mime_type=mime_type)
except Exception as e:
if hasattr(self.ap, 'logger') and self.ap.logger:
self.ap.logger.warning(f'Failed to externalize image to media cache: {e}')
self._clear_inline_image(node)
for k, v in list(node.items()):
if isinstance(v, (list, dict)):
node[k] = await self.externalize_chain_dump(v)