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): Conversation shape (shared with the Pro operator, history redesign §3.2):
the prompt is built every turn from a session :class:`TranscriptLedger` — the prompt is built every turn from a session :class:`TranscriptLedger` —
a byte-stable system prefix, the committed earlier turns (append-only; old UI a byte-stable system prefix, the committed earlier turns (append-only within
lists stripped at depth 1, screenshots resolved to visual summaries at depth a context phase; old observations and long spans compacted together only at
K, long spans chunk-compressed), and a fresh tail carrying the current pressure thresholds), and a fresh tail carrying the current
observation under a ``# CURRENT OBSERVATION [T+mm:ss]`` header. Actions run observation under a ``# CURRENT OBSERVATION [T+mm:ss]`` header. Actions run
synchronously and their tool messages already carry the outcome; only a turn synchronously and their tool messages already carry the outcome; only a turn
whose action failed ends with an ``--- Action Execution Result (T+mm:ss) ---`` 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 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 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 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 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. 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.llm.structured import ParseFailure, parse_structured
from artemis.mcp.action_executor import McpActionExecutor from artemis.mcp.action_executor import McpActionExecutor
from artemis.mcp.observation import DeviceObservationError, require_observation 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 ( from artemis.services.llm import (
RobustChatModelWrapper, RobustChatModelWrapper,
acomplete, acomplete,
@@ -272,6 +277,8 @@ class FlashRunner:
image_scrub_depth_relaxed=getattr(cfg, "image_scrub_depth_relaxed", None), image_scrub_depth_relaxed=getattr(cfg, "image_scrub_depth_relaxed", None),
context_budget_tokens=getattr(cfg, "context_budget_tokens", None), context_budget_tokens=getattr(cfg, "context_budget_tokens", None),
start_ratio=getattr(cfg, "start_ratio", 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, session_start=session_start if isinstance(session_start, (int, float)) else None,
) )
if engine is not None: if engine is not None:
@@ -1247,7 +1254,13 @@ class FlashRunner:
"""Installs the static prefix and captures the initial device state.""" """Installs the static prefix and captures the initial device state."""
ledger = self._build_ledger() ledger = self._build_ledger()
ledger.set_static_prefix( 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) # Capture Initial State (Screenshot + UI Tree)
@@ -1369,7 +1382,7 @@ class FlashRunner:
) )
pending_notices = [] 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]) messages = ledger.render([tail])
turn_base = len(messages) - 1 turn_base = len(messages) - 1
current_tools = report_only_tools if is_final else self._get_tools() 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 # ratio — only from a provider measurement, never from the
# estimate (same as the Pro operator). # estimate (same as the Pro operator).
ledger.record_prompt_tokens( 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, 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), image_scrub_depth_relaxed=getattr(cfg, "image_scrub_depth_relaxed", None),
context_budget_tokens=getattr(cfg, "context_budget_tokens", None), context_budget_tokens=getattr(cfg, "context_budget_tokens", None),
start_ratio=getattr(cfg, "start_ratio", 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, session_start=session_start if isinstance(session_start, (int, float)) else None,
) )
# L2/L3 chunk compression (M3) rides the same flag; without a # L2/L3 chunk compression (M3) rides the same flag; without a
@@ -392,7 +394,7 @@ class OperatorNode:
""" """
from langchain_core.messages import HumanMessage 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) ledger = self._ensure_transcript_ledger(state)
@@ -407,6 +409,7 @@ class OperatorNode:
if getattr(self.ctx, "plugins", None): if getattr(self.ctx, "plugins", None):
prefix.append(self.ctx.plugins.prompt()) prefix.append(self.ctx.plugins.prompt())
prefix.append(static_text) prefix.append(static_text)
prefix.append(OBSERVATION_SCOPE_RULE)
ledger.set_static_prefix([SystemMessage(content="\n".join(prefix))]) ledger.set_static_prefix([SystemMessage(content="\n".join(prefix))])
# 2. F region cold start: an empty ledger over an existing step record # 2. F region cold start: an empty ledger over an existing step record
@@ -496,7 +499,7 @@ class OperatorNode:
tail_content.append(part) tail_content.append(part)
tail = HumanMessage(content=tail_content) tail = HumanMessage(content=tail_content)
# Per-turn notices (reminders, hints, guidance, incident blocks) are # 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: if builder.ephemeral_indices:
mark_ephemeral(tail, builder.ephemeral_indices) mark_ephemeral(tail, builder.ephemeral_indices)
@@ -635,12 +638,8 @@ class OperatorNode:
transcript_ledger.record_prompt_tokens( transcript_ledger.record_prompt_tokens(
usage.get("prompt_tokens"), messages=current_messages usage.get("prompt_tokens"), messages=current_messages
) )
elif hasattr(transcript_ledger, "record_prompt_tokens"): # Without provider usage the ledger estimates the complete next
# No usage metadata from the provider: record an estimate so # prompt at render time; do not label that estimate as a measurement.
# 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))
if response.content: if response.content:
if isinstance(response.content, str): if isinstance(response.content, str):
+16 -24
View File
@@ -425,7 +425,7 @@ class MemoryRuntimeConfig(BaseModel):
class MemoryTranscriptConfig(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( enabled: bool = Field(
default=True, default=True,
@@ -443,7 +443,7 @@ class MemoryTranscriptConfig(BaseModel):
default=3, default=3,
ge=1, ge=1,
description=( 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" " K-th most recent screenshot (the live one counts as depth 1) is"
" replaced by its visual summary." " replaced by its visual summary."
), ),
@@ -452,11 +452,9 @@ class MemoryTranscriptConfig(BaseModel):
default=6, default=6,
ge=1, ge=1,
description=( description=(
"Screenshot scrub depth while the operator's measured context is" "Screenshot depth for an explicit low-occupancy compaction. Normal"
" below budget*start_ratio (nothing is being replaced yet, so" " turns retain previously sent images unchanged. Must be >="
" more raw screenshots stay in view). Must be >= image_scrub_depth;" " image_scrub_depth."
" set equal to it to disable the occupancy-driven depth. A change"
" of depth never rewrites an already-scrubbed message."
), ),
) )
pending_grace_steps: int = Field( pending_grace_steps: int = Field(
@@ -472,13 +470,10 @@ class MemoryTranscriptConfig(BaseModel):
default=1, default=1,
ge=1, ge=1,
description=( description=(
"Text edge of the scrub: once a message is deeper than this many" "At phase compaction, remove old UI lists, plan recitations and"
" image-bearing observations (1 = as soon as a newer observation" " ephemeral notices beyond this observation depth. Between phases"
" exists) its UI element list, plan recitation and per-turn" " old content stays immutable; only the latest observation may"
" ephemeral blocks are removed. Kept shallow on purpose: only the" " supply current action targets."
" 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."
), ),
) )
context_budget_tokens: int = Field( context_budget_tokens: int = Field(
@@ -496,12 +491,9 @@ class MemoryTranscriptConfig(BaseModel):
ge=0.0, ge=0.0,
lt=1.0, lt=1.0,
description=( description=(
"Start gate: below budget*start_ratio of the operator's measured" "Preparation gate: at budget*start_ratio begin background chunk"
" prompt size, closed segments only prepare their capsule in the" " summaries without replacing the live prefix. Zero enables early"
" background — the original turns stay in the transcript. Ready" " preparation, not immediate replacement. Swaps wait for soft_ratio."
" chunks replace their turns once the measured context reaches"
" this ratio (the hard threshold overrides it). 0 restores"
" swap-as-soon-as-ready."
), ),
) )
soft_ratio: float = Field( soft_ratio: float = Field(
@@ -509,9 +501,9 @@ class MemoryTranscriptConfig(BaseModel):
gt=0.0, gt=0.0,
lt=1.0, lt=1.0,
description=( description=(
"Soft threshold: when the operator's last measured prompt size" "At budget*soft_ratio, replace prepared summaries as one batch and"
" reaches budget*soft_ratio, the oldest open segment is" " scrub old observations, starting a new stable context phase."
" chunk-compressed (L2)." " Pending summaries retain original turns until ready or hard pressure."
), ),
) )
hard_ratio: float = Field( hard_ratio: float = Field(
@@ -519,7 +511,7 @@ class MemoryTranscriptConfig(BaseModel):
gt=0.0, gt=0.0,
le=1.0, le=1.0,
description=( 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;" " reaches budget*hard_ratio, ready chunks swap as L2 blocks first;"
" only if that would not bring the estimated context back under the" " only if that would not bring the estimated context back under the"
" line does everything closed force-swap (pending chunks included)" " 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``). operator's transcript prompt path (``agent.memory.transcript.enabled``).
Lives beside ``step_memory``; the State never carries message payloads.""" 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: async def __aenter__(self) -> ArtemisContext:
return self 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 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 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: if closed_any or soft or hard:
# Failure rung of the degradation ladder: exhausted capsules are # Failure rung of the degradation ladder: exhausted capsules are
# re-dispatched (the lens retries a fallback model per attempt); # 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 contiguous READY prefix may swap — an older pending segment keeps its
(and every younger segment's) original text live. (and every younger segment's) original text live.
Start gate: closing a segment (milestone/size triggers) only *prepares* The start gate enables background preparation. Only the soft gate
its capsule in the background; the original text is replaced only once replaces live history, in one batch after all prepared headers are
the operator's measured context has reached ``budget * start_ratio``. ready. The hard gate may force a swap if summaries are still pending.
Below that the ready chunks are held in the awaiting queue, so at low Missing provider usage uses the ledger's current prompt estimate.
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.
Hard threshold ladder (L2 first, L3 last): at ``budget * hard_ratio`` Hard threshold ladder (L2 first, L3 last): at ``budget * hard_ratio``
the ready prefix swaps as ordinary L2 chunk blocks when the estimated the ready prefix swaps as ordinary L2 chunk blocks when the estimated
@@ -1262,7 +1263,13 @@ class HistoryChunkManager:
else: else:
swap = list(self._awaiting) # rung 2: force everything, fold swap = list(self._awaiting) # rung 2: force everything, fold
fold = True 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) self._announce_held(base_tokens, ledger)
return return
else: else:
@@ -1290,7 +1297,7 @@ class HistoryChunkManager:
# merged into the period paragraph); a pending chunk has no summary # merged into the period paragraph); a pending chunk has no summary
# size to report, so its trace carries no compression ratio. # size to report, so its trace carries no compression ratio.
swapped = [e for e in swap if e["chunk"] is not None] 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 replacement_tokens_total = 0
for entry in swapped: for entry in swapped:
chunk = entry["chunk"] chunk = entry["chunk"]
@@ -1298,7 +1305,6 @@ class HistoryChunkManager:
chunk.summary_chars = len(render_chunk_block(chunk)) chunk.summary_chars = len(render_chunk_block(chunk))
else: else:
chunk.summary_chars = None chunk.summary_chars = None
source_tokens_total += self._chars_to_tokens(chunk.source_chars, ledger)
replacement_tokens_total += self._chars_to_tokens( replacement_tokens_total += self._chars_to_tokens(
self._replacement_chars(chunk, folded=fold), ledger self._replacement_chars(chunk, folded=fold), ledger
) )
@@ -1392,18 +1398,17 @@ class HistoryChunkManager:
delta = 0 delta = 0
for entry in entries: for entry in entries:
chunk = entry["chunk"] chunk = entry["chunk"]
if chunk is None: replacement = self._replacement_chars(chunk) if chunk is not None else 0
continue delta += self._chars_to_tokens(replacement, ledger) - ledger.turn_prompt_tokens(
delta += self._chars_to_tokens( entry["turns"]
self._replacement_chars(chunk), ledger )
) - self._chars_to_tokens(chunk.source_chars, ledger)
return max(0, int(base_tokens) + delta) return max(0, int(base_tokens) + delta)
def _announce_held(self, base_tokens: int, ledger: Any = None) -> None: 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 original text live. The timeline line stays ``running`` with a
``held`` note so the UI can say the summary is ready and waiting.""" ``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: for entry in self._awaiting:
chunk = entry["chunk"] chunk = entry["chunk"]
if chunk is None or chunk.status != "ready" or entry.get("held_announced"): 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. """The live context base for the start/soft/hard thresholds.
An injected ``meter_getter`` wins (tests / custom wiring); otherwise 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 on the ledger (:meth:`TranscriptLedger.record_prompt_tokens`). The
session-wide token meter is deliberately NOT consulted: its session-wide token meter is deliberately NOT consulted: its
``last_prompt_tokens`` is overwritten by whichever agent called last ``last_prompt_tokens`` is overwritten by whichever agent called last
@@ -1481,6 +1486,9 @@ class HistoryChunkManager:
except Exception: except Exception:
return None return None
try: try:
projected = getattr(ledger, "projected_prompt_tokens", None)
if isinstance(projected, int) and projected > 0:
return projected
last = getattr(ledger, "last_prompt_tokens", None) last = getattr(ledger, "last_prompt_tokens", None)
return int(last) if last else None return int(last) if last else None
except (TypeError, ValueError): 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 (tool_calls and native thinking preserved by reference), the in-turn
ToolMessages, and the turn's result message (every turn on the Pro path; ToolMessages, and the turn's result message (every turn on the Pro path;
only when an action failed on the Flash path). The only when an action failed on the Flash path). The
scrub edge (:class:`~artemis.agents.flash.context_compressor.ScrubEdgeCompressor`) scrub edge runs only at a pressure-driven phase boundary, together with
has two depths: a shallow text edge (UI list, plan recitation and ephemeral chunk replacement. Normal renders preserve all previously sent content,
per-turn blocks leave as soon as a newer observation exists) and a including screenshots and UI lists. Chunk summaries use a compact copy of
screenshot edge that follows the occupancy tier (relaxed below the start each turn; their background completion never rewrites the live prefix.
gate, tightened past it), where the screenshot is replaced in place by its Tool-call/response pairs are never split.
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.
- **T (current tail)**: built fresh every turn by the operator; passed to - **T (current tail)**: built fresh every turn by the operator; passed to
:meth:`render` and only enters A when the turn is committed. :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 #: ``HumanMessage.additional_kwargs`` key listing the indices of content blocks that
#: are only meaningful for the turn they were built for (reminders, hints, user #: 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 #: guidance, per-turn notices). At a compaction boundary the scrub edge deletes them;
#: they never reach the frozen region or a chunk capsule. The indices always #: capsule source copies also omit them. The indices always
#: describe the message's *current* block layout: a rewrite that removes blocks #: 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) #: 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 #: 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. #: marking costs the model nothing.
EPHEMERAL_BLOCKS_KEY = "ephemeral_blocks" EPHEMERAL_BLOCKS_KEY = "ephemeral_blocks"
OBSERVATION_SCOPE_RULE = (
"历史截图、元素编号、坐标和逐轮提醒仅用于回顾。每次操作必须依据同一设备/来源的"
"最新观察;旧消息中的 CURRENT OBSERVATION 不表示仍然有效。最新任务计划和用户指导"
"优先于旧版本;旧的结束提示不得覆盖新指导。需要确认当前状态时重新观察,禁止从旧"
"元素列表选择目标或用历史截图证明当前操作成功。"
)
def mark_ephemeral(message: BaseMessage, indices) -> None: def mark_ephemeral(message: BaseMessage, indices) -> None:
"""Flag content blocks of ``message`` (by index) as ephemeral, see """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). #: (Gemini's flat per-image cost; the Flash runner uses the same figure).
IMAGE_BLOCK_TOKENS = 258 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. #: Characters per token assumed before any calibrated measurement.
DEFAULT_CHARS_PER_TOKEN = 4.0 DEFAULT_CHARS_PER_TOKEN = 4.0
@@ -268,8 +276,10 @@ class TranscriptLedger:
pending_grace_steps: int = 3, pending_grace_steps: int = 3,
xml_scrub_depth: int | None = None, xml_scrub_depth: int | None = None,
image_scrub_depth_relaxed: 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, start_ratio: float | None = None,
soft_ratio: float = 0.7,
hard_ratio: float = 0.9,
clock: Callable[[], float] | None = None, clock: Callable[[], float] | None = None,
session_start: 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 # calls sharing the session can never masquerade as the operator's
# context (see HistoryChunkManager._context_base_tokens). # context (see HistoryChunkManager._context_base_tokens).
self._last_prompt_tokens: int | None = None 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 # Session-calibrated characters-per-token ratio (see
# record_prompt_tokens): the chunker converts transcript characters # record_prompt_tokens): the chunker converts transcript characters
# to tokens through it, so CJK-heavy sessions (~1 char/token on # 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. # it to decide whether to attach the shared reasoning reminder.
self._last_turn_silent: bool = False self._last_turn_silent: bool = False
# Text edge (default 1): UI list, plan recitation and ephemeral blocks # At phase compaction, strip old UI/plan/notices and resolve images.
# leave as soon as a newer observation exists; screenshots follow K. # Between boundaries none of these fields is rewritten.
self._xml_scrub_depth = xml_scrub_depth self._xml_scrub_depth = xml_scrub_depth
# Occupancy-driven screenshot depth. Below the start gate # Depth settings apply only inside an explicit compaction pass.
# (``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`.
self._image_scrub_depth = max(1, int(image_scrub_depth)) self._image_scrub_depth = max(1, int(image_scrub_depth))
self._image_scrub_depth_relaxed = ( self._image_scrub_depth_relaxed = (
max(self._image_scrub_depth, int(image_scrub_depth_relaxed)) max(self._image_scrub_depth, int(image_scrub_depth_relaxed))
if image_scrub_depth_relaxed is not None if image_scrub_depth_relaxed is not None
else self._image_scrub_depth 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._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 # id(message) -> summary-job key (DataEngine step id). A side map keeps
# the key out of the serialized message payload entirely. # the key out of the serialized message payload entirely.
@@ -356,7 +365,8 @@ class TranscriptLedger:
# whose failure depends on which side is imported first. # whose failure depends on which side is imported first.
from artemis.agents.flash.context_compressor import ScrubEdgeCompressor from artemis.agents.flash.context_compressor import ScrubEdgeCompressor
self._compressor = ScrubEdgeCompressor( self._compressor_type = ScrubEdgeCompressor
self._compressor_options = dict(
summarizer=step_memory, summarizer=step_memory,
prune_history_xml=prune_history_xml, prune_history_xml=prune_history_xml,
image_scrub_depth=image_scrub_depth, image_scrub_depth=image_scrub_depth,
@@ -366,6 +376,7 @@ class TranscriptLedger:
strip_markers=(PRO_UI_LIST_MARKER, PLAN_RECITATION_MARKER), strip_markers=(PRO_UI_LIST_MARKER, PLAN_RECITATION_MARKER),
tail_offset=1, tail_offset=1,
) )
self._compressor = ScrubEdgeCompressor(**self._compressor_options)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Session clock # Session clock
@@ -388,6 +399,27 @@ class TranscriptLedger:
first measured call (or when the provider reports no usage).""" first measured call (or when the provider reports no usage)."""
return self._last_prompt_tokens 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 @property
def last_turn_silent(self) -> bool: def last_turn_silent(self) -> bool:
"""True when the last committed turn had no visible reasoning text. """True when the last committed turn had no visible reasoning text.
@@ -455,7 +487,7 @@ class TranscriptLedger:
@property @property
def effective_image_scrub_depth(self) -> int: 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.""" relaxed depth below the start gate, the tight depth from there up."""
if self.below_start_gate: if self.below_start_gate:
return self._image_scrub_depth_relaxed return self._image_scrub_depth_relaxed
@@ -514,6 +546,7 @@ class TranscriptLedger:
if value <= 0: if value <= 0:
return return
self._last_prompt_tokens = value self._last_prompt_tokens = value
self._projected_prompt_tokens = None
if messages is None: if messages is None:
return return
try: try:
@@ -521,6 +554,7 @@ class TranscriptLedger:
except Exception as exc: except Exception as exc:
logger.debug(f"Prompt calibration skipped: {exc}", exc_info=True) logger.debug(f"Prompt calibration skipped: {exc}", exc_info=True)
return return
self._prompt_sample = (text_chars, image_count, value)
if text_chars <= 0: if text_chars <= 0:
return return
@@ -763,11 +797,24 @@ class TranscriptLedger:
return sum(len(self.turn_transcript(turn)) for turn in turns) return sum(len(self.turn_transcript(turn)) for turn in turns)
def turn_transcript(self, turn: dict) -> str: def turn_transcript(self, turn: dict) -> str:
"""Plain-text rendering of one committed turn exactly as it currently """Compact capsule source without rewriting the live model prefix."""
stands in the active region (i.e. after the scrub edge), see originals = self._active[turn["start"] : turn["end"]]
:func:`render_turn_transcript`. Chunk compression feeds this to the copies = [message.model_copy(deep=True) for message in originals]
segment capsule so the capsule digests precisely what it replaces.""" keys = {id(new): self._step_keys.get(id(old)) for old, new in zip(originals, copies)}
return render_turn_transcript(self._active[turn["start"] : turn["end"]]) 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: def freeze_turns(self, turn_count: int, frozen_blocks: list[BaseMessage]) -> None:
"""Compression event: consume the oldest ``turn_count`` unchunked turns """Compression event: consume the oldest ``turn_count`` unchunked turns
@@ -778,6 +825,7 @@ class TranscriptLedger:
""" """
if turn_count <= 0: if turn_count <= 0:
self._frozen_blocks = list(frozen_blocks) self._frozen_blocks = list(frozen_blocks)
self.context_phase += 1
return return
available = self._turns[self._chunked_turn_count :] available = self._turns[self._chunked_turn_count :]
if turn_count > len(available): if turn_count > len(available):
@@ -788,36 +836,38 @@ class TranscriptLedger:
self._chunked_turn_count += turn_count self._chunked_turn_count += turn_count
self._active_start = self._turns[self._chunked_turn_count - 1]["end"] self._active_start = self._turns[self._chunked_turn_count - 1]["end"]
self._frozen_blocks = list(frozen_blocks) self._frozen_blocks = list(frozen_blocks)
self.context_phase += 1
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Rendering # Rendering
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def render(self, tail: list[BaseMessage]) -> list[BaseMessage]: def compact_active(self) -> bool:
"""Advance compression and return ``S + F + A + tail``. """Scrub old observations only at an explicit context phase boundary.
Order per turn: the occupancy band is read once (from the operator's Copy messages first: a previously returned model input must remain
last measured prompt), the scrub edge advances over the active window immutable even when this ledger starts a new phase.
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.
""" """
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._compressor.compress(
self._active, copies,
image_scrub_depth=self.effective_image_scrub_depth, image_scrub_depth=self.effective_image_scrub_depth,
protected_message_ids=self._fresh_tool_messages, protected_message_ids=protected,
) )
self._fresh_tool_messages.clear() self._active = copies
if self._chunker is not None: return any(
try: old.content != new.content or old.additional_kwargs != new.additional_kwargs
self._chunker.on_render(self) for old, new in zip(active, copies, strict=True)
except Exception as e: )
logger.error(f"History chunker render hook failed: {e}")
def _messages(self, tail: list[BaseMessage]) -> list[BaseMessage]:
return [ return [
*self._static, *self._static,
*self._restored, *self._restored,
@@ -825,3 +875,35 @@ class TranscriptLedger:
*self._active[self._active_start :], *self._active[self._active_start :],
*tail, *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, "enabled": true,
// 历史压缩预算,允许范围 1024–300000;上下文参照上限固定为 300k。 // 历史压缩预算,允许范围 1024–300000;上下文参照上限固定为 300k。
"context_budget_tokens": 80000, "context_budget_tokens": 80000,
// 阶段内仅追加;35% 开始后台准备摘要,70% 成批压缩,90% 紧急折叠。
"start_ratio": 0.35, "start_ratio": 0.35,
"soft_ratio": 0.7, "soft_ratio": 0.7,
"hard_ratio": 0.9, "hard_ratio": 0.9,
+23
View File
@@ -58,6 +58,7 @@ from artemis.llm.reliability import (
retry_policy_for, retry_policy_for,
) )
from artemis.llm.messages import adapt_responses_messages 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.llm.router import ModelEndpoint, ModelFactory, ModelProvider
from artemis.services.token_meter import record_llm_usage from artemis.services.token_meter import record_llm_usage
from artemis.llm.usage import usage_from_message from artemis.llm.usage import usage_from_message
@@ -671,6 +672,27 @@ class RobustChatModelWrapper:
record_llm_usage(engine, response, source=self._endpoint_key()) record_llm_usage(engine, response, source=self._endpoint_key())
def _diagnostics(self, args, kwargs): 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 { return {
"provider": self._provider_value(), "provider": self._provider_value(),
"model": self.endpoint.model_name if self.endpoint else None, "model": self.endpoint.model_name if self.endpoint else None,
@@ -680,6 +702,7 @@ class RobustChatModelWrapper:
"is_fallback": self.is_fallback, "is_fallback": self.is_fallback,
"images": input_images(args, kwargs), "images": input_images(args, kwargs),
"prompt_cache_key": kwargs.get("prompt_cache_key"), "prompt_cache_key": kwargs.get("prompt_cache_key"),
"prefix_cache": prefix,
} }
async def _invoke_attempt(self, *args, **kwargs): async def _invoke_attempt(self, *args, **kwargs):
+1
View File
@@ -96,6 +96,7 @@
"enabled": true, "enabled": true,
// 历史压缩预算,允许范围 1024–300000;上下文参照上限固定为 300k。 // 历史压缩预算,允许范围 1024–300000;上下文参照上限固定为 300k。
"context_budget_tokens": 80000, "context_budget_tokens": 80000,
// 阶段内仅追加;35% 开始后台准备摘要,70% 成批压缩,90% 紧急折叠。
"start_ratio": 0.35, "start_ratio": 0.35,
"soft_ratio": 0.7, "soft_ratio": 0.7,
"hard_ratio": 0.9, "hard_ratio": 0.9,
+16
View File
@@ -82,3 +82,19 @@ Flash 和 Pro 默认复用只读 Checker,以任务提示词和用户指导中
通过 `mobile_config(section="capabilities")` 可读取 `recording` 参数;用户授权后可保存全局默认。单任务用 `runtime_overrides={"recording":{"codec":"h264","computer_scope":"window","max_long_edge":1280,"fps":15}}`,批次在 `task_config.recording` 提供同样对象。JSON 配置的 `agent.recording` 覆盖环境默认;不要把录像参数当作模型截图参数。录屏开关继续独立且默认关闭。 通过 `mobile_config(section="capabilities")` 可读取 `recording` 参数;用户授权后可保存全局默认。单任务用 `runtime_overrides={"recording":{"codec":"h264","computer_scope":"window","max_long_edge":1280,"fps":15}}`,批次在 `task_config.recording` 提供同样对象。JSON 配置的 `agent.recording` 覆盖环境默认;不要把录像参数当作模型截图参数。录屏开关继续独立且默认关闭。
运行信息的上下文参照上限固定为 300,000 tokens,前后端使用相同口径。历史压缩预算默认 80,000,JSON 的 `agent.memory.transcript.context_budget_tokens` 可调但不能超过 300,000;这是压缩触发预算,不是单次生成的输出 token 数,也不是整项任务累计 token 的限额。 运行信息的上下文参照上限固定为 300,000 tokens,前后端使用相同口径。历史压缩预算默认 80,000,JSON 的 `agent.memory.transcript.context_budget_tokens` 可调但不能超过 300,000;这是压缩触发预算,不是单次生成的输出 token 数,也不是整项任务累计 token 的限额。
### 多轮上下文与缓存诊断
Flash、Pro(启用 transcript)和探索共用分阶段上下文。一个阶段内只追加新观察、工具结果和用户指导,保留此前发送的截图、元素列表和消息原文。行动只能使用同一来源的最新观察,旧画面不能证明当前状态。检查器和摘要模型保持独立职责,不混入执行模型的对话。
默认压缩预算为 80,000 tokens:估算占用达到 28,000 后开始后台准备摘要,56,000 后等待本批摘要齐备再一次性替换历史、清理旧观察,开始下一阶段;72,000 时可紧急折叠为带步骤索引的历史。摘要失败时原文保留至硬阈值,原始证据仍可经历史工具读取。触发估算包含本轮新消息和截图,不只看上一轮用量;没有上游 usage 时使用本地保守估算。该预算不是任务累计限额,也不改变 300k 上下文参照上限。
任务 trace 的 `llm_diagnostic` / `llm_request` 在原有 `usage`、耗时和 `prompt_cache_key` 外增加 `prefix_cache`:
- `append_only`、`shared_prefix_messages`:相对同任务、角色和端点上次调用,输入是否仅追加、共享多少完整消息。
- `first_changed_message`:首个变化消息的位置(从 1 开始);`tools_or_format_changed` 表示工具或格式参数发生变化。
- `context_phase`、`last_compaction`:上下文阶段与上次压缩前后的估算 token 数。
- `cold_start`:该角色/端点在当前进程内没有比较基准;重启后首次请求会重新建立基准。
以上为适配后消息的本地指纹比较,不是网关最终序列化后的 token 前缀,也不是命中率。诊断仅持有哈希,不额外保存提示词、图片正文或凭据。实际缓存率按上游返回的 `cached_tokens / input_tokens` 加权统计,usage 缺失不能当作零命中。首次请求、阶段压缩、模型/工具切换可能降低缓存率;网关的路由、缓存保留时间和实现也会影响结果。该实现使用完整多轮消息,不依赖网关支持 `previous_response_id`,也不承诺固定命中率。
@@ -283,13 +283,13 @@ def test_injected_instruction_body_outlives_its_wrapper(mock_context):
assert guidance.wrapper.startswith(USER_GUIDANCE_MARKER) assert guidance.wrapper.startswith(USER_GUIDANCE_MARKER)
assert "don't send messages" not in guidance.wrapper assert "don't send messages" not in guidance.wrapper
# Scrub edge parity: once the turn has left the live position (a newer # Normal turns preserve guidance verbatim; an explicit compaction removes
# committed observation exists) the wrapper is gone and the body is # the old wrapper but preserves the user's standing instruction.
# still in the committed turn.
ledger.stage_turn([tail, AIMessage(content="ok")]) ledger.stage_turn([tail, AIMessage(content="ok")])
ledger.commit_staged(step_key="s1") ledger.commit_staged(step_key="s1")
ledger.stage_turn([runner._build_tail(ledger, 3, b"IMG", "[1] Send"), AIMessage(content="ok")]) ledger.stage_turn([runner._build_tail(ledger, 3, b"IMG", "[1] Send"), AIMessage(content="ok")])
ledger.commit_staged(step_key="s2") ledger.commit_staged(step_key="s2")
ledger.compact_active()
rendered = ledger.render([runner._build_tail(ledger, 4, b"IMG", "[1] Send")]) rendered = ledger.render([runner._build_tail(ledger, 4, b"IMG", "[1] Send")])
committed = next( committed = next(
m m
@@ -679,8 +679,8 @@ async def test_run_calibrates_the_ledger_only_from_measured_usage(mock_context):
assert record.call_count == 2 assert record.call_count == 2
first_call, second_call = record.call_args_list first_call, second_call = record.call_args_list
assert first_call.args[0] == 1200 and first_call.kwargs["messages"] is not None assert first_call.args[0] == 1200 and first_call.kwargs["messages"] is not None
# The report turn carries no usage metadata: estimated, recorded, not calibrated. # Missing usage stays unknown; the next render computes its own estimate.
assert second_call.args[0] >= 1 and second_call.kwargs["messages"] is None assert second_call.args[0] is None and second_call.kwargs["messages"] is None
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -30,7 +30,7 @@ from artemis.agents.operator.operator import OperatorNode
from artemis.config.agent import MemoryTranscriptConfig from artemis.config.agent import MemoryTranscriptConfig
from artemis.context import ArtemisContext from artemis.context import ArtemisContext
from artemis.memory.step_memory import StepMemoryService from artemis.memory.step_memory import StepMemoryService
from artemis.memory.transcript import TranscriptLedger, estimate_prompt_tokens from artemis.memory.transcript import TranscriptLedger
SCREENSHOT_B64 = base64.b64encode(b"fake-jpeg-bytes").decode("utf-8") SCREENSHOT_B64 = base64.b64encode(b"fake-jpeg-bytes").decode("utf-8")
@@ -92,7 +92,7 @@ def _llm(captured: list, usage_metadata=None):
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_operator_records_an_estimate_when_the_provider_reports_no_usage(): async def test_operator_uses_projected_context_without_fabricating_provider_usage():
ctx = _ctx() ctx = _ctx()
captured: list = [] captured: list = []
with patch("artemis.agents.operator.operator.get_llm", return_value=_llm(captured)): with patch("artemis.agents.operator.operator.get_llm", return_value=_llm(captured)):
@@ -102,8 +102,8 @@ async def test_operator_records_an_estimate_when_the_provider_reports_no_usage()
ledger = ctx.transcript_ledger ledger = ctx.transcript_ledger
assert isinstance(ledger, TranscriptLedger) assert isinstance(ledger, TranscriptLedger)
assert captured, "the operator made no model call" assert captured, "the operator made no model call"
assert ledger.last_prompt_tokens == estimate_prompt_tokens(captured[-1]) assert ledger.last_prompt_tokens is None
assert ledger.last_prompt_tokens > 0 assert ledger.projected_prompt_tokens > 0
# No measurement, no calibration. # No measurement, no calibration.
assert ledger.chars_per_token == 4.0 assert ledger.chars_per_token == 4.0
@@ -355,7 +355,7 @@ async def test_transcript_mode_two_turns_build_four_regions(tmp_path):
# Text edge at depth 1: the committed observation has lost its plan # Text edge at depth 1: the committed observation has lost its plan
# recitation as soon as the next tail exists (its screenshot stays # recitation as soon as the next tail exists (its screenshot stays
# until the screenshot edge). # until the screenshot edge).
assert PLAN_RECITATION_MARKER not in str(committed_obs.content) assert PLAN_RECITATION_MARKER in str(committed_obs.content) # retained until compaction
# The live tail still recites the plan. # The live tail still recites the plan.
assert PLAN_RECITATION_MARKER in str(turn2_messages[-1].content) assert PLAN_RECITATION_MARKER in str(turn2_messages[-1].content)
# Message layout is S + A + tail. # Message layout is S + A + tail.
+118 -42
View File
@@ -192,7 +192,17 @@ def _make(
capsule_service=capsule, capsule_service=capsule,
# No meter: the base comes from the ledger's operator-recorded prompt # No meter: the base comes from the ledger's operator-recorded prompt
# size (None until recorded), exactly like production wiring. # size (None until recorded), exactly like production wiring.
meter_getter=meter, meter_getter=meter
or (
lambda: (
75_000
if chunker.awaiting_chunks
and all(
capsule.get_summary(c.capsule_key) is not None for c in chunker.awaiting_chunks
)
else 40_000
)
),
goal="TEST GOAL", goal="TEST GOAL",
) )
ledger.attach_chunker(chunker) ledger.attach_chunker(chunker)
@@ -443,7 +453,8 @@ def test_segment_size_threshold_triggers_without_milestone():
assert len(chunker.chunks) >= 1 assert len(chunker.chunks) >= 1
assert chunker.chunks[0].start_step_number == 1 assert chunker.chunks[0].start_step_number == 1
total = sum(c.end_step_number - c.start_step_number + 1 for c in chunker.chunks) total = sum(c.end_step_number - c.start_step_number + 1 for c in chunker.chunks)
assert total >= 4 assert total >= 3
assert chunker.awaiting_chunks # the next prepared batch has not swapped yet
assert len(ledger.unchunked_turns()) >= 2 # floor respected assert len(ledger.unchunked_turns()) >= 2 # floor respected
@@ -458,7 +469,7 @@ def test_source_token_threshold_triggers_chunk():
def test_soft_threshold_compresses_oldest_open_segment(): def test_soft_threshold_compresses_oldest_open_segment():
steps = [_step(i) for i in range(1, 9)] steps = [_step(i) for i in range(1, 9)]
meter = {"value": None} meter = {"value": 40_000}
ledger, chunker, _, _ = _make( ledger, chunker, _, _ = _make(
steps, min_active=2, max_steps=100, meter=lambda: meter["value"], auto_capsule=True steps, min_active=2, max_steps=100, meter=lambda: meter["value"], auto_capsule=True
) )
@@ -843,7 +854,7 @@ def test_hard_threshold_folds_frozen_region_into_recall_only_era_with_step_index
merged) plus the per-step minimal index of every folded chunk — step, merged) plus the per-step minimal index of every folded chunk — step,
session offset and action phrase, results only via search_history.""" session offset and action phrase, results only via search_history."""
steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 13)] steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 13)]
meter = {"value": None} meter = {"value": 40_000}
ledger, chunker, _, capsule = _make(steps, min_active=2, meter=lambda: meter["value"]) ledger, chunker, _, capsule = _make(steps, min_active=2, meter=lambda: meter["value"])
_run_turns(ledger, chunker, 1, 8, _hashes(4)) _run_turns(ledger, chunker, 1, 8, _hashes(4))
capsule.resolve("chunk:1-4", _capsule(1, 4)) capsule.resolve("chunk:1-4", _capsule(1, 4))
@@ -1083,7 +1094,7 @@ def test_failed_capsule_redispatches_and_retains_original():
"""Failure rung of the ladder: an exhausted capsule job is re-dispatched at """Failure rung of the ladder: an exhausted capsule job is re-dispatched at
the next trigger/pressure render while the original turns stay live.""" the next trigger/pressure render while the original turns stay live."""
steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)]
meter = {"value": None} meter = {"value": 40_000}
ledger, chunker, _, capsule = _make(steps, min_active=2, meter=lambda: meter["value"]) ledger, chunker, _, capsule = _make(steps, min_active=2, meter=lambda: meter["value"])
_run_turns(ledger, chunker, 1, 8, _hashes(4)) _run_turns(ledger, chunker, 1, 8, _hashes(4))
key = chunker.awaiting_chunks[0].capsule_key key = chunker.awaiting_chunks[0].capsule_key
@@ -1106,7 +1117,7 @@ def test_hard_threshold_force_swaps_pending_chunks_into_recall_only_era():
paragraph degrades to the milestone/step-range list, and ③'s minimal paragraph degrades to the milestone/step-range list, and ③'s minimal
index survives for every folded step.""" index survives for every folded step."""
steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)]
meter = {"value": None} meter = {"value": 40_000}
ledger, chunker, _, _ = _make(steps, min_active=2, meter=lambda: meter["value"]) ledger, chunker, _, _ = _make(steps, min_active=2, meter=lambda: meter["value"])
_run_turns(ledger, chunker, 1, 8, _hashes(4)) _run_turns(ledger, chunker, 1, 8, _hashes(4))
assert chunker.awaiting_chunks[0].status == "pending" assert chunker.awaiting_chunks[0].status == "pending"
@@ -1150,7 +1161,9 @@ def test_pressure_close_waits_for_min_steps_instead_of_one_turn_chunks():
_run_turns(ledger, chunker, 7, 8) # eligible beyond the claim: 1, then 2 _run_turns(ledger, chunker, 7, 8) # eligible beyond the claim: 1, then 2
assert [c.capsule_key for c in chunker.awaiting_chunks] == ["chunk:1-3"] assert [c.capsule_key for c in chunker.awaiting_chunks] == ["chunk:1-3"]
_run_turns(ledger, chunker, 9, 9) _run_turns(ledger, chunker, 9, 9)
assert [c.capsule_key for c in chunker.awaiting_chunks] == ["chunk:1-3", "chunk:4-6"] assert [c.capsule_key for c in chunker.awaiting_chunks] == [
"chunk:1-3"
] # finish this batch first
def test_size_trigger_waits_for_min_steps_even_when_one_turn_exceeds_the_tokens(): def test_size_trigger_waits_for_min_steps_even_when_one_turn_exceeds_the_tokens():
@@ -1193,7 +1206,7 @@ def test_start_gate_holds_ready_chunk_below_start_ratio_then_swaps():
replaced only once the operator's measured context reaches replaced only once the operator's measured context reaches
budget*start_ratio; until then the ready chunk waits in the queue.""" budget*start_ratio; until then the ready chunk waits in the queue."""
steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 11)] steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 11)]
meter = {"value": 20_000} # < 0.35 * 100k meter = {"value": 40_000} # < 0.35 * 100k
ledger, chunker, engine, _ = _make( ledger, chunker, engine, _ = _make(
steps, min_active=2, meter=lambda: meter["value"], auto_capsule=True steps, min_active=2, meter=lambda: meter["value"], auto_capsule=True
) )
@@ -1212,13 +1225,13 @@ def test_start_gate_holds_ready_chunk_below_start_ratio_then_swaps():
args = held[0]["payload"]["args"] args = held[0]["payload"]["args"]
assert args["note"] == "held" assert args["note"] == "held"
assert (args["context_tokens"], args["context_budget"], args["swap_at_tokens"]) == ( assert (args["context_tokens"], args["context_budget"], args["swap_at_tokens"]) == (
20_000, 40_000,
100_000, 100_000,
35_000, 70_000,
) )
# Same render cadence, context now past the gate → the ready prefix swaps. # Same render cadence, context now past the gate → the ready prefix swaps.
meter["value"] = 40_000 meter["value"] = 75_000
_run_turns(ledger, chunker, 9, 9, _hashes(4)) _run_turns(ledger, chunker, 9, 9, _hashes(4))
assert len(chunker.chunks) == 1 assert len(chunker.chunks) == 1
assert chunker.chunks[0].start_step_number == 1 assert chunker.chunks[0].start_step_number == 1
@@ -1226,9 +1239,7 @@ def test_start_gate_holds_ready_chunk_below_start_ratio_then_swaps():
assert ledger.frozen_blocks != () assert ledger.frozen_blocks != ()
done = engine.traces[-1] done = engine.traces[-1]
assert done["status"] == "success" assert done["status"] == "success"
assert done["payload"]["args"]["context_tokens"] == 40_000 - ( assert done["payload"]["args"]["context_tokens"] < 75_000
chunker.chunks[0].source_chars // 4
) + (chunker.chunks[0].summary_chars // 4)
def test_leading_stampless_turn_joins_the_next_segment_instead_of_pinning_the_queue(): def test_leading_stampless_turn_joins_the_next_segment_instead_of_pinning_the_queue():
@@ -1238,13 +1249,14 @@ def test_leading_stampless_turn_joins_the_next_segment_instead_of_pinning_the_qu
the head of the awaiting queue, so no L2 swap ever happened and the hard the head of the awaiting queue, so no L2 swap ever happened and the hard
line jumped straight to the fold. It joins the following segment.""" line jumped straight to the fold. It joins the following segment."""
steps = [_step(i) for i in range(1, 13)] steps = [_step(i) for i in range(1, 13)]
meter = {"value": 50_000} # above the start gate meter = {"value": 40_000} # above the start gate
ledger, chunker, _, capsule = _make(steps, min_active=2, meter=lambda: meter["value"]) ledger, chunker, _, capsule = _make(steps, min_active=2, meter=lambda: meter["value"])
ledger.stage_turn([_observation(0), AIMessage(content="no tool call this turn")]) ledger.stage_turn([_observation(0), AIMessage(content="no tool call this turn")])
_run_turns(ledger, chunker, 1, 7, _hashes(4)) # commits turn 0 without a step key _run_turns(ledger, chunker, 1, 7, _hashes(4)) # commits turn 0 without a step key
assert [c.capsule_key for c in chunker.awaiting_chunks] == ["chunk:1-4"] assert [c.capsule_key for c in chunker.awaiting_chunks] == ["chunk:1-4"]
assert all(entry["chunk"] is not None for entry in chunker._awaiting) assert all(entry["chunk"] is not None for entry in chunker._awaiting)
meter["value"] = 75_000
capsule.resolve("chunk:1-4", _capsule(1, 4)) capsule.resolve("chunk:1-4", _capsule(1, 4))
_run_turns(ledger, chunker, 8, 8, _hashes(4)) _run_turns(ledger, chunker, 8, 8, _hashes(4))
@@ -1278,24 +1290,79 @@ def test_partition_folds_leading_unstamped_turns_into_the_first_segment():
] ]
def test_start_gate_zero_restores_swap_on_ready(): def test_start_gate_zero_only_prepares_and_does_not_rewrite_prefix():
steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)]
ledger, chunker, _, _ = _make( ledger, chunker, _, _ = _make(
steps, min_active=2, meter=lambda: 1_000, auto_capsule=True, start_ratio=0.0 steps, min_active=2, meter=lambda: 1_000, auto_capsule=True, start_ratio=0.0
) )
_run_turns(ledger, chunker, 1, 8, _hashes(4)) _run_turns(ledger, chunker, 1, 8, _hashes(4))
assert len(chunker.chunks) == 1 assert chunker.chunks == ()
assert chunker.awaiting_chunks == () assert chunker.awaiting_chunks[0].status == "ready"
def test_start_gate_unknown_base_falls_through_to_swap_on_ready(): def test_unknown_usage_uses_current_prompt_estimate_and_preserves_small_prefix():
"""No measured operator call (meter None, nothing recorded on the ledger):
the gate cannot decide and the legacy swap-on-ready behavior stands."""
steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)]
ledger, chunker, _, _ = _make(steps, min_active=2, auto_capsule=True) ledger, chunker, _, _ = _make(steps, min_active=2, auto_capsule=True)
assert ledger.last_prompt_tokens is None chunker._meter_getter = None
_run_turns(ledger, chunker, 1, 8, _hashes(4)) _run_turns(ledger, chunker, 1, 8, _hashes(4))
assert len(chunker.chunks) == 1 assert ledger.last_prompt_tokens is None
assert ledger.projected_prompt_tokens > 0
assert chunker.chunks == ()
assert ledger.context_phase == 0
def test_phase_batches_async_summaries_then_preserves_the_new_prefix():
steps = [_step(i) for i in range(1, 20)]
meter = {"value": 10_000}
ledger, chunker, _, capsule = _make(steps, meter=lambda: meter["value"])
ledger.set_static_prefix([HumanMessage(content="Stable rules")])
_run_turns(ledger, chunker, 1, 10, lambda i: "a" if i <= 4 else "b" if i <= 7 else "c")
assert capsule.submitted == [] # no background LLM work below preparation gate
tail = [_observation(11)]
original = ledger.render(tail)
fingerprint = [message.model_dump() for message in original]
meter["value"] = 40_000
ledger.render(tail)
assert len(chunker.awaiting_chunks) == 2
first, second = chunker.awaiting_chunks
capsule.resolve(first.capsule_key, _capsule(first.start_step_number, first.end_step_number))
meter["value"] = 75_000
assert [m.model_dump() for m in ledger.render(tail)] == fingerprint
assert ledger.context_phase == 0 # partial completion must not break the prefix
capsule.resolve(second.capsule_key, _capsule(second.start_step_number, second.end_step_number))
compacted = ledger.render(tail)
assert ledger.context_phase == 1
assert ledger.last_compaction["after_tokens"] < ledger.last_compaction["before_tokens"]
assert [m.model_dump() for m in original] == fingerprint # prior input objects never mutated
assert len(chunker.chunks) == 2 and not chunker.awaiting_chunks
meter["value"] = 10_000 # provider confirms lower occupancy after compaction
# Commit the previous turn, then append a newly observed turn.
ledger.commit_staged(step_key="s10")
next_input = ledger.render(tail)
snapshot = [m.model_dump() for m in next_input]
turn = _turn(11)
turn[0] = tail[0]
ledger.stage_turn(turn)
ledger.commit_staged(step_key="s11")
following = ledger.render([_observation(12)])
assert [m.model_dump() for m in following[: len(snapshot)]] == snapshot
assert ledger.context_phase == 1
assert compacted[0].content == "Stable rules"
def test_new_large_tail_triggers_pressure_without_waiting_for_next_usage():
steps = [_step(i) for i in range(1, 12)]
ledger, chunker, _, _ = _make(steps, auto_capsule=True)
chunker._meter_getter = None
previous = _run_turns(ledger, chunker, 1, 9, _hashes(4))
ledger.record_prompt_tokens(10_000, previous)
assert ledger.context_phase == 0
# New evidence already exceeds the hard line, despite a small last call.
ledger.render([HumanMessage(content="新证据" * 40_000)])
assert ledger.projected_prompt_tokens > 90_000
assert ledger.context_phase == 1
assert ledger.frozen_blocks
def _big_turn(i: int, chars: int = 4000) -> list: def _big_turn(i: int, chars: int = 4000) -> list:
@@ -1323,7 +1390,7 @@ def test_hard_threshold_swaps_ready_prefix_as_l2_when_that_is_enough():
estimated context back under the hard line, the swap is an ordinary L2 estimated context back under the hard line, the swap is an ordinary L2
event — no fold, and the younger pending segment stays awaiting.""" event — no fold, and the younger pending segment stays awaiting."""
steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 13)] steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 13)]
meter = {"value": None} meter = {"value": 40_000}
ledger, chunker, engine, capsule = _make(steps, min_active=2, meter=lambda: meter["value"]) ledger, chunker, engine, capsule = _make(steps, min_active=2, meter=lambda: meter["value"])
# Steps 1–4 (hash-a) close at the switch; steps 5–8 (hash-b) close at the # Steps 1–4 (hash-a) close at the switch; steps 5–8 (hash-b) close at the
# next switch. Turns are ~1k tokens each so the L2 swap is worth ~4k. # next switch. Turns are ~1k tokens each so the L2 swap is worth ~4k.
@@ -1352,7 +1419,7 @@ def test_hard_threshold_escalates_to_fold_when_l2_swap_is_not_enough():
and the whole frozen region folds into one recall-only era whose index and the whole frozen region folds into one recall-only era whose index
lists every folded step — the pending chunk's steps too.""" lists every folded step — the pending chunk's steps too."""
steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 13)] steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 13)]
meter = {"value": None} meter = {"value": 40_000}
ledger, chunker, engine, capsule = _make(steps, min_active=2, meter=lambda: meter["value"]) ledger, chunker, engine, capsule = _make(steps, min_active=2, meter=lambda: meter["value"])
_run_turns(ledger, chunker, 1, 10, lambda i: "a" if i <= 4 else ("b" if i <= 8 else "c")) _run_turns(ledger, chunker, 1, 10, lambda i: "a" if i <= 4 else ("b" if i <= 8 else "c"))
capsule.resolve("chunk:1-4", _capsule(1, 4)) capsule.resolve("chunk:1-4", _capsule(1, 4))
@@ -1394,9 +1461,10 @@ def test_hard_fold_is_monotonic_later_l2_swaps_append_after_the_folded_era():
flips back to full chunk blocks.""" flips back to full chunk blocks."""
steps = [_step(i) for i in range(1, 17)] steps = [_step(i) for i in range(1, 17)]
hash_for = lambda i: "a" if i <= 4 else ("b" if i <= 8 else ("c" if i <= 12 else "d")) # noqa: E731 hash_for = lambda i: "a" if i <= 4 else ("b" if i <= 8 else ("c" if i <= 12 else "d")) # noqa: E731
meter = {"value": None} meter = {"value": 40_000}
ledger, chunker, _, capsule = _make(steps, min_active=2, meter=lambda: meter["value"]) ledger, chunker, _, capsule = _make(steps, min_active=2, meter=lambda: meter["value"])
_run_turns(ledger, chunker, 1, 10, hash_for) _run_turns(ledger, chunker, 1, 10, hash_for)
meter["value"] = 75_000
capsule.resolve("chunk:1-4", _capsule(1, 4)) capsule.resolve("chunk:1-4", _capsule(1, 4))
meter["value"] = 99_000 meter["value"] = 99_000
_run_turns(ledger, chunker, 11, 11, hash_for) # hard: folds 1–4 and 5–8 _run_turns(ledger, chunker, 11, 11, hash_for) # hard: folds 1–4 and 5–8
@@ -1405,10 +1473,11 @@ def test_hard_fold_is_monotonic_later_l2_swaps_append_after_the_folded_era():
# Pressure gone (above the start gate, below soft): the next milestone # Pressure gone (above the start gate, below soft): the next milestone
# switch closes 9–12; once ready it swaps as an ordinary L2 chunk. # switch closes 9–12; once ready it swaps as an ordinary L2 chunk.
meter["value"] = 50_000 meter["value"] = 40_000
_run_turns(ledger, chunker, 12, 15, hash_for) _run_turns(ledger, chunker, 12, 15, hash_for)
pending = [c for c in chunker.awaiting_chunks if c.status == "pending"] pending = [c for c in chunker.awaiting_chunks if c.status == "pending"]
assert [c.capsule_key for c in pending] == ["chunk:9-12"] assert [c.capsule_key for c in pending] == ["chunk:9-12"]
meter["value"] = 75_000
capsule.resolve("chunk:9-12", _capsule(9, 12)) capsule.resolve("chunk:9-12", _capsule(9, 12))
_run_turns(ledger, chunker, 16, 16, hash_for) _run_turns(ledger, chunker, 16, 16, hash_for)
@@ -1430,11 +1499,13 @@ def test_hard_threshold_with_nothing_awaiting_folds_frozen_region_once():
into a recall-only era without consuming turns, and repeated hard renders into a recall-only era without consuming turns, and repeated hard renders
leave the frozen blocks byte-identical.""" leave the frozen blocks byte-identical."""
steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 13)] steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 13)]
meter = {"value": 50_000} meter = {"value": 40_000}
ledger, chunker, _, _ = _make( ledger, chunker, _, _ = _make(
steps, min_active=2, meter=lambda: meter["value"], auto_capsule=True steps, min_active=2, meter=lambda: meter["value"], auto_capsule=True
) )
_run_turns(ledger, chunker, 1, 7, _hashes(4)) _run_turns(ledger, chunker, 1, 6, _hashes(4))
meter["value"] = 75_000
_run_turns(ledger, chunker, 7, 7, _hashes(4))
# Chunk 1–4 swapped as L2 at the step-7 render; s5..s6 remain = the floor. # Chunk 1–4 swapped as L2 at the step-7 render; s5..s6 remain = the floor.
assert [c.capsule_key for c in chunker.chunks] == ["chunk:1-4"] assert [c.capsule_key for c in chunker.chunks] == ["chunk:1-4"]
assert chunker.awaiting_chunks == () assert chunker.awaiting_chunks == ()
@@ -1479,14 +1550,15 @@ def test_context_base_reads_the_ledger_operator_measurement_not_the_session_mete
# Some other agent's small call lands on the session meter. # Some other agent's small call lands on the session meter.
get_meter("session-1").record({"prompt_tokens": 2_700, "completion_tokens": 10}) get_meter("session-1").record({"prompt_tokens": 2_700, "completion_tokens": 10})
# The operator's own measured prompt is well below the gate. # The operator's own measured prompt is well below the gate.
ledger.record_prompt_tokens(20_000) chunker._meter_getter = None
assert chunker._context_base_tokens(ledger) == 20_000 ledger.record_prompt_tokens(40_000)
assert chunker._context_base_tokens(ledger) == 40_000
_run_turns(ledger, chunker, 1, 8, _hashes(4)) _run_turns(ledger, chunker, 1, 8, _hashes(4))
assert chunker.chunks == () # held: 20k < 35k assert chunker.chunks == () # held: 20k < 35k
assert [c.status for c in chunker.awaiting_chunks] == ["ready"] assert [c.status for c in chunker.awaiting_chunks] == ["ready"]
# Operator prompt grows past the gate → swap at the next render. # Operator prompt grows past the gate → swap at the next render.
ledger.record_prompt_tokens(36_000) ledger.record_prompt_tokens(75_000)
_run_turns(ledger, chunker, 9, 9, _hashes(4)) _run_turns(ledger, chunker, 9, 9, _hashes(4))
assert len(chunker.chunks) == 1 assert len(chunker.chunks) == 1
@@ -1507,7 +1579,7 @@ def test_held_note_is_fresh_after_a_retry():
then readied while the start gate still holds it gets a fresh ``held`` then readied while the start gate still holds it gets a fresh ``held``
note: the re-dispatch resets the once-per-chunk held marker.""" note: the re-dispatch resets the once-per-chunk held marker."""
steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 14)] steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 14)]
meter = {"value": 20_000} # < 0.35 * 100k: the gate holds ready chunks meter = {"value": 40_000} # < 0.35 * 100k: the gate holds ready chunks
ledger, chunker, engine, capsule = _make(steps, min_active=2, meter=lambda: meter["value"]) ledger, chunker, engine, capsule = _make(steps, min_active=2, meter=lambda: meter["value"])
_run_turns(ledger, chunker, 1, 8, _hashes(4)) _run_turns(ledger, chunker, 1, 8, _hashes(4))
entry = chunker._awaiting[0] entry = chunker._awaiting[0]
@@ -1797,7 +1869,7 @@ def test_calibrated_ratio_scales_the_size_trigger():
# Default ratio: the eligible text (two ~4k-char turns once turn 5 is # Default ratio: the eligible text (two ~4k-char turns once turn 5 is
# staged) is worth ~chars/4 tokens, under the 5k target → nothing closes. # staged) is worth ~chars/4 tokens, under the 5k target → nothing closes.
ledger, chunker, _, _ = _make( ledger, chunker, _, _ = _make(
steps, min_active=2, min_steps=2, max_steps=100, target_tokens=5000 steps, min_active=2, min_steps=2, max_steps=100, target_tokens=5000, meter=lambda: 40_000
) )
ledger.record_prompt_tokens(1000, messages=[HumanMessage(content="a" * 4000)]) ledger.record_prompt_tokens(1000, messages=[HumanMessage(content="a" * 4000)])
_run_big_turns(ledger, chunker, 1, 5, lambda i: "hash-a") _run_big_turns(ledger, chunker, 1, 5, lambda i: "hash-a")
@@ -1807,7 +1879,7 @@ def test_calibrated_ratio_scales_the_size_trigger():
# Calibrated to 1 char/token: the same text is worth ~chars tokens → closes. # Calibrated to 1 char/token: the same text is worth ~chars tokens → closes.
ledger, chunker, _, _ = _make( ledger, chunker, _, _ = _make(
steps, min_active=2, min_steps=2, max_steps=100, target_tokens=5000 steps, min_active=2, min_steps=2, max_steps=100, target_tokens=5000, meter=lambda: 40_000
) )
ledger.record_prompt_tokens(4000, messages=[HumanMessage(content="中" * 4000)]) ledger.record_prompt_tokens(4000, messages=[HumanMessage(content="中" * 4000)])
assert ledger.chars_per_token == pytest.approx(1.0) assert ledger.chars_per_token == pytest.approx(1.0)
@@ -1840,6 +1912,7 @@ def test_compression_announces_one_tool_line_from_dispatch_to_swap():
assert args["source_tokens"] > 0 assert args["source_tokens"] > 0
assert "context_tokens" not in args assert "context_tokens" not in args
meter["value"] = 75_000
capsule.resolve("chunk:1-4", _capsule(1, 4)) capsule.resolve("chunk:1-4", _capsule(1, 4))
ledger.commit_staged(step_key="s8", validator_result={"status": "dispatched"}) ledger.commit_staged(step_key="s8", validator_result={"status": "dispatched"})
ledger.render([_observation(9)]) ledger.render([_observation(9)])
@@ -1853,16 +1926,14 @@ def test_compression_announces_one_tool_line_from_dispatch_to_swap():
assert done_args["summary_tokens"] > 0 assert done_args["summary_tokens"] > 0
assert done_args["context_budget"] == 100_000 assert done_args["context_budget"] == 100_000
assert done_args["context_estimated"] is True assert done_args["context_estimated"] is True
assert done_args["context_tokens"] == ( assert 0 < done_args["context_tokens"] < 75_000
60_000 - done_args["source_tokens"] + done_args["summary_tokens"]
)
assert "forced" not in done_args assert "forced" not in done_args
def test_forced_swap_announces_fold_without_a_ratio(): def test_forced_swap_announces_fold_without_a_ratio():
"""Forced folds report estimated context usage without a summary size.""" """Forced folds report estimated context usage without a summary size."""
steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)] steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 9)]
meter = {"value": None} meter = {"value": 40_000}
ledger, chunker, engine, _ = _make(steps, min_active=2, meter=lambda: meter["value"]) ledger, chunker, engine, _ = _make(steps, min_active=2, meter=lambda: meter["value"])
_run_turns(ledger, chunker, 1, 8, _hashes(4)) _run_turns(ledger, chunker, 1, 8, _hashes(4))
assert any(t["status"] == "running" for t in engine.traces) assert any(t["status"] == "running" for t in engine.traces)
@@ -1896,7 +1967,7 @@ def test_compression_trace_carries_a_plain_language_phase_on_every_path():
assert COMPRESSION_PHASES == ("summarizing", "ready", "applied", "failed") assert COMPRESSION_PHASES == ("summarizing", "ready", "applied", "failed")
steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 15)] steps = [_step(i, "hash-a" if i <= 4 else "hash-b") for i in range(1, 15)]
meter = {"value": 20_000} # < 0.35 * 100k: the start gate holds ready chunks meter = {"value": 40_000} # < 0.35 * 100k: the start gate holds ready chunks
ledger, chunker, engine, capsule = _make(steps, min_active=2, meter=lambda: meter["value"]) ledger, chunker, engine, capsule = _make(steps, min_active=2, meter=lambda: meter["value"])
_run_turns(ledger, chunker, 1, 8, _hashes(4)) _run_turns(ledger, chunker, 1, 8, _hashes(4))
chunk = chunker.awaiting_chunks[0] chunk = chunker.awaiting_chunks[0]
@@ -1933,9 +2004,14 @@ def test_compression_trace_carries_a_plain_language_phase_on_every_path():
assert held["payload"]["args"]["note"] == "held" assert held["payload"]["args"]["note"] == "held"
# Context past the gate → the swap applies the summary. # Context past the gate → the swap applies the summary.
meter["value"] = 40_000 for pending in chunker.awaiting_chunks:
capsule.resolve(
pending.capsule_key, _capsule(pending.start_step_number, pending.end_step_number)
)
meter["value"] = 75_000
_run_turns(ledger, chunker, 13, 13, lambda i: "hash-c") _run_turns(ledger, chunker, 13, 13, lambda i: "hash-c")
assert [c.start_step_number for c in chunker.chunks] == [1] assert chunker.chunks[0].start_step_number == 1
assert not chunker.awaiting_chunks
assert mine()[-1] == ("success", "applied") assert mine()[-1] == ("success", "applied")
# Every announcement on every trace carries a known phase. # Every announcement on every trace carries a known phase.
+39 -42
View File
@@ -71,9 +71,19 @@ def _turn(i: int, tool_call_count: int = 1) -> list:
return messages return messages
def _play_turn(ledger: TranscriptLedger, i: int, *, prev_key=None, prev_result=None, **turn_kwargs): def _play_turn(
ledger: TranscriptLedger,
i: int,
*,
prev_key=None,
prev_result=None,
compact=False,
**turn_kwargs,
):
"""Mimic the operator's real ordering: commit previous, render, stage.""" """Mimic the operator's real ordering: commit previous, render, stage."""
ledger.commit_staged(step_key=prev_key, validator_result=prev_result) ledger.commit_staged(step_key=prev_key, validator_result=prev_result)
if compact:
ledger.compact_active()
turn = _turn(i, **turn_kwargs) turn = _turn(i, **turn_kwargs)
rendered = ledger.render([turn[0]]) rendered = ledger.render([turn[0]])
ledger.stage_turn(turn) ledger.stage_turn(turn)
@@ -159,8 +169,8 @@ def test_recitation_and_ui_list_stripped_at_the_text_edge_and_screenshot_at_k():
until depth K and is then resolved in place; only the live tail ever until depth K and is then resolved in place; only the live tail ever
carries an indexed element list.""" carries an indexed element list."""
ledger = TranscriptLedger(step_memory=_service(), image_scrub_depth=3) ledger = TranscriptLedger(step_memory=_service(), image_scrub_depth=3)
_play_turn(ledger, 1) _play_turn(ledger, 1, compact=True)
rendered = _play_turn(ledger, 2, prev_key="step-1") rendered = _play_turn(ledger, 2, compact=True, prev_key="step-1")
obs1 = ledger.active_messages[0] obs1 = ledger.active_messages[0]
# Turn 1 is now depth 2: text edge passed, screenshot kept. # Turn 1 is now depth 2: text edge passed, screenshot kept.
assert PLAN_RECITATION_MARKER not in str(obs1.content) assert PLAN_RECITATION_MARKER not in str(obs1.content)
@@ -168,10 +178,10 @@ def test_recitation_and_ui_list_stripped_at_the_text_edge_and_screenshot_at_k():
assert _has_image(obs1) assert _has_image(obs1)
original_obs1 = _fingerprint(obs1) original_obs1 = _fingerprint(obs1)
rendered = _play_turn(ledger, 3, prev_key="step-2") rendered = _play_turn(ledger, 3, compact=True, prev_key="step-2")
# Turn 1 reached depth 3: the screenshot is resolved. # Turn 1 reached depth 3: the screenshot is resolved.
assert not _has_image(obs1) assert not _has_image(ledger.active_messages[0])
assert _fingerprint(obs1) != original_obs1 assert _fingerprint(ledger.active_messages[0]) != original_obs1
# Turn 2 (depth 2): text edge passed, screenshot still there; the live tail keeps both. # Turn 2 (depth 2): text edge passed, screenshot still there; the live tail keeps both.
obs2 = next(m for m in ledger.active_messages if "OBSERVATION [T+00:02]" in str(m.content)) obs2 = next(m for m in ledger.active_messages if "OBSERVATION [T+00:02]" in str(m.content))
assert PLAN_RECITATION_MARKER not in str(obs2.content) assert PLAN_RECITATION_MARKER not in str(obs2.content)
@@ -189,7 +199,7 @@ def test_depth_k_image_resolved_to_ready_visual_summary():
ledger = TranscriptLedger(step_memory=service, image_scrub_depth=3) ledger = TranscriptLedger(step_memory=service, image_scrub_depth=3)
for i in range(1, 5): for i in range(1, 5):
_play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None) _play_turn(ledger, i, compact=True, prev_key=f"step-{i - 1}" if i > 1 else None)
first_obs = ledger.active_messages[0] first_obs = ledger.active_messages[0]
assert not _has_image(first_obs) assert not _has_image(first_obs)
@@ -212,7 +222,7 @@ def test_pending_grace_then_placeholder_never_backfilled():
ledger = TranscriptLedger(step_memory=service, image_scrub_depth=2, pending_grace_steps=1) ledger = TranscriptLedger(step_memory=service, image_scrub_depth=2, pending_grace_steps=1)
# Enough turns to push turn 1 past K + grace. # Enough turns to push turn 1 past K + grace.
for i in range(1, 7): for i in range(1, 7):
_play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None) _play_turn(ledger, i, compact=True, prev_key=f"step-{i - 1}" if i > 1 else None)
first_obs = ledger.active_messages[0] first_obs = ledger.active_messages[0]
assert not _has_image(first_obs) assert not _has_image(first_obs)
@@ -221,7 +231,7 @@ def test_pending_grace_then_placeholder_never_backfilled():
# A late summary must never mutate the frozen message. # A late summary must never mutate the frozen message.
service._summaries["step-1"] = "Too late." service._summaries["step-1"] = "Too late."
_play_turn(ledger, 7, prev_key="step-6") _play_turn(ledger, 7, compact=True, prev_key="step-6")
assert _fingerprint(ledger.active_messages[0]) == frozen assert _fingerprint(ledger.active_messages[0]) == frozen
@@ -232,7 +242,7 @@ def test_failed_summary_becomes_unavailable_placeholder():
ledger = TranscriptLedger(step_memory=service, image_scrub_depth=2, pending_grace_steps=5) ledger = TranscriptLedger(step_memory=service, image_scrub_depth=2, pending_grace_steps=5)
for i in range(1, 4): for i in range(1, 4):
_play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None) _play_turn(ledger, i, compact=True, prev_key=f"step-{i - 1}" if i > 1 else None)
first_obs = ledger.active_messages[0] first_obs = ledger.active_messages[0]
assert not _has_image(first_obs) assert not _has_image(first_obs)
@@ -473,7 +483,7 @@ def test_relaxed_depth_below_start_gate_keeps_more_screenshots():
assert ledger.occupancy is None assert ledger.occupancy is None
assert ledger.effective_image_scrub_depth == 6 assert ledger.effective_image_scrub_depth == 6
for i in range(1, 10): for i in range(1, 10):
_play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None) _play_turn(ledger, i, compact=True, prev_key=f"step-{i - 1}" if i > 1 else None)
# At the render of turn 9 its observation is the live tail (depth 1); # At the render of turn 9 its observation is the live tail (depth 1);
# committed turns 8..5 are depths 2..5 and turn 4 (depth 6) is scrubbed. # committed turns 8..5 are depths 2..5 and turn 4 (depth 6) is scrubbed.
ledger.record_prompt_tokens(20_000) # 20% < 35% ledger.record_prompt_tokens(20_000) # 20% < 35%
@@ -482,34 +492,34 @@ def test_relaxed_depth_below_start_gate_keeps_more_screenshots():
tight = TranscriptLedger(step_memory=_service(), image_scrub_depth=3) tight = TranscriptLedger(step_memory=_service(), image_scrub_depth=3)
for i in range(1, 10): for i in range(1, 10):
_play_turn(tight, i, prev_key=f"step-{i - 1}" if i > 1 else None) _play_turn(tight, i, compact=True, prev_key=f"step-{i - 1}" if i > 1 else None)
assert _image_turns(tight) == [8] assert _image_turns(tight) == [8]
def test_crossing_the_start_gate_tightens_once_and_relaxing_never_backfills(): def test_crossing_the_start_gate_tightens_once_and_relaxing_never_backfills():
ledger = _banded_ledger() ledger = _banded_ledger()
for i in range(1, 10): for i in range(1, 10):
_play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None) _play_turn(ledger, i, compact=True, prev_key=f"step-{i - 1}" if i > 1 else None)
assert _image_turns(ledger) == [5, 6, 7, 8] assert _image_turns(ledger) == [5, 6, 7, 8]
# Past the gate: the next render scrubs the extra depths in one pass # Past the gate: the next render scrubs the extra depths in one pass
# (turns 5–8 were depths 3–6 at that render; only turn 9 at depth 2 stays). # (turns 5–8 were depths 3–6 at that render; only turn 9 at depth 2 stays).
ledger.record_prompt_tokens(40_000) # 40% >= 35% ledger.record_prompt_tokens(40_000) # 40% >= 35%
assert ledger.effective_image_scrub_depth == 3 assert ledger.effective_image_scrub_depth == 3
_play_turn(ledger, 10, prev_key="step-9") _play_turn(ledger, 10, compact=True, prev_key="step-9")
assert _image_turns(ledger) == [9] assert _image_turns(ledger) == [9]
# Back under the gate: nothing is restored; new turns accumulate again # Back under the gate: nothing is restored; new turns accumulate again
# until four historical screenshots are in view. # until four historical screenshots are in view.
ledger.record_prompt_tokens(20_000) ledger.record_prompt_tokens(20_000)
assert ledger.effective_image_scrub_depth == 6 assert ledger.effective_image_scrub_depth == 6
_play_turn(ledger, 11, prev_key="step-10") _play_turn(ledger, 11, compact=True, prev_key="step-10")
assert _image_turns(ledger) == [9, 10] assert _image_turns(ledger) == [9, 10]
_play_turn(ledger, 12, prev_key="step-11") _play_turn(ledger, 12, compact=True, prev_key="step-11")
assert _image_turns(ledger) == [9, 10, 11] assert _image_turns(ledger) == [9, 10, 11]
_play_turn(ledger, 13, prev_key="step-12") _play_turn(ledger, 13, compact=True, prev_key="step-12")
assert _image_turns(ledger) == [9, 10, 11, 12] assert _image_turns(ledger) == [9, 10, 11, 12]
_play_turn(ledger, 14, prev_key="step-13") # turn 9 reaches depth 6 _play_turn(ledger, 14, compact=True, prev_key="step-13") # turn 9 reaches depth 6
assert _image_turns(ledger) == [10, 11, 12, 13] assert _image_turns(ledger) == [10, 11, 12, 13]
@@ -523,10 +533,8 @@ def test_no_band_configuration_keeps_the_fixed_depth():
assert relaxed_only.effective_image_scrub_depth == 3 # no gate → never relaxed assert relaxed_only.effective_image_scrub_depth == 3 # no gate → never relaxed
def test_scrub_edge_runs_before_the_chunker_at_render(): def test_chunker_sees_immutable_raw_history_before_a_phase_boundary():
"""The chunker sees the active window after the scrub edge advanced, so a """Preparing capsules must not scrub any previously sent observation."""
chunk closing over turns older than the floor never captures a live
screenshot in its source transcript."""
ledger = _banded_ledger() ledger = _banded_ledger()
seen: list[list[int]] = [] seen: list[list[int]] = []
@@ -537,9 +545,9 @@ def test_scrub_edge_runs_before_the_chunker_at_render():
ledger.attach_chunker(Probe()) ledger.attach_chunker(Probe())
for i in range(1, 9): for i in range(1, 9):
_play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None) _play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None)
# At the render of turn 8 the scrub edge (depth 6) had already resolved # A render below pressure preserves every historical image.
# turn 3 (depth 6); the probe saw turns 4–7 with images, never turn 3. assert seen[-1] == list(range(1, 8))
assert seen[-1] == [4, 5, 6, 7] assert ledger.context_phase == 0
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -580,6 +588,7 @@ def test_ephemeral_blocks_vanish_at_the_text_edge_and_not_before():
ledger.stage_turn(turn_with_notice(1)) ledger.stage_turn(turn_with_notice(1))
for i in range(2, 4): for i in range(2, 4):
ledger.commit_staged(step_key=f"step-{i - 1}") ledger.commit_staged(step_key=f"step-{i - 1}")
ledger.compact_active()
ledger.render([_observation(i)]) ledger.render([_observation(i)])
ledger.stage_turn(turn_with_notice(i)) ledger.stage_turn(turn_with_notice(i))
@@ -600,15 +609,12 @@ def test_ephemeral_blocks_vanish_at_the_text_edge_and_not_before():
def test_rendering_past_the_edge_is_byte_identical_and_prefix_stable(): def test_rendering_past_the_edge_is_byte_identical_and_prefix_stable():
"""(iii) A render with nothing new is byte-identical to the previous one, """Every full previous prompt, including its live tail, survives unchanged."""
and across turns exactly one message — the observation reaching depth K —
differs from the previous render's prefix, so the request prefix before
it is what the provider's prompt cache already holds."""
ledger = TranscriptLedger(step_memory=_ready_service(12), image_scrub_depth=3) ledger = TranscriptLedger(step_memory=_ready_service(12), image_scrub_depth=3)
ledger.set_static_prefix([SystemMessage(content="STATIC")]) ledger.set_static_prefix([SystemMessage(content="STATIC")])
def snapshot(rendered: list) -> list[str]: def snapshot(rendered: list) -> list[str]:
return [_fingerprint(m) for m in rendered[:-1]] # everything but the live tail return [_fingerprint(m) for m in rendered] # includes the previously live observation
previous: list[str] = [] previous: list[str] = []
for i in range(1, 11): for i in range(1, 11):
@@ -620,24 +626,15 @@ def test_rendering_past_the_edge_is_byte_identical_and_prefix_stable():
) )
current = snapshot(rendered) current = snapshot(rendered)
changed = [idx for idx, fp in enumerate(previous) if current[idx] != fp] changed = [idx for idx, fp in enumerate(previous) if current[idx] != fp]
if i >= 3: assert changed == [], (i, changed)
# The live tail is depth 1 and committed turn i-1 depth 2, so the
# turn reaching depth 3 is i-2; its observation heads its span.
expected = next(
idx
for idx, m in enumerate(rendered[:-1])
if f"OBSERVATION [T+00:0{(i - 2) % 10}]" in str(m.content)
)
assert changed == [expected], (i, changed)
else:
assert changed == [], (i, changed)
previous = current previous = current
# Same state, rendered again: byte-identical, including the tail. # Same state, rendered again: byte-identical, including the tail.
ledger.commit_staged(step_key="step-10", validator_result={"status": "dispatched"})
tail = [_observation(11)] tail = [_observation(11)]
again = [_fingerprint(m) for m in ledger.render(tail)] again = [_fingerprint(m) for m in ledger.render(tail)]
assert again == [_fingerprint(m) for m in ledger.render(tail)] assert again == [_fingerprint(m) for m in ledger.render(tail)]
assert again[:-1] == previous assert again[: len(previous)] == previous
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
+2
View File
@@ -134,6 +134,8 @@ def test_xml_scrub_depth_positions_the_text_edge():
ledger.stage_turn([obs(1)]) ledger.stage_turn([obs(1)])
ledger.commit_staged(step_key="s1") ledger.commit_staged(step_key="s1")
ledger.render([obs(2)]) ledger.render([obs(2)])
assert PRO_UI_LIST_MARKER in str(ledger.active_messages[0].content)
ledger.compact_active() # depth settings apply at a compaction boundary
committed = ledger.active_messages[0] committed = ledger.active_messages[0]
assert PRO_UI_LIST_MARKER not in str(committed.content) assert PRO_UI_LIST_MARKER not in str(committed.content)
assert any(isinstance(b, dict) and b.get("type") == "image_url" for b in committed.content) assert any(isinstance(b, dict) and b.get("type") == "image_url" for b in committed.content)
+8 -2
View File
@@ -65,7 +65,9 @@ def test_burst_results_reach_responses_once_with_images_before_compression(tmp_p
actions[2].pop("post_image_name") actions[2].pop("post_image_name")
actions[2]["attempts"] = ["Skipped"] actions[2]["attempts"] = ["Skipped"]
report = {"status": "failed", "execution": actions} report = {"status": "failed", "execution": actions}
ledger = TranscriptLedger(image_scrub_depth=1, pending_grace_steps=0) ledger = TranscriptLedger(
image_scrub_depth=1, pending_grace_steps=0, context_budget_tokens=300_000
)
_stage(ledger, actions) _stage(ledger, actions)
ledger.commit_staged( ledger.commit_staged(
step_key="step1", step_key="step1",
@@ -84,13 +86,17 @@ def test_burst_results_reach_responses_once_with_images_before_compression(tmp_p
assert len([item for item in payload if item.get("type") == "function_call_output"]) == 4 assert len([item for item in payload if item.get("type") == "function_call_output"]) == 4
assert str(payload).count(base64.b64encode(image.read_bytes()).decode()) == 3 assert str(payload).count(base64.b64encode(image.read_bytes()).decode()) == 3
assert f"data:{Image.MIME[image_format]};base64," in str(payload) assert f"data:{Image.MIME[image_format]};base64," in str(payload)
# No duplicate outputs at the following turn; old images are eligible for scrubbing again. # No duplicate outputs or rewritten images on the following ordinary turn.
ledger.commit_staged( ledger.commit_staged(
validator_result=report, tool_results=plugin_result_messages(report, engine) validator_result=report, tool_results=plugin_result_messages(report, engine)
) )
later = ledger.render([]) later = ledger.render([])
assert len([m for m in later if isinstance(m, ToolMessage)]) == 4 assert len([m for m in later if isinstance(m, ToolMessage)]) == 4
assert str(later).count(base64.b64encode(image.read_bytes()).decode()) == 3
ledger.compact_active()
later = ledger.render([])
assert str(later).count(base64.b64encode(image.read_bytes()).decode()) < 3 assert str(later).count(base64.b64encode(image.read_bytes()).decode()) < 3
assert str(messages).count(base64.b64encode(image.read_bytes()).decode()) == 3
def test_missing_plugin_screenshot_keeps_real_result_and_reports_missing_evidence(tmp_path): def test_missing_plugin_screenshot_keeps_real_result_and_reports_missing_evidence(tmp_path):
+63
View File
@@ -0,0 +1,63 @@
import json
from types import SimpleNamespace
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
from artemis.llm.prefix_cache import PromptPrefixTracker
from artemis.llm.router import ModelEndpoint
from artemis.services.llm import RobustChatModelWrapper
def test_prefix_diagnostics_distinguish_append_rewrite_and_tool_changes_without_payloads():
tracker = PromptPrefixTracker()
messages = [SystemMessage(content="private rules"), HumanMessage(content="private screenshot")]
options = {"tools": [{"name": "click"}, {"name": "observe"}]}
assert tracker.observe("task:flash", messages, options)["cold_start"]
messages.extend(
[
AIMessage(content="", tool_calls=[{"name": "click", "args": {"x": 5}, "id": "c1"}]),
ToolMessage(content="clicked", tool_call_id="c1"),
]
)
extended = tracker.observe("task:flash", messages, options)
assert extended["append_only"] and extended["shared_prefix_messages"] == 2
messages[1] = HumanMessage(content="compressed summary")
rewritten = tracker.observe("task:flash", messages, options)
assert rewritten["first_changed_message"] == 2 and not rewritten["append_only"]
reordered = tracker.observe("task:flash", messages, {"tools": list(reversed(options["tools"]))})
assert reordered["tools_or_format_changed"] and not reordered["append_only"]
assert tracker.observe("task:checker", messages, options)["cold_start"]
assert "private" not in json.dumps(tracker._previous)
def test_bound_wrappers_reuse_task_prefix_tracker_after_message_adaptation():
class Model:
kwargs = {"tools": [{"type": "function", "name": "observe"}]}
def bind_tools(self, *_args, **_kwargs):
return self
ctx = SimpleNamespace(data_engine=SimpleNamespace(current_session_id="session"))
wrapper = RobustChatModelWrapper(Model(), ctx=ctx, endpoint=ModelEndpoint(), role="flash")
messages = [SystemMessage(content="rules"), HumanMessage(content="step 1")]
def diagnostics(items):
bound = wrapper.bind_tools([]) # Flash binds again for each call
args, kwargs = bound._prepare_model_call((items,), {})
return bound._diagnostics(args, kwargs)
first = diagnostics(messages)
messages.append(HumanMessage(content="step 2"))
second = diagnostics(messages)
assert first["prefix_cache"]["cold_start"]
assert second["prefix_cache"]["append_only"]
assert first["prompt_cache_key"] == second["prompt_cache_key"]
assert not any("rules" in str(value) for value in ctx.prompt_prefix_tracker._previous.values())
ctx.transcript_ledger = SimpleNamespace(
context_phase=1, last_compaction={"reason": "context_pressure"}
)
third = diagnostics(messages[:1] + [HumanMessage(content="history summary")])
assert third["prefix_cache"]["context_phase"] == 1
assert third["prefix_cache"]["last_compaction"]["reason"] == "context_pressure"
assert not third["prefix_cache"]["append_only"]