feat(memory): add incremental transcript compaction
This commit is contained in:
1 parent
cbe8f4f0c6
commit
41ae386a3a
65 files changed
+9971
-815
No files matched your search
@@ -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
@@ -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)
|
||||
@@ -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": "..."}
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
]
|
||||
Reference in new issue
Block a user