"""Anthropic messages <-> Agent SDK transcript entries. :func:`build_entries` renders a message list as the entries the Claude CLI itself writes (reference: ``t/spike_sdk/entries_reference.json``, CLI 2.1.248 via ``import_session_to_store``): one ``user`` entry per prompt, one ``assistant`` entry per content block sharing a message id, one ``user`` entry per ``tool_result`` parented on the matching ``tool_use`` entry. Only ``user``/``assistant`` entries are produced - no attachments, titles or queue markers. Appending the result to a session store and resuming that session id seeds an external history into the SDK. :func:`messages_from_entries` is the projection back, used by tests and by anything that needs Anthropic-shape history out of a mirrored transcript. """ from __future__ import annotations import hashlib import uuid as _uuid from collections.abc import Mapping from datetime import UTC, datetime from typing import TYPE_CHECKING, Any try: from claude_agent_sdk._cli_version import __cli_version__ _cli_version = str(__cli_version__) except ImportError: # pragma: no cover _cli_version = "2.1.248" if TYPE_CHECKING: from collections.abc import Iterable __all__ = [ "CLI_VERSION", "build_entries", "close_open_tool_uses", "fingerprint", "messages_from_entries", "open_tool_uses", "prompt_count", "render_messages", "strip_tool_entries", "text_of", "window_entries", ] CLI_VERSION = _cli_version _ENTRYPOINT = "sdk-py" _USER_TYPE = "external" _PROMPT_SOURCE = "sdk" _GIT_BRANCH = "HEAD" def build_entries( messages: Iterable[Mapping[str, Any]], *, session_id: str, cwd: str, model: str, permission_mode: str = "bypassPermissions", now: datetime | None = None, ) -> list[dict[str, Any]]: stamp = (now or datetime.now(UTC)).strftime("%Y-%m-%dT%H:%M:%S.") + ( f"{(now or datetime.now(UTC)).microsecond // 1000:03d}Z" ) common = { "isSidechain": False, "userType": _USER_TYPE, "entrypoint": _ENTRYPOINT, "cwd": cwd, "sessionId": session_id, "version": CLI_VERSION, "gitBranch": _GIT_BRANCH, } entries: list[dict[str, Any]] = [] parent: str | None = None prompt_id: str | None = None tool_use_owner: dict[str, str] = {} for message in messages: role = message.get("role") content = message.get("content") if role == "user": results = _tool_results(content) if results: for block in results: owner = tool_use_owner.get( str(block.get("tool_use_id", "")), parent ) uid = _new_uuid() entries.append( { "parentUuid": owner, "promptId": prompt_id, "type": "user", "message": {"role": "user", "content": [block]}, "uuid": uid, "timestamp": stamp, "toolUseResult": _result_text(block), "sourceToolAssistantUUID": owner, **common, } ) parent = uid continue prompt_id = _new_uuid() uid = _new_uuid() entries.append( { "parentUuid": parent, "promptId": prompt_id, "type": "user", "message": {"role": "user", "content": _user_content(content)}, "uuid": uid, "timestamp": stamp, "permissionMode": permission_mode, "promptSource": _PROMPT_SOURCE, **common, } ) parent = uid elif role == "assistant": blocks = _assistant_blocks(content) message_id = f"msg_{_uuid.uuid4().hex[:24]}" request_id = f"req_{_uuid.uuid4().hex[:24]}" stop_reason = ( "tool_use" if any(b.get("type") == "tool_use" for b in blocks) else "end_turn" ) for block in blocks: uid = _new_uuid() entries.append( { "parentUuid": parent, "message": { "model": model, "id": message_id, "type": "message", "role": "assistant", "content": [block], "stop_reason": stop_reason, "stop_sequence": None, "stop_details": None, "usage": _zero_usage(), "diagnostics": None, }, "requestId": request_id, "type": "assistant", "uuid": uid, "timestamp": stamp, **common, } ) if block.get("type") == "tool_use" and block.get("id"): tool_use_owner[str(block["id"])] = uid parent = uid else: msg = f"message role must be user or assistant, got {role!r}" raise ValueError(msg) return entries def messages_from_entries(entries: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]: out: list[dict[str, Any]] = [] last_message_id: str | None = None for entry in entries: kind = entry.get("type") message = entry.get("message") if kind not in ("user", "assistant") or not isinstance(message, dict): continue content = message.get("content") if kind == "user": results = _tool_results(content) if ( results and out and out[-1]["role"] == "user" and _tool_results(out[-1]["content"]) ): out[-1]["content"].extend(results) else: out.append({"role": "user", "content": _user_content(content)}) last_message_id = None continue blocks = _assistant_blocks(content) message_id = message.get("id") if ( out and out[-1]["role"] == "assistant" and message_id is not None and message_id == last_message_id ): out[-1]["content"].extend(blocks) else: out.append({"role": "assistant", "content": blocks}) last_message_id = message_id return out def _new_uuid() -> str: return str(_uuid.uuid4()) def _tool_results(content: Any) -> list[dict[str, Any]]: if not isinstance(content, list): return [] return [ _clean_block(b) for b in content if isinstance(b, dict) and b.get("type") == "tool_result" ] def _user_content(content: Any) -> Any: if isinstance(content, str): return content if isinstance(content, list): return [_clean_block(b) for b in content if isinstance(b, dict)] msg = f"user content must be str or list, got {type(content).__name__}" raise TypeError(msg) def _assistant_blocks(content: Any) -> list[dict[str, Any]]: if isinstance(content, str): return [{"type": "text", "text": content}] if isinstance(content, list): blocks = [_clean_block(b) for b in content if isinstance(b, dict)] if blocks: return blocks msg = "assistant content must be a non-empty list of blocks" raise ValueError(msg) def _clean_block(block: dict[str, Any]) -> dict[str, Any]: kind = block.get("type") if kind == "tool_use": return { "type": "tool_use", "id": block.get("id", ""), "name": block.get("name", ""), "input": block.get("input", {}), } if kind == "tool_result": out: dict[str, Any] = { "type": "tool_result", "tool_use_id": block.get("tool_use_id", ""), "content": block.get("content", ""), } if block.get("is_error") is not None: out["is_error"] = bool(block["is_error"]) return out if kind == "thinking": return { "type": "thinking", "thinking": block.get("thinking", ""), "signature": block.get("signature", ""), } if kind == "text": return {"type": "text", "text": str(block.get("text", ""))} return dict(block) def _result_text(block: dict[str, Any]) -> str: content = block.get("content", "") if isinstance(content, str): return content if isinstance(content, list): return "\n".join( str(b.get("text", "")) for b in content if isinstance(b, dict) and b.get("type") == "text" ) return str(content) def _zero_usage() -> dict[str, Any]: return { "input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0, "service_tier": "standard", "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "cache_creation": { "ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0, }, "inference_geo": "not_available", "iterations": [], "speed": "standard", } # ---- repair, windows, projections --------------------------------------- _PROMPT_TYPES = ("user", "assistant") _INTERRUPTED = "прервано" def open_tool_uses( entries: Iterable[Mapping[str, Any]], ) -> list[tuple[Mapping[str, Any], dict[str, Any]]]: """``(assistant entry, tool_use block)`` pairs that never got a ``tool_result``.""" closed: set[str] = set() uses: list[tuple[Mapping[str, Any], dict[str, Any]]] = [] for entry in entries: content = _entry_content(entry) if entry.get("type") == "user": closed.update( str(b.get("tool_use_id", "")) for b in content if b.get("type") == "tool_result" ) elif entry.get("type") == "assistant": uses.extend((entry, b) for b in content if b.get("type") == "tool_use") return [ (owner, block) for owner, block in uses if str(block.get("id", "")) not in closed ] def close_open_tool_uses( entries: list[Mapping[str, Any]], *, text: str = _INTERRUPTED ) -> list[dict[str, Any]]: """Synthetic ``tool_result`` entries on the leaf, one per open ``tool_use``. Appending the result to the session store gives the next ``resume`` a transcript the CLI accepts: an assistant message ending in ``tool_use`` without its result is rejected by the API on the next call. """ pending = open_tool_uses(entries) if not pending: return [] leaf = next( ( e for e in reversed(entries) if e.get("type") in _PROMPT_TYPES and e.get("uuid") ), None, ) parent = str(leaf["uuid"]) if leaf is not None else None stamp = datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%S.000Z") out: list[dict[str, Any]] = [] for owner, block in pending: uid = _new_uuid() result = { "type": "tool_result", "tool_use_id": block.get("id", ""), "content": text, "is_error": True, } out.append( { "parentUuid": parent, "promptId": owner.get("promptId"), "type": "user", "message": {"role": "user", "content": [result]}, "uuid": uid, "timestamp": stamp, "toolUseResult": text, "sourceToolAssistantUUID": owner.get("uuid"), **{k: owner[k] for k in _COMMON_KEYS if k in owner}, } ) parent = uid return out _COMMON_KEYS = ( "isSidechain", "userType", "entrypoint", "cwd", "sessionId", "version", "gitBranch", ) def window_entries( entries: Iterable[Mapping[str, Any]], *, window: int | None = None ) -> list[dict[str, Any]]: """``user``/``assistant`` entries only, last ``window`` prompts, relinked.""" kept = [ dict(e) for e in entries if e.get("type") in _PROMPT_TYPES and isinstance(e.get("uuid"), str) ] if window is not None and window > 0: starts = [i for i, e in enumerate(kept) if _is_prompt_entry(e)] if len(starts) > window: kept = kept[starts[-window] :] return _relink(kept) def strip_tool_entries(entries: Iterable[Mapping[str, Any]]) -> list[dict[str, Any]]: """Drop ``tool_result`` entries and ``tool_use``/``thinking`` blocks; relink.""" out: list[dict[str, Any]] = [] for raw in entries: entry = dict(raw) message = entry.get("message") if not isinstance(message, dict): out.append(entry) continue content = message.get("content") if isinstance(content, list): blocks = [ b for b in content if isinstance(b, dict) and b.get("type") not in ("tool_use", "tool_result", "thinking") ] if not blocks: continue entry["message"] = {**message, "content": blocks} out.append(entry) return _relink(out) def prompt_count(entries: Iterable[Mapping[str, Any]]) -> int: return sum(1 for e in entries if _is_prompt_entry(e)) def render_messages( messages: Iterable[Mapping[str, Any]], *, window: int | None = None ) -> str: """Plain-text projection for ``read_conversation``: ``user:``/``assistant:`` turns. Tool calls collapse to a one-line summary per assistant turn; tool results and thinking are dropped. """ turns: list[str] = [] tools: list[str] = [] current: list[str] = [] def flush() -> None: if not current and not tools: return body = "\n\n".join(current).strip() if tools: body = (body + "\n" if body else "") + "(tools: " + ", ".join(tools) + ")" turns.append("assistant:\n" + body) current.clear() tools.clear() for message in messages: role = message.get("role") content = message.get("content") if role == "user": if _tool_results(content): continue flush() turns.append("user:\n" + _text_of_content(content)) continue for block in _assistant_blocks(content): if block.get("type") == "text" and block.get("text"): current.append(str(block["text"])) elif block.get("type") == "tool_use": tools.append(str(block.get("name", ""))) flush() if window is not None and window > 0: starts = [i for i, t in enumerate(turns) if t.startswith("user:")] if len(starts) > window: turns = turns[starts[-window] :] return "\n\n".join(turns) def _entry_content(entry: Mapping[str, Any]) -> list[dict[str, Any]]: message = entry.get("message") if not isinstance(message, dict): return [] content = message.get("content") if not isinstance(content, list): return [] return [b for b in content if isinstance(b, dict)] def _is_prompt_entry(entry: Mapping[str, Any]) -> bool: if entry.get("type") != "user": return False message = entry.get("message") if not isinstance(message, dict): return False content = message.get("content") if isinstance(content, str): return True return not _tool_results(content) def _relink(entries: list[dict[str, Any]]) -> list[dict[str, Any]]: parent: str | None = None for entry in entries: entry["parentUuid"] = parent parent = entry.get("uuid") return entries def _text_of_content(content: Any) -> str: if isinstance(content, str): return content if isinstance(content, list): return "\n\n".join( str(b.get("text", "")) for b in content if isinstance(b, dict) and b.get("type") == "text" and b.get("text") ) return "" def fingerprint(messages: Iterable[Mapping[str, Any]]) -> str: """Text-only hash of a history; stateless callers are keyed by it.""" turns: list[tuple[str, str]] = [] for message in messages: text = text_of(message.get("content")) if not text: continue role = str(message.get("role", "")) if turns and turns[-1][0] == role: turns[-1] = (role, turns[-1][1] + "\n" + text) else: turns.append((role, text)) digest = hashlib.sha1(usedforsecurity=False) for role, text in turns: digest.update(role.encode("utf-8")) digest.update(b"\x00") digest.update(text.strip().encode("utf-8")) digest.update(b"\x01") return digest.hexdigest() def text_of(content: Any) -> str: if isinstance(content, str): return content if isinstance(content, list): parts = [ str(b.get("text", "")) for b in content if isinstance(b, Mapping) and b.get("type") == "text" ] return "\n".join(p for p in parts if p) return ""