Files
LangBot/plugins/Runner/LocalAgent/pkg/context_pipeline.py
T

2525 lines
93 KiB
Python

"""Context assembly, budgeting, and deterministic compaction for local-agent."""
from __future__ import annotations
import hashlib
import json
import logging
import re
import time
import typing
import unicodedata
from dataclasses import dataclass, field
from datetime import datetime, timezone
from langbot_plugin.api.entities.builtin.provider.message import ContentElement, Message
from langbot_plugin.api.entities.builtin.runner import RunnerContext
from pkg.config import get_knowledge_base_ids, get_rerank_config, get_retrieval_top_k
from pkg.messages import (
build_event_context_message,
build_platform_tools_system_message,
build_prompt_messages,
build_rag_context_message,
build_skills_system_message,
build_user_message,
get_effective_prompt_config,
)
from pkg.rag import RagChunk, format_rag_chunks, retrieve_rag_chunks
DEFAULT_HISTORY_FETCH_LIMIT = 50
DEFAULT_CONTEXT_WINDOW_TOKENS = 200_000
DEFAULT_CONTEXT_RESERVE_TOKENS = 16_384
DEFAULT_CONTEXT_KEEP_RECENT_TOKENS = 20_000
DEFAULT_CONTEXT_SUMMARY_TOKENS = 8_000
RAG_CONTEXT_MAX_INPUT_FRACTION = 4
ESTIMATED_ATTACHMENT_CHARS = 4800
ESTIMATED_ATTACHMENT_TOKENS = 1600
ESTIMATED_CHARS_PER_TOKEN = 4
SUMMARY_REFERENCE_LIMIT = 40
SUMMARY_OPEN_TAG = "<conversation_summary>"
SUMMARY_CLOSE_TAG = "</conversation_summary>"
SUMMARY_SCHEMA_VERSION = "langbot.conversation_summary.v1"
CHECKPOINT_STATE_KEY = "runner.compaction.checkpoint"
CHECKPOINT_SCHEMA_VERSION = "langbot.local_agent.compaction_checkpoint.v1"
SUMMARY_ENTRY_RE = re.compile(r"^\s*\d+\.\s+([A-Za-z_][A-Za-z0-9_-]*):\s?(.*)$", re.DOTALL)
SUMMARY_COUNT_RE = re.compile(r"\bcount=(\d+)\b")
CRITICAL_REF_RE = re.compile(
r"(?<![A-Za-z0-9_./:-])"
r"(?:[A-Za-z][A-Za-z0-9]*[_.:/-]){2,}[A-Za-z0-9][A-Za-z0-9_.:/-]*"
)
CRITICAL_REF_GUIDANCE = (
"Instruction: exact opaque values from omitted messages; when asked for a remembered "
"passcode, secret, sentinel, or identifier, reply with the full matching value verbatim."
)
SUMMARIZATION_SYSTEM_PROMPT = """You are a context summarization assistant. Your task is to read a conversation between a user and an AI assistant, then produce a structured summary following the exact format specified.
Do NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary."""
SUMMARIZATION_PROMPT = """The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
Use this EXACT format:
## Goal
[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
## Constraints & Preferences
- [Any constraints, preferences, or requirements mentioned by user]
- [Or "(none)" if none were mentioned]
## Progress
### Done
- [x] [Completed tasks/changes]
### In Progress
- [ ] [Current work]
### Blocked
- [Issues preventing progress, if any]
## Key Decisions
- **[Decision]**: [Brief rationale]
## Next Steps
1. [Ordered list of what should happen next]
## Critical Context
- [Any data, examples, or references needed to continue]
- [Or "(none)" if not applicable]
Keep each section concise. Preserve exact file paths, function names, and error messages."""
UPDATE_SUMMARIZATION_PROMPT = """The messages above are NEW conversation messages to incorporate into the existing summary provided in <previous-summary> tags.
Update the existing structured summary with new information. RULES:
- PRESERVE all existing information from the previous summary
- ADD new progress, decisions, and context from the new messages
- UPDATE the Progress section: move items from "In Progress" to "Done" when completed
- UPDATE "Next Steps" based on what was accomplished
- PRESERVE exact file paths, function names, and error messages
- If something is no longer relevant, you may remove it
Use this EXACT format:
## Goal
[Preserve existing goals, add new ones if the task expanded]
## Constraints & Preferences
- [Preserve existing, add new ones discovered]
## Progress
### Done
- [x] [Include previously done items AND newly completed items]
### In Progress
- [ ] [Current work - update based on progress]
### Blocked
- [Current blockers - remove if resolved]
## Key Decisions
- **[Decision]**: [Brief rationale] (preserve all previous, add new)
## Next Steps
1. [Update based on current state]
## Critical Context
- [Preserve important context, add new if needed]
Keep each section concise. Preserve exact file paths, function names, and error messages."""
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ContextBudget:
"""Token-style context budget resolved from runner config and Host metadata."""
window_tokens: int = DEFAULT_CONTEXT_WINDOW_TOKENS
reserve_tokens: int = DEFAULT_CONTEXT_RESERVE_TOKENS
keep_recent_tokens: int = DEFAULT_CONTEXT_KEEP_RECENT_TOKENS
summary_tokens: int = DEFAULT_CONTEXT_SUMMARY_TOKENS
history_fetch_limit: int = DEFAULT_HISTORY_FETCH_LIMIT
@classmethod
def from_context(cls, ctx: RunnerContext) -> "ContextBudget":
config = ctx.config if isinstance(ctx.config, dict) else {}
runtime = getattr(ctx, "runtime", None)
metadata = getattr(runtime, "metadata", {}) if runtime is not None else {}
if not isinstance(metadata, dict):
metadata = {}
host_window_tokens = _runtime_context_window_tokens(metadata)
host_max_output_tokens = _runtime_max_output_tokens(metadata)
config_window_tokens = _config_token_value(
config,
"context-window-tokens",
DEFAULT_CONTEXT_WINDOW_TOKENS,
minimum=0,
)
window_tokens = config_window_tokens
if host_window_tokens is not None:
window_tokens = min(host_window_tokens, config_window_tokens)
return cls.from_config(
config,
window_tokens=window_tokens,
max_output_tokens=host_max_output_tokens,
)
@classmethod
def from_config(
cls,
config: dict[str, typing.Any],
*,
window_tokens: int | None = None,
max_output_tokens: int | None = None,
) -> "ContextBudget":
resolved_window_tokens = (
window_tokens
if window_tokens is not None
else _config_token_value(
config,
"context-window-tokens",
DEFAULT_CONTEXT_WINDOW_TOKENS,
minimum=0,
)
)
reserve_tokens = _clamp_reserve_tokens(
resolved_window_tokens,
_config_token_value(
config,
"context-reserve-tokens",
DEFAULT_CONTEXT_RESERVE_TOKENS,
minimum=0,
),
)
summary_tokens = _config_token_value(
config,
"context-summary-tokens",
DEFAULT_CONTEXT_SUMMARY_TOKENS,
minimum=0,
)
if max_output_tokens is not None and max_output_tokens > 0:
summary_tokens = min(summary_tokens, max_output_tokens, max(1, (reserve_tokens * 4) // 5))
return cls(
window_tokens=resolved_window_tokens,
reserve_tokens=reserve_tokens,
keep_recent_tokens=_config_token_value(
config,
"context-keep-recent-tokens",
DEFAULT_CONTEXT_KEEP_RECENT_TOKENS,
minimum=0,
),
summary_tokens=summary_tokens,
history_fetch_limit=_config_int(
config,
"context-history-fetch-limit",
DEFAULT_HISTORY_FETCH_LIMIT,
minimum=1,
),
)
@property
def enabled(self) -> bool:
return self.window_tokens > 0
@property
def input_tokens(self) -> int:
if not self.enabled:
return 0
return max(self.window_tokens - self.reserve_tokens, 1)
@dataclass(frozen=True)
class ContextAssembly:
"""Final model context plus compaction diagnostics."""
messages: list[Message]
compacted: bool
tokens_before: int
tokens_after: int
summary_message: Message | None = None
frame: "ContextFrame | None" = None
diagnostics: "ContextDiagnostics | None" = None
@dataclass(frozen=True)
class ContextFrame:
"""Structured model context before provider rendering."""
prompt: list[Message] = field(default_factory=list)
summaries: list[Message] = field(default_factory=list)
history: list[Message] = field(default_factory=list)
rag: list[Message] = field(default_factory=list)
current: list[Message] = field(default_factory=list)
rag_chunks: list[RagChunk] = field(default_factory=list)
def to_messages(self) -> list[Message]:
return self.prompt + self.summaries + self.history + self.rag + self.current
@dataclass(frozen=True)
class ContextDiagnostics:
"""Token diagnostics for the rendered context."""
tokens_before: int
tokens_after: int
prompt_tokens: int
summary_tokens: int
history_tokens: int
rag_tokens: int
current_tokens: int
compacted_message_count: int = 0
@dataclass(frozen=True)
class ContextUsageAnchor:
"""Provider-reported token usage for an already-rendered message prefix."""
message_count: int
total_tokens: int
model_id: str | None = None
class ContextTokenCounter(typing.Protocol):
async def count(self, messages: list[Message]) -> int:
"""Return provider/tokenizer token count for this model request."""
class ContextTokenCounterRequiredError(RuntimeError):
"""Raised when runtime compaction cannot access Host token counting."""
@dataclass(frozen=True)
class ConversationSummaryEntry:
index: int
role: str
text: str
@dataclass(frozen=True)
class ConversationSummary:
schema_version: str
message_count: int
entries: list[ConversationSummaryEntry]
def render(self, max_tokens: int) -> str:
if max_tokens <= 0:
return ""
lines = [
SUMMARY_OPEN_TAG,
f"v={self.schema_version.rsplit('.', 1)[-1]} count={self.message_count}",
]
for entry in self.entries:
prefix = f"{entry.index}. {entry.role}: "
prefix_tokens = estimate_text_tokens(_close_summary_lines(lines + [prefix]))
if prefix_tokens > max_tokens:
break
text = _truncate_text_to_tokens(entry.text, max_tokens - prefix_tokens)
candidate = lines + [prefix + text]
if estimate_text_tokens(_close_summary_lines(candidate)) > max_tokens:
break
lines = candidate
rendered = _close_summary_lines(lines)
if estimate_text_tokens(rendered) <= max_tokens:
return rendered
return _truncate_text_to_tokens(rendered, max_tokens)
@dataclass(frozen=True)
class CompactionCheckpoint:
summary: str
covers_until: str | None
tokens_before: int | None = None
created_at: int | None = None
class ContextSummarizer(typing.Protocol):
async def summarize(self, messages: list[Message], max_tokens: int) -> str | None:
"""Return a model-facing summary message body or None to use deterministic fallback."""
class LLMContextSummarizer:
"""Generate Pi-style structured summaries through LangBot Host model APIs."""
def __init__(
self,
api: typing.Any,
model_id: str,
*,
remove_think: bool = False,
reasoning_level: str = "provider_default",
):
self.api = api
self.model_id = model_id
self.remove_think = remove_think
self.reasoning_level = reasoning_level
async def summarize(self, messages: list[Message], max_tokens: int) -> str | None:
if not messages or max_tokens <= 0:
return None
previous_summary = _extract_previous_summary_text(messages)
current_messages = [message for message in messages if not _is_conversation_summary_message(message)]
if not current_messages:
return None
prompt = _build_llm_summary_prompt(current_messages, previous_summary=previous_summary)
try:
response = await self.api.invoke_llm(
llm_model_uuid=self.model_id,
messages=[
Message(role="system", content=SUMMARIZATION_SYSTEM_PROMPT),
Message(role="user", content=prompt),
],
funcs=[],
remove_think=self.remove_think,
reasoning_level=self.reasoning_level,
)
except Exception:
logger.warning("LLM context summarization failed; using deterministic fallback", exc_info=True)
return None
summary_text = _message_content_text(response).strip()
if not summary_text:
logger.warning("LLM context summarization returned empty content; using deterministic fallback")
return None
return _wrap_llm_summary(summary_text, max_tokens, message_count=len(messages))
class HostContextTokenCounter:
"""Count model input tokens through the run-scoped Host API."""
def __init__(
self,
api: typing.Any,
model_id: str,
tools: list[typing.Any] | None = None,
*,
reasoning_level: str = "provider_default",
):
self.api = api
self.model_id = model_id
self.tools = tools or []
self.reasoning_level = reasoning_level
self._counts: dict[tuple[str, str, bytes], int] = {}
async def count(self, messages: list[Message]) -> int:
count_tokens = getattr(self.api, "count_tokens", None)
if not callable(count_tokens):
raise ContextTokenCounterRequiredError(
"Host count_tokens API is required for local-agent context budgeting"
)
payload = json.dumps([message.model_dump(mode="json") for message in messages], sort_keys=True)
key = (self.model_id, self.reasoning_level, hashlib.sha256(payload.encode()).digest())
if key in self._counts:
return self._counts[key]
tokens = await count_tokens(
llm_model_uuid=self.model_id,
messages=messages,
funcs=[],
reasoning_level=self.reasoning_level,
)
if isinstance(tokens, bool) or not isinstance(tokens, int) or tokens < 0:
raise ContextTokenCounterRequiredError(f"Host count_tokens returned invalid token count: {tokens!r}")
# This counter belongs to one run; retain only a bounded set of input digests.
if len(self._counts) >= 32:
self._counts.pop(next(iter(self._counts)))
self._counts[key] = tokens
return tokens
class ContextAssembler:
"""Build the model-facing context for one Runner run."""
def __init__(
self,
api: typing.Any,
ctx: RunnerContext,
budget: ContextBudget | None = None,
summarizer: ContextSummarizer | None = None,
token_counter: ContextTokenCounter | None = None,
):
self.api = api
self.ctx = ctx
self.budget = budget or ContextBudget.from_context(ctx)
self.summarizer = summarizer
self.token_counter = token_counter
async def assemble(self) -> ContextAssembly:
user_text = self.ctx.input.to_text()
rag_chunks = await self._retrieve_rag_chunks(user_text)
rag_message = build_rag_context_message(format_rag_chunks(rag_chunks))
checkpoint = await self._load_compaction_checkpoint()
history_messages, history_cursors = await self._get_history_messages(checkpoint)
prompt_messages = build_prompt_messages(await self._get_prompt_config())
system_instructions = []
if self.ctx.config.get("date-grounding", True) is not False:
# Runtime clock, not the event timestamp: replayed events may be old.
# UTC is explicit so a separate plugin process cannot imply Host/user time.
date_guidance = (
f"Current date: {datetime.now(timezone.utc).strftime('%Y-%m-%d (%A)')} UTC. "
'Resolve relative time references (e.g. "today", "this quarter", "latest", '
'"currently") based on this date, not your training cutoff. For anything '
"time-sensitive that may have changed since training — stock prices, "
"financial results, news, current events, exchange rates, or similar — "
"verify with a search tool if one is available rather than answering from memory."
)
system_instructions.append(date_guidance)
skills_message = build_skills_system_message(self.ctx)
if skills_message is not None:
system_instructions.append(skills_message.content)
platform_tools_message = build_platform_tools_system_message(self.ctx)
if platform_tools_message is not None:
system_instructions.append(platform_tools_message.content)
if system_instructions:
# Strict chat templates only accept a system message at position zero.
guidance = "\n\n".join(system_instructions)
if prompt_messages and prompt_messages[0].role == "system":
head = prompt_messages[0]
if isinstance(head.content, str):
head.content += "\n\n" + guidance
else:
head.content.append(ContentElement.from_text(guidance))
else:
prompt_messages.insert(0, Message(role="system", content=guidance))
checkpoint_messages = self._checkpoint_messages(checkpoint)
checkpoint_cursors = [checkpoint.covers_until for _ in checkpoint_messages] if checkpoint else []
rag_messages = [rag_message] if rag_message is not None else []
current_messages = []
event_message = build_event_context_message(self.ctx)
if event_message is not None:
current_messages.append(event_message)
user_message = build_user_message(
user_text=user_text,
input_contents=self.ctx.input.contents,
input_attachments=self.ctx.input.attachments,
)
if user_message is not None:
current_messages.append(user_message)
assembly = await ContextCompactor(
self.budget,
summarizer=self.summarizer,
token_counter=self.token_counter,
).compact_async(
prompt_messages=prompt_messages,
history_messages=checkpoint_messages + history_messages,
rag_messages=rag_messages,
current_messages=current_messages,
rag_chunks=rag_chunks,
)
await self._store_compaction_checkpoint(
assembly,
checkpoint=checkpoint,
compaction_cursors=checkpoint_cursors + history_cursors,
)
return assembly
async def _get_prompt_config(self) -> list[dict[str, typing.Any]]:
available_apis = getattr(getattr(self.ctx, "context", None), "available_apis", None)
prompt_get = getattr(available_apis, "prompt_get", None)
if bool(prompt_get) or (prompt_get is None and hasattr(self.api, "get_prompt")):
try:
prompt = await self.api.get_prompt()
if isinstance(prompt, list):
# An empty effective prompt is an intentional Host override.
return prompt
except Exception:
logger.debug("Host prompt_get failed; falling back to static prompt", exc_info=True)
return get_effective_prompt_config(self.ctx)
async def _retrieve_rag_chunks(self, user_text: str) -> list[RagChunk]:
allowed_kb_ids = set(kb.kb_id for kb in self.api.get_allowed_knowledge_bases())
# TODO(agentic-rag): when AgenticRAG disables naive retrieval, Host should
# pass no configured KBs here, or expose an explicit retrieval mode that
# tells this runner to skip automatic RAG and rely on the query tool.
kb_ids = get_knowledge_base_ids(self.ctx.config, allowed_kb_ids)
if not kb_ids or not user_text:
return []
rerank_model_id, rerank_top_k = get_rerank_config(self.ctx.config)
return await retrieve_rag_chunks(
api=self.api,
kb_ids=kb_ids,
query_text=user_text,
top_k=get_retrieval_top_k(self.ctx.config),
rerank_model_id=rerank_model_id,
rerank_top_k=rerank_top_k,
)
async def _get_history_messages(
self,
checkpoint: CompactionCheckpoint | None = None,
) -> tuple[list[Message], list[str | None]]:
context = self.ctx.context
if not context.available_apis.history_page or not context.conversation_id:
return [], []
direction = "forward" if checkpoint and checkpoint.covers_until else "backward"
messages: list[Message] = []
cursors: list[str | None] = []
after_cursor = checkpoint.covers_until if direction == "forward" and checkpoint else None
seen_page_cursors = {after_cursor} if after_cursor is not None else set()
while True:
kwargs: dict[str, typing.Any] = {
"conversation_id": context.conversation_id,
"limit": self.budget.history_fetch_limit,
"direction": direction,
}
if direction == "forward":
kwargs["after_cursor"] = after_cursor
try:
page = await self.api.history_page(**kwargs)
except Exception:
if checkpoint is not None:
logger.warning(
"Compaction checkpoint history cursor failed; falling back to recent tail", exc_info=True
)
return await self._get_history_messages(None)
raise
raw_items = list(_model_or_mapping_get(page, "items", []))
iterable = raw_items if direction == "forward" else list(reversed(raw_items))
for raw_item in iterable:
item = _as_mapping(raw_item)
if item is None:
continue
message = _message_from_transcript_item(item, self.ctx.event.event_id)
if message is not None:
messages.append(message)
cursor = item.get("cursor") or item.get("seq")
cursors.append(str(cursor) if cursor is not None else None)
if direction != "forward" or not bool(_model_or_mapping_get(page, "has_more", False)):
break
raw_next_cursor = _model_or_mapping_get(page, "next_cursor")
next_cursor = str(raw_next_cursor) if raw_next_cursor is not None else None
if not next_cursor or next_cursor in seen_page_cursors:
logger.warning("Compaction checkpoint history pagination stopped due to invalid next_cursor")
break
seen_page_cursors.add(next_cursor)
after_cursor = next_cursor
return messages, cursors
def _state_api_available(self) -> bool:
available_apis = getattr(getattr(self.ctx, "context", None), "available_apis", None)
return (
bool(getattr(available_apis, "state", False))
and hasattr(self.api, "state_get")
and hasattr(self.api, "state_set")
)
async def _load_compaction_checkpoint(self) -> CompactionCheckpoint | None:
if not self._state_api_available():
return None
try:
response = await self.api.state_get("conversation", CHECKPOINT_STATE_KEY)
except Exception:
logger.debug("Failed to read compaction checkpoint state", exc_info=True)
return None
value = response.get("value") if isinstance(response, dict) else None
if not isinstance(value, dict):
return None
if value.get("schema_version") != CHECKPOINT_SCHEMA_VERSION:
return None
conversation_id = getattr(getattr(self.ctx, "context", None), "conversation_id", None)
stored_conversation_id = value.get("conversation_id")
if stored_conversation_id and conversation_id and stored_conversation_id != conversation_id:
return None
summary = value.get("summary")
if not isinstance(summary, str) or not summary.strip():
return None
covers_until = value.get("covers_until")
return CompactionCheckpoint(
summary=summary,
covers_until=str(covers_until) if covers_until is not None else None,
tokens_before=value.get("tokens_before") if isinstance(value.get("tokens_before"), int) else None,
created_at=value.get("created_at") if isinstance(value.get("created_at"), int) else None,
)
def _checkpoint_messages(self, checkpoint: CompactionCheckpoint | None) -> list[Message]:
if checkpoint is None or not checkpoint.summary:
return []
return [Message(role="system", content=checkpoint.summary)]
async def _store_compaction_checkpoint(
self,
assembly: ContextAssembly,
*,
checkpoint: CompactionCheckpoint | None,
compaction_cursors: list[str | None],
) -> None:
if not self._state_api_available() or not assembly.compacted or assembly.summary_message is None:
return
compacted_count = assembly.diagnostics.compacted_message_count if assembly.diagnostics else 0
covered_cursor = _last_non_empty(compaction_cursors[:compacted_count])
if covered_cursor is None and checkpoint is not None:
covered_cursor = checkpoint.covers_until
if covered_cursor is None:
return
summary = _message_content_text(assembly.summary_message).strip()
if not summary:
return
payload = {
"schema_version": CHECKPOINT_SCHEMA_VERSION,
"summary": summary,
"covers_until": covered_cursor,
"tokens_before": assembly.tokens_before,
"created_at": int(time.time()),
"conversation_id": getattr(getattr(self.ctx, "context", None), "conversation_id", None),
}
try:
await self.api.state_set("conversation", CHECKPOINT_STATE_KEY, payload)
except Exception:
logger.debug("Failed to write compaction checkpoint state", exc_info=True)
class ContextCompactor:
"""Compact old history into a summary message while keeping recent context."""
def __init__(
self,
budget: ContextBudget,
summarizer: ContextSummarizer | None = None,
usage_anchor: ContextUsageAnchor | None = None,
token_counter: ContextTokenCounter | None = None,
):
self.budget = budget
self.summarizer = summarizer
self.usage_anchor = usage_anchor
self.token_counter = token_counter
def compact_messages(self, messages: list[Message]) -> ContextAssembly:
"""Compact an already-assembled runtime message list.
Initial assembly knows prompt/history/current boundaries. Follow-up turns
only have the loop's flat model context, so preserve leading prompt-like
messages plus the current turn tail, and compact older runtime messages
between them.
"""
messages = sanitize_provider_messages(messages)
prompt_messages, existing_summaries, runtime_messages = _split_leading_prompt_messages(messages)
history_messages, current_messages = _split_runtime_history_and_current(runtime_messages)
return self.compact(
prompt_messages=prompt_messages,
history_messages=existing_summaries + history_messages,
current_messages=current_messages,
)
async def compact_messages_async(self, messages: list[Message]) -> ContextAssembly:
self._require_token_counter()
messages = sanitize_provider_messages(messages)
prompt_messages, existing_summaries, runtime_messages = _split_leading_prompt_messages(messages)
history_messages, current_messages = _split_runtime_history_and_current(runtime_messages)
return await self.compact_async(
prompt_messages=prompt_messages,
history_messages=existing_summaries + history_messages,
current_messages=current_messages,
)
def compact(
self,
*,
prompt_messages: list[Message],
history_messages: list[Message],
current_messages: list[Message],
rag_messages: list[Message] | None = None,
rag_chunks: list[RagChunk] | None = None,
) -> ContextAssembly:
prompt = sanitize_provider_messages(prompt_messages)
prompt, prompt_summaries = _partition_summary_messages(prompt)
history = sanitize_provider_messages(prompt_summaries + history_messages)
rag = sanitize_provider_messages(rag_messages or [])
current = sanitize_provider_messages(current_messages)
original_messages = prompt + history + rag + current
tokens_before = estimate_messages_tokens_with_anchor(original_messages, self.usage_anchor)
if not self.budget.enabled or tokens_before <= self.budget.input_tokens:
return self._build_assembly(
prompt=prompt,
summaries=[],
history=history,
rag=rag,
current=current,
rag_chunks=rag_chunks or [],
compacted=False,
tokens_before=tokens_before,
compacted_message_count=0,
)
rag = self._fit_rag_before_history_budget(prompt=prompt, history=history, rag=rag, current=current)
history_budget = max(self.budget.input_tokens - estimate_messages_tokens(prompt + rag + current), 0)
if not history:
return self._build_assembly(
prompt=prompt,
summaries=[],
history=[],
rag=rag,
current=current,
rag_chunks=rag_chunks or [],
compacted=False,
tokens_before=tokens_before,
compacted_message_count=0,
)
if history_budget <= 0:
summary_message = self._build_summary_message(history, self.budget.summary_tokens)
return self._build_assembly(
prompt=prompt,
summaries=[summary_message] if summary_message is not None else [],
history=[],
rag=rag,
current=current,
rag_chunks=rag_chunks or [],
compacted=True,
tokens_before=tokens_before,
summary_message=summary_message,
compacted_message_count=len(history),
)
summary_budget = self._summary_budget(history_budget)
recent_budget = max(history_budget - summary_budget, 0)
first_kept_index = self._select_first_kept_index(history, recent_budget)
omitted = history[:first_kept_index]
recent = history[first_kept_index:]
summary_message = self._build_summary_message(omitted, summary_budget)
return self._build_assembly(
prompt=prompt,
summaries=[summary_message] if summary_message is not None else [],
history=recent,
rag=rag,
current=current,
rag_chunks=rag_chunks or [],
compacted=bool(omitted),
tokens_before=tokens_before,
summary_message=summary_message,
compacted_message_count=len(omitted),
)
async def compact_async(
self,
*,
prompt_messages: list[Message],
history_messages: list[Message],
current_messages: list[Message],
rag_messages: list[Message] | None = None,
rag_chunks: list[RagChunk] | None = None,
) -> ContextAssembly:
self._require_token_counter()
prompt = sanitize_provider_messages(prompt_messages)
prompt, prompt_summaries = _partition_summary_messages(prompt)
history = sanitize_provider_messages(prompt_summaries + history_messages)
rag = sanitize_provider_messages(rag_messages or [])
current = sanitize_provider_messages(current_messages)
original_messages = prompt + history + rag + current
tokens_before = await self._count_messages_async(original_messages, allow_usage_anchor=True)
if not self.budget.enabled or tokens_before <= self.budget.input_tokens:
return await self._build_assembly_async(
prompt=prompt,
summaries=[],
history=history,
rag=rag,
current=current,
rag_chunks=rag_chunks or [],
compacted=False,
tokens_before=tokens_before,
compacted_message_count=0,
)
rag = await self._fit_rag_before_history_budget_async(prompt=prompt, history=history, rag=rag, current=current)
history_budget = max(self.budget.input_tokens - await self._count_messages_async(prompt + rag + current), 0)
if not history:
return await self._build_assembly_async(
prompt=prompt,
summaries=[],
history=[],
rag=rag,
current=current,
rag_chunks=rag_chunks or [],
compacted=False,
tokens_before=tokens_before,
compacted_message_count=0,
)
if history_budget <= 0:
summary_message = await self._build_summary_message_async(history, self.budget.summary_tokens)
return await self._build_assembly_async(
prompt=prompt,
summaries=[summary_message] if summary_message is not None else [],
history=[],
rag=rag,
current=current,
rag_chunks=rag_chunks or [],
compacted=True,
tokens_before=tokens_before,
summary_message=summary_message,
compacted_message_count=len(history),
)
summary_budget = self._summary_budget(history_budget)
recent_budget = max(history_budget - summary_budget, 0)
first_kept_index = await self._select_first_kept_index_async(history, recent_budget)
omitted = history[:first_kept_index]
recent = history[first_kept_index:]
summary_message = await self._build_summary_message_async(omitted, summary_budget)
return await self._build_assembly_async(
prompt=prompt,
summaries=[summary_message] if summary_message is not None else [],
history=recent,
rag=rag,
current=current,
rag_chunks=rag_chunks or [],
compacted=bool(omitted),
tokens_before=tokens_before,
summary_message=summary_message,
compacted_message_count=len(omitted),
)
def _build_assembly(
self,
*,
prompt: list[Message],
summaries: list[Message],
history: list[Message],
rag: list[Message],
current: list[Message],
rag_chunks: list[RagChunk],
compacted: bool,
tokens_before: int,
summary_message: Message | None = None,
compacted_message_count: int,
) -> ContextAssembly:
prompt, summaries, history, rag, current = self._fit_frame_to_budget(
prompt=prompt,
summaries=summaries,
history=history,
rag=rag,
current=current,
)
frame = ContextFrame(
prompt=_copy_messages(prompt),
summaries=_copy_messages(summaries),
history=_copy_messages(history),
rag=_copy_messages(rag),
current=_copy_messages(current),
rag_chunks=list(rag_chunks),
)
messages = sanitize_provider_messages(frame.to_messages())
tokens_after = estimate_messages_tokens(messages)
diagnostics = ContextDiagnostics(
tokens_before=tokens_before,
tokens_after=tokens_after,
prompt_tokens=estimate_messages_tokens(frame.prompt),
summary_tokens=estimate_messages_tokens(frame.summaries),
history_tokens=estimate_messages_tokens(frame.history),
rag_tokens=estimate_messages_tokens(frame.rag),
current_tokens=estimate_messages_tokens(frame.current),
compacted_message_count=compacted_message_count,
)
return ContextAssembly(
messages=messages,
compacted=compacted,
tokens_before=tokens_before,
tokens_after=tokens_after,
summary_message=summary_message,
frame=frame,
diagnostics=diagnostics,
)
async def _build_assembly_async(
self,
*,
prompt: list[Message],
summaries: list[Message],
history: list[Message],
rag: list[Message],
current: list[Message],
rag_chunks: list[RagChunk],
compacted: bool,
tokens_before: int,
summary_message: Message | None = None,
compacted_message_count: int,
) -> ContextAssembly:
prompt, summaries, history, rag, current = await self._fit_frame_to_budget_async(
prompt=prompt,
summaries=summaries,
history=history,
rag=rag,
current=current,
)
frame = ContextFrame(
prompt=_copy_messages(prompt),
summaries=_copy_messages(summaries),
history=_copy_messages(history),
rag=_copy_messages(rag),
current=_copy_messages(current),
rag_chunks=list(rag_chunks),
)
messages = sanitize_provider_messages(frame.to_messages())
tokens_after = await self._count_messages_async(messages)
diagnostics = ContextDiagnostics(
tokens_before=tokens_before,
tokens_after=tokens_after,
prompt_tokens=await self._count_messages_async(frame.prompt),
summary_tokens=await self._count_messages_async(frame.summaries),
history_tokens=await self._count_messages_async(frame.history),
rag_tokens=await self._count_messages_async(frame.rag),
current_tokens=await self._count_messages_async(frame.current),
compacted_message_count=compacted_message_count,
)
return ContextAssembly(
messages=messages,
compacted=compacted,
tokens_before=tokens_before,
tokens_after=tokens_after,
summary_message=summary_message,
frame=frame,
diagnostics=diagnostics,
)
def _fit_rag_before_history_budget(
self,
*,
prompt: list[Message],
history: list[Message],
rag: list[Message],
current: list[Message],
) -> list[Message]:
if not self.budget.enabled or not rag:
return rag
available_after_fixed = self.budget.input_tokens - estimate_messages_tokens(prompt + current)
if available_after_fixed <= 0:
return []
if not history:
return _fit_messages_to_budget(rag, available_after_fixed, keep_tail=False)
history_reserve = min(self.budget.keep_recent_tokens, max(1, available_after_fixed // 2))
rag_budget = max(available_after_fixed - history_reserve, 0)
rag_budget = min(rag_budget, max(1, self.budget.input_tokens // RAG_CONTEXT_MAX_INPUT_FRACTION))
return _fit_messages_to_budget(rag, rag_budget, keep_tail=False)
async def _fit_rag_before_history_budget_async(
self,
*,
prompt: list[Message],
history: list[Message],
rag: list[Message],
current: list[Message],
) -> list[Message]:
if not self.budget.enabled or not rag:
return rag
available_after_fixed = self.budget.input_tokens - await self._count_messages_async(prompt + current)
if available_after_fixed <= 0:
return []
if not history:
return await self._fit_messages_to_budget_async(rag, available_after_fixed, keep_tail=False)
history_reserve = min(self.budget.keep_recent_tokens, max(1, available_after_fixed // 2))
rag_budget = max(available_after_fixed - history_reserve, 0)
rag_budget = min(rag_budget, max(1, self.budget.input_tokens // RAG_CONTEXT_MAX_INPUT_FRACTION))
return await self._fit_messages_to_budget_async(rag, rag_budget, keep_tail=False)
def _fit_frame_to_budget(
self,
*,
prompt: list[Message],
summaries: list[Message],
history: list[Message],
rag: list[Message],
current: list[Message],
) -> tuple[list[Message], list[Message], list[Message], list[Message], list[Message]]:
if not self.budget.enabled:
return prompt, summaries, history, rag, current
input_budget = self.budget.input_tokens
if estimate_messages_tokens(prompt + summaries + history + rag + current) <= input_budget:
return prompt, summaries, history, rag, current
rag = _fit_messages_to_budget(
rag,
input_budget - estimate_messages_tokens(prompt + summaries + history + current),
keep_tail=False,
)
if estimate_messages_tokens(prompt + summaries + history + rag + current) <= input_budget:
return prompt, summaries, history, rag, current
summaries = _fit_messages_to_budget(
summaries,
input_budget - estimate_messages_tokens(prompt + history + rag + current),
keep_tail=False,
)
if estimate_messages_tokens(prompt + summaries + history + rag + current) <= input_budget:
return prompt, summaries, history, rag, current
history = _fit_messages_to_budget(
history,
input_budget - estimate_messages_tokens(prompt + summaries + rag + current),
keep_tail=True,
)
if estimate_messages_tokens(prompt + summaries + history + rag + current) <= input_budget:
return prompt, summaries, history, rag, current
prompt = _fit_messages_to_budget(
prompt,
input_budget - estimate_messages_tokens(summaries + history + rag + current),
keep_tail=True,
)
if estimate_messages_tokens(prompt + summaries + history + rag + current) <= input_budget:
return prompt, summaries, history, rag, current
prompt, summaries, history, rag, current = self._preserve_current_with_sync_budget(
prompt=prompt,
summaries=summaries,
history=history,
rag=rag,
current=current,
input_budget=input_budget,
)
if estimate_messages_tokens(prompt + summaries + history + rag + current) <= input_budget:
return prompt, summaries, history, rag, current
current = _fit_messages_to_budget(
current,
input_budget - estimate_messages_tokens(prompt + summaries + history + rag),
keep_tail=True,
)
return prompt, summaries, history, rag, current
async def _fit_frame_to_budget_async(
self,
*,
prompt: list[Message],
summaries: list[Message],
history: list[Message],
rag: list[Message],
current: list[Message],
) -> tuple[list[Message], list[Message], list[Message], list[Message], list[Message]]:
if not self.budget.enabled:
return prompt, summaries, history, rag, current
input_budget = self.budget.input_tokens
if await self._count_messages_async(prompt + summaries + history + rag + current) <= input_budget:
return prompt, summaries, history, rag, current
rag = await self._fit_messages_to_budget_async(
rag,
input_budget - await self._count_messages_async(prompt + summaries + history + current),
keep_tail=False,
)
if await self._count_messages_async(prompt + summaries + history + rag + current) <= input_budget:
return prompt, summaries, history, rag, current
summaries = await self._fit_messages_to_budget_async(
summaries,
input_budget - await self._count_messages_async(prompt + history + rag + current),
keep_tail=False,
)
if await self._count_messages_async(prompt + summaries + history + rag + current) <= input_budget:
return prompt, summaries, history, rag, current
history = await self._fit_messages_to_budget_async(
history,
input_budget - await self._count_messages_async(prompt + summaries + rag + current),
keep_tail=True,
)
if await self._count_messages_async(prompt + summaries + history + rag + current) <= input_budget:
return prompt, summaries, history, rag, current
prompt = await self._fit_messages_to_budget_async(
prompt,
input_budget - await self._count_messages_async(summaries + history + rag + current),
keep_tail=True,
)
if await self._count_messages_async(prompt + summaries + history + rag + current) <= input_budget:
return prompt, summaries, history, rag, current
prompt, summaries, history, rag, current = await self._preserve_current_with_async_budget(
prompt=prompt,
summaries=summaries,
history=history,
rag=rag,
current=current,
input_budget=input_budget,
)
if await self._count_messages_async(prompt + summaries + history + rag + current) <= input_budget:
return prompt, summaries, history, rag, current
current = await self._fit_messages_to_budget_async(
current,
input_budget - await self._count_messages_async(prompt + summaries + history + rag),
keep_tail=True,
)
return prompt, summaries, history, rag, current
def _preserve_current_with_sync_budget(
self,
*,
prompt: list[Message],
summaries: list[Message],
history: list[Message],
rag: list[Message],
current: list[Message],
input_budget: int,
) -> tuple[list[Message], list[Message], list[Message], list[Message], list[Message]]:
if not current or input_budget <= 0:
return prompt, summaries, history, rag, current
current = _fit_messages_to_budget(current, input_budget, keep_tail=True)
if not current:
return [], [], [], [], []
fixed_current_tokens = estimate_messages_tokens(current)
remaining = max(input_budget - fixed_current_tokens, 0)
if remaining <= 0:
return [], [], [], [], current
prompt = _fit_messages_to_budget(prompt, remaining, keep_tail=True)
remaining = max(remaining - estimate_messages_tokens(prompt), 0)
summaries = _fit_messages_to_budget(summaries, remaining, keep_tail=False)
remaining = max(remaining - estimate_messages_tokens(summaries), 0)
history = _fit_messages_to_budget(history, remaining, keep_tail=True)
remaining = max(remaining - estimate_messages_tokens(history), 0)
rag = _fit_messages_to_budget(rag, remaining, keep_tail=False)
return prompt, summaries, history, rag, current
async def _preserve_current_with_async_budget(
self,
*,
prompt: list[Message],
summaries: list[Message],
history: list[Message],
rag: list[Message],
current: list[Message],
input_budget: int,
) -> tuple[list[Message], list[Message], list[Message], list[Message], list[Message]]:
if not current or input_budget <= 0:
return prompt, summaries, history, rag, current
current = await self._fit_messages_to_budget_async(current, input_budget, keep_tail=True)
if not current:
return [], [], [], [], []
fixed_current_tokens = await self._count_messages_async(current)
remaining = max(input_budget - fixed_current_tokens, 0)
if remaining <= 0:
return [], [], [], [], current
prompt = await self._fit_messages_to_budget_async(prompt, remaining, keep_tail=True)
remaining = max(remaining - await self._count_messages_async(prompt), 0)
summaries = await self._fit_messages_to_budget_async(summaries, remaining, keep_tail=False)
remaining = max(remaining - await self._count_messages_async(summaries), 0)
history = await self._fit_messages_to_budget_async(history, remaining, keep_tail=True)
remaining = max(remaining - await self._count_messages_async(history), 0)
rag = await self._fit_messages_to_budget_async(rag, remaining, keep_tail=False)
return prompt, summaries, history, rag, current
def _summary_budget(self, history_budget: int) -> int:
if self.budget.summary_tokens <= 0:
return 0
if history_budget > self.budget.keep_recent_tokens:
return min(self.budget.summary_tokens, history_budget - self.budget.keep_recent_tokens)
if history_budget >= 30:
return min(self.budget.summary_tokens, max(20, history_budget // 3))
return 0
def _select_first_kept_index(self, history: list[Message], recent_budget: int) -> int:
if recent_budget <= 0:
return len(history)
total = 0
first_kept_index = len(history)
for index in range(len(history) - 1, -1, -1):
message_tokens = estimate_message_tokens(history[index])
if first_kept_index < len(history) and total + message_tokens > recent_budget:
break
if first_kept_index == len(history) and message_tokens > recent_budget:
break
total += message_tokens
first_kept_index = index
return _move_cut_before_tool_result(history, first_kept_index)
async def _select_first_kept_index_async(self, history: list[Message], recent_budget: int) -> int:
if recent_budget <= 0:
return len(history)
total = 0
first_kept_index = len(history)
for index in range(len(history) - 1, -1, -1):
message_tokens = await self._count_messages_async([history[index]])
if first_kept_index < len(history) and total + message_tokens > recent_budget:
break
if first_kept_index == len(history) and message_tokens > recent_budget:
break
total += message_tokens
first_kept_index = index
return _move_cut_before_tool_result(history, first_kept_index)
def _require_token_counter(self) -> None:
if self.token_counter is None:
raise ContextTokenCounterRequiredError(
"Host count_tokens API is required for local-agent context budgeting"
)
async def _count_messages_async(self, messages: list[Message], *, allow_usage_anchor: bool = False) -> int:
self._require_token_counter()
if allow_usage_anchor and self.usage_anchor is not None:
anchor = self.usage_anchor
if 0 <= anchor.message_count <= len(messages) and anchor.total_tokens > 0:
return anchor.total_tokens + await self._count_messages_async(messages[anchor.message_count :])
return await self.token_counter.count(messages)
async def _fit_messages_to_budget_async(
self,
messages: list[Message],
max_tokens: int,
*,
keep_tail: bool,
) -> list[Message]:
if max_tokens <= 0 or not messages:
return []
copied = _copy_messages(messages)
if await self._count_messages_async(copied) <= max_tokens:
return copied
selected: list[Message] = []
used_tokens = 0
if keep_tail:
end = len(copied)
while end > 0:
start = _tail_unit_start(copied, end)
unit = copied[start:end]
unit_tokens = await self._count_messages_async(unit)
remaining = max_tokens - used_tokens
if remaining <= 0:
break
if unit_tokens <= remaining:
selected[0:0] = _copy_messages(unit)
used_tokens += unit_tokens
end = start
continue
if not selected:
truncated = await self._fit_unit_to_budget_async(unit, remaining, keep_tail=True)
selected[0:0] = truncated
break
return selected
for message in copied:
remaining = max_tokens - used_tokens
if remaining <= 0:
break
message_tokens = await self._count_messages_async([message])
if message_tokens <= remaining:
selected.append(message.model_copy(deep=True))
used_tokens += message_tokens
continue
truncated = await self._truncate_message_to_tokens_async(message, remaining)
if truncated is not None:
selected.append(truncated)
break
return selected
async def _fit_unit_to_budget_async(
self,
unit: list[Message],
max_tokens: int,
*,
keep_tail: bool,
) -> list[Message]:
if max_tokens <= 0 or not unit:
return []
if len(unit) == 1:
message = await self._truncate_message_to_tokens_async(unit[0], max_tokens)
return [message] if message is not None else []
if unit[0].role == "assistant" and unit[0].tool_calls:
assistant_tokens = await self._count_messages_async([unit[0]])
if assistant_tokens > max_tokens:
return []
selected = [unit[0].model_copy(deep=True)]
used_tokens = assistant_tokens
for message in unit[1:]:
remaining = max_tokens - used_tokens
if remaining <= 0:
break
message_tokens = await self._count_messages_async([message])
if message_tokens <= remaining:
selected.append(message.model_copy(deep=True))
used_tokens += message_tokens
continue
truncated = await self._truncate_message_to_tokens_async(message, remaining)
if truncated is not None:
selected.append(truncated)
break
return selected
selected: list[Message] = []
used_tokens = 0
iterable = reversed(unit) if keep_tail else iter(unit)
for message in iterable:
remaining = max_tokens - used_tokens
if remaining <= 0:
break
message_tokens = await self._count_messages_async([message])
if message_tokens <= remaining:
if keep_tail:
selected.insert(0, message.model_copy(deep=True))
else:
selected.append(message.model_copy(deep=True))
used_tokens += message_tokens
continue
truncated = await self._truncate_message_to_tokens_async(message, remaining)
if truncated is not None:
if keep_tail:
selected.insert(0, truncated)
else:
selected.append(truncated)
break
return selected
async def _truncate_message_to_tokens_async(self, message: Message, max_tokens: int) -> Message | None:
if max_tokens <= 0:
return None
if await self._count_messages_async([message]) <= max_tokens:
return message.model_copy(deep=True)
copied = message.model_copy(deep=True)
content = copied.content
copied.content = ""
fixed_tokens = await self._count_messages_async([copied])
available_tokens = max_tokens - fixed_tokens
if available_tokens <= 0:
return None
if isinstance(content, str):
copied.content = await self._truncate_text_to_tokens_async(
content,
available_tokens,
message_template=copied,
)
return copied if _message_has_model_content(copied) or copied.role == "tool" else None
if isinstance(content, list):
fitted_content = []
used_tokens = 0
for item in content:
remaining = available_tokens - used_tokens
if remaining <= 0:
break
item_type = getattr(item, "type", "")
if item_type == "text" and getattr(item, "text", None):
fitted_item = item.model_copy(deep=True)
probe = copied.model_copy(deep=True)
probe.content = [fitted_item]
item_tokens = await self._count_messages_async([probe]) - fixed_tokens
if item_tokens > remaining:
fitted_item.text = await self._truncate_content_item_text_async(
copied,
item,
fixed_tokens + remaining,
)
if not fitted_item.text:
break
fitted_content.append(fitted_item)
break
fitted_content.append(fitted_item)
used_tokens += item_tokens
elif isinstance(item_type, str) and item_type.startswith(("image", "file")):
probe = copied.model_copy(deep=True)
probe.content = [item]
item_tokens = await self._count_messages_async([probe]) - fixed_tokens
if item_tokens > remaining:
continue
fitted_content.append(item.model_copy(deep=True))
used_tokens += item_tokens
copied.content = fitted_content
return copied if _message_has_model_content(copied) or copied.role == "tool" else None
return None
async def _truncate_text_to_tokens_async(
self,
text: str,
max_tokens: int,
message_template: Message | None = None,
) -> str:
if max_tokens <= 0:
return ""
def probe_message(content: str) -> Message:
if message_template is None:
return Message(role="user", content=content)
copied = message_template.model_copy(deep=True)
copied.content = content
return copied
empty_tokens = await self._count_messages_async([probe_message("")])
target_total = max_tokens + empty_tokens
if await self._count_messages_async([probe_message(text)]) <= target_total:
return text
low = 0
high = len(text)
while low < high:
mid = (low + high + 1) // 2
if await self._count_messages_async([probe_message(text[:mid])]) <= target_total:
low = mid
else:
high = mid - 1
return text[:low]
async def _truncate_content_item_text_async(
self,
message_template: Message,
item: typing.Any,
max_total_tokens: int,
) -> str:
if max_total_tokens <= 0 or not getattr(item, "text", None):
return ""
def probe_message(content: str) -> Message:
probe_item = item.model_copy(deep=True)
probe_item.text = content
probe = message_template.model_copy(deep=True)
probe.content = [probe_item]
return probe
if await self._count_messages_async([probe_message(item.text)]) <= max_total_tokens:
return item.text
low = 0
high = len(item.text)
while low < high:
mid = (low + high + 1) // 2
if await self._count_messages_async([probe_message(item.text[:mid])]) <= max_total_tokens:
low = mid
else:
high = mid - 1
return item.text[:low]
def _build_summary_message(self, omitted: list[Message], summary_budget: int) -> Message | None:
if not omitted or summary_budget <= 0:
return None
summary = _attach_summary_references(
summarize_messages(omitted, summary_budget),
omitted,
summary_budget,
)
if not summary:
return None
return Message(role="system", content=summary)
async def _build_summary_message_async(self, omitted: list[Message], summary_budget: int) -> Message | None:
if not omitted or summary_budget <= 0:
return None
summary = None
if self.summarizer is not None:
summary = await self.summarizer.summarize(omitted, summary_budget)
if summary is None:
summary = summarize_messages(omitted, summary_budget)
summary = _attach_summary_references(summary, omitted, summary_budget)
if not summary:
return None
return Message(role="system", content=summary)
def sanitize_provider_messages(messages: list[Message]) -> list[Message]:
"""Return model messages with provider-unsafe tool/thinking blocks removed.
Anthropic-style providers require every tool result to be adjacent to the
assistant tool use that produced it. Host transcript history can contain
partial old turns, so normalize that shape before every model call.
"""
cleaned = [_sanitize_message(message) for message in messages]
cleaned = [message for message in cleaned if message is not None]
normalized: list[Message] = []
index = 0
while index < len(cleaned):
message = cleaned[index]
if message.role == "tool":
index += 1
continue
if message.role == "assistant" and message.tool_calls:
next_index = index + 1
tool_result_messages: list[Message] = []
seen_result_ids: set[str] = set()
valid_tool_call_ids = {
tool_call.id
for tool_call in message.tool_calls
if isinstance(getattr(tool_call, "id", None), str) and tool_call.id
}
while next_index < len(cleaned) and cleaned[next_index].role == "tool":
tool_message = cleaned[next_index]
tool_call_id = tool_message.tool_call_id
if (
isinstance(tool_call_id, str)
and tool_call_id in valid_tool_call_ids
and tool_call_id not in seen_result_ids
):
tool_result_messages.append(tool_message)
seen_result_ids.add(tool_call_id)
next_index += 1
if tool_result_messages:
message.tool_calls = [tool_call for tool_call in message.tool_calls if tool_call.id in seen_result_ids]
normalized.append(message)
normalized.extend(tool_result_messages)
else:
message.tool_calls = None
if _message_has_model_content(message):
normalized.append(message)
index = next_index
continue
normalized.append(message)
index += 1
return normalized
def summarize_messages(messages: list[Message], max_tokens: int) -> str:
"""Create a deterministic compacted-history summary.
This is intentionally not a model call yet. It provides the same structural
shape as Pi compaction, while future iterations can replace the summary
generator with an LLM + host state checkpoint.
"""
if max_tokens <= 0:
return ""
entries: list[ConversationSummaryEntry] = []
message_count = 0
for index, message in enumerate(messages, start=1):
parsed_summary = _parse_conversation_summary_message(message)
if parsed_summary is not None:
count, summary_entries = parsed_summary
message_count += count
for summary_entry in summary_entries:
entries.append(
ConversationSummaryEntry(
index=len(entries) + 1,
role=summary_entry.role,
text=summary_entry.text,
)
)
continue
message_count += 1
text = message_to_text(message)
if not text and message.tool_calls:
text = "; ".join(tool_call.function.name for tool_call in message.tool_calls if tool_call.function)
entries.append(ConversationSummaryEntry(index=len(entries) + 1, role=message.role, text=text))
summary = ConversationSummary(
schema_version=SUMMARY_SCHEMA_VERSION,
message_count=message_count,
entries=entries,
)
return summary.render(max_tokens)
def estimate_messages_tokens(messages: list[Message]) -> int:
return sum(estimate_message_tokens(message) for message in messages)
def estimate_messages_tokens_with_anchor(messages: list[Message], anchor: ContextUsageAnchor | None) -> int:
if anchor is None or anchor.message_count < 0 or anchor.total_tokens <= 0 or anchor.message_count > len(messages):
return estimate_messages_tokens(messages)
return anchor.total_tokens + estimate_messages_tokens(messages[anchor.message_count :])
def estimate_message_chars(message: Message) -> int:
chars = len(message.role) + 8
chars += _content_chars(message.content)
if message.name:
chars += len(message.name)
if message.tool_call_id:
chars += len(message.tool_call_id)
if message.tool_calls:
for tool_call in message.tool_calls:
chars += len(tool_call.id) + len(tool_call.type)
if tool_call.function:
chars += len(tool_call.function.name) + len(tool_call.function.arguments)
return chars
def estimate_message_tokens(message: Message) -> int:
tokens = _chars_to_tokens(len(message.role) + 8)
tokens += _content_tokens(message.content)
if message.name:
tokens += estimate_text_tokens(message.name)
if message.tool_call_id:
tokens += estimate_text_tokens(message.tool_call_id)
if message.tool_calls:
for tool_call in message.tool_calls:
tokens += estimate_text_tokens(tool_call.id)
tokens += estimate_text_tokens(tool_call.type)
if tool_call.function:
tokens += estimate_text_tokens(tool_call.function.name)
tokens += estimate_text_tokens(tool_call.function.arguments)
return tokens
def estimate_text_tokens(text: str) -> int:
if not text:
return 0
tokens = 0
non_cjk_chars = 0
for char in text:
if _is_cjk_token_char(char) or _is_symbol_token_char(char):
tokens += _chars_to_tokens(non_cjk_chars)
non_cjk_chars = 0
tokens += 1
else:
non_cjk_chars += 1
tokens += _chars_to_tokens(non_cjk_chars)
return tokens
def message_to_text(message: Message) -> str:
parts: list[str] = []
content = message.content
if isinstance(content, str):
parts.append(content)
elif isinstance(content, list):
for item in content:
if item.type == "text" and item.text:
parts.append(item.text)
elif item.type.startswith("image"):
parts.append("[image]")
elif item.type.startswith("file"):
parts.append(f"[file:{item.file_name or 'unnamed'}]")
if message.tool_calls:
for tool_call in message.tool_calls:
if tool_call.function:
parts.append(f"[tool_call:{tool_call.function.name} {tool_call.function.arguments}]")
return "\n".join(parts)
def _message_from_transcript_item(item: dict[str, typing.Any], current_event_id: str) -> Message | None:
if item.get("event_id") == current_event_id:
return None
content_json = item.get("content_json")
if isinstance(content_json, dict):
try:
return Message.model_validate(content_json)
except Exception:
logger.debug("Failed to parse transcript content_json as Message", exc_info=True)
role = item.get("role")
content = item.get("content")
if isinstance(role, str) and isinstance(content, str) and content:
return Message(role=role, content=content)
return None
def _as_mapping(value: typing.Any) -> dict[str, typing.Any] | None:
if isinstance(value, dict):
return value
if hasattr(value, "model_dump"):
dumped = value.model_dump()
if isinstance(dumped, dict):
return dumped
return None
def _model_or_mapping_get(value: typing.Any, key: str, default: typing.Any = None) -> typing.Any:
if isinstance(value, dict):
return value.get(key, default)
return getattr(value, key, default)
def _last_non_empty(values: list[str | None]) -> str | None:
for value in reversed(values):
if value:
return value
return None
def _copy_messages(messages: list[Message]) -> list[Message]:
return [message.model_copy(deep=True) for message in messages]
def _sanitize_message(message: Message) -> Message | None:
copied = message.model_copy(deep=True)
copied.content = _sanitize_content(copied.content)
if copied.tool_calls:
copied.tool_calls = [
tool_call
for tool_call in copied.tool_calls
if isinstance(getattr(tool_call, "id", None), str)
and tool_call.id
and getattr(tool_call, "function", None) is not None
and isinstance(getattr(tool_call.function, "name", None), str)
and tool_call.function.name
]
if not copied.tool_calls:
copied.tool_calls = None
if copied.role == "tool" and not copied.tool_call_id:
return None
if copied.role not in {"system", "developer", "user", "assistant", "tool"}:
return None
if not _message_has_model_content(copied) and copied.role != "tool":
return None
return copied
def _sanitize_content(content: typing.Any) -> typing.Any:
if not isinstance(content, list):
return content
safe_content = []
for item in content:
item_type = getattr(item, "type", "")
if item_type == "text":
if getattr(item, "text", None):
safe_content.append(item)
elif isinstance(item_type, str) and item_type.startswith(("image", "file")):
safe_content.append(item)
return safe_content if safe_content else ""
def _message_has_model_content(message: Message) -> bool:
if message.tool_calls:
return True
if isinstance(message.content, str):
return bool(message.content)
if isinstance(message.content, list):
return bool(message.content)
return False
def _split_leading_prompt_messages(messages: list[Message]) -> tuple[list[Message], list[Message], list[Message]]:
prompt_messages: list[Message] = []
summary_messages: list[Message] = []
first_history_index = 0
for index, message in enumerate(messages):
if _is_conversation_summary_message(message):
summary_messages.append(message)
first_history_index = index + 1
continue
if message.role not in {"system", "developer"}:
first_history_index = index
break
prompt_messages.append(message)
else:
first_history_index = len(messages)
return (
_copy_messages(prompt_messages),
_copy_messages(summary_messages),
_copy_messages(messages[first_history_index:]),
)
def _split_runtime_history_and_current(messages: list[Message]) -> tuple[list[Message], list[Message]]:
current_start = _current_turn_tail_start(messages)
if current_start >= len(messages):
return _copy_messages(messages), []
return _copy_messages(messages[:current_start]), _copy_messages(messages[current_start:])
def _current_turn_tail_start(messages: list[Message]) -> int:
if not messages:
return 0
tool_tail_start = _tool_turn_tail_start(messages)
if tool_tail_start is not None:
return tool_tail_start
last = messages[-1]
if last.role == "user":
return len(messages) - 1
if last.role == "assistant" and last.tool_calls:
return len(messages) - 1
return len(messages)
def _tool_turn_tail_start(messages: list[Message]) -> int | None:
if not messages or messages[-1].role != "tool":
return None
index = len(messages) - 1
while index >= 0 and messages[index].role == "tool":
index -= 1
if index >= 0 and messages[index].role == "assistant" and messages[index].tool_calls:
start = index
scan = index - 1
while scan >= 0 and messages[scan].role == "user":
start = scan
scan -= 1
return start
return None
def _content_chars(content: typing.Any) -> int:
if isinstance(content, str):
return len(content)
if isinstance(content, list):
chars = 0
for item in content:
if item.type == "text" and item.text:
chars += len(item.text)
elif item.type.startswith(("image", "file")):
chars += _attachment_tokens(item) * ESTIMATED_CHARS_PER_TOKEN
return chars
return 0
def _content_tokens(content: typing.Any) -> int:
if isinstance(content, str):
return estimate_text_tokens(content)
if isinstance(content, list):
tokens = 0
for item in content:
if item.type == "text" and item.text:
tokens += estimate_text_tokens(item.text)
elif item.type.startswith(("image", "file")):
tokens += _attachment_tokens(item)
return tokens
return 0
def _attachment_tokens(item: typing.Any) -> int:
payload_chars = 0
for attr in ("image_base64", "file_base64"):
value = getattr(item, attr, None)
if isinstance(value, str):
payload_chars += len(value)
descriptor_tokens = 0
for attr in ("image_url", "file_url", "file_name"):
value = getattr(item, attr, None)
if isinstance(value, str):
descriptor_tokens += estimate_text_tokens(value)
payload_tokens = _chars_to_tokens(payload_chars)
return max(ESTIMATED_ATTACHMENT_TOKENS, payload_tokens, descriptor_tokens)
def _move_cut_before_tool_result(history: list[Message], first_kept_index: int) -> int:
while first_kept_index > 0 and first_kept_index < len(history) and history[first_kept_index].role == "tool":
first_kept_index -= 1
return first_kept_index
def _partition_summary_messages(messages: list[Message]) -> tuple[list[Message], list[Message]]:
prompts: list[Message] = []
summaries: list[Message] = []
for message in messages:
if _is_conversation_summary_message(message):
summaries.append(message)
else:
prompts.append(message)
return _copy_messages(prompts), _copy_messages(summaries)
def _is_conversation_summary_message(message: Message) -> bool:
return (
message.role == "system"
and isinstance(message.content, str)
and SUMMARY_OPEN_TAG in message.content
and SUMMARY_CLOSE_TAG in message.content
)
def _conversation_summary_body(message: Message) -> str:
if not _is_conversation_summary_message(message):
return ""
assert isinstance(message.content, str)
return message.content.split(SUMMARY_OPEN_TAG, 1)[1].split(SUMMARY_CLOSE_TAG, 1)[0].strip()
def _parse_conversation_summary_message(message: Message) -> tuple[int, list[ConversationSummaryEntry]] | None:
if not _is_conversation_summary_message(message):
return None
body = _conversation_summary_body(message)
lines = [line for line in body.splitlines() if line.strip()]
count = 0
entries: list[ConversationSummaryEntry] = []
for line in lines:
count_match = SUMMARY_COUNT_RE.search(line)
if count_match:
count = int(count_match.group(1))
continue
entry_match = SUMMARY_ENTRY_RE.match(line)
if entry_match:
entries.append(
ConversationSummaryEntry(
index=len(entries) + 1,
role=entry_match.group(1),
text=entry_match.group(2),
)
)
if not entries and body:
entries.append(ConversationSummaryEntry(index=1, role="summary", text=body))
return max(count, len(entries), 1), entries
def _extract_previous_summary_text(messages: list[Message]) -> str:
summaries = [
_conversation_summary_body(message) for message in messages if _is_conversation_summary_message(message)
]
summaries = [summary for summary in summaries if summary]
return "\n\n---\n\n".join(summaries)
def _build_llm_summary_prompt(messages: list[Message], *, previous_summary: str) -> str:
conversation_text = _serialize_messages_for_summary(messages)
prompt_parts = [f"<conversation>\n{conversation_text}\n</conversation>"]
if previous_summary:
prompt_parts.append(f"<previous-summary>\n{previous_summary}\n</previous-summary>")
prompt_parts.append(UPDATE_SUMMARIZATION_PROMPT)
else:
prompt_parts.append(SUMMARIZATION_PROMPT)
return "\n\n".join(prompt_parts)
def _serialize_messages_for_summary(messages: list[Message]) -> str:
parts: list[str] = []
for index, message in enumerate(messages, start=1):
text = message_to_text(message)
if not text:
text = "(empty)"
parts.append(f'<message index="{index}" role="{message.role}">\n{text}\n</message>')
return "\n\n".join(parts)
def _message_content_text(message: typing.Any) -> str:
content = getattr(message, "content", None)
if isinstance(content, str):
return content
if isinstance(content, list):
text_parts = []
for item in content:
if getattr(item, "type", None) == "text" and isinstance(getattr(item, "text", None), str):
text_parts.append(item.text)
elif isinstance(item, dict) and item.get("type") == "text" and isinstance(item.get("text"), str):
text_parts.append(item["text"])
return "\n".join(text_parts)
return ""
def _wrap_llm_summary(summary: str, max_tokens: int, *, message_count: int) -> str | None:
summary = summary.strip()
if not summary or max_tokens <= 0:
return None
prefix = f"{SUMMARY_OPEN_TAG}\nv=1 source=llm count={max(message_count, 1)}\n"
suffix = f"\n{SUMMARY_CLOSE_TAG}"
fixed_tokens = estimate_text_tokens(prefix + suffix)
available_tokens = max_tokens - fixed_tokens
if available_tokens <= 0:
return None
body = _truncate_text_to_tokens(summary, available_tokens).strip()
if not body:
return None
return f"{prefix}{body}{suffix}"
def _attach_summary_references(summary: str | None, messages: list[Message], max_tokens: int) -> str:
if not summary or max_tokens <= 0:
return ""
block = _summary_reference_block(messages)
if not block:
return summary
summary = summary.strip()
if SUMMARY_CLOSE_TAG in summary:
body, close = summary.rsplit(SUMMARY_CLOSE_TAG, 1)
combined = f"{body.rstrip()}\n\n{block}\n{SUMMARY_CLOSE_TAG}{close}"
else:
combined = f"{summary}\n\n{block}"
if estimate_text_tokens(combined) <= max_tokens:
return combined
if SUMMARY_CLOSE_TAG in summary:
minimal = f"{SUMMARY_OPEN_TAG}\nv=1 references=preserved\n{block}\n{SUMMARY_CLOSE_TAG}"
if estimate_text_tokens(minimal) <= max_tokens:
return minimal
block_tokens = estimate_text_tokens(f"\n\n{block}\n{SUMMARY_CLOSE_TAG}")
if SUMMARY_CLOSE_TAG in summary and block_tokens < max_tokens:
body, close = summary.rsplit(SUMMARY_CLOSE_TAG, 1)
truncated_body = _truncate_text_to_tokens(body.rstrip(), max_tokens - block_tokens).rstrip()
return f"{truncated_body}\n\n{block}\n{SUMMARY_CLOSE_TAG}{close}"
return _truncate_text_to_tokens(combined, max_tokens)
def _summary_reference_block(messages: list[Message]) -> str:
critical_refs: list[str] = []
files: list[str] = []
seen_critical_refs: set[str] = set()
seen_files: set[str] = set()
def add_critical_ref(ref: str) -> None:
ref = ref.strip().strip(".,;:)")
if not ref or ref in seen_critical_refs:
return
seen_critical_refs.add(ref)
critical_refs.append(f"- {ref}")
def add_file(value: dict[str, typing.Any]) -> None:
path = value.get("path")
if not isinstance(path, str) or not path or path in seen_files:
return
seen_files.add(path)
fields = [f"path={path}"]
files.append("- " + " ".join(str(field) for field in fields))
def walk(value: typing.Any, depth: int = 0) -> None:
if depth > 6 or len(critical_refs) + len(files) >= SUMMARY_REFERENCE_LIMIT:
return
if isinstance(value, dict):
add_file(value)
for nested in value.values():
if isinstance(nested, (dict, list, tuple)):
walk(nested, depth + 1)
return
if isinstance(value, (list, tuple)):
for item in value:
walk(item, depth + 1)
return
if isinstance(value, str):
for ref in CRITICAL_REF_RE.findall(value):
add_critical_ref(ref)
for message in messages:
if _is_conversation_summary_message(message):
_copy_existing_reference_lines(_conversation_summary_body(message), critical_refs, files)
content = message.content
if isinstance(content, str):
try:
walk(json.loads(content))
except Exception:
for ref in CRITICAL_REF_RE.findall(content):
add_critical_ref(ref)
elif isinstance(content, list):
for item in content:
if item.type == "text" and item.text:
for ref in CRITICAL_REF_RE.findall(item.text):
add_critical_ref(ref)
elif item.type.startswith(("image", "file")) and item.file_name:
for ref in CRITICAL_REF_RE.findall(item.file_name):
add_critical_ref(ref)
blocks: list[str] = []
if critical_refs:
blocks.append(
"<critical_refs>\n"
+ CRITICAL_REF_GUIDANCE
+ "\n"
+ "\n".join(critical_refs[:SUMMARY_REFERENCE_LIMIT])
+ "\n</critical_refs>"
)
remaining = max(0, SUMMARY_REFERENCE_LIMIT - len(critical_refs))
if files and remaining:
blocks.append("<files>\n" + "\n".join(files[:remaining]) + "\n</files>")
return "\n".join(blocks)
def _copy_existing_reference_lines(
summary: str,
critical_refs: list[str],
files: list[str],
) -> None:
for tag, target in (("critical_refs", critical_refs), ("files", files)):
open_tag = f"<{tag}>"
close_tag = f"</{tag}>"
if open_tag not in summary or close_tag not in summary:
continue
block = summary.split(open_tag, 1)[1].split(close_tag, 1)[0]
for line in block.splitlines():
line = line.strip()
if line.startswith("- ") and line not in target:
target.append(line)
def _close_summary_lines(lines: list[str]) -> str:
return "\n".join(lines + [SUMMARY_CLOSE_TAG])
def _truncate_text_to_tokens(text: str, max_tokens: int) -> str:
if max_tokens <= 0:
return ""
if estimate_text_tokens(text) <= max_tokens:
return text
low = 0
high = len(text)
while low < high:
mid = (low + high + 1) // 2
if estimate_text_tokens(text[:mid]) <= max_tokens:
low = mid
else:
high = mid - 1
return text[:low]
def _fit_messages_to_budget(messages: list[Message], max_tokens: int, *, keep_tail: bool) -> list[Message]:
if max_tokens <= 0 or not messages:
return []
copied = _copy_messages(messages)
if estimate_messages_tokens(copied) <= max_tokens:
return copied
selected: list[Message] = []
used_tokens = 0
if keep_tail:
end = len(copied)
while end > 0:
start = _tail_unit_start(copied, end)
unit = copied[start:end]
unit_tokens = estimate_messages_tokens(unit)
remaining = max_tokens - used_tokens
if remaining <= 0:
break
if unit_tokens <= remaining:
selected[0:0] = _copy_messages(unit)
used_tokens += unit_tokens
end = start
continue
if not selected:
truncated = _fit_unit_to_budget(unit, remaining, keep_tail=True)
selected[0:0] = truncated
break
return selected
for message in copied:
remaining = max_tokens - used_tokens
if remaining <= 0:
break
message_tokens = estimate_message_tokens(message)
if message_tokens <= remaining:
selected.append(message.model_copy(deep=True))
used_tokens += message_tokens
continue
truncated = _truncate_message_to_tokens(message, remaining)
if truncated is not None:
selected.append(truncated)
break
return selected
def _fit_unit_to_budget(unit: list[Message], max_tokens: int, *, keep_tail: bool) -> list[Message]:
if max_tokens <= 0 or not unit:
return []
if len(unit) == 1:
message = _truncate_message_to_tokens(unit[0], max_tokens)
return [message] if message is not None else []
if unit[0].role == "assistant" and unit[0].tool_calls:
assistant_tokens = estimate_message_tokens(unit[0])
if assistant_tokens > max_tokens:
return []
selected = [unit[0].model_copy(deep=True)]
used_tokens = assistant_tokens
for message in unit[1:]:
remaining = max_tokens - used_tokens
if remaining <= 0:
break
message_tokens = estimate_message_tokens(message)
if message_tokens <= remaining:
selected.append(message.model_copy(deep=True))
used_tokens += message_tokens
continue
truncated = _truncate_message_to_tokens(message, remaining)
if truncated is not None:
selected.append(truncated)
break
return selected
selected: list[Message] = []
used_tokens = 0
iterable = reversed(unit) if keep_tail else iter(unit)
for message in iterable:
remaining = max_tokens - used_tokens
if remaining <= 0:
break
message_tokens = estimate_message_tokens(message)
if message_tokens <= remaining:
if keep_tail:
selected.insert(0, message.model_copy(deep=True))
else:
selected.append(message.model_copy(deep=True))
used_tokens += message_tokens
continue
truncated = _truncate_message_to_tokens(message, remaining)
if truncated is not None:
if keep_tail:
selected.insert(0, truncated)
else:
selected.append(truncated)
break
return selected
def _tail_unit_start(messages: list[Message], end: int) -> int:
index = end - 1
while index > 0 and messages[index].role == "tool":
index -= 1
if index >= 0 and messages[index].role == "assistant" and messages[index].tool_calls:
return index
return end - 1
def _truncate_message_to_tokens(message: Message, max_tokens: int) -> Message | None:
if max_tokens <= 0:
return None
if estimate_message_tokens(message) <= max_tokens:
return message.model_copy(deep=True)
copied = message.model_copy(deep=True)
content = copied.content
copied.content = ""
fixed_tokens = estimate_message_tokens(copied)
available_tokens = max_tokens - fixed_tokens
if available_tokens <= 0:
return None
if isinstance(content, str):
copied.content = _truncate_text_to_tokens(content, available_tokens)
return copied if _message_has_model_content(copied) or copied.role == "tool" else None
if isinstance(content, list):
fitted_content = []
used_tokens = 0
for item in content:
remaining = available_tokens - used_tokens
if remaining <= 0:
break
item_type = getattr(item, "type", "")
if item_type == "text" and getattr(item, "text", None):
item_tokens = estimate_text_tokens(item.text)
fitted_item = item.model_copy(deep=True)
if item_tokens > remaining:
fitted_item.text = _truncate_text_to_tokens(item.text, remaining)
if not fitted_item.text:
break
fitted_content.append(fitted_item)
break
fitted_content.append(fitted_item)
used_tokens += item_tokens
elif isinstance(item_type, str) and item_type.startswith(("image", "file")):
item_tokens = _attachment_tokens(item)
if item_tokens > remaining:
continue
fitted_content.append(item.model_copy(deep=True))
used_tokens += item_tokens
copied.content = fitted_content
return copied if _message_has_model_content(copied) or copied.role == "tool" else None
return None
def _truncate_text(text: str, max_chars: int) -> str:
if max_chars <= 0:
return ""
if len(text) <= max_chars:
return text
if max_chars <= 32:
return text[:max_chars]
omitted = len(text) - max_chars
suffix = f"\n[... {omitted} characters compacted]"
return text[: max_chars - len(suffix)] + suffix
def _runtime_context_window_tokens(metadata: dict[str, typing.Any]) -> int | None:
for key in (
"model_context_window_tokens",
"context_window_tokens",
"model_context_tokens",
"contextWindowTokens",
"context_window",
):
value = metadata.get(key)
if isinstance(value, bool):
continue
if isinstance(value, int) and value > 0:
return value
model = metadata.get("model")
if isinstance(model, dict):
for key in ("context_window_tokens", "contextWindowTokens", "context_window"):
value = model.get(key)
if isinstance(value, bool):
continue
if isinstance(value, int) and value > 0:
return value
return None
def _runtime_max_output_tokens(metadata: dict[str, typing.Any]) -> int | None:
for key in (
"model_max_output_tokens",
"max_output_tokens",
"model_output_tokens",
"maxOutputTokens",
):
value = metadata.get(key)
if isinstance(value, bool):
continue
if isinstance(value, int) and value > 0:
return value
model = metadata.get("model")
if isinstance(model, dict):
for key in ("max_output_tokens", "maxOutputTokens", "output_tokens"):
value = model.get(key)
if isinstance(value, bool):
continue
if isinstance(value, int) and value > 0:
return value
return None
def _clamp_reserve_tokens(window_tokens: int, reserve_tokens: int) -> int:
if window_tokens <= 0 or reserve_tokens <= 0:
return 0
return min(reserve_tokens, max(1, window_tokens // 4))
def _config_token_value(
config: dict[str, typing.Any],
key: str,
default: int | None,
*,
minimum: int,
) -> int | None:
if key in config:
return _config_int(config, key, default, minimum=minimum)
return default
def _chars_to_tokens(chars: int) -> int:
if chars <= 0:
return 0
return max(1, (chars + ESTIMATED_CHARS_PER_TOKEN - 1) // ESTIMATED_CHARS_PER_TOKEN)
def _is_cjk_token_char(char: str) -> bool:
codepoint = ord(char)
return (
0x1100 <= codepoint <= 0x11FF
or 0x2E80 <= codepoint <= 0x2EFF
or 0x2F00 <= codepoint <= 0x2FDF
or 0x3000 <= codepoint <= 0x303F
or 0x3040 <= codepoint <= 0x309F
or 0x30A0 <= codepoint <= 0x30FF
or 0x3100 <= codepoint <= 0x312F
or 0x3130 <= codepoint <= 0x318F
or 0x31A0 <= codepoint <= 0x31BF
or 0x31F0 <= codepoint <= 0x31FF
or 0x3400 <= codepoint <= 0x4DBF
or 0x4E00 <= codepoint <= 0x9FFF
or 0xAC00 <= codepoint <= 0xD7AF
or 0xF900 <= codepoint <= 0xFAFF
or 0xFF00 <= codepoint <= 0xFFEF
)
def _is_symbol_token_char(char: str) -> bool:
codepoint = ord(char)
if 0x1F000 <= codepoint <= 0x1FAFF or 0x2600 <= codepoint <= 0x27BF or 0xFE00 <= codepoint <= 0xFE0F:
return True
return unicodedata.category(char) == "So"
def _config_int(
config: dict[str, typing.Any],
key: str,
default: int | None,
*,
minimum: int,
) -> int | None:
value = config.get(key, default)
if isinstance(value, bool):
return default
if not isinstance(value, int):
return default
if value < minimum:
return default
return value
def usage_total_tokens(usage: typing.Any) -> int | None:
usage_map = _as_mapping(usage)
if usage_map is None:
return None
total_tokens = _positive_usage_int(usage_map.get("total_tokens"))
if total_tokens is not None:
return total_tokens
prompt_tokens = _positive_usage_int(usage_map.get("prompt_tokens")) or 0
completion_tokens = _positive_usage_int(usage_map.get("completion_tokens")) or 0
if prompt_tokens or completion_tokens:
return prompt_tokens + completion_tokens
return None
def _positive_usage_int(value: typing.Any) -> int | None:
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
return None
return value