fix(claude_sdk,conversations,rotation): context size from the last api call, new-day text by rotation reason
This commit is contained in:
@@ -391,7 +391,7 @@ class ClaudeSdkBackend:
|
||||
live.session_id = turn.result.session_id
|
||||
if conversation_id is None:
|
||||
self._rekey(live.key, fingerprint([*history, *turn.synthesized]))
|
||||
usage = _usage_of(turn.result)
|
||||
usage = _usage_of(turn.result, context_tokens=turn.context_tokens)
|
||||
interrupted = live.interrupt_requested
|
||||
live.interrupt_requested = False
|
||||
if capture is not None:
|
||||
@@ -455,6 +455,7 @@ class ClaudeSdkBackend:
|
||||
event = message.event
|
||||
if event.get("type") == "message_start":
|
||||
offset = next_index
|
||||
turn.context_tokens = _context_of(event.get("message"))
|
||||
continue
|
||||
index = event.get("index")
|
||||
if isinstance(index, int):
|
||||
@@ -740,6 +741,9 @@ class _SessionSpec:
|
||||
class _Turn:
|
||||
events: int = 0
|
||||
"""Wire events already yielded to the caller."""
|
||||
|
||||
context_tokens: int = 0
|
||||
"""Input size of the latest API call (``message_start`` usage)."""
|
||||
synthesized: list[dict[str, Any]] = field(default_factory=list)
|
||||
result: ResultMessage | None = None
|
||||
stop_reason: StopReason = "end_turn"
|
||||
@@ -895,11 +899,23 @@ def _block_to_dict(block: Any) -> dict[str, Any]:
|
||||
raise TypeError(msg)
|
||||
|
||||
|
||||
def _usage_of(result: ResultMessage | None) -> TurnUsage:
|
||||
def _context_of(message: Any) -> int:
|
||||
usage = message.get("usage") if isinstance(message, dict) else None
|
||||
if not isinstance(usage, dict):
|
||||
return 0
|
||||
return (
|
||||
_int(usage.get("input_tokens"))
|
||||
+ _int(usage.get("cache_read_input_tokens"))
|
||||
+ _int(usage.get("cache_creation_input_tokens"))
|
||||
)
|
||||
|
||||
|
||||
def _usage_of(result: ResultMessage | None, *, context_tokens: int = 0) -> TurnUsage:
|
||||
if result is None:
|
||||
return TurnUsage()
|
||||
return TurnUsage(context_tokens=context_tokens)
|
||||
usage = result.usage or {}
|
||||
return TurnUsage(
|
||||
context_tokens=context_tokens,
|
||||
input_tokens=_int(usage.get("input_tokens")),
|
||||
output_tokens=_int(usage.get("output_tokens")),
|
||||
cache_read_tokens=_int(usage.get("cache_read_input_tokens")),
|
||||
|
||||
@@ -428,6 +428,7 @@ async def _build_backends(
|
||||
output_tokens=event.usage.output_tokens,
|
||||
cache_read_tokens=event.usage.cache_read_tokens,
|
||||
cache_creation_tokens=event.usage.cache_creation_tokens,
|
||||
context_tokens=event.usage.context_tokens,
|
||||
cost_usd=event.usage.cost_usd,
|
||||
duration_ms=event.usage.duration_ms,
|
||||
num_turns=event.usage.num_turns,
|
||||
|
||||
@@ -23,7 +23,7 @@ import logging
|
||||
import re
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, date, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from claude_agent_sdk import (
|
||||
@@ -134,6 +134,19 @@ class SeedContext:
|
||||
title: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NewDayContext:
|
||||
"""What the new master hears first.
|
||||
|
||||
``reason`` is ``ночь`` / ``возраст`` / ``транскрипт`` - only the first
|
||||
one is actually a new day.
|
||||
"""
|
||||
|
||||
day: date
|
||||
reason: str
|
||||
moved: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConversationTexts:
|
||||
"""Texts the gateway cannot invent for a setup.
|
||||
@@ -155,7 +168,10 @@ class ConversationTexts:
|
||||
"Этот мастер закрывается ({reason}). Напиши хендаут за {day}: справку "
|
||||
"на утро, не задание - прошедшее время, без повелительного наклонения."
|
||||
)
|
||||
new_day: str = "Новый день: мастер сменился, хендаут за {day} записан."
|
||||
new_day: Callable[[NewDayContext], Awaitable[str] | str] | str = (
|
||||
"Мастер сменился ({reason}), хендаут за {day} записан."
|
||||
)
|
||||
"""First inject of the new master; a callable sees the rotation reason."""
|
||||
distill: Callable[[DistillContext], Awaitable[str] | str] | None = None
|
||||
"""The distiller fork's first message (§8.4): which chat, what day, where
|
||||
the digest goes; ``None`` uses a path-less default."""
|
||||
@@ -553,9 +569,7 @@ class Conversations:
|
||||
.limit(1)
|
||||
)
|
||||
).first()
|
||||
if row is None:
|
||||
return 0
|
||||
return row.input_tokens + row.cache_read_tokens + row.cache_creation_tokens
|
||||
return context_of(row)
|
||||
|
||||
async def usage_tokens(self, since: datetime) -> int:
|
||||
async with self._db.session() as session:
|
||||
@@ -1213,9 +1227,18 @@ class Conversations:
|
||||
_log.exception("%s could not mark %s", fe.name, conv.external_id)
|
||||
return marked
|
||||
|
||||
async def new_day(self, conv: Conversation, *, moved: int = 0) -> InjectQueueItem:
|
||||
day = datetime.now(UTC).astimezone().date().isoformat()
|
||||
text = self._texts.new_day.format(day=day)
|
||||
async def new_day(
|
||||
self, conv: Conversation, *, reason: str = "ночь", moved: int = 0
|
||||
) -> InjectQueueItem:
|
||||
ctx = NewDayContext(
|
||||
day=datetime.now(UTC).astimezone().date(), reason=reason, moved=moved
|
||||
)
|
||||
source = self._texts.new_day
|
||||
if isinstance(source, str):
|
||||
text = source.format(day=ctx.day.isoformat(), reason=ctx.reason)
|
||||
else:
|
||||
produced: Any = source(ctx)
|
||||
text = await produced if inspect.isawaitable(produced) else produced
|
||||
if moved:
|
||||
text += f" Инжектов переехало из старого мастера: {moved}."
|
||||
return await self.inject(
|
||||
@@ -1902,3 +1925,17 @@ def _usage_dict(capture: TurnCapture) -> dict[str, Any] | None:
|
||||
"cost_usd": usage.cost_usd,
|
||||
"duration_ms": usage.duration_ms,
|
||||
}
|
||||
|
||||
|
||||
def context_of(row: Usage | None) -> int:
|
||||
"""Context size of a turn.
|
||||
|
||||
The last API call's input, or, for rows written before it was recorded,
|
||||
the per-call average of the turn's input sums.
|
||||
"""
|
||||
if row is None:
|
||||
return 0
|
||||
if row.context_tokens:
|
||||
return row.context_tokens
|
||||
total = row.input_tokens + row.cache_read_tokens + row.cache_creation_tokens
|
||||
return round(total / max(row.num_turns or 1, 1))
|
||||
|
||||
@@ -115,7 +115,7 @@ class Rotation:
|
||||
elif not branch.running_turn:
|
||||
await conversations.mark_closed(branch)
|
||||
moved = await conversations.queue.move(old, new, priority="normal")
|
||||
await conversations.new_day(new, moved=moved)
|
||||
await conversations.new_day(new, reason=reason, moved=moved)
|
||||
conversations.bus.publish(
|
||||
"conversation.rotated",
|
||||
conversation_id=new.external_id,
|
||||
|
||||
@@ -27,6 +27,12 @@ class TurnUsage:
|
||||
model_usage: dict[str, Any] | None = None
|
||||
"""``ResultMessage.model_usage`` verbatim: per-model tokens, cost, web searches."""
|
||||
|
||||
context_tokens: int = 0
|
||||
"""Input of the last API call in the turn (fresh + cached + written to
|
||||
cache) - the context size the model actually ran with, what Claude Code
|
||||
shows as the context. The token fields above are sums over every API
|
||||
call of the turn and grow with the number of tool calls."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class TurnCapture:
|
||||
|
||||
@@ -299,6 +299,8 @@ class Usage(SQLModel, table=True):
|
||||
output_tokens: int = 0
|
||||
cache_read_tokens: int = 0
|
||||
cache_creation_tokens: int = 0
|
||||
context_tokens: int | None = Field(default=None)
|
||||
"""Input of the turn's last API call - the real context size."""
|
||||
cost_usd: float | None = Field(default=None)
|
||||
duration_ms: int | None = Field(default=None)
|
||||
num_turns: int | None = Field(default=None)
|
||||
|
||||
Reference in New Issue
Block a user