from __future__ import annotations import hashlib import json from pathlib import Path from typing import Any, Iterable MAX_CHARS = 2000 LAST_MSG_BUDGET = 4000 HEAD_RATIO = 0.5 def compact(content: str, budget: int) -> str: if len(content) <= budget: return content head = int(budget * HEAD_RATIO) tail = budget - head - 50 return ( content[:head] + f"\n...[truncated {len(content)-head-tail} chars]...\n" + content[-tail:] ) def _unwrap_text_content(value: str) -> str: prefix = "@{type=text; text=" if value.startswith(prefix) and value.endswith("}"): return value[len(prefix) : -1] return value def _tool_result_text(event: dict[str, Any]) -> str: parts: list[str] = [] content_items = event.get("content") if isinstance(content_items, list): for item in content_items: if not isinstance(item, dict) or item.get("type") != "content": continue value = item.get("content") if isinstance(value, str) and value: parts.append(_unwrap_text_content(value)) elif ( isinstance(value, dict) and value.get("type") == "text" and isinstance(value.get("text"), str) and value["text"] ): parts.append(value["text"]) return "\n".join(parts) if parts else "(no output)" def acp_events_to_state(events: Iterable[dict[str, Any]]) -> list[dict[str, str]]: merged: list[dict[str, str]] = [] for event in events: event_type = event.get("type") role: str | None = None content: str | None = None if event_type == "user_message": role, content = "user", event.get("text") elif event_type == "agent_thought": role, content = "assistant", event.get("text") elif event_type == "agent_message": role, content = "assistant", event.get("text") if content == "": continue elif event_type == "tool_call": role = "user" kind = event.get("kind", "other") status = event.get("status", "unknown") content = f"Tool result ({kind}; {status}):\n{_tool_result_text(event)}" elif event_type == "agent_timeout": continue else: continue if not isinstance(content, str): raise ValueError(f"{event_type} event has a non-string text/content value") if merged and merged[-1]["role"] == role: merged[-1]["content"] += "\n" + content else: merged.append({"role": role, "content": content}) if not merged: raise ValueError("trajectory produced an empty state") if merged[0]["role"] != "user": raise ValueError("state must start with a user message") seen: set[str] = set() last_index = len(merged) - 1 for index, message in enumerate(merged): budget = LAST_MSG_BUDGET if index == last_index else MAX_CHARS content = compact(message["content"], budget) if len(content) > 200: digest = hashlib.md5(content.encode("utf-8")).hexdigest() if digest in seen: content = "[same as previous tool result]" else: seen.add(digest) message["content"] = content return merged def read_acp_events(path: Path) -> list[dict[str, Any]]: events: list[dict[str, Any]] = [] with path.open("r", encoding="utf-8") as handle: for line_number, line in enumerate(handle, 1): if not line.strip(): continue try: event = json.loads(line) except json.JSONDecodeError as exc: raise ValueError(f"invalid JSON on line {line_number}: {exc.msg}") from exc if not isinstance(event, dict): raise ValueError(f"line {line_number} is not a JSON object") events.append(event) return events def read_acp_state(path: Path) -> list[dict[str, str]]: return acp_events_to_state(read_acp_events(path))