perf: preserve conversation prefixes across agent turns
This commit is contained in:
1 parent
1440a1f508
commit
f1bb5d02aa
19 files changed
+552
-205
No files matched your search
+131
-49
@@ -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)
|
||||
Reference in new issue
Block a user