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
@@ -22,6 +22,7 @@ from nisse_core.log.canonical import (
|
||||
)
|
||||
from nisse_core.loop.driver import Driver, RunOutcome
|
||||
from nisse_core.models.catalog import ModelFacts
|
||||
from nisse_core.models.compact import Compactor
|
||||
from nisse_core.oplog import OpLog
|
||||
from nisse_dev.bench.tasks import Task
|
||||
from nisse_dev.config import Tuning
|
||||
@@ -78,10 +79,13 @@ class MeteringClient:
|
||||
tools: list[dict[str, Any]] | None,
|
||||
model: str,
|
||||
system: str | None = None,
|
||||
**options: Any,
|
||||
) -> list[CanonicalEvent]:
|
||||
self.calls += 1
|
||||
try:
|
||||
produced = self._inner.complete(events, tools, model, system=system)
|
||||
produced = self._inner.complete(
|
||||
events, tools, model, system=system, **options
|
||||
)
|
||||
finally:
|
||||
# Counted even when the call fails: a rate limit that never
|
||||
# cleared still cost the run its wait.
|
||||
@@ -95,10 +99,16 @@ class MeteringClient:
|
||||
answered = getattr(self._inner, "last_model", None)
|
||||
if not self._facts.answered(answered):
|
||||
self.fallbacks.append(str(answered))
|
||||
if not any(isinstance(e, ToolCall) for e in produced):
|
||||
# Compaction's notes are prose by request, not a broken turn.
|
||||
asked_for_prose = options.get("require_tool_call") is False
|
||||
if not asked_for_prose and not any(isinstance(e, ToolCall) for e in produced):
|
||||
self.prose_turns += 1
|
||||
return produced
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
# The budget (budget_for, estimate) compaction reads.
|
||||
return getattr(self._inner, name)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunRecord:
|
||||
@@ -222,6 +232,8 @@ def run_task(
|
||||
model,
|
||||
max_turns=tuning.driver.max_turns,
|
||||
failure_limit=tuning.driver.failure_limit,
|
||||
# As the agent runs it, so a long task measures the same thing.
|
||||
compactor=Compactor(meter, tuning.context.compaction()),
|
||||
)
|
||||
system = SYSTEM.format(
|
||||
repo=f"bench/{task.name}",
|
||||
|
||||
@@ -34,6 +34,7 @@ from nisse_core.config import (
|
||||
resolve,
|
||||
)
|
||||
from nisse_core.conversation.matrix import MatrixConfig
|
||||
from nisse_core.models.compact import CompactionTuning, check_tuning
|
||||
from nisse_core.models.llm import (
|
||||
DEFAULT_MAX_OUTPUT_TOKENS,
|
||||
DEFAULT_REQUEST_TIMEOUT_S,
|
||||
@@ -134,6 +135,19 @@ class ToolsTuning:
|
||||
class ContextTuning:
|
||||
keep_recent_turns: int = 6
|
||||
safety_margin: float = 0.1
|
||||
# Compaction (L6): over compact_at of the prompt budget, the model's
|
||||
# notes replace the middle of the run, keeping at most compact_keep_turns
|
||||
# recent turns, in compact_to of it. compact_at 0: never.
|
||||
compact_at: float = field(default=0.6, metadata={"zero": True})
|
||||
compact_to: float = 0.25
|
||||
compact_keep_turns: int = 3
|
||||
|
||||
def compaction(self) -> CompactionTuning:
|
||||
return CompactionTuning(
|
||||
compact_at=self.compact_at,
|
||||
compact_to=self.compact_to,
|
||||
keep_turns=self.compact_keep_turns,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -182,6 +196,7 @@ def _parse_tuning(raw: Any, errors: list[str]) -> Tuning:
|
||||
)
|
||||
if tuning.context.safety_margin >= 1:
|
||||
errors.append("tuning.context.safety_margin: must be less than 1")
|
||||
check_tuning(tuning.context.compaction(), "tuning.context", errors)
|
||||
return tuning
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from nisse_core.knowledge.memory import Memory
|
||||
from nisse_core.knowledge.vault import Vault
|
||||
from nisse_core.loop.driver import Driver
|
||||
from nisse_core.models.catalog import ModelCatalog, select_client
|
||||
from nisse_core.models.compact import Compactor
|
||||
from nisse_core.models.context import BudgetedClient
|
||||
from nisse_core.models.llm import AnthropicPassthroughClient, LiteLLMClient
|
||||
from nisse_core.oplog import OpLog
|
||||
@@ -110,7 +111,7 @@ class RunningLoop:
|
||||
|
||||
|
||||
def make_llm_clients(
|
||||
config: AgentConfig, catalog: ModelCatalog
|
||||
config: AgentConfig, catalog: ModelCatalog, oplog: OpLog | None = None
|
||||
) -> tuple[BudgetedClient, BudgetedClient]:
|
||||
"""The two LLM routes, as every model call in the agent uses them.
|
||||
|
||||
@@ -136,6 +137,7 @@ def make_llm_clients(
|
||||
enforces_tool_choice=lambda m: (
|
||||
catalog.facts(m).supports_tool_choice is True
|
||||
),
|
||||
oplog=oplog, # a cut that fitting has to make is logged
|
||||
)
|
||||
|
||||
normalized = budgeted(
|
||||
@@ -276,7 +278,7 @@ def build_loop_agent(
|
||||
project_runtime(config, entry, store, gitea, account)
|
||||
for entry in config.projects
|
||||
]
|
||||
normalized, passthrough = make_llm_clients(config, catalog)
|
||||
normalized, passthrough = make_llm_clients(config, catalog, oplog)
|
||||
cancel = Cancel()
|
||||
# The configured driver; each work item runs on the one it pinned (L9).
|
||||
driver = Driver(
|
||||
@@ -285,6 +287,9 @@ def build_loop_agent(
|
||||
max_turns=config.tuning.driver.max_turns,
|
||||
cancel=cancel,
|
||||
failure_limit=config.tuning.driver.failure_limit,
|
||||
compactor=Compactor(
|
||||
normalized, config.tuning.context.compaction(), oplog=oplog
|
||||
),
|
||||
)
|
||||
tuning = config.tuning
|
||||
helpers = Helpers(
|
||||
|
||||
@@ -354,6 +354,18 @@ def test_context_tuning_is_read_and_checked() -> None:
|
||||
build(tuning={"context": {"safety_margin": 1}})
|
||||
|
||||
|
||||
def test_compaction_tuning_is_read_and_checked() -> None:
|
||||
compaction = build().tuning.context.compaction()
|
||||
assert (compaction.compact_at, compaction.compact_to) == (0.6, 0.25)
|
||||
assert compaction.keep_turns == 3
|
||||
off = build(tuning={"context": {"compact_at": 0}}).tuning.context
|
||||
assert off.compaction().compact_at == 0
|
||||
with pytest.raises(ConfigError, match="compact_to must be less than compact_at"):
|
||||
build(tuning={"context": {"compact_at": 0.3, "compact_to": 0.5}})
|
||||
with pytest.raises(ConfigError, match="compact_to must be less than compact_at"):
|
||||
build(tuning={"context": {"compact_at": 1.5}})
|
||||
|
||||
|
||||
def test_the_build_host_is_required_and_defaults_to_root_on_22() -> None:
|
||||
host = only(build()).build_host
|
||||
assert (host.address, host.user) == ("build.test", "root")
|
||||
|
||||
Reference in new issue
Block a user