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

+21 -8
View File
@@ -18,9 +18,9 @@ Executes autonomous reactive mobile workflows through OpenAI Responses.
Conversation shape (shared with the Pro operator, history redesign §3.2):
the prompt is built every turn from a session :class:`TranscriptLedger` —
a byte-stable system prefix, the committed earlier turns (append-only; old UI
lists stripped at depth 1, screenshots resolved to visual summaries at depth
K, long spans chunk-compressed), and a fresh tail carrying the current
a byte-stable system prefix, the committed earlier turns (append-only within
a context phase; old observations and long spans compacted together only at
pressure thresholds), and a fresh tail carrying the current
observation under a ``# CURRENT OBSERVATION [T+mm:ss]`` header. Actions run
synchronously and their tool messages already carry the outcome; only a turn
whose action failed ends with an ``--- Action Execution Result (T+mm:ss) ---``
@@ -30,7 +30,7 @@ session recording.
Per-turn notices on the tail (the reasoning reminder after a silent turn, the
no-tool-call nudge, the final-turn warning, the user-guidance wrapper) are
marked ephemeral: the ledger's scrub edge deletes them once the message leaves
marked ephemeral: the ledger's compaction pass deletes them when it rewrites
the recent window, so they never reach the frozen history or a chunk capsule.
The verbatim body of a user instruction is the exception: it stays in the
active window until its turn is chunked, whose user lines then carry it on.
@@ -83,7 +83,12 @@ from artemis.llm.router import ModelEndpoint
from artemis.llm.structured import ParseFailure, parse_structured
from artemis.mcp.action_executor import McpActionExecutor
from artemis.mcp.observation import DeviceObservationError, require_observation
from artemis.memory.transcript import PRO_UI_LIST_MARKER, TranscriptLedger, mark_ephemeral
from artemis.memory.transcript import (
OBSERVATION_SCOPE_RULE,
PRO_UI_LIST_MARKER,
TranscriptLedger,
mark_ephemeral,
)
from artemis.services.llm import (
RobustChatModelWrapper,
acomplete,
@@ -272,6 +277,8 @@ class FlashRunner:
image_scrub_depth_relaxed=getattr(cfg, "image_scrub_depth_relaxed", None),
context_budget_tokens=getattr(cfg, "context_budget_tokens", None),
start_ratio=getattr(cfg, "start_ratio", None),
soft_ratio=getattr(cfg, "soft_ratio", 0.7),
hard_ratio=getattr(cfg, "hard_ratio", 0.9),
session_start=session_start if isinstance(session_start, (int, float)) else None,
)
if engine is not None:
@@ -1247,7 +1254,13 @@ class FlashRunner:
"""Installs the static prefix and captures the initial device state."""
ledger = self._build_ledger()
ledger.set_static_prefix(
[SystemMessage(content=self._render_system_prompt(tools_declaration))]
[
SystemMessage(
content=self._render_system_prompt(tools_declaration)
+ "\n"
+ OBSERVATION_SCOPE_RULE
)
]
)
# Capture Initial State (Screenshot + UI Tree)
@@ -1369,7 +1382,7 @@ class FlashRunner:
)
pending_notices = []
# S + F + A + tail: chunk compression and the scrub edge advance here.
# S + F + A + tail: history changes only at a compaction boundary.
messages = ledger.render([tail])
turn_base = len(messages) - 1
current_tools = report_only_tools if is_final else self._get_tools()
@@ -1427,7 +1440,7 @@ class FlashRunner:
# ratio — only from a provider measurement, never from the
# estimate (same as the Pro operator).
ledger.record_prompt_tokens(
step_token_usage.get("prompt_tokens"),
step_token_usage.get("prompt_tokens") if measured else None,
messages=messages[:-1] if measured else None,
)
+7 -8
View File
@@ -330,6 +330,8 @@ class OperatorNode:
image_scrub_depth_relaxed=getattr(cfg, "image_scrub_depth_relaxed", None),
context_budget_tokens=getattr(cfg, "context_budget_tokens", None),
start_ratio=getattr(cfg, "start_ratio", None),
soft_ratio=getattr(cfg, "soft_ratio", 0.7),
hard_ratio=getattr(cfg, "hard_ratio", 0.9),
session_start=session_start if isinstance(session_start, (int, float)) else None,
)
# L2/L3 chunk compression (M3) rides the same flag; without a
@@ -392,7 +394,7 @@ class OperatorNode:
"""
from langchain_core.messages import HumanMessage
from artemis.memory.transcript import mark_ephemeral
from artemis.memory.transcript import OBSERVATION_SCOPE_RULE, mark_ephemeral
ledger = self._ensure_transcript_ledger(state)
@@ -407,6 +409,7 @@ class OperatorNode:
if getattr(self.ctx, "plugins", None):
prefix.append(self.ctx.plugins.prompt())
prefix.append(static_text)
prefix.append(OBSERVATION_SCOPE_RULE)
ledger.set_static_prefix([SystemMessage(content="\n".join(prefix))])
# 2. F region cold start: an empty ledger over an existing step record
@@ -496,7 +499,7 @@ class OperatorNode:
tail_content.append(part)
tail = HumanMessage(content=tail_content)
# Per-turn notices (reminders, hints, guidance, incident blocks) are
# deleted by the scrub edge once the turn leaves the recent window.
# deleted only when phase compaction rewrites the recent window.
if builder.ephemeral_indices:
mark_ephemeral(tail, builder.ephemeral_indices)
@@ -635,12 +638,8 @@ class OperatorNode:
transcript_ledger.record_prompt_tokens(
usage.get("prompt_tokens"), messages=current_messages
)
elif hasattr(transcript_ledger, "record_prompt_tokens"):
# No usage metadata from the provider: record an estimate so
# the start gate and thresholds still see a context base.
from artemis.memory.transcript import estimate_prompt_tokens
transcript_ledger.record_prompt_tokens(estimate_prompt_tokens(current_messages))
# Without provider usage the ledger estimates the complete next
# prompt at render time; do not label that estimate as a measurement.
if response.content:
if isinstance(response.content, str):
+16 -24
View File
@@ -425,7 +425,7 @@ class MemoryRuntimeConfig(BaseModel):
class MemoryTranscriptConfig(BaseModel):
"""Scrub-edge options for the message transcript (agent.memory.transcript)."""
"""Phased context compaction for the shared Flash/Pro transcript."""
enabled: bool = Field(
default=True,
@@ -443,7 +443,7 @@ class MemoryTranscriptConfig(BaseModel):
default=3,
ge=1,
description=(
"Depth K of the screenshot scrub edge from the start gate up: the"
"At a compaction boundary, depth K of the screenshot scrub edge: the"
" K-th most recent screenshot (the live one counts as depth 1) is"
" replaced by its visual summary."
),
@@ -452,11 +452,9 @@ class MemoryTranscriptConfig(BaseModel):
default=6,
ge=1,
description=(
"Screenshot scrub depth while the operator's measured context is"
" below budget*start_ratio (nothing is being replaced yet, so"
" more raw screenshots stay in view). Must be >= image_scrub_depth;"
" set equal to it to disable the occupancy-driven depth. A change"
" of depth never rewrites an already-scrubbed message."
"Screenshot depth for an explicit low-occupancy compaction. Normal"
" turns retain previously sent images unchanged. Must be >="
" image_scrub_depth."
),
)
pending_grace_steps: int = Field(
@@ -472,13 +470,10 @@ class MemoryTranscriptConfig(BaseModel):
default=1,
ge=1,
description=(
"Text edge of the scrub: once a message is deeper than this many"
" image-bearing observations (1 = as soon as a newer observation"
" exists) its UI element list, plan recitation and per-turn"
" ephemeral blocks are removed. Kept shallow on purpose: only the"
" live observation carries an indexed element list, so a stale"
" [n] index can never be picked up as a target. Screenshots follow"
" image_scrub_depth / image_scrub_depth_relaxed."
"At phase compaction, remove old UI lists, plan recitations and"
" ephemeral notices beyond this observation depth. Between phases"
" old content stays immutable; only the latest observation may"
" supply current action targets."
),
)
context_budget_tokens: int = Field(
@@ -496,12 +491,9 @@ class MemoryTranscriptConfig(BaseModel):
ge=0.0,
lt=1.0,
description=(
"Start gate: below budget*start_ratio of the operator's measured"
" prompt size, closed segments only prepare their capsule in the"
" background — the original turns stay in the transcript. Ready"
" chunks replace their turns once the measured context reaches"
" this ratio (the hard threshold overrides it). 0 restores"
" swap-as-soon-as-ready."
"Preparation gate: at budget*start_ratio begin background chunk"
" summaries without replacing the live prefix. Zero enables early"
" preparation, not immediate replacement. Swaps wait for soft_ratio."
),
)
soft_ratio: float = Field(
@@ -509,9 +501,9 @@ class MemoryTranscriptConfig(BaseModel):
gt=0.0,
lt=1.0,
description=(
"Soft threshold: when the operator's last measured prompt size"
" reaches budget*soft_ratio, the oldest open segment is"
" chunk-compressed (L2)."
"At budget*soft_ratio, replace prepared summaries as one batch and"
" scrub old observations, starting a new stable context phase."
" Pending summaries retain original turns until ready or hard pressure."
),
)
hard_ratio: float = Field(
@@ -519,7 +511,7 @@ class MemoryTranscriptConfig(BaseModel):
gt=0.0,
le=1.0,
description=(
"Hard threshold: when the operator's last measured prompt size"
"Hard threshold: when the projected current prompt size"
" reaches budget*hard_ratio, ready chunks swap as L2 blocks first;"
" only if that would not bring the estimated context back under the"
" line does everything closed force-swap (pending chunks included)"
+3
View File
@@ -267,6 +267,9 @@ class ArtemisContext(BaseModel):
operator's transcript prompt path (``agent.memory.transcript.enabled``).
Lives beside ``step_memory``; the State never carries message payloads."""
prompt_prefix_tracker: Any | None = None
"""Task-local, hash-only prefix diagnostics shared by bound LLM wrappers."""
async def __aenter__(self) -> ArtemisContext:
return self
+65
View File
@@ -0,0 +1,65 @@
"""Local prefix diagnostics; retain hashes only, never prompt/image payloads.
These compare adapted messages, not the gateway's tokenized wire prefix.
Only provider-reported cached_tokens is a measured cache hit.
"""
import hashlib
import json
from langchain_core.messages import BaseMessage
def _digest(value) -> str:
return hashlib.sha256(
json.dumps(value, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8")
).hexdigest()
class PromptPrefixTracker:
def __init__(self):
self._previous: dict[str, tuple[str, tuple[str, ...]]] = {}
def observe(self, identity: str, messages, options: dict) -> dict:
identity = _digest(identity)
if not isinstance(messages, (list, tuple)) or not all(
isinstance(message, BaseMessage) for message in messages
):
return {"comparison": "unavailable"}
fingerprints = tuple(
_digest(
{
"role": message.type.removesuffix("MessageChunk").lower(),
"content": message.content,
"name": message.name,
"tool_calls": getattr(message, "tool_calls", None),
"tool_call_id": getattr(message, "tool_call_id", None),
}
)
for message in messages
)
options_hash = _digest(options)
previous = self._previous.get(identity)
# Task-scoped and bounded even if a caller tries many endpoint roles.
if identity not in self._previous and len(self._previous) >= 32:
self._previous.pop(next(iter(self._previous)))
self._previous[identity] = (options_hash, fingerprints)
common = 0
if previous:
for old, new in zip(previous[1], fingerprints):
if old != new:
break
common += 1
schema_changed = previous is not None and previous[0] != options_hash
return {
"comparison": "local_message_fingerprints",
"message_count": len(fingerprints),
"previous_message_count": len(previous[1]) if previous else None,
"shared_prefix_messages": common if previous else None,
"first_changed_message": (
common + 1 if previous and common < len(previous[1]) else None
),
"append_only": bool(previous and common == len(previous[1]) and not schema_changed),
"tools_or_format_changed": schema_changed,
"cold_start": previous is None,
}
+28 -20
View File
@@ -1074,7 +1074,12 @@ class HistoryChunkManager:
soft = base_tokens is not None and base_tokens >= self._budget * self._soft_ratio
hard = base_tokens is not None and base_tokens >= self._budget * self._hard_ratio
closed_any = self._close_new_segments(ledger, soft, hard)
preparing = base_tokens is not None and base_tokens >= self._budget * self._start_ratio
closed_any = (
self._close_new_segments(ledger, soft, hard)
if hard or (preparing and (not soft or not self._awaiting))
else False
)
if closed_any or soft or hard:
# Failure rung of the degradation ladder: exhausted capsules are
# re-dispatched (the lens retries a fallback model per attempt);
@@ -1206,14 +1211,10 @@ class HistoryChunkManager:
contiguous READY prefix may swap — an older pending segment keeps its
(and every younger segment's) original text live.
Start gate: closing a segment (milestone/size triggers) only *prepares*
its capsule in the background; the original text is replaced only once
the operator's measured context has reached ``budget * start_ratio``.
Below that the ready chunks are held in the awaiting queue, so at low
occupancy the model keeps reading the full transcript while capsules
are already on hand for when pressure arrives. An unknown base (no
measured operator call yet, or a provider without usage metadata)
cannot gate and falls through to swap-on-ready.
The start gate enables background preparation. Only the soft gate
replaces live history, in one batch after all prepared headers are
ready. The hard gate may force a swap if summaries are still pending.
Missing provider usage uses the ledger's current prompt estimate.
Hard threshold ladder (L2 first, L3 last): at ``budget * hard_ratio``
the ready prefix swaps as ordinary L2 chunk blocks when the estimated
@@ -1262,7 +1263,13 @@ class HistoryChunkManager:
else:
swap = list(self._awaiting) # rung 2: force everything, fold
fold = True
elif base_tokens is not None and base_tokens < self._budget * self._start_ratio:
elif base_tokens is None or base_tokens < self._budget * self._soft_ratio:
if base_tokens is not None:
self._announce_held(base_tokens, ledger)
return
elif len(ready_prefix) < len(self._awaiting):
# Batch the prepared summaries into one rewrite. Async completion
# must not cause a succession of partial prefix replacements.
self._announce_held(base_tokens, ledger)
return
else:
@@ -1290,7 +1297,7 @@ class HistoryChunkManager:
# merged into the period paragraph); a pending chunk has no summary
# size to report, so its trace carries no compression ratio.
swapped = [e for e in swap if e["chunk"] is not None]
source_tokens_total = 0
source_tokens_total = sum(ledger.turn_prompt_tokens(e["turns"]) for e in swap)
replacement_tokens_total = 0
for entry in swapped:
chunk = entry["chunk"]
@@ -1298,7 +1305,6 @@ class HistoryChunkManager:
chunk.summary_chars = len(render_chunk_block(chunk))
else:
chunk.summary_chars = None
source_tokens_total += self._chars_to_tokens(chunk.source_chars, ledger)
replacement_tokens_total += self._chars_to_tokens(
self._replacement_chars(chunk, folded=fold), ledger
)
@@ -1392,18 +1398,17 @@ class HistoryChunkManager:
delta = 0
for entry in entries:
chunk = entry["chunk"]
if chunk is None:
continue
delta += self._chars_to_tokens(
self._replacement_chars(chunk), ledger
) - self._chars_to_tokens(chunk.source_chars, ledger)
replacement = self._replacement_chars(chunk) if chunk is not None else 0
delta += self._chars_to_tokens(replacement, ledger) - ledger.turn_prompt_tokens(
entry["turns"]
)
return max(0, int(base_tokens) + delta)
def _announce_held(self, base_tokens: int, ledger: Any = None) -> None:
"""Once per chunk: its capsule is ready but the start gate holds the
"""Once per chunk: its capsule is ready but the soft gate holds the
original text live. The timeline line stays ``running`` with a
``held`` note so the UI can say the summary is ready and waiting."""
threshold = int(self._budget * self._start_ratio)
threshold = int(self._budget * self._soft_ratio)
for entry in self._awaiting:
chunk = entry["chunk"]
if chunk is None or chunk.status != "ready" or entry.get("held_announced"):
@@ -1468,7 +1473,7 @@ class HistoryChunkManager:
"""The live context base for the start/soft/hard thresholds.
An injected ``meter_getter`` wins (tests / custom wiring); otherwise
the base is the owning operator's last measured prompt size recorded
the base is the ledger's projected current prompt, calibrated from usage recorded
on the ledger (:meth:`TranscriptLedger.record_prompt_tokens`). The
session-wide token meter is deliberately NOT consulted: its
``last_prompt_tokens`` is overwritten by whichever agent called last
@@ -1481,6 +1486,9 @@ class HistoryChunkManager:
except Exception:
return None
try:
projected = getattr(ledger, "projected_prompt_tokens", None)
if isinstance(projected, int) and projected > 0:
return projected
last = getattr(ledger, "last_prompt_tokens", None)
return int(last) if last else None
except (TypeError, ValueError):
+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)
+1
View File
@@ -96,6 +96,7 @@
"enabled": true,
// 历史压缩预算,允许范围 1024–300000;上下文参照上限固定为 300k。
"context_budget_tokens": 80000,
// 阶段内仅追加;35% 开始后台准备摘要,70% 成批压缩,90% 紧急折叠。
"start_ratio": 0.35,
"soft_ratio": 0.7,
"hard_ratio": 0.9,
+23
View File
@@ -58,6 +58,7 @@ from artemis.llm.reliability import (
retry_policy_for,
)
from artemis.llm.messages import adapt_responses_messages
from artemis.llm.prefix_cache import PromptPrefixTracker
from artemis.llm.router import ModelEndpoint, ModelFactory, ModelProvider
from artemis.services.token_meter import record_llm_usage
from artemis.llm.usage import usage_from_message
@@ -671,6 +672,27 @@ class RobustChatModelWrapper:
record_llm_usage(engine, response, source=self._endpoint_key())
def _diagnostics(self, args, kwargs):
prefix = None
if self.ctx is not None:
tracker = getattr(self.ctx, "prompt_prefix_tracker", None)
if not isinstance(tracker, PromptPrefixTracker):
tracker = PromptPrefixTracker()
self.ctx.prompt_prefix_tracker = tracker
bound_options = getattr(self.base_model, "kwargs", {})
options = {
**(bound_options if isinstance(bound_options, dict) else {}),
**{
key: kwargs[key]
for key in ("tools", "tool_choice", "response_format")
if key in kwargs
},
}
identity = f"{kwargs.get('prompt_cache_key')}:{self.role}:{self._endpoint_key()}"
prefix = tracker.observe(identity, args[0] if args else kwargs.get("input"), options)
ledger = getattr(self.ctx, "transcript_ledger", None)
if ledger is not None:
prefix["context_phase"] = getattr(ledger, "context_phase", 0)
prefix["last_compaction"] = getattr(ledger, "last_compaction", None)
return {
"provider": self._provider_value(),
"model": self.endpoint.model_name if self.endpoint else None,
@@ -680,6 +702,7 @@ class RobustChatModelWrapper:
"is_fallback": self.is_fallback,
"images": input_images(args, kwargs),
"prompt_cache_key": kwargs.get("prompt_cache_key"),
"prefix_cache": prefix,
}
async def _invoke_attempt(self, *args, **kwargs):