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
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user