perf: preserve conversation prefixes across agent turns

This commit is contained in:
chenmingxuan committed 2026-10-17 17:25:15 +08:00
1 parent 1440a1f508
commit f1bb5d02aa
19 files changed
+552 -205

No files matched your search

+131 -49
View File
@@ -24,14 +24,11 @@ Messages are organized into four regions:
(tool_calls and native thinking preserved by reference), the in-turn
ToolMessages, and the turn's result message (every turn on the Pro path;
only when an action failed on the Flash path). The
scrub edge (:class:`~artemis.agents.flash.context_compressor.ScrubEdgeCompressor`)
has two depths: a shallow text edge (UI list, plan recitation and ephemeral
per-turn blocks leave as soon as a newer observation exists) and a
screenshot edge that follows the occupancy tier (relaxed below the start
gate, tightened past it), where the screenshot is replaced in place by its
visual summary. Every rewrite happens once and is never undone; everything
newer is byte-identical to what the model was first shown. Messages are
never removed or reordered, so tool-call/response pairs are never split.
scrub edge runs only at a pressure-driven phase boundary, together with
chunk replacement. Normal renders preserve all previously sent content,
including screenshots and UI lists. Chunk summaries use a compact copy of
each turn; their background completion never rewrites the live prefix.
Tool-call/response pairs are never split.
- **T (current tail)**: built fresh every turn by the operator; passed to
:meth:`render` and only enters A when the turn is committed.
@@ -74,8 +71,8 @@ RESTORED_HISTORY_HEADER = "[Restored history]"
#: ``HumanMessage.additional_kwargs`` key listing the indices of content blocks that
#: are only meaningful for the turn they were built for (reminders, hints, user
#: guidance, per-turn notices). The scrub edge deletes them at its text edge, so
#: they never reach the frozen region or a chunk capsule. The indices always
#: guidance, per-turn notices). At a compaction boundary the scrub edge deletes them;
#: capsule source copies also omit them. The indices always
#: describe the message's *current* block layout: a rewrite that removes blocks
#: before the text edge (the screenshot swap when the image edge is shallower)
#: remaps the surviving indices in place, and the key is dropped once the blocks
@@ -83,6 +80,13 @@ RESTORED_HISTORY_HEADER = "[Restored history]"
#: marking costs the model nothing.
EPHEMERAL_BLOCKS_KEY = "ephemeral_blocks"
OBSERVATION_SCOPE_RULE = (
"历史截图、元素编号、坐标和逐轮提醒仅用于回顾。每次操作必须依据同一设备/来源的"
"最新观察;旧消息中的 CURRENT OBSERVATION 不表示仍然有效。最新任务计划和用户指导"
"优先于旧版本;旧的结束提示不得覆盖新指导。需要确认当前状态时重新观察,禁止从旧"
"元素列表选择目标或用历史截图证明当前操作成功。"
)
def mark_ephemeral(message: BaseMessage, indices) -> None:
"""Flag content blocks of ``message`` (by index) as ephemeral, see
@@ -190,6 +194,10 @@ def render_turn_transcript(messages: list[BaseMessage]) -> str:
#: (Gemini's flat per-image cost; the Flash runner uses the same figure).
IMAGE_BLOCK_TOKENS = 258
# Conservative growth allowance for a new screenshot before provider usage
# arrives. Removed images use IMAGE_BLOCK_TOKENS to avoid overstating savings.
PROJECTED_IMAGE_TOKENS = 2048
#: Characters per token assumed before any calibrated measurement.
DEFAULT_CHARS_PER_TOKEN = 4.0
@@ -268,8 +276,10 @@ class TranscriptLedger:
pending_grace_steps: int = 3,
xml_scrub_depth: int | None = None,
image_scrub_depth_relaxed: int | None = None,
context_budget_tokens: int | None = None,
context_budget_tokens: int | None = 80000,
start_ratio: float | None = None,
soft_ratio: float = 0.7,
hard_ratio: float = 0.9,
clock: Callable[[], float] | None = None,
session_start: float | None = None,
):
@@ -308,6 +318,10 @@ class TranscriptLedger:
# calls sharing the session can never masquerade as the operator's
# context (see HistoryChunkManager._context_base_tokens).
self._last_prompt_tokens: int | None = None
self._prompt_sample: tuple[int, int, int] | None = None
self._projected_prompt_tokens: int | None = None
self.context_phase = 0
self.last_compaction: dict[str, Any] | None = None
# Session-calibrated characters-per-token ratio (see
# record_prompt_tokens): the chunker converts transcript characters
# to tokens through it, so CJK-heavy sessions (~1 char/token on
@@ -326,26 +340,21 @@ class TranscriptLedger:
# it to decide whether to attach the shared reasoning reminder.
self._last_turn_silent: bool = False
# Text edge (default 1): UI list, plan recitation and ephemeral blocks
# leave as soon as a newer observation exists; screenshots follow K.
# At phase compaction, strip old UI/plan/notices and resolve images.
# Between boundaries none of these fields is rewritten.
self._xml_scrub_depth = xml_scrub_depth
# Occupancy-driven screenshot depth. Below the start gate
# (``last_prompt_tokens < budget * start_ratio``, or no measurement
# yet) the scrub edge sits at the relaxed depth so the model keeps
# more raw screenshots while nothing is being replaced; from the
# start gate up it tightens to ``image_scrub_depth``. Scrubbing is
# irreversible, so a change of depth never rewrites a message:
# tightening scrubs the extra depths once, relaxing lets new images
# live longer. See :attr:`effective_image_scrub_depth`.
# Depth settings apply only inside an explicit compaction pass.
self._image_scrub_depth = max(1, int(image_scrub_depth))
self._image_scrub_depth_relaxed = (
max(self._image_scrub_depth, int(image_scrub_depth_relaxed))
if image_scrub_depth_relaxed is not None
else self._image_scrub_depth
)
self._context_budget_tokens = int(context_budget_tokens) if context_budget_tokens else None
self._context_budget_tokens = int(context_budget_tokens or 80000)
self._start_ratio = float(start_ratio) if start_ratio is not None else None
self._soft_ratio = float(soft_ratio)
self._hard_ratio = float(hard_ratio)
# id(message) -> summary-job key (DataEngine step id). A side map keeps
# the key out of the serialized message payload entirely.
@@ -356,7 +365,8 @@ class TranscriptLedger:
# whose failure depends on which side is imported first.
from artemis.agents.flash.context_compressor import ScrubEdgeCompressor
self._compressor = ScrubEdgeCompressor(
self._compressor_type = ScrubEdgeCompressor
self._compressor_options = dict(
summarizer=step_memory,
prune_history_xml=prune_history_xml,
image_scrub_depth=image_scrub_depth,
@@ -366,6 +376,7 @@ class TranscriptLedger:
strip_markers=(PRO_UI_LIST_MARKER, PLAN_RECITATION_MARKER),
tail_offset=1,
)
self._compressor = ScrubEdgeCompressor(**self._compressor_options)
# ------------------------------------------------------------------
# Session clock
@@ -388,6 +399,27 @@ class TranscriptLedger:
first measured call (or when the provider reports no usage)."""
return self._last_prompt_tokens
@property
def projected_prompt_tokens(self) -> int | None:
"""Current prompt including the fresh tail, estimated before compaction."""
return self._projected_prompt_tokens
def estimate_context_tokens(self, messages: list[BaseMessage]) -> int:
chars, images = measure_prompt_content(messages)
if self._prompt_sample is None:
# Without provider usage, count CJK conservatively rather than //4.
return max(1, self._last_prompt_tokens or 0, chars + images * PROJECTED_IMAGE_TOKENS)
prev_chars, prev_images, tokens = self._prompt_sample
return max(
1,
tokens
+ int((chars - prev_chars) / self._chars_per_token)
# New screenshots may tile to thousands of tokens. For removals,
# use the smaller lower bound rather than overclaiming savings.
+ (images - prev_images)
* (PROJECTED_IMAGE_TOKENS if images >= prev_images else IMAGE_BLOCK_TOKENS),
)
@property
def last_turn_silent(self) -> bool:
"""True when the last committed turn had no visible reasoning text.
@@ -455,7 +487,7 @@ class TranscriptLedger:
@property
def effective_image_scrub_depth(self) -> int:
"""The screenshot depth the scrub edge uses at the next render: the
"""The screenshot depth used at the next compaction boundary: the
relaxed depth below the start gate, the tight depth from there up."""
if self.below_start_gate:
return self._image_scrub_depth_relaxed
@@ -514,6 +546,7 @@ class TranscriptLedger:
if value <= 0:
return
self._last_prompt_tokens = value
self._projected_prompt_tokens = None
if messages is None:
return
try:
@@ -521,6 +554,7 @@ class TranscriptLedger:
except Exception as exc:
logger.debug(f"Prompt calibration skipped: {exc}", exc_info=True)
return
self._prompt_sample = (text_chars, image_count, value)
if text_chars <= 0:
return
@@ -763,11 +797,24 @@ class TranscriptLedger:
return sum(len(self.turn_transcript(turn)) for turn in turns)
def turn_transcript(self, turn: dict) -> str:
"""Plain-text rendering of one committed turn exactly as it currently
stands in the active region (i.e. after the scrub edge), see
:func:`render_turn_transcript`. Chunk compression feeds this to the
segment capsule so the capsule digests precisely what it replaces."""
return render_turn_transcript(self._active[turn["start"] : turn["end"]])
"""Compact capsule source without rewriting the live model prefix."""
originals = self._active[turn["start"] : turn["end"]]
copies = [message.model_copy(deep=True) for message in originals]
keys = {id(new): self._step_keys.get(id(old)) for old, new in zip(originals, copies)}
options = {
**self._compressor_options,
"pending_grace_steps": 0,
"summary_key_getter": lambda message: keys.get(id(message)),
}
self._compressor_type(**options).compress(copies, image_scrub_depth=1)
return render_turn_transcript(copies)
def turn_prompt_tokens(self, turns: list[dict]) -> int:
"""Size actually removed by a swap, including retained image blocks."""
chars, images = measure_prompt_content(
[message for turn in turns for message in self._active[turn["start"] : turn["end"]]]
)
return self.chars_to_tokens(chars) + images * IMAGE_BLOCK_TOKENS
def freeze_turns(self, turn_count: int, frozen_blocks: list[BaseMessage]) -> None:
"""Compression event: consume the oldest ``turn_count`` unchunked turns
@@ -778,6 +825,7 @@ class TranscriptLedger:
"""
if turn_count <= 0:
self._frozen_blocks = list(frozen_blocks)
self.context_phase += 1
return
available = self._turns[self._chunked_turn_count :]
if turn_count > len(available):
@@ -788,36 +836,38 @@ class TranscriptLedger:
self._chunked_turn_count += turn_count
self._active_start = self._turns[self._chunked_turn_count - 1]["end"]
self._frozen_blocks = list(frozen_blocks)
self.context_phase += 1
# ------------------------------------------------------------------
# Rendering
# ------------------------------------------------------------------
def render(self, tail: list[BaseMessage]) -> list[BaseMessage]:
"""Advance compression and return ``S + F + A + tail``.
def compact_active(self) -> bool:
"""Scrub old observations only at an explicit context phase boundary.
Order per turn: the occupancy band is read once (from the operator's
last measured prompt), the scrub edge advances over the active window
at that band's screenshot depth, then the chunker runs (a triggered
compression event deep-mutates the frozen region and advances the F/A
boundary). Scrub-before-chunk guarantees that the turns a chunk
closes over — all older than the sliding-window floor — have already
had their screenshots resolved when the relaxed depth is no deeper
than the floor plus one. The returned list is a fresh container:
appending to it (the operator's in-turn tool loop) never mutates the
ledger regions.
Copy messages first: a previously returned model input must remain
immutable even when this ledger starts a new phase.
"""
active = self._active
copies = [message.model_copy(deep=True) for message in active]
protected = set()
for old, new in zip(active, copies, strict=True):
if id(old) in self._step_keys:
self._step_keys[id(new)] = self._step_keys.pop(id(old))
if id(old) in self._fresh_tool_messages:
protected.add(id(new))
self._compressor.compress(
self._active,
copies,
image_scrub_depth=self.effective_image_scrub_depth,
protected_message_ids=self._fresh_tool_messages,
protected_message_ids=protected,
)
self._fresh_tool_messages.clear()
if self._chunker is not None:
try:
self._chunker.on_render(self)
except Exception as e:
logger.error(f"History chunker render hook failed: {e}")
self._active = copies
return any(
old.content != new.content or old.additional_kwargs != new.additional_kwargs
for old, new in zip(active, copies, strict=True)
)
def _messages(self, tail: list[BaseMessage]) -> list[BaseMessage]:
return [
*self._static,
*self._restored,
@@ -825,3 +875,35 @@ class TranscriptLedger:
*self._active[self._active_start :],
*tail,
]
def render(self, tail: list[BaseMessage]) -> list[BaseMessage]:
"""Append normally; batch history rewrites at pressure-driven boundaries."""
before = self.estimate_context_tokens(self._messages(tail))
self._projected_prompt_tokens = before
phase = self.context_phase
if self._chunker is not None:
try:
self._chunker.on_render(self)
except Exception as e:
logger.error(f"History chunker render hook failed: {e}")
if (
self.context_phase != phase
or (self._chunker is None and before >= self._context_budget_tokens * self._soft_ratio)
or before >= self._context_budget_tokens * self._hard_ratio
):
rewritten = self.compact_active()
after = self.estimate_context_tokens(self._messages(tail))
if self.context_phase != phase or rewritten:
self.context_phase = phase + 1
self.last_compaction = {
"phase": self.context_phase,
"reason": "context_pressure",
"before_tokens": before,
"after_tokens": after,
"estimated": True,
}
logger.info(
f"Context phase {self.context_phase}: {before} -> {after} tokens (estimated)"
)
self._fresh_tool_messages.clear()
return self._messages(tail)