Compaction: the model's notes replace the middle of a long run (#158)
Before each driver call, over compact_at (0.6) of the prompt budget, the same model is asked for notes over the very request it was about to get, plus one message, so the server's cache covers all but the notes. The notes, with code's list of the calls they replace, become a compaction event that renders in place of its range, keeping word for word what started the work and the last few turns. Rare, large steps keep the prompt's start unchanged between them. A later compaction folds in the earlier; one from an earlier run's view is hidden. Failed notes are logged and fitting takes over. Fitting now applies compactions before it measures, records what it cuts (Trim) and logs each cut as context.trimmed. Wired into both agents' drivers and the benchmark; nisse-dev's helper loop is a follow-up. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J5Utyfzw7gF7UhjRDEtjTk
This commit is contained in:
1 parent
fa8d93cf41
commit
cb638fe687
20 files changed
+952
-44
No files matched your search
@@ -2,7 +2,8 @@
|
||||
tool, runs out of turns, or is stopped.
|
||||
|
||||
Everything the run does is written to the item's transcript as the driver
|
||||
(L7), and every model call sees the driver's view of it (L6). Execution tools
|
||||
(L7), and every model call sees the driver's view of it (L6), compacted
|
||||
when it grows long (``compact``). Execution tools
|
||||
go to the item's executor, on its tool VM (L13); control tools are answered
|
||||
here, and a waiting one ends the run, so the scheduler can put it to the
|
||||
operator. Which tools wait is the agent's: each run is told.
|
||||
@@ -14,6 +15,7 @@ from typing import Any, Literal, Protocol
|
||||
|
||||
from nisse_core.log.canonical import AssistantText, Fact, ToolCall, ToolResult
|
||||
from nisse_core.loop.tools import names
|
||||
from nisse_core.models.compact import Compactor
|
||||
from nisse_core.models.llm import LLMClient, LLMError
|
||||
from nisse_core.state.cancel import Cancel, skip_calls
|
||||
from nisse_core.state.store import Transcript
|
||||
@@ -82,8 +84,10 @@ class Driver:
|
||||
max_turns: int = DEFAULT_MAX_TURNS,
|
||||
cancel: Cancel | None = None,
|
||||
failure_limit: int = 0, # 0: no limit
|
||||
compactor: Compactor | None = None,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._compactor = compactor
|
||||
self._model = model
|
||||
self._max_turns = max_turns
|
||||
self._failure_limit = failure_limit
|
||||
@@ -123,6 +127,12 @@ class Driver:
|
||||
# L2: stop takes effect at the next tool boundary.
|
||||
if self._cancel.requested:
|
||||
return RunOutcome("stopped", note=f"before turn {turn + 1}")
|
||||
if self._compactor is not None:
|
||||
# Not a turn: the model's notes replace the middle of a long
|
||||
# run before it outgrows the window (L6).
|
||||
self._compactor.compact(
|
||||
transcript, system=system, tools=tools, model=model
|
||||
)
|
||||
try:
|
||||
produced = self._client.complete(
|
||||
transcript.view(), tools=tools, model=model, system=system
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""Compaction (architecture L6, L7): the model's own notes replace the middle
|
||||
of a long run, so it stays inside the window without losing what it found.
|
||||
|
||||
Before each model call of a driver run, ``Compactor.compact`` looks at the
|
||||
size of the request. Below ``compact_at`` (a fraction of the prompt budget)
|
||||
it does nothing. Above it, in one step:
|
||||
|
||||
1. It keeps, word for word, everything before the first turn (the brief, or
|
||||
the conversation that started the work) and the last few turns, as many
|
||||
as fit in ``compact_to`` of the budget, at most ``keep_turns``.
|
||||
2. It asks the same model, over the very same request plus one more
|
||||
message, for notes on everything in between. The model server has that
|
||||
request cached from the last call, so the notes cost only their own
|
||||
output. The model offered the run's tools is not made to call one.
|
||||
3. It records a ``Compaction`` event replacing that range, the notes with a
|
||||
list of the calls made in it appended by code, so the model knows what it
|
||||
can look at again. The log keeps everything it replaces.
|
||||
|
||||
Doing this rarely, and well below the limit, keeps the start of the prompt
|
||||
the same for many calls, which is what lets the server reuse its cache.
|
||||
Cutting a little every call, as fitting does, changes it every time.
|
||||
|
||||
A compaction that fails (the call fails, the notes are empty, or they leave
|
||||
the request still over ``compact_at``) is logged and the run goes on; the
|
||||
request is then fitted as before (``context``). It isn't tried again until
|
||||
``keep_turns`` more turns have passed.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from nisse_core.log.canonical import (
|
||||
AssistantText,
|
||||
Compaction,
|
||||
StoredEvent,
|
||||
ToolCall,
|
||||
UserMessage,
|
||||
)
|
||||
from nisse_core.models.context import _MODEL_SIDE, _turn_starts
|
||||
from nisse_core.models.llm import LLMError
|
||||
from nisse_core.models.render import effective_events
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_core.state.store import Transcript
|
||||
|
||||
# The list of calls appended to the notes: how many, and how long each line.
|
||||
MAX_LISTED_CALLS = 80
|
||||
LISTED_CALL_CHARS = 200
|
||||
|
||||
NOTES_REQUEST = """\
|
||||
[orchestrator] Your context is nearly full. Write notes to replace the \
|
||||
conversation from your task up to your last few turns (the task itself and \
|
||||
those turns stay as they are). Reply with the notes as plain text, not a \
|
||||
tool call. Include:
|
||||
|
||||
- What the task is, and what you have done so far.
|
||||
- Your findings and conclusions so far, each with its file and line and \
|
||||
the exact code or text it rests on, quoted.
|
||||
- Decisions made and anything the operator or a peer asked for, word for \
|
||||
word where the wording matters.
|
||||
- What each file you read or command you ran told you that you still need.
|
||||
- What is left to do, in order.
|
||||
|
||||
Leave out what you no longer need. These notes are all you will have of that \
|
||||
part of the conversation; anything else you would have to look at again."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompactionTuning:
|
||||
compact_at: float = 0.6 # of the prompt budget; 0: never compact
|
||||
compact_to: float = 0.25 # what the kept recent turns may take
|
||||
keep_turns: int = 3 # recent turns kept word for word, at most
|
||||
|
||||
|
||||
def check_tuning(tuning: CompactionTuning, section: str, errors: list[str]) -> None:
|
||||
"""The agents' config check: notes must leave the request smaller than
|
||||
what set them off, or every call would compact again."""
|
||||
if tuning.compact_at and not tuning.compact_to < tuning.compact_at <= 1:
|
||||
errors.append(
|
||||
f"{section}: compact_to must be less than compact_at, which is at "
|
||||
"most 1 (or 0: never compact)"
|
||||
)
|
||||
|
||||
|
||||
class Compactor:
|
||||
def __init__(
|
||||
self,
|
||||
client: Any,
|
||||
tuning: CompactionTuning | None = None,
|
||||
*,
|
||||
oplog: OpLog | None = None,
|
||||
) -> None:
|
||||
"""``client``: the run's client. Compaction needs its budget
|
||||
(``budget_for``, ``estimate``); with a client that has none, as in
|
||||
tests with a scripted model, it never compacts."""
|
||||
self._client = client
|
||||
self._tuning = tuning or CompactionTuning()
|
||||
self._oplog = oplog
|
||||
# Per run (work item, first event shown): the number of turns when
|
||||
# compaction last failed.
|
||||
self._failed_at: dict[tuple[str | None, int], int] = {}
|
||||
|
||||
def compact(
|
||||
self,
|
||||
transcript: Transcript,
|
||||
*,
|
||||
system: str | None,
|
||||
tools: list[dict[str, Any]] | None,
|
||||
model: str,
|
||||
) -> bool:
|
||||
"""Compact the transcript if its request is over ``compact_at``.
|
||||
True if a compaction was recorded."""
|
||||
tuning = self._tuning
|
||||
budget_for = getattr(self._client, "budget_for", None)
|
||||
estimate = getattr(self._client, "estimate", None)
|
||||
if not tuning.compact_at or budget_for is None or estimate is None:
|
||||
return False
|
||||
limit = budget_for(model).for_prompt
|
||||
events = effective_events(transcript.view())
|
||||
before = estimate(events, system, tools, model)
|
||||
if before <= tuning.compact_at * limit:
|
||||
return False
|
||||
|
||||
starts = _turn_starts(events)
|
||||
run = (transcript.work_item, events[0].id if events else 0)
|
||||
failed_at = self._failed_at.get(run)
|
||||
if failed_at is not None and len(starts) < failed_at + tuning.keep_turns:
|
||||
return False
|
||||
head = _head_end(events)
|
||||
# The recent turns kept: at most keep_turns, as many as fit in
|
||||
# compact_to, and at least one, so the model sees where it is.
|
||||
later = [s for s in starts if s > head]
|
||||
recent_from = len(events)
|
||||
for start in reversed(later[-tuning.keep_turns :]):
|
||||
if recent_from < len(events) and (
|
||||
estimate(events[start:], None, None, model) > tuning.compact_to * limit
|
||||
):
|
||||
break
|
||||
recent_from = start
|
||||
middle = events[head:recent_from]
|
||||
turns = sum(1 for s in later if s < recent_from)
|
||||
if turns < 2:
|
||||
# Too little to be worth notes (or nothing at all): fitting
|
||||
# covers it, and logs what it cuts.
|
||||
return False
|
||||
|
||||
started = time.monotonic()
|
||||
try:
|
||||
notes = self._notes(events, system, tools, model)
|
||||
except LLMError as exc: # a failed compaction never fails the run
|
||||
return self._failed(run, transcript, len(starts), f"the call failed: {exc}")
|
||||
if not notes:
|
||||
return self._failed(run, transcript, len(starts), "the notes were empty")
|
||||
|
||||
first = min(
|
||||
e.event.replaces[0] if isinstance(e.event, Compaction) else e.id
|
||||
for e in middle
|
||||
)
|
||||
last = max(e.id for e in middle if not isinstance(e.event, Compaction))
|
||||
listed = _calls(middle)
|
||||
summary = notes + (
|
||||
"\n\nCalls made in the part these notes replace (make one again "
|
||||
"to see its result):\n" + "\n".join(listed)
|
||||
if listed
|
||||
else ""
|
||||
)
|
||||
compaction = Compaction(summary=summary, model=model, replaces=[first, last])
|
||||
placed = StoredEvent(id=first, ts="", event=compaction)
|
||||
after = estimate(
|
||||
[*events[:head], placed, *events[recent_from:]], system, tools, model
|
||||
)
|
||||
if after > tuning.compact_at * limit:
|
||||
return self._failed(
|
||||
run,
|
||||
transcript,
|
||||
len(starts),
|
||||
f"the request would still be {after} tokens, over "
|
||||
f"{int(tuning.compact_at * limit)}",
|
||||
)
|
||||
transcript.append(compaction)
|
||||
self._failed_at.pop(run, None)
|
||||
if self._oplog is not None:
|
||||
self._oplog.emit(
|
||||
"context.compacted",
|
||||
cause=f"the request was {before} tokens of {limit}",
|
||||
work_item=transcript.work_item,
|
||||
model=model,
|
||||
tokens_before=before,
|
||||
tokens_after=after,
|
||||
replaced=[first, last],
|
||||
turns_replaced=turns,
|
||||
seconds=round(time.monotonic() - started, 1),
|
||||
)
|
||||
return True
|
||||
|
||||
def _notes(
|
||||
self,
|
||||
events: list[StoredEvent],
|
||||
system: str | None,
|
||||
tools: list[dict[str, Any]] | None,
|
||||
model: str,
|
||||
) -> str:
|
||||
# The same system prompt and tools as the run's calls, so the server
|
||||
# reuses the cached request: only the last message is new.
|
||||
ask = StoredEvent(
|
||||
id=0, ts="", event=UserMessage(text=NOTES_REQUEST, source="compaction")
|
||||
)
|
||||
produced = self._client.complete(
|
||||
[*events, ask], tools, model, system=system, require_tool_call=False
|
||||
)
|
||||
return "\n".join(
|
||||
e.text.strip() for e in produced if isinstance(e, AssistantText)
|
||||
).strip()
|
||||
|
||||
def _failed(
|
||||
self,
|
||||
run: tuple[str | None, int],
|
||||
transcript: Transcript,
|
||||
turns: int,
|
||||
why: str,
|
||||
) -> bool:
|
||||
self._failed_at[run] = turns
|
||||
if self._oplog is not None:
|
||||
self._oplog.emit(
|
||||
"context.compaction_failed",
|
||||
cause=why[:300],
|
||||
work_item=transcript.work_item,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def _head_end(events: Sequence[StoredEvent]) -> int:
|
||||
"""Where the part before the first turn ends: the brief, or what started
|
||||
the work. An earlier compaction is the start of the middle."""
|
||||
for index, stored in enumerate(events):
|
||||
if isinstance(stored.event, (*_MODEL_SIDE, Compaction)):
|
||||
return index
|
||||
return len(events)
|
||||
|
||||
|
||||
def _calls(events: Sequence[StoredEvent]) -> list[str]:
|
||||
"""The calls made in ``events``, once each, in order, as lines; earlier
|
||||
compactions' lists carried over."""
|
||||
lines: list[str] = []
|
||||
for stored in events:
|
||||
event = stored.event
|
||||
if isinstance(event, Compaction):
|
||||
_, marker, listed = event.summary.partition(
|
||||
"(make one again to see its result):\n"
|
||||
)
|
||||
if marker:
|
||||
lines.extend(listed.splitlines())
|
||||
elif isinstance(event, ToolCall):
|
||||
arguments = json.dumps(event.arguments, ensure_ascii=False)
|
||||
line = f"- {event.tool} {arguments}"
|
||||
if len(line) > LISTED_CALL_CHARS:
|
||||
line = line[: LISTED_CALL_CHARS - 3] + "..."
|
||||
lines.append(line)
|
||||
unique = list(dict.fromkeys(lines))
|
||||
return unique[-MAX_LISTED_CALLS:]
|
||||
@@ -6,8 +6,15 @@ start of the prompt, which is where the system prompt and the plan are. So a
|
||||
request is made to fit the model's declared window *before* it is sent, and
|
||||
one that cannot be made to fit is never sent.
|
||||
|
||||
The log itself is never changed. Fitting works on the events about to be
|
||||
rendered, in three steps, stopping at the first that fits:
|
||||
The log itself is never changed. Compaction (``compact``) is what keeps a
|
||||
long run inside the window: the model's own notes replace the middle of the
|
||||
conversation, now and then, in one large step. Fitting is the last guard
|
||||
under it, and normally sends everything as it is. When it has to cut, the
|
||||
cut is recorded (``Trim``) and logged as ``context.trimmed``: after
|
||||
compaction that should be rare, and each one is worth a look.
|
||||
|
||||
Fitting works on the events about to be rendered, compactions applied, in
|
||||
three steps, stopping at the first that fits:
|
||||
|
||||
1. Tool results older than the most recent turns are cut to their first and
|
||||
last lines, with a note saying how much was left out.
|
||||
@@ -39,6 +46,8 @@ from nisse_core.log.canonical import (
|
||||
UserMessage,
|
||||
)
|
||||
from nisse_core.models.llm import DEFAULT_MAX_OUTPUT_TOKENS, LLMError
|
||||
from nisse_core.models.render import effective_events
|
||||
from nisse_core.oplog import OpLog
|
||||
|
||||
# English and code run at about four characters a token. Three is deliberately
|
||||
# pessimistic, before any correction from what the server reports.
|
||||
@@ -129,6 +138,18 @@ class Budget:
|
||||
return int(self.window * (1 - self.safety_margin)) - self.max_output
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Trim:
|
||||
"""What fitting cut from one request."""
|
||||
|
||||
step: int # 1: older results shortened; 2: the oldest events dropped too
|
||||
tokens_before: int
|
||||
tokens_after: int
|
||||
limit: int
|
||||
results_cut: int
|
||||
events_dropped: int
|
||||
|
||||
|
||||
def fit(
|
||||
events: Sequence[StoredEvent],
|
||||
system: str | None,
|
||||
@@ -138,14 +159,29 @@ def fit(
|
||||
) -> list[StoredEvent]:
|
||||
"""The events to render: all of them if they fit, otherwise the result of
|
||||
the three steps in the module docstring."""
|
||||
return fit_report(events, system, tools, budget, factor)[0]
|
||||
|
||||
|
||||
def fit_report(
|
||||
events: Sequence[StoredEvent],
|
||||
system: str | None,
|
||||
tools: list[dict[str, Any]] | None,
|
||||
budget: Budget,
|
||||
factor: float = 1.0,
|
||||
) -> tuple[list[StoredEvent], Trim | None]:
|
||||
"""``fit``, and what it cut: None when nothing was."""
|
||||
limit = budget.for_prompt
|
||||
|
||||
def tokens(candidate: Sequence[StoredEvent]) -> int:
|
||||
return estimate_tokens(candidate, system, tools, factor)
|
||||
|
||||
def fits(candidate: Sequence[StoredEvent]) -> bool:
|
||||
return estimate_tokens(candidate, system, tools, factor) <= limit
|
||||
return tokens(candidate) <= limit
|
||||
|
||||
kept = list(events)
|
||||
if fits(kept):
|
||||
return kept
|
||||
before = tokens(kept)
|
||||
if before <= limit:
|
||||
return kept, None
|
||||
|
||||
starts = _turn_starts(kept)
|
||||
recent_from = (
|
||||
@@ -155,6 +191,13 @@ def fit(
|
||||
)
|
||||
|
||||
# Step 1: cut older tool results down.
|
||||
results_cut = sum(
|
||||
1
|
||||
for index, stored in enumerate(kept)
|
||||
if index < recent_from
|
||||
and isinstance(stored.event, ToolResult)
|
||||
and len(stored.event.result) > ELIDED_RESULT_CHARS
|
||||
)
|
||||
kept = [
|
||||
replace(
|
||||
stored,
|
||||
@@ -168,7 +211,7 @@ def fit(
|
||||
for index, stored in enumerate(kept)
|
||||
]
|
||||
if fits(kept):
|
||||
return kept
|
||||
return kept, Trim(1, before, tokens(kept), limit, results_cut, 0)
|
||||
|
||||
# Step 2: drop the oldest events, one turn at a time, never the recent
|
||||
# ones. The note keeps the transcript starting with a user message, which
|
||||
@@ -189,7 +232,10 @@ def fit(
|
||||
)
|
||||
candidate = [note, *remaining]
|
||||
if fits(candidate):
|
||||
return candidate
|
||||
dropped = len(kept) - len(remaining)
|
||||
return candidate, Trim(
|
||||
2, before, tokens(candidate), limit, results_cut, dropped
|
||||
)
|
||||
|
||||
# Step 3: nothing more can go.
|
||||
needed = estimate_tokens(kept[recent_from:], system, tools, factor)
|
||||
@@ -216,8 +262,10 @@ class BudgetedClient:
|
||||
keep_recent_turns: int = 6,
|
||||
safety_margin: float = 0.1,
|
||||
enforces_tool_choice: Callable[[str], bool] = lambda model: False,
|
||||
oplog: OpLog | None = None,
|
||||
) -> None:
|
||||
self._inner = inner
|
||||
self._oplog = oplog
|
||||
self._limits_for = limits_for
|
||||
# Whether a model's server holds it to the offered tools when asked
|
||||
# (LiteLLM's supports_tool_choice). Asking one that doesn't can be
|
||||
@@ -231,6 +279,7 @@ class BudgetedClient:
|
||||
# from reported usage. Starts at 1 and only grows.
|
||||
self._factors: dict[str, float] = {}
|
||||
self.trimmed_requests = 0
|
||||
self.last_trim: Trim | None = None # the last request's, if it was cut
|
||||
|
||||
@property
|
||||
def last_usage(self) -> dict[str, int]:
|
||||
@@ -260,20 +309,57 @@ class BudgetedClient:
|
||||
safety_margin=self._safety_margin,
|
||||
)
|
||||
|
||||
def estimate(
|
||||
self,
|
||||
events: Sequence[StoredEvent],
|
||||
system: str | None,
|
||||
tools: list[dict[str, Any]] | None,
|
||||
model: str,
|
||||
) -> int:
|
||||
"""A request's size in tokens, compactions applied, as fitting
|
||||
would count it."""
|
||||
factor = self._factors.get(model, 1.0)
|
||||
return estimate_tokens(effective_events(list(events)), system, tools, factor)
|
||||
|
||||
def complete(
|
||||
self,
|
||||
events: list[StoredEvent],
|
||||
tools: list[dict[str, Any]] | None,
|
||||
model: str,
|
||||
system: str | None = None,
|
||||
*,
|
||||
require_tool_call: bool | None = None,
|
||||
) -> list[CanonicalEvent]:
|
||||
"""``require_tool_call``: None asks for a tool call whenever tools
|
||||
are offered (and the model's server enforces it); False lets the
|
||||
model answer in prose, as compaction's notes do."""
|
||||
budget = self.budget_for(model)
|
||||
factor = self._factors.get(model, 1.0)
|
||||
fitted = fit(events, system, tools, budget, factor)
|
||||
# Counted so the benchmark can report how often a model's window was
|
||||
# the constraint: any request that had to be cut down or shortened.
|
||||
if fitted != list(events):
|
||||
events = effective_events(list(events))
|
||||
fitted, trim = fit_report(events, system, tools, budget, factor)
|
||||
self.last_trim = trim
|
||||
if trim is not None:
|
||||
# Counted so the benchmark can report how often a model's window
|
||||
# was the constraint, and logged: each cut lost the model
|
||||
# something it had read.
|
||||
self.trimmed_requests += 1
|
||||
if self._oplog is not None:
|
||||
self._oplog.emit(
|
||||
"context.trimmed",
|
||||
cause=(
|
||||
"older tool results shortened"
|
||||
if trim.step == 1
|
||||
else "the oldest events dropped"
|
||||
),
|
||||
model=model,
|
||||
tokens_before=trim.tokens_before,
|
||||
tokens_after=trim.tokens_after,
|
||||
limit=trim.limit,
|
||||
results_cut=trim.results_cut,
|
||||
events_dropped=trim.events_dropped,
|
||||
)
|
||||
if require_tool_call is None:
|
||||
require_tool_call = bool(tools) and self._enforces_tool_choice(model)
|
||||
produced = self._inner.complete(
|
||||
fitted,
|
||||
tools,
|
||||
@@ -283,7 +369,7 @@ class BudgetedClient:
|
||||
# Every caller that offers tools needs one called: the executor
|
||||
# finishes with finish, the planner with submit_plan or
|
||||
# ask_human, a delegation with hand_back.
|
||||
require_tool_call=bool(tools) and self._enforces_tool_choice(model),
|
||||
require_tool_call=require_tool_call,
|
||||
)
|
||||
reported = int(self.last_usage.get("input", 0))
|
||||
if reported > 0:
|
||||
|
||||
@@ -35,10 +35,11 @@ class Interruptible:
|
||||
tools: list[dict[str, Any]] | None,
|
||||
model: str,
|
||||
system: str | None = None,
|
||||
**options: Any,
|
||||
) -> list[CanonicalEvent]:
|
||||
self._calling = True
|
||||
try:
|
||||
return self._client.complete(events, tools, model, system)
|
||||
return self._client.complete(events, tools, model, system, **options)
|
||||
finally:
|
||||
self._calling = False
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@ call time, never stored provider-shaped.
|
||||
First adapter: the OpenAI-compatible chat format LiteLLM's normalized route
|
||||
speaks — the executor role's path (L9). The Anthropic passthrough adapter
|
||||
arrives with delegations (L9). Token budgeting and prompt-cache breakpoints
|
||||
are render-time concerns and are never stored (L7); neither exists yet in
|
||||
this skeleton.
|
||||
are render-time concerns and are never stored (L7).
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -27,20 +26,54 @@ from nisse_core.log.canonical import (
|
||||
|
||||
|
||||
def effective_events(stored: list[StoredEvent]) -> list[StoredEvent]:
|
||||
"""Apply compactions: events whose ids fall in a compaction's replaced
|
||||
range are dropped; the compaction event itself stays as their summary."""
|
||||
replaced: list[tuple[int, int]] = [
|
||||
(e.event.replaces[0], e.event.replaces[1])
|
||||
for e in stored
|
||||
if isinstance(e.event, Compaction)
|
||||
"""Apply compactions: the events in a compaction's replaced range give way
|
||||
to the compaction, which takes the place of the first of them, so the
|
||||
summary sits where the conversation it summarises was. A compaction
|
||||
whose range a later one covers is folded into it and not shown. One
|
||||
whose range is no longer in the list (it was applied already) stays
|
||||
where it is, so applying this twice changes nothing."""
|
||||
compactions = [e for e in stored if isinstance(e.event, Compaction)]
|
||||
active = [
|
||||
c
|
||||
for c in compactions
|
||||
if not any(
|
||||
later.id > c.id
|
||||
and later.event.replaces[0] <= c.event.replaces[0]
|
||||
and later.event.replaces[1] >= c.event.replaces[1]
|
||||
for later in compactions
|
||||
)
|
||||
]
|
||||
|
||||
def is_replaced(event_id: int) -> bool:
|
||||
return any(first <= event_id <= last for first, last in replaced)
|
||||
active_ids = {c.id for c in active}
|
||||
|
||||
return [
|
||||
e for e in stored if isinstance(e.event, Compaction) or not is_replaced(e.id)
|
||||
]
|
||||
def covering(event_id: int) -> StoredEvent | None:
|
||||
for c in active:
|
||||
first, last = c.event.replaces
|
||||
if first <= event_id <= last:
|
||||
return c
|
||||
return None
|
||||
|
||||
present = {e.id for e in stored if not isinstance(e.event, Compaction)}
|
||||
placed = {
|
||||
c.id
|
||||
for c in active
|
||||
if any(c.event.replaces[0] <= i <= c.event.replaces[1] for i in present)
|
||||
}
|
||||
out: list[StoredEvent] = []
|
||||
shown: set[int] = set()
|
||||
for e in stored:
|
||||
if isinstance(e.event, Compaction):
|
||||
if e.id in active_ids and e.id not in placed and e.id not in shown:
|
||||
out.append(e)
|
||||
shown.add(e.id)
|
||||
continue
|
||||
c = covering(e.id)
|
||||
if c is None:
|
||||
out.append(e)
|
||||
elif c.id not in shown:
|
||||
out.append(c)
|
||||
shown.add(c.id)
|
||||
return out
|
||||
|
||||
|
||||
class OpenAIChatRenderer:
|
||||
|
||||
@@ -20,6 +20,7 @@ from typing import Any, Self
|
||||
|
||||
from nisse_core.log.canonical import (
|
||||
CanonicalEvent,
|
||||
Compaction,
|
||||
StoredEvent,
|
||||
UserMessage,
|
||||
event_type,
|
||||
@@ -573,9 +574,15 @@ class Transcript:
|
||||
def view(self, after: int = 0) -> list[StoredEvent]:
|
||||
"""The log as this actor is shown it: what a model's context is
|
||||
built from (L6). See ``views``."""
|
||||
return project(
|
||||
self.read(max(after, self._start)), self.actor, self._store.SHOWN
|
||||
)
|
||||
start = max(after, self._start)
|
||||
# A compaction of events before the start belongs to an earlier
|
||||
# run's view, not this one's.
|
||||
events = [
|
||||
e
|
||||
for e in self.read(start)
|
||||
if not (isinstance(e.event, Compaction) and e.event.replaces[0] <= start)
|
||||
]
|
||||
return project(events, self.actor, self._store.SHOWN)
|
||||
|
||||
def since(self, event_id: int) -> "Transcript":
|
||||
"""The same transcript, its view starting after ``event_id``: a run
|
||||
|
||||
Reference in new issue
Block a user