546 lines
17 KiB
Python
546 lines
17 KiB
Python
"""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 ""
|