feat(memory): add incremental transcript compaction

This commit is contained in:
somew1nd committed 2026-09-01 17:46:44 -07:00
1 parent cbe8f4f0c6
commit 41ae386a3a
65 files changed
+9971 -815

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Shared step-memory runtime for background summarization across profiles."""
from artemis.memory.chunking import (
ChunkCapsuleService,
HistoryChunkManager,
StepCapsuleLens,
build_action_ledger,
)
from artemis.memory.context_policy import (
CONTEXT_POLICIES,
ContextPolicy,
build_history_for,
resolve_policy,
)
from artemis.memory.step_memory import StepLens, StepMemoryService
from artemis.memory.transcript import TranscriptLedger, format_session_offset
def ensure_step_memory(ctx):
"""Return the session's shared step-memory service, creating it on demand.
Composition root for ``ctx.step_memory`` (history redesign §6.2): the
first caller — FlashRunner, the Pro SummarizerNode, or the Pro operator's
transcript path — instantiates the visual-transition lens service and
publishes it on the context so every profile shares one runtime.
"""
service = getattr(ctx, "step_memory", None)
if service is not None:
return service
from artemis.agents.flash.summarizer import VisualStepSummarizer
kwargs: dict = {}
model_name = None
try:
from artemis.config import load_agent_config
cfg = load_agent_config()
model_name = cfg.flash.step_summarizer.model
kwargs = {
"retry_limit": cfg.memory.runtime.retry_limit,
"max_concurrency": cfg.memory.runtime.max_concurrency,
"flush_timeout_s": cfg.memory.runtime.flush_timeout_s,
}
except Exception:
pass
service = VisualStepSummarizer(ctx, model_name=model_name, **kwargs)
try:
ctx.step_memory = service
except Exception:
pass
return service
__all__ = [
"CONTEXT_POLICIES",
"ChunkCapsuleService",
"ContextPolicy",
"HistoryChunkManager",
"StepCapsuleLens",
"build_history_for",
"resolve_policy",
"StepLens",
"StepMemoryService",
"TranscriptLedger",
"build_action_ledger",
"ensure_step_memory",
"format_session_offset",
]
File diff suppressed because it is too large. Load diff
+248
View File
@@ -0,0 +1,248 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""ContextPolicy: the declarative per-agent compiled-history policy table (M4).
Every production caller of :func:`artemis.utils.task_tree.build_plan_and_history`
used to hard-code its visibility kwargs at the call site. This module gathers
those call sites into one declaration per agent — the rendered output for each
agent is byte-identical to the previous hard-coded call (pinned by the golden
tests in ``tests/unit/memory/test_context_policy.py``) — and adds the M4 chunk
hand-off: when the transcript flag is on and history chunks exist, the
compiled view replaces chunked-away step lines with chunk blocks.
Chunk view per policy (§10 decision 4):
- ``"full"`` (outputter / history_analyzer): the chunk's stored three-band
rendering including the ③ full-width per-step ledger;
- ``"digest"`` (everything else): bands ① + ② with the ③ ledger replaced by
a ``ledger via recall_history`` marker line.
Because chunks only exist when ``agent.memory.transcript.enabled`` is on, the
flag-off output of every agent stays byte-for-byte unchanged.
``agent.memory.policies`` allows per-agent field overrides, e.g.::
"memory": {"policies": {"planner": {"last_n_detailed": 3}}}
"""
from dataclasses import dataclass, fields, replace
from typing import Any
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
#: Marker appended to a digest-view chunk block in place of the ③ ledger.
DIGEST_LEDGER_MARKER_TEMPLATE = (
"③ Step action ledger: available via recall_history (Steps {start}–{end})"
)
#: The band-③ heading inside a chunk's stored ``rendered_text``.
_BAND3_HEADING = "③ Step action ledger"
@dataclass(frozen=True)
class ContextPolicy:
"""One agent's compiled-history rendering declaration.
Field semantics map 1:1 onto ``build_plan_and_history`` kwargs, with two
conveniences: ``uncompressed=True`` renders every step (the historical
``min_summaries=len(steps)`` idiom) and ``whitelist=True`` marks policies
whose caller supplies ``keep_subgoal_hashes`` at runtime.
"""
strategy: str = "sliding_window" # documentation only
strict_milestone_pruning: bool = False
recent_window_size: int = 3
min_summaries: int = 5
uncompressed: bool = False
last_n_detailed: int = 1
all_detailed: bool = False
for_failure_analyzer: bool = False
whitelist: bool = False
chunk_view: str | None = "digest" # "full" | "digest" | None (never chunks)
#: The eight production call sites, verbatim (kwargs recorded 2026-09-01).
CONTEXT_POLICIES: dict[str, ContextPolicy] = {
# operator.py:996 — last_n_detailed comes from the node constructor.
"operator": ContextPolicy(
strategy="strict_milestone",
strict_milestone_pruning=True,
recent_window_size=3,
),
# operator.py:341 — transcript F-region cold-start restore.
"operator_cold_start": ContextPolicy(
strategy="full",
uncompressed=True,
last_n_detailed=1,
chunk_view=None,
),
# failure_analyzer.py:361
"failure_analyzer": ContextPolicy(
strategy="strict_milestone",
strict_milestone_pruning=True,
recent_window_size=3,
last_n_detailed=1,
for_failure_analyzer=True,
),
# planner.py:133 — caller passes an empty plan and hash "default".
"planner": ContextPolicy(
strategy="strict_milestone",
strict_milestone_pruning=True,
recent_window_size=5,
last_n_detailed=2,
),
# outputter.py:81
"outputter": ContextPolicy(
strategy="full",
uncompressed=True,
last_n_detailed=0,
chunk_view="full",
),
# history_analyzer.py:113
"history_analyzer": ContextPolicy(
strategy="full",
uncompressed=True,
last_n_detailed=1,
chunk_view="full",
),
# diagnoser.py:166 — every non-whitelist kwarg is the historical default.
"diagnoser": ContextPolicy(strategy="milestone_whitelist", whitelist=True),
# committee_tool.py:189
"committee": ContextPolicy(
strategy="milestone_whitelist",
whitelist=True,
last_n_detailed=3,
),
}
_POLICY_FIELDS = {f.name for f in fields(ContextPolicy)}
def resolve_policy(agent: str) -> ContextPolicy:
"""The agent's policy with any ``agent.memory.policies`` overrides applied."""
try:
policy = CONTEXT_POLICIES[agent]
except KeyError:
raise KeyError(
f"No ContextPolicy declared for agent '{agent}';"
f" known agents: {sorted(CONTEXT_POLICIES)}"
) from None
overrides: dict[str, Any] = {}
try:
from artemis.config import load_agent_config
configured = (load_agent_config().memory.policies or {}).get(agent) or {}
overrides = {k: v for k, v in configured.items() if k in _POLICY_FIELDS}
unknown = set(configured) - _POLICY_FIELDS
if unknown:
logger.warning(
f"agent.memory.policies['{agent}'] ignores unknown fields: {sorted(unknown)}"
)
except Exception:
overrides = {}
return replace(policy, **overrides) if overrides else policy
def _chunk_digest_text(rendered_text: str, start: int, end: int) -> str:
"""Bands ① + ② of a stored chunk rendering, ③ replaced by the recall marker."""
marker = DIGEST_LEDGER_MARKER_TEMPLATE.format(start=start, end=end)
head, sep, _ = rendered_text.partition(f"{_BAND3_HEADING}\n")
if not sep:
return f"{rendered_text.rstrip()}\n{marker}"
return f"{head.rstrip()}\n\n{marker}"
def load_chunk_blocks(engine: Any, view: str | None) -> list[dict] | None:
"""The persisted history chunks rendered for the compiled view, or None.
Returns None (→ byte-identical legacy rendering) unless the transcript
flag is on, the engine exposes chunks, and at least one chunk exists.
"""
if engine is None or view not in ("full", "digest"):
return None
try:
from artemis.config import load_agent_config
if not load_agent_config().memory.transcript.enabled:
return None
except Exception:
return None
try:
rows = engine.get_history_chunks() or []
except Exception as e:
logger.debug(f"load_chunk_blocks: no chunks available: {e}")
return None
blocks: list[dict] = []
for row in rows:
start = getattr(row, "start_step_number", None)
end = getattr(row, "end_step_number", None)
rendered = getattr(row, "rendered_text", None)
if not isinstance(start, int) or not isinstance(end, int) or not rendered:
continue
text = (
rendered
if view == "full"
else _chunk_digest_text(rendered, start, end)
)
blocks.append(
{"start_step_number": start, "end_step_number": end, "text": text}
)
return blocks or None
def build_history_for(
agent: str,
task_plan: str,
steps: list,
current_subgoal_hash: str,
*,
keep_subgoal_hashes: set | None = None,
last_n_detailed: int | None = None,
engine: Any = None,
) -> str:
"""Render the agent's compiled plan-and-history view per its policy.
Args:
agent: Key into :data:`CONTEXT_POLICIES`.
keep_subgoal_hashes: Runtime whitelist (whitelist policies only).
last_n_detailed: Runtime override (the operator's constructor knob).
engine: DataEngine handing chunks to the compiled view; omit at call
sites that must never receive chunk blocks.
"""
from artemis.utils.task_tree import build_plan_and_history
policy = resolve_policy(agent)
kwargs: dict[str, Any] = {
"min_summaries": len(steps) if policy.uncompressed else policy.min_summaries,
"last_n_detailed": (
policy.last_n_detailed if last_n_detailed is None else last_n_detailed
),
"all_detailed": policy.all_detailed,
"strict_milestone_pruning": policy.strict_milestone_pruning,
"recent_window_size": policy.recent_window_size,
"for_failure_analyzer": policy.for_failure_analyzer,
}
if policy.whitelist:
kwargs["keep_subgoal_hashes"] = keep_subgoal_hashes
chunks = load_chunk_blocks(engine, policy.chunk_view)
if chunks:
kwargs["chunks"] = chunks
return build_plan_and_history(task_plan, steps, current_subgoal_hash, **kwargs)
+57
View File
@@ -0,0 +1,57 @@
# ROLE
You are the Segment Capsule writer for a mobile automation agent's history compression. You receive one contiguous segment of executed steps (per-step visual transition summaries, exact actions, controller/validator results, operator reasoning excerpts, notes written, and user-injected instructions) and produce the two LLM-authored bands of the segment's history chunk:
- Band ① — Synopsis & effects: what this segment was doing, what was actually done, and what effects/artifacts it left behind, plus structured fields.
- Band ② — Compressed step summary: an interval narrative ("Steps xx–xx did ..., Step xx did ...") that seamlessly covers every step of the segment.
The mechanical per-step action ledger (band ③) is assembled outside of you; do NOT reproduce it.
# NEUTRAL-WORDING CONTRACT (STRICT)
1. **Zero subjective validation**: never declare semantic success, completion, or final failure. Subjective verdicts in compressed history cause future turns to falsely believe a goal was definitively met or permanently broken.
- **BANNED WORDS**: "successfully", "completed", "achieved", "entered", "navigated to", "arrived at", "failed", "unsuccessful", "could not", "impossible".
- Describe observable transitions and recorded results instead (e.g. "the settings page was displayed", "the controller reported an execution error").
2. **First-person perspective**: write from the agent's perspective using "I". Never "the agent" / "the operator".
3. **Planned iterations are not anomalies**: systematic processing of different candidate items, or periodic polling cycles under a `[Loop]` / `[Loop:continuous]` milestone, is planned progress — record it objectively (e.g. "polling round #2"). Only flag a loop when the exact same target is repeated without progress or states oscillate blindly; then record the pattern and count factually.
4. **Preserve, don't abstract**: be deliberately detailed and stay close to the source wording. Prefer longer output over abstracting facts beyond recognition. Faithfully carry visible text, codes, prices, account names, file names, error banners.
5. **Fact priority**: user instructions > validator/controller recorded results > UI/OCR deltas > sourced visual summaries > reasoning excerpts.
# BAND ① REQUIREMENTS
Answer three questions in detailed prose (field `doing` / `did` / `effect`):
- `doing`: what this segment was trying to do and why (strategic context within the plan).
- `did`: what was actually carried out, including detours, retries, recoveries, and pivots.
- `effect`: what state/effects/artifacts the segment left behind. **This MUST include every note left during the segment**: for each notes write in the input, name the target note file and the gist of what was recorded. Every note's target key is machine-checked against your effect field — an omitted note key forces regeneration.
Structured fields (arrays of short strings, may be empty):
- `verified_facts`: states/values actually observed or verified on screen.
- `unresolved`: open questions, unverified assumptions, missing prerequisites.
- `failed_paths`: approaches attempted in this segment whose recorded results were errors (state the recorded error factually).
- `important_entities`: accounts, files, order/tracking numbers, package names, key UI entry points.
- `entry_state` / `exit_state` (single strings): the observable screen/app state at segment start and end.
# BAND ② REQUIREMENTS
Field `intervals`: an ordered array of `{"start_step": N, "end_step": M, "text": "..."}` objects.
- Every line's text must reference concrete behavior for those steps.
- **The union of intervals MUST cover the segment's step range seamlessly** — no gaps, no overlaps, in ascending order. This is machine-checked; a gap forces regeneration.
- Group only homogeneous consecutive actions into one interval; a heterogeneous action gets its own single-step interval (`start_step == end_step`).
- Keep the neutral-wording contract in every line.
# OUTPUT FORMAT
Return ONLY one JSON object (no markdown fences, no commentary):
```json
{
"doing": "...",
"did": "...",
"effect": "...",
"entry_state": "...",
"exit_state": "...",
"verified_facts": ["..."],
"unresolved": ["..."],
"failed_paths": ["..."],
"important_entities": ["..."],
"intervals": [
{"start_step": 18, "end_step": 20, "text": "..."},
{"start_step": 21, "end_step": 21, "text": "..."}
]
}
```
+254
View File
@@ -0,0 +1,254 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Shared background step-summary runtime (StepMemoryService).
Hosts the scheduling skeleton lifted from the Flash VisualStepSummarizer:
zero-blocking dispatch, bounded retry, bounded flush, canonical step_id keying
with alias resolution (tool_call_id / legacy ordinals), and status queries.
What a summary *contains* (the lens semantics) lives in subclasses — the
service only guarantees how jobs are scheduled, retried, keyed, and drained.
Both Flash and (from M2 on) Pro profiles share this runtime.
"""
import asyncio
from typing import Any
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
JobKey = int | str
class StepLens:
"""Formal lens interface: what one summary attempt *produces*.
A lens turns one job payload into summary text; the surrounding service
owns scheduling, retries, keying, and draining. Implementations must be
side-effect-tolerant under retries (an attempt may run again after a
failure) and return ``None``/raise to signal a failed attempt.
Two lens altitudes exist in the redesign (§5): the step-level visual
transition lens (:class:`~artemis.agents.flash.summarizer.VisualStepSummarizer`
keeps it inlined as its ``_attempt`` for compatibility) and the chunk-level
:class:`~artemis.memory.chunking.StepCapsuleLens`.
"""
name = "lens"
async def render(self, key: JobKey, payload: dict[str, Any]) -> str | None:
"""Produce the summary text for one payload, or None on failure."""
raise NotImplementedError
class StepMemoryService:
"""Non-blocking background runtime for per-step summary jobs.
Key design properties:
1. Zero-blocking dispatch: callers submit and proceed immediately.
2. Bounded retry: each job attempts at most ``1 + retry_limit`` times and
then enters an explicit failed state — never an unbounded loop.
3. Bounded flush: ``flush()`` waits up to the configured timeout, then
cancels whatever is still in flight.
4. Canonical keying with aliases: jobs are keyed by the DataEngine step id
where available; tool_call_ids (and legacy ordinals) remain queryable
through an alias map so message-level consumers need no migration.
5. Bounded concurrency: at most ``max_concurrency`` attempts run at once.
Subclasses implement :meth:`_attempt` (one summarization attempt for a
payload) and may override :meth:`_on_status` to persist pending/failed
status transitions.
"""
def __init__(
self,
ctx: Any,
*,
lens: StepLens | None = None,
max_concurrency: int = 1,
retry_limit: int = 3,
flush_timeout_s: float = 30.0,
):
self.ctx = ctx
self._lens = lens
self._summaries: dict[JobKey, str] = {}
self._failed: set[JobKey] = set()
self._pending_tasks: dict[JobKey, asyncio.Task] = {}
self._step_inputs: dict[JobKey, dict[str, Any]] = {}
self._retry_counts: dict[JobKey, int] = {}
self._retry_delays = (0.0, 0.5, 1.0, 2.0, 3.0)
self._retry_limit = max(0, retry_limit)
self._flush_timeout_s = max(0.0, flush_timeout_s)
self._aliases: dict[JobKey, JobKey] = {}
self._semaphore = asyncio.Semaphore(max(1, max_concurrency))
# ------------------------------------------------------------------
# Keying
# ------------------------------------------------------------------
def resolve_key(self, key: JobKey) -> JobKey:
"""Resolve an alias (tool_call_id / legacy ordinal) to its canonical key."""
return self._aliases.get(key, key)
def submit(
self,
key: JobKey,
payload: dict[str, Any],
aliases: tuple[JobKey, ...] = (),
) -> None:
"""Submit one summary job without blocking the caller.
Args:
key: Canonical job key (DataEngine step id where available).
payload: Lens-specific input data; stored until the job resolves.
aliases: Additional keys (e.g. tool_call_id) that resolve to ``key``.
"""
for alias in aliases:
if alias is not None and alias != key:
self._aliases[alias] = key
self._step_inputs[key] = payload
self._failed.discard(key)
self._on_status(key, "pending")
task = asyncio.create_task(self._run_until_ready(key))
self._pending_tasks[key] = task
# ------------------------------------------------------------------
# Scheduling core
# ------------------------------------------------------------------
async def _run_until_ready(self, key: JobKey) -> None:
"""Retry one job independently, bounded by the configured retry limit.
Attempts at most ``1 + retry_limit`` times; on exhaustion the job
enters an explicit failed state (surfaced via :meth:`has_failed` and
the ``_on_status`` hook). Retry backoff sleeps happen outside the
concurrency semaphore so a waiting job never starves its peers.
"""
while key not in self._summaries:
async with self._semaphore:
succeeded = await self._attempt(key)
if succeeded:
return
retry_count = self._retry_counts.get(key, 0) + 1
self._retry_counts[key] = retry_count
payload = self._step_inputs.get(key)
display_step = payload.get("step_number") if payload else key
if retry_count > self._retry_limit:
self._failed.add(key)
logger.warning(
f"StepMemoryService: Giving up on Step {display_step} after"
f" {retry_count} failed attempts (retry_limit={self._retry_limit})."
)
self._on_status(key, "failed")
return
delay = self._retry_delays[min(retry_count - 1, len(self._retry_delays) - 1)]
logger.info(
f"StepMemoryService: Retrying Step {display_step} "
f"(attempt {retry_count + 1}) after {delay:.1f}s"
)
if delay:
await asyncio.sleep(delay)
async def _attempt(self, key: JobKey) -> bool:
"""Run one summarization attempt for ``key``; True when a summary landed.
The default implementation delegates to the configured :class:`StepLens`;
subclasses without a lens override this method directly (the Flash
visual summarizer keeps its inlined attempt for compatibility).
"""
if self._lens is None:
raise NotImplementedError
payload = self._step_inputs.get(key)
if payload is None:
return False
try:
summary = await self._lens.render(key, payload)
except Exception as e:
logger.warning(f"StepMemoryService: lens '{self._lens.name}' attempt failed: {e}")
return False
if not summary:
return False
self._summaries[key] = summary
self._on_ready(key, summary)
return True
def _on_ready(self, key: JobKey, summary: str) -> None:
"""Hook invoked when a lens-produced summary lands; default is a no-op."""
def _on_status(self, key: JobKey, status: str) -> None:
"""Hook invoked on pending/failed transitions; default is a no-op."""
# ------------------------------------------------------------------
# Status queries (alias-resolving)
# ------------------------------------------------------------------
def get_summary(self, key: JobKey, fallback_text: str | None = None) -> str | None:
"""Retrieve the summary for a job key (or alias), or fallback text."""
return self._summaries.get(self.resolve_key(key), fallback_text)
def has_summary(self, key: JobKey) -> bool:
"""Check whether a job's summary has completed."""
return self.resolve_key(key) in self._summaries
def has_job(self, key: JobKey) -> bool:
"""Check whether a job has been submitted for summarization."""
return self.resolve_key(key) in self._step_inputs
def has_failed(self, key: JobKey) -> bool:
"""Check whether a job permanently failed (retries exhausted)."""
return self.resolve_key(key) in self._failed
def is_pending(self, key: JobKey) -> bool:
"""Check whether a job still needs a summary."""
return self.has_job(key) and not self.has_summary(key)
def get_job_payload(self, key: JobKey) -> dict[str, Any] | None:
"""Return the stored payload for a job key (or alias), if any."""
return self._step_inputs.get(self.resolve_key(key))
def get_step_number(self, key: JobKey) -> int | None:
"""Return the human-facing step ordinal recorded for a job, if known."""
payload = self.get_job_payload(key)
if payload is None:
return None
step_number = payload.get("step_number")
return step_number if isinstance(step_number, int) else None
# ------------------------------------------------------------------
# Draining
# ------------------------------------------------------------------
async def flush(self, timeout_seconds: float | None = None) -> None:
"""Wait for active jobs up to a bound, then cancel remaining retry loops."""
if timeout_seconds is None:
timeout_seconds = self._flush_timeout_s
tasks = [t for t in self._pending_tasks.values() if not t.done()]
if tasks:
logger.info(f"StepMemoryService: Flushing {len(tasks)} pending summary tasks...")
_, pending = await asyncio.wait(tasks, timeout=timeout_seconds)
if pending:
logger.warning(
f"StepMemoryService: Cancelling {len(pending)} unfinished summary tasks "
f"after {timeout_seconds:.1f}s flush timeout."
)
for task in pending:
task.cancel()
await asyncio.gather(*pending, return_exceptions=True)
+364
View File
@@ -0,0 +1,364 @@
# Copyright 2026 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Session-level message transcript ledger for the Pro operator (M2).
Implements the four-region discipline of the history-module redesign §3.2:
- **S (stable prefix)**: the static system message — byte-identical for the
whole session (the plan+history section is moved out by the template split).
- **F (frozen history)**: in M2 only the cold-start restored-history block; L2
chunks arrive in M3.
- **A (active window)**: raw per-turn messages, append-only. Each committed
turn contributes its tail observation HumanMessage, the operator AIMessages
(tool_calls and native thinking preserved by reference), the in-turn
ToolMessages, and the turn's validator result message. The scrub edge
(:class:`~artemis.agents.flash.context_compressor.ScrubEdgeCompressor`,
generalized in M2) strips old UI lists / plan recitations at depth 1 and
resolves screenshots to visual summaries at depth K — 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
:meth:`render` and only enters A when the turn is committed.
All timestamps inside the transcript use the session-start offset ``T+mm:ss``
(byte-stable once frozen); "ago" wording is reserved for the auxiliary agents'
per-call compiled views.
"""
import json
import time
from typing import Any, Callable
from langchain_core.messages import BaseMessage, HumanMessage
from artemis.agents.flash.context_compressor import ScrubEdgeCompressor
from artemis.memory.step_memory import StepMemoryService
from artemis.utils.logger import get_logger
logger = get_logger(__name__)
#: Marker for the per-turn task-plan recitation block in the observation tail.
PLAN_RECITATION_MARKER = "--- Task Plan (recited) ---"
#: Marker of the Pro observation's UI element list block (see
#: ``ObservationPromptComponent``).
PRO_UI_LIST_MARKER = "--- Visible UI Elements ---"
#: Marker prefix of a committed turn's validator result message.
EXECUTION_RESULT_MARKER = "--- Action Execution Result"
#: Header prefix of the cold-start restored-history block.
RESTORED_HISTORY_HEADER = "[Restored history]"
def format_session_offset(seconds: float) -> str:
"""Render a session-start offset as ``T+mm:ss`` (minutes never wrap)."""
total = max(0, int(seconds))
return f"T+{total // 60:02d}:{total % 60:02d}"
class TranscriptLedger:
"""Append-only four-region message ledger for one Pro session.
The ledger owns no LLM calls: summary lookups go through the shared
:class:`StepMemoryService` (``ctx.step_memory``), whose visual-transition
lens is fed by the Pro SummarizerNode each step.
"""
def __init__(
self,
*,
step_memory: StepMemoryService | None = None,
prune_history_xml: bool = True,
image_scrub_depth: int = 3,
pending_grace_steps: int = 3,
xml_scrub_depth: int = 1,
clock: Callable[[], float] | None = None,
):
self._clock = clock or time.monotonic
self._session_start = self._clock()
self._static: list[BaseMessage] = []
self._restored: list[BaseMessage] = []
self._active: list[BaseMessage] = []
self._staged: list[BaseMessage] | None = None
self._turn_count = 0
# L2/L3 chunk compression (M3). ``_turns`` records each committed
# turn's message span in ``_active``; a compression event advances
# ``_active_start`` past whole turns (never splitting tool-call pairs)
# and replaces the frozen chunk blocks wholesale. The underlying
# ``_active`` list stays append-only so scrub-edge indices stay valid.
self._turns: list[dict] = []
self._chunked_turn_count = 0
self._active_start = 0
self._frozen_blocks: list[BaseMessage] = []
self._chunker: Any | None = None
# id(message) -> summary-job key (DataEngine step id). A side map keeps
# the key out of the serialized message payload entirely.
self._step_keys: dict[int, str] = {}
self._compressor = ScrubEdgeCompressor(
summarizer=step_memory,
prune_history_xml=prune_history_xml,
image_scrub_depth=image_scrub_depth,
pending_grace_steps=pending_grace_steps,
xml_scrub_depth=xml_scrub_depth,
summary_key_getter=lambda msg: self._step_keys.get(id(msg)),
strip_markers=(PRO_UI_LIST_MARKER, PLAN_RECITATION_MARKER),
tail_offset=1,
)
# ------------------------------------------------------------------
# Session clock
# ------------------------------------------------------------------
def elapsed_seconds(self) -> float:
return max(0.0, self._clock() - self._session_start)
def elapsed_label(self) -> str:
"""The current session offset as ``T+mm:ss``."""
return format_session_offset(self.elapsed_seconds())
# ------------------------------------------------------------------
# S region
# ------------------------------------------------------------------
@property
def has_static_prefix(self) -> bool:
return bool(self._static)
def set_static_prefix(self, messages: list[BaseMessage]) -> None:
"""Install the byte-stable system prefix; only settable once."""
if self._static:
raise RuntimeError("TranscriptLedger static prefix is already set.")
self._static = list(messages)
# ------------------------------------------------------------------
# F region (M2: cold-start restored history only)
# ------------------------------------------------------------------
@property
def has_restored_history(self) -> bool:
return bool(self._restored)
def set_restored_history(self, text: str) -> None:
"""Install the cold-start frozen history block (empty ledger only)."""
if self._restored:
raise RuntimeError("TranscriptLedger restored history is already set.")
if self._active or self._staged or self._turn_count:
raise RuntimeError(
"Restored history can only seed an empty ledger (cold start)."
)
self._restored = [
HumanMessage(content=[{"type": "text", "text": text}])
]
# ------------------------------------------------------------------
# A region (append-only turn commits)
# ------------------------------------------------------------------
@property
def turn_count(self) -> int:
return self._turn_count
@property
def has_staged_turn(self) -> bool:
return self._staged is not None
@property
def active_messages(self) -> tuple[BaseMessage, ...]:
return tuple(self._active)
def stage_turn(self, messages: list[BaseMessage]) -> None:
"""Hold a finished turn's messages until the next build commits them.
Committing is deferred because the turn's DataEngine step id and its
validator result only exist after the operator returns.
"""
if self._staged is not None:
logger.warning(
"TranscriptLedger: previous staged turn was never committed;"
" committing it without step metadata."
)
self.commit_staged()
self._staged = [m for m in messages if isinstance(m, BaseMessage)]
def commit_staged(
self,
*,
step_key: str | None = None,
validator_result: Any | None = None,
) -> None:
"""Move the staged turn into the active region.
Args:
step_key: The DataEngine step id recorded for this turn; keys the
turn's observation screenshot to its visual-transition summary
job for the depth-K scrub.
validator_result: The turn's validator report (``None`` when the
turn executed no terminal action); appended as a frozen result
message carrying the ``T+mm:ss`` session offset.
"""
if self._staged is None:
return
staged = self._staged
self._staged = None
if step_key is not None:
observation = self._first_image_message(staged)
if observation is not None:
self._step_keys[id(observation)] = str(step_key)
span_start = len(self._active)
self._active.extend(staged)
if validator_result is not None:
result_message = self._build_result_message(validator_result)
if result_message is not None:
self._active.append(result_message)
self._turns.append(
{
"step_key": str(step_key) if step_key is not None else None,
"start": span_start,
"end": len(self._active),
}
)
self._turn_count += 1
@staticmethod
def _first_image_message(messages: list[BaseMessage]) -> BaseMessage | None:
for msg in messages:
content = getattr(msg, "content", None)
if not isinstance(content, list):
continue
for block in content:
if isinstance(block, dict) and block.get("type") in ("image_url", "image"):
return msg
return None
def _build_result_message(self, result: Any) -> HumanMessage | None:
rendered = None
if isinstance(result, dict):
status = result.get("status") or "unknown"
detail = None
try:
from artemis.utils.task_tree import format_result_clean
# format_result_clean reports only errors/repairs; success
# renders as the bare status line.
detail = format_result_clean(result)
except Exception:
detail = None
rendered = f"Status: {status}" + (f"\n{detail}" if detail else "")
else:
try:
rendered = json.dumps(result, ensure_ascii=False, default=str)
except Exception:
rendered = str(result)
if not rendered:
return None
return HumanMessage(
content=[
{
"type": "text",
"text": (
f"{EXECUTION_RESULT_MARKER} ({self.elapsed_label()}) ---\n{rendered}"
),
}
]
)
# ------------------------------------------------------------------
# F region (M3): chunk compression primitives
# ------------------------------------------------------------------
def attach_chunker(self, chunker: Any) -> None:
"""Install the L2/L3 :class:`HistoryChunkManager` consulted at render."""
self._chunker = chunker
@property
def chunker(self) -> Any | None:
return self._chunker
@property
def frozen_blocks(self) -> tuple[BaseMessage, ...]:
return tuple(self._frozen_blocks)
def unchunked_turns(self) -> list[dict]:
"""Committed turns not yet consumed by a compression event (copies)."""
return [dict(t) for t in self._turns[self._chunked_turn_count :]]
def turn_text_chars(self, turns: list[dict]) -> int:
"""Total text characters of the given turns' messages (size trigger)."""
total = 0
for turn in turns:
for msg in self._active[turn["start"] : turn["end"]]:
content = getattr(msg, "content", None)
if isinstance(content, str):
total += len(content)
elif isinstance(content, list):
for block in content:
if isinstance(block, dict) and block.get("type") == "text":
total += len(block.get("text", ""))
return total
def freeze_turns(self, turn_count: int, frozen_blocks: list[BaseMessage]) -> None:
"""Compression event: consume the oldest ``turn_count`` unchunked turns
and replace the frozen chunk blocks wholesale.
The boundary always advances to a committed turn's end, so a tool-call
message and its responses are never split across the F/A boundary.
"""
if turn_count <= 0:
self._frozen_blocks = list(frozen_blocks)
return
available = self._turns[self._chunked_turn_count :]
if turn_count > len(available):
raise ValueError(
f"freeze_turns({turn_count}) exceeds the {len(available)}"
" unchunked committed turns."
)
self._chunked_turn_count += turn_count
self._active_start = self._turns[self._chunked_turn_count - 1]["end"]
self._frozen_blocks = list(frozen_blocks)
# ------------------------------------------------------------------
# Rendering
# ------------------------------------------------------------------
def render(self, tail: list[BaseMessage]) -> list[BaseMessage]:
"""Advance compression and return ``S + F + A + tail``.
Order per turn: the chunker runs first (a triggered compression event
deep-mutates the frozen region and advances the F/A boundary), then
the scrub edge advances over the active window. The returned list is a
fresh container: appending to it (the operator's in-turn tool loop)
never mutates the ledger regions.
"""
if self._chunker is not None:
try:
self._chunker.on_render(self)
except Exception as e:
logger.error(f"History chunker render hook failed: {e}")
self._compressor.compress(self._active)
return [
*self._static,
*self._restored,
*self._frozen_blocks,
*self._active[self._active_start :],
*tail,
]