"""Transport-level cache_control 4-boundary auto-placement (Sprint 29 P2-4.3). Anthropic Prompt Caching 在 4 个 boundary 上接受 ``cache_control: {type: ephemeral}`` 标记, 4 个 boundary 都在 transport 这一层自动布点能最大化 cache 命中率: 1. system prompt — 给 system 末尾 text block 加 marker 2. 最后 user message — 给最后 role=user 的 content 末尾加 marker block 3. tools 数组 — 给 tools 列表最后一项顶层加 marker 4. 最后 tool_result — 给 tool_result 块的 content 末尾加 marker 与 ``cache_safe_delegate`` 的关系: 后者是 delegate 侧"防 prompt prefix 改坏 cache" 的安全策略 (不同层次); 本模块是 transport 侧的"自动布点"优化层. bypass 开关: ``BUTLER_TRANSPORT_CACHE_CONTROL=0`` (默认 True) → 4 boundary 全部 早退, kwargs 不动. """ from __future__ import annotations from typing import Any from butler.utilities.env_parse import env_truthy from butler.defaults.env_defaults import TRANSPORT_CACHE_CONTROL_DEFAULT _CACHE_CONTROL_MARKER: dict[str, str] = {"type": "ephemeral"} def cache_control_enabled() -> bool: """True unless ``BUTLER_TRANSPORT_CACHE_CONTROL`` is explicitly disabled.""" return bool(env_truthy("BUTLER_TRANSPORT_CACHE_CONTROL", default=TRANSPORT_CACHE_CONTROL_DEFAULT)) def apply_cache_control_to_system(system: Any) -> list[dict[str, Any]]: """Return system prompt as a list of text blocks with cache marker on tail. - 关闭时返 ``[]`` (caller 走原 str 路径) - 开启 + 空 system → 返 ``[]`` (不构造空 block) - 开启 + 有 text → 返 ``[{"type": "text", "text": ..., "cache_control": ...}]`` """ if not cache_control_enabled(): return [] text = str(system or "").strip() if not text: return [] return [ { "type": "text", "text": text, "cache_control": dict(_CACHE_CONTROL_MARKER), } ] def _ensure_content_list(content: Any) -> list[dict[str, Any]]: """Normalize message content to a list-of-blocks form. - str → ``[{"type": "text", "text": }]`` - list → 透传 (assumed to already be list of blocks) - 其他 → 包装成 ``[{"type": "text", "text": str(content)}]`` """ if isinstance(content, list): return list(content) if isinstance(content, str): return [{"type": "text", "text": content}] return [{"type": "text", "text": str(content)}] def apply_cache_control_to_messages(messages: list[dict[str, Any]] | None) -> list[dict[str, Any]]: """Append cache marker to the last ``role=user`` message's content tail. - 关闭时透传 (原 list 引用, 调用方不依赖深拷贝) - 没有 ``role=user`` 的消息 → 整体不动 - user content 已是 list → 在末尾追加 1 个 marker block - user content 是 str → 转 list, 末尾追加 marker block - 最后一条是 assistant → 不动 (assistant 不作 cache breakpoint) """ if not cache_control_enabled(): return messages if messages is not None else [] if not messages: return messages if messages is not None else [] last_user_idx: int | None = None for i in range(len(messages) - 1, -1, -1): if messages[i].get("role") == "user": last_user_idx = i break if last_user_idx is None: return messages msg = dict(messages[last_user_idx]) blocks = _ensure_content_list(msg.get("content")) blocks = list(blocks) + [ {"type": "text", "text": "", "cache_control": dict(_CACHE_CONTROL_MARKER)} ] msg["content"] = blocks out = list(messages) out[last_user_idx] = msg return out def apply_cache_control_to_tools(tools: list[dict[str, Any]] | None) -> list[dict[str, Any]]: """Add cache marker to the last tool in the tools array. - 关闭时透传 - 空 tools / None → 返 ``[]`` - 开启 + tools → 最后 1 个 tool 顶层加 ``cache_control`` 字段 """ if not cache_control_enabled(): return tools if tools is not None else [] if not tools: return [] out = list(tools) last = dict(out[-1]) last["cache_control"] = dict(_CACHE_CONTROL_MARKER) out[-1] = last return out def apply_cache_control_to_last_tool_result( messages: list[dict[str, Any]] | None, ) -> list[dict[str, Any]]: """Add cache marker to the last ``tool_result`` block in messages. Operates on Anthropic-format messages (list of role/content). Iterates reverse to find the last user message whose content list contains a ``tool_result`` block; mutates a copy of that block with the marker. - 关闭时透传 - 无 tool_result 块 → 整体不动 - 多个 tool_result → 只给最后 1 个加 marker """ if not cache_control_enabled(): return messages if messages is not None else [] if not messages: return messages if messages is not None else [] target_idx: int | None = None target_block_idx: int | None = None for i in range(len(messages) - 1, -1, -1): m = messages[i] if m.get("role") != "user": continue content = m.get("content") if not isinstance(content, list): continue for j in range(len(content) - 1, -1, -1): if isinstance(content[j], dict) and content[j].get("type") == "tool_result": target_idx = i target_block_idx = j break if target_idx is not None: break if target_idx is None or target_block_idx is None: return messages out = list(messages) msg = dict(out[target_idx]) new_content = list(msg["content"]) block = dict(new_content[target_block_idx]) block["cache_control"] = dict(_CACHE_CONTROL_MARKER) new_content[target_block_idx] = block msg["content"] = new_content out[target_idx] = msg return out __all__ = [ "cache_control_enabled", "apply_cache_control_to_system", "apply_cache_control_to_messages", "apply_cache_control_to_tools", "apply_cache_control_to_last_tool_result", ]