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,304 @@
|
||||
# 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 table (M4): byte-exact parity with the eight historical call
|
||||
sites, config overrides, and the chunk hand-off to the compiled view."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from artemis.memory.context_policy import (
|
||||
CONTEXT_POLICIES,
|
||||
DIGEST_LEDGER_MARKER_TEMPLATE,
|
||||
build_history_for,
|
||||
load_chunk_blocks,
|
||||
resolve_policy,
|
||||
)
|
||||
from artemis.utils.task_tree import build_plan_and_history
|
||||
|
||||
HASH_A = "hash-a"
|
||||
HASH_B = "hash-b"
|
||||
|
||||
TASK_PLAN = f"- [x] First milestone <!-- {HASH_A} -->\n- [/] Second milestone <!-- {HASH_B} -->"
|
||||
|
||||
|
||||
def _step(number: int, subgoal_hash: str, summary: str | None = None) -> dict:
|
||||
return {
|
||||
"step_id": f"id-{number}",
|
||||
"step_number": number,
|
||||
"relative_time": f"{number * 10}.0s",
|
||||
"summary": summary or f"Step {number} summary text.",
|
||||
"action_taken": {"action": "click", "target_text": f"button {number}"},
|
||||
"operator_raw_thinking": f"thinking {number}",
|
||||
"last_execution_result": {"status": "success"},
|
||||
"extra_metadata": {"subgoal_hash": subgoal_hash},
|
||||
}
|
||||
|
||||
|
||||
STEPS = [
|
||||
_step(1, HASH_A),
|
||||
_step(2, HASH_A),
|
||||
_step(3, HASH_A),
|
||||
_step(4, HASH_B),
|
||||
_step(5, HASH_B),
|
||||
_step(6, HASH_B),
|
||||
_step(7, HASH_B),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Golden parity: the policy table must reproduce every historical hard-coded
|
||||
# call byte for byte (kwargs recorded verbatim from the 2026-09-01 call sites).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
GOLDEN_CALLS = {
|
||||
"operator": lambda: build_plan_and_history(
|
||||
TASK_PLAN,
|
||||
STEPS,
|
||||
HASH_B,
|
||||
last_n_detailed=1,
|
||||
strict_milestone_pruning=True,
|
||||
recent_window_size=3,
|
||||
),
|
||||
"operator_cold_start": lambda: build_plan_and_history(
|
||||
TASK_PLAN,
|
||||
STEPS,
|
||||
HASH_B,
|
||||
min_summaries=len(STEPS),
|
||||
last_n_detailed=1,
|
||||
),
|
||||
"failure_analyzer": lambda: build_plan_and_history(
|
||||
TASK_PLAN,
|
||||
STEPS,
|
||||
HASH_B,
|
||||
last_n_detailed=1,
|
||||
strict_milestone_pruning=True,
|
||||
recent_window_size=3,
|
||||
for_failure_analyzer=True,
|
||||
),
|
||||
"planner": lambda: build_plan_and_history(
|
||||
"",
|
||||
STEPS,
|
||||
"default",
|
||||
last_n_detailed=2,
|
||||
strict_milestone_pruning=True,
|
||||
recent_window_size=5,
|
||||
),
|
||||
"outputter": lambda: build_plan_and_history(
|
||||
TASK_PLAN,
|
||||
STEPS,
|
||||
HASH_B,
|
||||
last_n_detailed=0,
|
||||
min_summaries=len(STEPS),
|
||||
strict_milestone_pruning=False,
|
||||
),
|
||||
"history_analyzer": lambda: build_plan_and_history(
|
||||
TASK_PLAN,
|
||||
STEPS,
|
||||
HASH_B,
|
||||
last_n_detailed=1,
|
||||
min_summaries=len(STEPS),
|
||||
),
|
||||
"diagnoser": lambda: build_plan_and_history(
|
||||
TASK_PLAN,
|
||||
STEPS,
|
||||
HASH_B,
|
||||
keep_subgoal_hashes={HASH_B},
|
||||
),
|
||||
"committee": lambda: build_plan_and_history(
|
||||
TASK_PLAN,
|
||||
STEPS,
|
||||
HASH_B,
|
||||
keep_subgoal_hashes={HASH_B},
|
||||
last_n_detailed=3,
|
||||
),
|
||||
}
|
||||
|
||||
POLICY_CALLS = {
|
||||
"operator": lambda: build_history_for(
|
||||
"operator", TASK_PLAN, STEPS, HASH_B, last_n_detailed=1
|
||||
),
|
||||
"operator_cold_start": lambda: build_history_for(
|
||||
"operator_cold_start", TASK_PLAN, STEPS, HASH_B
|
||||
),
|
||||
"failure_analyzer": lambda: build_history_for(
|
||||
"failure_analyzer", TASK_PLAN, STEPS, HASH_B
|
||||
),
|
||||
"planner": lambda: build_history_for("planner", "", STEPS, "default"),
|
||||
"outputter": lambda: build_history_for("outputter", TASK_PLAN, STEPS, HASH_B),
|
||||
"history_analyzer": lambda: build_history_for(
|
||||
"history_analyzer", TASK_PLAN, STEPS, HASH_B
|
||||
),
|
||||
"diagnoser": lambda: build_history_for(
|
||||
"diagnoser", TASK_PLAN, STEPS, HASH_B, keep_subgoal_hashes={HASH_B}
|
||||
),
|
||||
"committee": lambda: build_history_for(
|
||||
"committee", TASK_PLAN, STEPS, HASH_B, keep_subgoal_hashes={HASH_B}
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("agent", sorted(GOLDEN_CALLS))
|
||||
def test_policy_table_reproduces_call_site_bytes(agent):
|
||||
assert POLICY_CALLS[agent]() == GOLDEN_CALLS[agent]()
|
||||
|
||||
|
||||
def test_every_declared_policy_is_golden_tested():
|
||||
assert set(GOLDEN_CALLS) == set(CONTEXT_POLICIES)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config overrides
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _config_with_policies(policies):
|
||||
return SimpleNamespace(memory=SimpleNamespace(policies=policies))
|
||||
|
||||
|
||||
def test_resolve_policy_applies_config_override():
|
||||
cfg = _config_with_policies({"planner": {"last_n_detailed": 3, "bogus_field": 1}})
|
||||
with patch("artemis.config.load_agent_config", return_value=cfg):
|
||||
policy = resolve_policy("planner")
|
||||
assert policy.last_n_detailed == 3
|
||||
assert policy.strict_milestone_pruning is True # untouched fields survive
|
||||
|
||||
|
||||
def test_resolve_policy_unknown_agent_raises():
|
||||
with pytest.raises(KeyError):
|
||||
resolve_policy("no_such_agent")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Chunk hand-off (§10 decision 4)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
RENDERED_CHUNK = (
|
||||
"[Chunk 1 | Steps 1–3 | T+00:10 → T+00:30]\n"
|
||||
"\n"
|
||||
"① Synopsis & effects\n"
|
||||
" What this segment was doing: warming up.\n"
|
||||
"\n"
|
||||
"② Compressed step summary\n"
|
||||
" - Steps 1–3: opened the app and logged in\n"
|
||||
"\n"
|
||||
"③ Step action ledger\n"
|
||||
"- Step 1 (T+00:10): click['button 1'] -> executed\n"
|
||||
"- Step 2 (T+00:20): click['button 2'] -> executed\n"
|
||||
"- Step 3 (T+00:30): click['button 3'] -> executed"
|
||||
)
|
||||
|
||||
|
||||
class _FakeEngine:
|
||||
def __init__(self, rows):
|
||||
self._rows = rows
|
||||
|
||||
def get_history_chunks(self, **kwargs):
|
||||
return self._rows
|
||||
|
||||
|
||||
def _chunk_row():
|
||||
return SimpleNamespace(
|
||||
start_step_number=1,
|
||||
end_step_number=3,
|
||||
rendered_text=RENDERED_CHUNK,
|
||||
status="ready",
|
||||
)
|
||||
|
||||
|
||||
def _transcript_cfg(enabled: bool):
|
||||
return SimpleNamespace(
|
||||
memory=SimpleNamespace(
|
||||
transcript=SimpleNamespace(enabled=enabled), policies={}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_flag_off_output_is_byte_identical_even_with_engine():
|
||||
engine = _FakeEngine([_chunk_row()])
|
||||
with patch("artemis.config.load_agent_config", return_value=_transcript_cfg(False)):
|
||||
with_engine = build_history_for(
|
||||
"outputter", TASK_PLAN, STEPS, HASH_B, engine=engine
|
||||
)
|
||||
without_engine = build_history_for("outputter", TASK_PLAN, STEPS, HASH_B)
|
||||
assert with_engine == without_engine
|
||||
assert "[Chunk 1" not in with_engine
|
||||
|
||||
|
||||
def test_flag_on_full_view_renders_chunk_block_and_drops_covered_steps():
|
||||
engine = _FakeEngine([_chunk_row()])
|
||||
with patch("artemis.config.load_agent_config", return_value=_transcript_cfg(True)):
|
||||
out = build_history_for("outputter", TASK_PLAN, STEPS, HASH_B, engine=engine)
|
||||
# Full view: the whole three-band block including the ③ ledger.
|
||||
assert "[Chunk 1 | Steps 1–3" in out
|
||||
assert "③ Step action ledger" in out
|
||||
assert "- Step 2 (T+00:20)" in out
|
||||
# Covered per-step summary lines are replaced by the chunk block.
|
||||
assert "*Step 2 (Start: 20.0s)" not in out
|
||||
# Steps outside the chunk range still render.
|
||||
assert "Step 5" in out
|
||||
|
||||
|
||||
def test_flag_on_digest_view_replaces_ledger_with_recall_marker():
|
||||
engine = _FakeEngine([_chunk_row()])
|
||||
cfg = _transcript_cfg(True)
|
||||
with patch("artemis.config.load_agent_config", return_value=cfg):
|
||||
out = build_history_for(
|
||||
"diagnoser",
|
||||
TASK_PLAN,
|
||||
STEPS,
|
||||
HASH_B,
|
||||
keep_subgoal_hashes={HASH_A, HASH_B},
|
||||
engine=engine,
|
||||
)
|
||||
assert "[Chunk 1 | Steps 1–3" in out
|
||||
assert "② Compressed step summary" in out
|
||||
marker = DIGEST_LEDGER_MARKER_TEMPLATE.format(start=1, end=3)
|
||||
assert marker in out
|
||||
assert "- Step 2 (T+00:20)" not in out # digest carries no ③ rows
|
||||
|
||||
|
||||
def test_chunk_view_none_never_loads_chunks():
|
||||
engine = _FakeEngine([_chunk_row()])
|
||||
with patch("artemis.config.load_agent_config", return_value=_transcript_cfg(True)):
|
||||
assert load_chunk_blocks(engine, None) is None
|
||||
# The cold-start policy declares chunk_view=None: even with an engine
|
||||
# and the flag on, its output carries no chunk blocks (the restored
|
||||
# F region must stay a pure step rendering).
|
||||
out = build_history_for(
|
||||
"operator_cold_start", TASK_PLAN, STEPS, HASH_B, engine=engine
|
||||
)
|
||||
assert CONTEXT_POLICIES["operator_cold_start"].chunk_view is None
|
||||
assert "[Chunk 1" not in out
|
||||
|
||||
|
||||
def test_most_recent_step_is_never_suppressed_by_a_chunk():
|
||||
engine = _FakeEngine(
|
||||
[
|
||||
SimpleNamespace(
|
||||
start_step_number=1,
|
||||
end_step_number=7, # covers every step incl. the newest
|
||||
rendered_text=RENDERED_CHUNK,
|
||||
status="ready",
|
||||
)
|
||||
]
|
||||
)
|
||||
with patch("artemis.config.load_agent_config", return_value=_transcript_cfg(True)):
|
||||
out = build_history_for("outputter", TASK_PLAN, STEPS, HASH_B, engine=engine)
|
||||
# The most-recent step must keep its own (detailed) rendering.
|
||||
assert "Step 7" in out
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,187 @@
|
||||
# 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.
|
||||
|
||||
"""Unit tests for the shared StepMemoryService scheduling runtime (M1)."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from artemis.memory.step_memory import StepMemoryService
|
||||
|
||||
|
||||
class _ScriptedService(StepMemoryService):
|
||||
"""StepMemoryService with a scriptable attempt function and status log."""
|
||||
|
||||
def __init__(self, attempt_fn, **kwargs):
|
||||
super().__init__(Mock(), **kwargs)
|
||||
self._attempt_fn = attempt_fn
|
||||
self.status_events: list[tuple] = []
|
||||
|
||||
async def _attempt(self, key):
|
||||
return await self._attempt_fn(self, key)
|
||||
|
||||
def _on_status(self, key, status):
|
||||
self.status_events.append((key, status))
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_blocking_submit_and_flush():
|
||||
"""submit() returns immediately; flush() drains the background job."""
|
||||
release = asyncio.Event()
|
||||
|
||||
async def attempt(svc, key):
|
||||
await release.wait()
|
||||
svc._summaries[key] = f"summary-for-{key}"
|
||||
return True
|
||||
|
||||
service = _ScriptedService(attempt)
|
||||
service.submit("step-1", {"step_number": 1})
|
||||
|
||||
# Zero-blocking: the job cannot have completed yet (its gate is closed).
|
||||
assert service.is_pending("step-1")
|
||||
assert not service.has_summary("step-1")
|
||||
|
||||
release.set()
|
||||
await service.flush()
|
||||
|
||||
assert service.get_summary("step-1") == "summary-for-step-1"
|
||||
assert not service.is_pending("step-1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bounded_retry_enters_failed_state():
|
||||
"""A permanently failing job stops after 1 + retry_limit attempts."""
|
||||
attempts = 0
|
||||
|
||||
async def attempt(svc, key):
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
return False
|
||||
|
||||
service = _ScriptedService(attempt, retry_limit=2)
|
||||
service._retry_delays = (0.0,)
|
||||
service.submit("step-x", {"step_number": 7})
|
||||
await service.flush()
|
||||
|
||||
assert attempts == 3
|
||||
assert service.has_failed("step-x")
|
||||
assert not service.has_summary("step-x")
|
||||
# The failed job still reads as pending (lossless semantics downstream).
|
||||
assert service.is_pending("step-x")
|
||||
assert service.status_events == [("step-x", "pending"), ("step-x", "failed")]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_step_id_keying_with_tool_call_alias():
|
||||
"""Jobs keyed by step id remain queryable through tool_call_id aliases."""
|
||||
|
||||
async def attempt(svc, key):
|
||||
svc._summaries[key] = "canonical summary"
|
||||
return True
|
||||
|
||||
service = _ScriptedService(attempt)
|
||||
service.submit("uuid-123", {"step_number": 4}, aliases=("tc-abc",))
|
||||
await service.flush()
|
||||
|
||||
assert service.resolve_key("tc-abc") == "uuid-123"
|
||||
assert service.has_job("tc-abc")
|
||||
assert service.has_summary("tc-abc")
|
||||
assert service.get_summary("tc-abc") == "canonical summary"
|
||||
assert service.get_summary("uuid-123") == "canonical summary"
|
||||
assert service.get_job_payload("tc-abc") == {"step_number": 4}
|
||||
assert not service.has_failed("tc-abc")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_max_concurrency_bounds_parallel_attempts():
|
||||
"""With max_concurrency=1, two jobs never run their attempts concurrently."""
|
||||
active = 0
|
||||
max_active = 0
|
||||
|
||||
async def attempt(svc, key):
|
||||
nonlocal active, max_active
|
||||
active += 1
|
||||
max_active = max(max_active, active)
|
||||
await asyncio.sleep(0.01)
|
||||
active -= 1
|
||||
svc._summaries[key] = "done"
|
||||
return True
|
||||
|
||||
service = _ScriptedService(attempt, max_concurrency=1)
|
||||
service.submit("a", {"step_number": 1})
|
||||
service.submit("b", {"step_number": 2})
|
||||
await service.flush()
|
||||
|
||||
assert service.has_summary("a") and service.has_summary("b")
|
||||
assert max_active == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_timeout_cancels_stragglers():
|
||||
"""flush() cancels jobs that outlive the timeout without failing them."""
|
||||
|
||||
async def attempt(svc, key):
|
||||
await asyncio.sleep(30)
|
||||
return True
|
||||
|
||||
service = _ScriptedService(attempt)
|
||||
service.submit("slow", {"step_number": 1})
|
||||
await service.flush(timeout_seconds=0.05)
|
||||
|
||||
assert service.is_pending("slow")
|
||||
assert not service.has_failed("slow")
|
||||
assert service._pending_tasks["slow"].done()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flush_uses_configured_default_timeout():
|
||||
"""flush() with no argument honors the constructor's flush_timeout_s."""
|
||||
|
||||
async def attempt(svc, key):
|
||||
await asyncio.sleep(30)
|
||||
return True
|
||||
|
||||
service = _ScriptedService(attempt, flush_timeout_s=0.05)
|
||||
service.submit("slow", {"step_number": 1})
|
||||
await asyncio.wait_for(service.flush(), timeout=5)
|
||||
|
||||
assert service._pending_tasks["slow"].done()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resubmit_clears_failed_state():
|
||||
"""Re-submitting a failed job gives it a fresh retry budget."""
|
||||
behaviors = {"fail": True}
|
||||
|
||||
async def attempt(svc, key):
|
||||
if behaviors["fail"]:
|
||||
return False
|
||||
svc._summaries[key] = "second time lucky"
|
||||
return True
|
||||
|
||||
service = _ScriptedService(attempt, retry_limit=0)
|
||||
service._retry_delays = (0.0,)
|
||||
service.submit("job", {"step_number": 1})
|
||||
await service.flush()
|
||||
assert service.has_failed("job")
|
||||
|
||||
behaviors["fail"] = False
|
||||
service._retry_counts.clear()
|
||||
service.submit("job", {"step_number": 1})
|
||||
await service.flush()
|
||||
|
||||
assert not service.has_failed("job")
|
||||
assert service.get_summary("job") == "second time lucky"
|
||||
@@ -0,0 +1,298 @@
|
||||
# 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.
|
||||
|
||||
"""Unit tests for the Pro session transcript ledger (history redesign §3.2, M2).
|
||||
|
||||
Covers the four-region discipline: S-region byte stability across turns, the
|
||||
append-only active region, depth-1 stripping of old UI lists and plan
|
||||
recitations, the depth-K screenshot scrub in the Pro message shape (grace /
|
||||
placeholder / freeze — the M1 race regressions re-run against HumanMessage
|
||||
observations keyed by step id), ``T+mm:ss`` session-offset timestamps, the
|
||||
tool-call/response pairing invariant, and the cold-start restored-history
|
||||
block.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
|
||||
|
||||
from artemis.memory.step_memory import StepMemoryService
|
||||
from artemis.memory.transcript import (
|
||||
EXECUTION_RESULT_MARKER,
|
||||
PLAN_RECITATION_MARKER,
|
||||
PRO_UI_LIST_MARKER,
|
||||
RESTORED_HISTORY_HEADER,
|
||||
TranscriptLedger,
|
||||
format_session_offset,
|
||||
)
|
||||
|
||||
OFFSET_RE = re.compile(r"T\+\d{2,}:\d{2}")
|
||||
|
||||
|
||||
def _service() -> StepMemoryService:
|
||||
return StepMemoryService(ctx=None)
|
||||
|
||||
|
||||
def _observation(i: int) -> HumanMessage:
|
||||
return HumanMessage(
|
||||
content=[
|
||||
{"type": "text", "text": f"# CURRENT OBSERVATION [T+00:0{i % 10}]"},
|
||||
{"type": "text", "text": f"{PLAN_RECITATION_MARKER}\n- [/] milestone {i}"},
|
||||
{"type": "text", "text": "--- Current Screenshot ---"},
|
||||
{"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,IMG_{i}"}},
|
||||
{"type": "text", "text": f"{PRO_UI_LIST_MARKER}\n[1] button {i}"},
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _turn(i: int, tool_call_count: int = 1) -> list:
|
||||
messages: list = [_observation(i)]
|
||||
if tool_call_count:
|
||||
tool_calls = [
|
||||
{"name": "click", "args": {"target": n + 1}, "id": f"tc{i}-{n}", "type": "tool_call"}
|
||||
for n in range(tool_call_count)
|
||||
]
|
||||
messages.append(AIMessage(content=f"thinking {i}", tool_calls=tool_calls))
|
||||
for tc in tool_calls:
|
||||
messages.append(ToolMessage(tool_call_id=tc["id"], content="Action Recorded"))
|
||||
return messages
|
||||
|
||||
|
||||
def _play_turn(ledger: TranscriptLedger, i: int, *, prev_key=None, prev_result=None, **turn_kwargs):
|
||||
"""Mimic the operator's real ordering: commit previous, render, stage."""
|
||||
ledger.commit_staged(step_key=prev_key, validator_result=prev_result)
|
||||
turn = _turn(i, **turn_kwargs)
|
||||
rendered = ledger.render([turn[0]])
|
||||
ledger.stage_turn(turn)
|
||||
return rendered
|
||||
|
||||
|
||||
def _fingerprint(msg) -> str:
|
||||
return json.dumps(msg.content, sort_keys=True, default=str)
|
||||
|
||||
|
||||
def _has_image(msg) -> bool:
|
||||
return any(
|
||||
isinstance(b, dict) and b.get("type") in ("image_url", "image") for b in msg.content
|
||||
)
|
||||
|
||||
|
||||
def test_format_session_offset():
|
||||
assert format_session_offset(0) == "T+00:00"
|
||||
assert format_session_offset(83) == "T+01:23"
|
||||
# Minutes never wrap into hours: byte-stable monotonic labels.
|
||||
assert format_session_offset(3725) == "T+62:05"
|
||||
assert format_session_offset(-5) == "T+00:00"
|
||||
|
||||
|
||||
def test_static_prefix_set_once_and_byte_stable():
|
||||
ledger = TranscriptLedger(step_memory=_service())
|
||||
system = SystemMessage(content="STATIC SYSTEM PROMPT")
|
||||
ledger.set_static_prefix([system])
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
ledger.set_static_prefix([SystemMessage(content="other")])
|
||||
|
||||
fingerprints = set()
|
||||
for i in range(1, 6):
|
||||
rendered = _play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None)
|
||||
assert rendered[0] is system
|
||||
fingerprints.add(rendered[0].content)
|
||||
assert fingerprints == {"STATIC SYSTEM PROMPT"}
|
||||
|
||||
|
||||
def test_active_region_is_append_only():
|
||||
ledger = TranscriptLedger(step_memory=_service())
|
||||
seen_ids: list[int] = []
|
||||
for i in range(1, 5):
|
||||
_play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None)
|
||||
active = ledger.active_messages
|
||||
# The previously observed prefix is unchanged in identity and order.
|
||||
assert [id(m) for m in active[: len(seen_ids)]] == seen_ids
|
||||
seen_ids = [id(m) for m in active]
|
||||
assert ledger.turn_count == 3 # 3 committed, 1 still staged
|
||||
|
||||
|
||||
def test_validator_result_message_carries_session_offset():
|
||||
ledger = TranscriptLedger(step_memory=_service())
|
||||
_play_turn(ledger, 1)
|
||||
_play_turn(
|
||||
ledger,
|
||||
2,
|
||||
prev_key="step-1",
|
||||
prev_result={"status": "success", "execution": [{"action": "tap", "attempts": ["ok"]}]},
|
||||
)
|
||||
|
||||
result_messages = [
|
||||
m
|
||||
for m in ledger.active_messages
|
||||
if isinstance(m, HumanMessage)
|
||||
and str(m.content).find(EXECUTION_RESULT_MARKER) >= 0
|
||||
]
|
||||
assert len(result_messages) == 1
|
||||
text = result_messages[0].content[0]["text"]
|
||||
assert OFFSET_RE.search(text), text
|
||||
assert "Status: success" in text
|
||||
assert "ago" not in text
|
||||
|
||||
|
||||
def test_validator_result_skipped_for_actionless_turn():
|
||||
ledger = TranscriptLedger(step_memory=_service())
|
||||
_play_turn(ledger, 1)
|
||||
_play_turn(ledger, 2, prev_key="step-1", prev_result=None)
|
||||
assert not any(
|
||||
EXECUTION_RESULT_MARKER in str(m.content) for m in ledger.active_messages
|
||||
)
|
||||
|
||||
|
||||
def test_old_turn_recitation_and_ui_list_stripped_at_depth_1():
|
||||
ledger = TranscriptLedger(step_memory=_service())
|
||||
_play_turn(ledger, 1)
|
||||
rendered = _play_turn(ledger, 2, prev_key="step-1")
|
||||
|
||||
committed_obs = ledger.active_messages[0]
|
||||
committed_text = str(committed_obs.content)
|
||||
# Depth-1 scrub: the previous turn's plan recitation and UI list are gone.
|
||||
assert PLAN_RECITATION_MARKER not in committed_text
|
||||
assert PRO_UI_LIST_MARKER not in committed_text
|
||||
# Its screenshot is still inside the K-depth active window.
|
||||
assert _has_image(committed_obs)
|
||||
# The live tail keeps both.
|
||||
tail_text = str(rendered[-1].content)
|
||||
assert PLAN_RECITATION_MARKER in tail_text
|
||||
assert PRO_UI_LIST_MARKER in tail_text
|
||||
|
||||
|
||||
def test_depth_k_image_resolved_to_ready_visual_summary():
|
||||
service = _service()
|
||||
service._step_inputs["step-1"] = {"step_number": 1}
|
||||
service._summaries["step-1"] = "Objective visual transition 1."
|
||||
|
||||
ledger = TranscriptLedger(step_memory=service, image_scrub_depth=3)
|
||||
for i in range(1, 5):
|
||||
_play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None)
|
||||
|
||||
first_obs = ledger.active_messages[0]
|
||||
assert not _has_image(first_obs)
|
||||
assert {
|
||||
"type": "text",
|
||||
"text": "--- Historical Visual Transition ---\nObjective visual transition 1.",
|
||||
} in first_obs.content
|
||||
|
||||
|
||||
def test_pending_grace_then_placeholder_never_backfilled():
|
||||
service = _service()
|
||||
service._step_inputs["step-1"] = {"step_number": 1} # pending job, no summary
|
||||
|
||||
ledger = TranscriptLedger(
|
||||
step_memory=service, image_scrub_depth=2, pending_grace_steps=1
|
||||
)
|
||||
# Enough turns to push turn 1 past K + grace.
|
||||
for i in range(1, 7):
|
||||
_play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None)
|
||||
|
||||
first_obs = ledger.active_messages[0]
|
||||
assert not _has_image(first_obs)
|
||||
assert "[visual summary pending; evidence at DataEngine step 1]" in str(
|
||||
first_obs.content
|
||||
)
|
||||
frozen = _fingerprint(first_obs)
|
||||
|
||||
# A late summary must never mutate the frozen message.
|
||||
service._summaries["step-1"] = "Too late."
|
||||
_play_turn(ledger, 7, prev_key="step-6")
|
||||
assert _fingerprint(ledger.active_messages[0]) == frozen
|
||||
|
||||
|
||||
def test_failed_summary_becomes_unavailable_placeholder():
|
||||
service = _service()
|
||||
service._step_inputs["step-1"] = {"step_number": 1}
|
||||
service._failed.add("step-1")
|
||||
|
||||
ledger = TranscriptLedger(
|
||||
step_memory=service, image_scrub_depth=2, pending_grace_steps=5
|
||||
)
|
||||
for i in range(1, 4):
|
||||
_play_turn(ledger, i, prev_key=f"step-{i - 1}" if i > 1 else None)
|
||||
|
||||
first_obs = ledger.active_messages[0]
|
||||
assert not _has_image(first_obs)
|
||||
assert "[visual summary unavailable; evidence at DataEngine step 1]" in str(
|
||||
first_obs.content
|
||||
)
|
||||
|
||||
|
||||
def test_tool_call_response_pairs_are_never_split():
|
||||
ledger = TranscriptLedger(step_memory=_service(), image_scrub_depth=2)
|
||||
for i in range(1, 7):
|
||||
_play_turn(
|
||||
ledger,
|
||||
i,
|
||||
prev_key=f"step-{i - 1}" if i > 1 else None,
|
||||
prev_result={"status": "success"} if i > 1 else None,
|
||||
tool_call_count=2,
|
||||
)
|
||||
|
||||
active = list(ledger.active_messages)
|
||||
for idx, msg in enumerate(active):
|
||||
if isinstance(msg, AIMessage) and msg.tool_calls:
|
||||
following = active[idx + 1 : idx + 1 + len(msg.tool_calls)]
|
||||
assert [getattr(m, "tool_call_id", None) for m in following] == [
|
||||
tc["id"] for tc in msg.tool_calls
|
||||
], f"tool-call pairing split at active index {idx}"
|
||||
|
||||
|
||||
def test_restored_history_only_seeds_an_empty_ledger():
|
||||
ledger = TranscriptLedger(step_memory=_service())
|
||||
ledger.set_restored_history(f"{RESTORED_HISTORY_HEADER} steps 1-9 ...")
|
||||
assert ledger.has_restored_history
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
ledger.set_restored_history("again")
|
||||
|
||||
ledger.set_static_prefix([SystemMessage(content="S")])
|
||||
rendered = _play_turn(ledger, 1)
|
||||
# Order: S region, then the frozen restored block, then the live tail.
|
||||
assert rendered[0].content == "S"
|
||||
assert RESTORED_HISTORY_HEADER in str(rendered[1].content)
|
||||
assert "# CURRENT OBSERVATION" in str(rendered[-1].content)
|
||||
|
||||
ledger2 = TranscriptLedger(step_memory=_service())
|
||||
_play_turn(ledger2, 1)
|
||||
ledger2.commit_staged(step_key="step-1")
|
||||
with pytest.raises(RuntimeError):
|
||||
ledger2.set_restored_history("late")
|
||||
|
||||
|
||||
def test_stage_twice_commits_the_forgotten_turn():
|
||||
ledger = TranscriptLedger(step_memory=_service())
|
||||
ledger.stage_turn(_turn(1))
|
||||
ledger.stage_turn(_turn(2))
|
||||
assert ledger.turn_count == 1
|
||||
assert ledger.has_staged_turn
|
||||
|
||||
|
||||
def test_no_ago_wording_in_ledger_output():
|
||||
ledger = TranscriptLedger(step_memory=_service())
|
||||
for i in range(1, 4):
|
||||
_play_turn(
|
||||
ledger,
|
||||
i,
|
||||
prev_key=f"step-{i - 1}" if i > 1 else None,
|
||||
prev_result={"status": "success"} if i > 1 else None,
|
||||
)
|
||||
blob = " ".join(str(m.content) for m in ledger.active_messages)
|
||||
assert " ago" not in blob
|
||||
Reference in new issue
Block a user