Guest User

subagent-cache-rewrites.py

a guest
Sep 19th, 2026
72
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 7.29 KB | Source Code | 0 0
  1. #!/usr/bin/env python3
  2. """Measure prompt-cache re-writes in Claude Code subagent transcripts.
  3.  
  4. A subagent that sits in a tool call longer than its cache TTL re-writes its
  5. whole context on the next request. This reports how often that happens, how
  6. many tokens it costs, which TTL the writes used, and what the agent was
  7. blocked in.
  8.  
  9. subagent-cache-rewrites.py # all history
  10. subagent-cache-rewrites.py --since 2026-09-19
  11. """
  12.  
  13. from __future__ import annotations
  14.  
  15. import argparse
  16. import json
  17. from collections import Counter, defaultdict
  18. from collections.abc import Iterator
  19. from dataclasses import dataclass
  20. from datetime import date, datetime
  21. from pathlib import Path
  22.  
  23. TRANSCRIPTS = "*/*/subagents/agent-*.jsonl"
  24. GAP_5M = 300
  25. GAP_1H = 3600
  26. MIN_CONTEXT = 20_000 # ignore re-writes of contexts too small to matter
  27. REWRITE_SHARE = 0.5 # a re-write is a request that writes most of its context
  28.  
  29.  
  30. @dataclass(frozen=True)
  31. class Request:
  32. """One API request made by a subagent."""
  33.  
  34. time: datetime
  35. gap: float # seconds since the agent's previous transcript entry
  36. first: bool
  37. written: int
  38. written_5m: int
  39. written_1h: int
  40. context: int
  41. blocked_in: str | None # tool call the agent was in before this request
  42. agent: str
  43. model: str
  44. project: str
  45.  
  46. @property
  47. def is_rewrite(self) -> bool:
  48. return (
  49. not self.first
  50. and self.context > MIN_CONTEXT
  51. and self.written > REWRITE_SHARE * self.context
  52. )
  53.  
  54.  
  55. def parse_time(stamp: str) -> datetime:
  56. return datetime.fromisoformat(stamp.replace("Z", "+00:00"))
  57.  
  58.  
  59. def entries(path: Path) -> list[dict]:
  60. rows = []
  61. with path.open() as lines:
  62. for line in lines:
  63. try:
  64. row = json.loads(line)
  65. except json.JSONDecodeError:
  66. continue
  67. if row.get("type") in ("assistant", "user") and "timestamp" in row:
  68. rows.append(row)
  69. return sorted(rows, key=lambda row: row["timestamp"])
  70.  
  71.  
  72. def tool_name(block: dict) -> str:
  73. background = block["name"] == "Bash" and (block.get("input") or {}).get("run_in_background")
  74. return "Bash(bg)" if background else block["name"]
  75.  
  76.  
  77. def requests(path: Path, projects: Path) -> Iterator[Request]:
  78. project = path.relative_to(projects).parts[0]
  79. seen: set[str] = set()
  80. previous: datetime | None = None
  81. blocked_in: str | None = None
  82. for row in entries(path):
  83. time = parse_time(row["timestamp"])
  84. if row["type"] == "user":
  85. previous = previous or time
  86. continue
  87. message = row.get("message") or {}
  88. usage = message.get("usage")
  89. key = message.get("id") or row.get("requestId") or row["timestamp"]
  90. # Streaming repeats one request's usage on every content block.
  91. if usage and key not in seen:
  92. seen.add(key)
  93. written = usage.get("cache_creation_input_tokens") or 0
  94. split = usage.get("cache_creation") or {}
  95. yield Request(
  96. time=time,
  97. gap=(time - previous).total_seconds() if previous else 0.0,
  98. first=len(seen) == 1,
  99. written=written,
  100. written_5m=split.get("ephemeral_5m_input_tokens") or 0,
  101. written_1h=split.get("ephemeral_1h_input_tokens") or 0,
  102. context=written
  103. + (usage.get("cache_read_input_tokens") or 0)
  104. + (usage.get("input_tokens") or 0),
  105. blocked_in=blocked_in,
  106. agent=row.get("attributionAgent", "?"),
  107. model=message.get("model", "?"),
  108. project=project,
  109. )
  110. content = message.get("content")
  111. for block in content if isinstance(content, list) else []:
  112. if isinstance(block, dict) and block.get("type") == "tool_use":
  113. blocked_in = tool_name(block)
  114. previous = time
  115.  
  116.  
  117. def millions(tokens: int) -> str:
  118. return f"{tokens / 1e6:.1f}M"
  119.  
  120.  
  121. def share(part: int, whole: int) -> str:
  122. return f"{100 * part / whole:.1f}%" if whole else "n/a"
  123.  
  124.  
  125. def report(found: list[Request], agents: int) -> None:
  126. written = sum(r.written for r in found)
  127. gap_rewrites = [r for r in found if r.is_rewrite and r.gap > GAP_5M]
  128. other_rewrites = [r for r in found if r.is_rewrite and r.gap <= GAP_5M]
  129. gap_tokens = sum(r.written for r in gap_rewrites)
  130. past_1h = [r for r in gap_rewrites if r.gap > GAP_1H]
  131.  
  132. print(f"agents {agents} requests {len(found)} cache writes {millions(written)}")
  133. print(
  134. f" at 5m TTL {share(sum(r.written_5m for r in found), written)}"
  135. f" at 1h TTL {share(sum(r.written_1h for r in found), written)}"
  136. )
  137. print(
  138. f"re-writes after a gap over 5m: {len(gap_rewrites)} events, "
  139. f"{millions(gap_tokens)}, {share(gap_tokens, written)} of writes"
  140. )
  141. print(
  142. f" gap over 1h (a 1h TTL does not help): {len(past_1h)} events, "
  143. f"{millions(sum(r.written for r in past_1h))}"
  144. )
  145. print(
  146. f"re-writes with no gap (not TTL expiry): {len(other_rewrites)} events, "
  147. f"{millions(sum(r.written for r in other_rewrites))}"
  148. )
  149.  
  150. weeks: dict[str, Counter[str]] = defaultdict(Counter)
  151. for r in found:
  152. weeks[r.time.strftime("%G-W%V")]["written"] += r.written
  153. for r in gap_rewrites:
  154. weeks[r.time.strftime("%G-W%V")]["rewritten"] += r.written
  155. weeks[r.time.strftime("%G-W%V")]["events"] += 1
  156. print("\nweek writes gap re-writes share events")
  157. for week, c in sorted(weeks.items()):
  158. print(
  159. f"{week} {millions(c['written']):>7} {millions(c['rewritten']):>13}"
  160. f" {share(c['rewritten'], c['written']):>6} {c['events']:>6}"
  161. )
  162.  
  163. for title, key in (("blocked in", "blocked_in"), ("agent type", "agent")):
  164. tokens: Counter[str] = Counter()
  165. events: Counter[str] = Counter()
  166. for r in gap_rewrites:
  167. tokens[str(getattr(r, key))] += r.written
  168. events[str(getattr(r, key))] += 1
  169. print(f"\ngap re-writes by {title}:")
  170. for name, total in tokens.most_common(8):
  171. print(f" {name:<20} {events[name]:>4} events {millions(total):>7}")
  172.  
  173. print("\nlargest gap re-writes:")
  174. for r in sorted(gap_rewrites, key=lambda r: r.written, reverse=True)[:10]:
  175. print(
  176. f" {r.written:>7} tok {r.gap / 60:5.1f} min {r.time.date()}"
  177. f" {r.agent} {r.model} {r.project}"
  178. )
  179.  
  180.  
  181. def main() -> None:
  182. parser = argparse.ArgumentParser(description="Measure prompt-cache re-writes in subagent transcripts.")
  183. parser.add_argument("--since", type=date.fromisoformat, help="only requests on or after YYYY-MM-DD")
  184. parser.add_argument("--projects", type=Path, default=Path.home() / ".claude" / "projects")
  185. args = parser.parse_args()
  186.  
  187. per_agent = [list(requests(path, args.projects)) for path in args.projects.glob(TRANSCRIPTS)]
  188. if args.since:
  189. per_agent = [[r for r in agent if r.time.date() >= args.since] for agent in per_agent]
  190. per_agent = [agent for agent in per_agent if agent]
  191. report([r for agent in per_agent for r in agent], agents=len(per_agent))
  192.  
  193.  
  194. if __name__ == "__main__":
  195. main()
Tags: claude-code
Advertisement
Add Comment
Please, Sign In to add comment