feat(session): enforce replay/file-cap invariants for history lifecycle
This commit is contained in:
+71
-2
@@ -192,6 +192,9 @@ class AgentLoop:
|
||||
timezone: str | None = None,
|
||||
session_ttl_minutes: int = 0,
|
||||
consolidation_ratio: float = 0.5,
|
||||
session_history_max_messages: int | None = None,
|
||||
session_history_max_tokens: int | None = None,
|
||||
session_file_max_messages: int | None = None,
|
||||
hooks: list[AgentHook] | None = None,
|
||||
unified_session: bool = False,
|
||||
disabled_skills: list[str] | None = None,
|
||||
@@ -224,6 +227,21 @@ class AgentLoop:
|
||||
if max_tool_result_chars is not None
|
||||
else defaults.max_tool_result_chars
|
||||
)
|
||||
self.session_history_max_messages = (
|
||||
session_history_max_messages
|
||||
if session_history_max_messages is not None
|
||||
else defaults.session_history_max_messages
|
||||
)
|
||||
self.session_history_max_tokens = (
|
||||
session_history_max_tokens
|
||||
if session_history_max_tokens is not None
|
||||
else defaults.session_history_max_tokens
|
||||
)
|
||||
self.session_file_max_messages = (
|
||||
session_file_max_messages
|
||||
if session_file_max_messages is not None
|
||||
else defaults.session_file_max_messages
|
||||
)
|
||||
self.provider_retry_mode = provider_retry_mode
|
||||
self.web_config = web_config or WebToolsConfig()
|
||||
self.exec_config = exec_config or ExecToolConfig()
|
||||
@@ -452,6 +470,49 @@ class AgentLoop:
|
||||
return UNIFIED_SESSION_KEY
|
||||
return msg.session_key
|
||||
|
||||
def _history_token_budget(self) -> int:
|
||||
"""Resolve token budget for session history replay."""
|
||||
if self.session_history_max_tokens > 0:
|
||||
return self.session_history_max_tokens
|
||||
if self.context_window_tokens <= 0:
|
||||
return 0
|
||||
max_output = getattr(getattr(self.provider, "generation", None), "max_tokens", 4096)
|
||||
try:
|
||||
reserved_output = int(max_output)
|
||||
except (TypeError, ValueError):
|
||||
reserved_output = 4096
|
||||
budget = self.context_window_tokens - max(1, reserved_output) - 1024
|
||||
if budget > 0:
|
||||
return budget
|
||||
return max(128, self.context_window_tokens // 2)
|
||||
|
||||
def _enforce_session_file_cap(self, session: Session) -> None:
|
||||
"""Bound session.jsonl growth by archiving and trimming old prefixes."""
|
||||
limit = self.session_file_max_messages
|
||||
if limit <= 0 or len(session.messages) <= limit:
|
||||
return
|
||||
|
||||
before = list(session.messages)
|
||||
before_last_consolidated = session.last_consolidated
|
||||
before_count = len(before)
|
||||
session.retain_recent_legal_suffix(limit)
|
||||
dropped_count = before_count - len(session.messages)
|
||||
if dropped_count <= 0:
|
||||
return
|
||||
|
||||
dropped = before[:dropped_count]
|
||||
already_consolidated = min(before_last_consolidated, dropped_count)
|
||||
archive_chunk = dropped[already_consolidated:]
|
||||
if archive_chunk:
|
||||
self.context.memory.raw_archive(archive_chunk)
|
||||
logger.info(
|
||||
"Session file cap hit for {}: dropped {}, raw-archived {}, kept {}",
|
||||
session.key,
|
||||
dropped_count,
|
||||
len(archive_chunk),
|
||||
len(session.messages),
|
||||
)
|
||||
|
||||
async def _run_agent_loop(
|
||||
self,
|
||||
initial_messages: list[dict],
|
||||
@@ -832,7 +893,10 @@ class AgentLoop:
|
||||
if is_subagent and self._persist_subagent_followup(session, msg):
|
||||
self.sessions.save(session)
|
||||
self._set_tool_context(channel, chat_id, msg.metadata.get("message_id"))
|
||||
history = session.get_history(max_messages=0)
|
||||
history = session.get_history(
|
||||
max_messages=self.session_history_max_messages,
|
||||
max_tokens=self._history_token_budget(),
|
||||
)
|
||||
current_role = "assistant" if is_subagent else "user"
|
||||
|
||||
# Subagent content is already in `history` above; passing it again
|
||||
@@ -851,6 +915,7 @@ class AgentLoop:
|
||||
pending_queue=pending_queue,
|
||||
)
|
||||
self._save_turn(session, all_msgs, 1 + len(history))
|
||||
self._enforce_session_file_cap(session)
|
||||
self._clear_runtime_checkpoint(session)
|
||||
self.sessions.save(session)
|
||||
self._schedule_background(self.consolidator.maybe_consolidate_by_tokens(session))
|
||||
@@ -901,7 +966,10 @@ class AgentLoop:
|
||||
if isinstance(message_tool, MessageTool):
|
||||
message_tool.start_turn()
|
||||
|
||||
history = session.get_history(max_messages=0)
|
||||
history = session.get_history(
|
||||
max_messages=self.session_history_max_messages,
|
||||
max_tokens=self._history_token_budget(),
|
||||
)
|
||||
|
||||
pending_ask_id = pending_ask_user_id(history)
|
||||
if pending_ask_id:
|
||||
@@ -987,6 +1055,7 @@ class AgentLoop:
|
||||
# Skip the already-persisted user message when saving the turn
|
||||
save_skip = 1 + len(history) + (1 if user_persisted_early else 0)
|
||||
self._save_turn(session, all_msgs, save_skip)
|
||||
self._enforce_session_file_cap(session)
|
||||
self._clear_pending_user_turn(session)
|
||||
self._clear_runtime_checkpoint(session)
|
||||
self.sessions.save(session)
|
||||
|
||||
@@ -538,6 +538,9 @@ def serve(
|
||||
disabled_skills=runtime_config.agents.defaults.disabled_skills,
|
||||
session_ttl_minutes=runtime_config.agents.defaults.session_ttl_minutes,
|
||||
consolidation_ratio=runtime_config.agents.defaults.consolidation_ratio,
|
||||
session_history_max_messages=runtime_config.agents.defaults.session_history_max_messages,
|
||||
session_history_max_tokens=runtime_config.agents.defaults.session_history_max_tokens,
|
||||
session_file_max_messages=runtime_config.agents.defaults.session_file_max_messages,
|
||||
tools_config=runtime_config.tools,
|
||||
)
|
||||
|
||||
@@ -651,6 +654,9 @@ def _run_gateway(
|
||||
disabled_skills=config.agents.defaults.disabled_skills,
|
||||
session_ttl_minutes=config.agents.defaults.session_ttl_minutes,
|
||||
consolidation_ratio=config.agents.defaults.consolidation_ratio,
|
||||
session_history_max_messages=config.agents.defaults.session_history_max_messages,
|
||||
session_history_max_tokens=config.agents.defaults.session_history_max_tokens,
|
||||
session_file_max_messages=config.agents.defaults.session_file_max_messages,
|
||||
tools_config=config.tools,
|
||||
provider_snapshot_loader=load_provider_snapshot,
|
||||
provider_signature=provider_snapshot.signature,
|
||||
@@ -1028,6 +1034,9 @@ def agent(
|
||||
disabled_skills=config.agents.defaults.disabled_skills,
|
||||
session_ttl_minutes=config.agents.defaults.session_ttl_minutes,
|
||||
consolidation_ratio=config.agents.defaults.consolidation_ratio,
|
||||
session_history_max_messages=config.agents.defaults.session_history_max_messages,
|
||||
session_history_max_tokens=config.agents.defaults.session_history_max_tokens,
|
||||
session_file_max_messages=config.agents.defaults.session_file_max_messages,
|
||||
tools_config=config.tools,
|
||||
)
|
||||
restart_notice = consume_restart_notice_from_env()
|
||||
|
||||
@@ -97,6 +97,18 @@ class AgentDefaults(Base):
|
||||
validation_alias=AliasChoices("consolidationRatio"),
|
||||
serialization_alias="consolidationRatio",
|
||||
) # Consolidation target ratio (0.5 = 50% of budget retained after compression)
|
||||
session_history_max_messages: int = Field(
|
||||
default=120,
|
||||
ge=0,
|
||||
) # Per-turn session history window for prompt replay (0 = unlimited)
|
||||
session_history_max_tokens: int = Field(
|
||||
default=0,
|
||||
ge=0,
|
||||
) # Per-turn token budget for replay history (0 = auto based on context window)
|
||||
session_file_max_messages: int = Field(
|
||||
default=2000,
|
||||
ge=0,
|
||||
) # Hard cap for on-disk session.jsonl messages (0 = disabled)
|
||||
dream: DreamConfig = Field(default_factory=DreamConfig)
|
||||
|
||||
|
||||
|
||||
@@ -85,6 +85,9 @@ class Nanobot:
|
||||
disabled_skills=defaults.disabled_skills,
|
||||
session_ttl_minutes=defaults.session_ttl_minutes,
|
||||
consolidation_ratio=defaults.consolidation_ratio,
|
||||
session_history_max_messages=defaults.session_history_max_messages,
|
||||
session_history_max_tokens=defaults.session_history_max_tokens,
|
||||
session_file_max_messages=defaults.session_file_max_messages,
|
||||
tools_config=config.tools,
|
||||
)
|
||||
return cls(loop)
|
||||
|
||||
@@ -12,6 +12,7 @@ from loguru import logger
|
||||
|
||||
from nanobot.config.paths import get_legacy_sessions_dir
|
||||
from nanobot.utils.helpers import (
|
||||
estimate_message_tokens,
|
||||
ensure_dir,
|
||||
find_legal_message_start,
|
||||
image_placeholder_text,
|
||||
@@ -41,8 +42,17 @@ class Session:
|
||||
self.messages.append(msg)
|
||||
self.updated_at = datetime.now()
|
||||
|
||||
def get_history(self, max_messages: int = 500) -> list[dict[str, Any]]:
|
||||
"""Return unconsolidated messages for LLM input, aligned to a legal tool-call boundary."""
|
||||
def get_history(
|
||||
self,
|
||||
max_messages: int = 500,
|
||||
*,
|
||||
max_tokens: int = 0,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return unconsolidated messages for LLM input.
|
||||
|
||||
History is sliced by message count first (``max_messages``), then by
|
||||
token budget from the tail (``max_tokens``) when provided.
|
||||
"""
|
||||
unconsolidated = self.messages[self.last_consolidated:]
|
||||
sliced = unconsolidated[-max_messages:]
|
||||
|
||||
@@ -80,6 +90,38 @@ class Session:
|
||||
if key in message:
|
||||
entry[key] = message[key]
|
||||
out.append(entry)
|
||||
|
||||
if max_tokens > 0 and out:
|
||||
kept: list[dict[str, Any]] = []
|
||||
used = 0
|
||||
for message in reversed(out):
|
||||
tokens = estimate_message_tokens(message)
|
||||
if kept and used + tokens > max_tokens:
|
||||
break
|
||||
kept.append(message)
|
||||
used += tokens
|
||||
kept.reverse()
|
||||
|
||||
# Keep history aligned to the first visible user turn.
|
||||
first_user = next((i for i, m in enumerate(kept) if m.get("role") == "user"), None)
|
||||
if first_user is not None:
|
||||
kept = kept[first_user:]
|
||||
else:
|
||||
# Tight token budgets can otherwise leave assistant-only tails.
|
||||
# If a user turn exists in the unsliced output, recover the
|
||||
# nearest one even if it slightly exceeds the token budget.
|
||||
recovered_user = next(
|
||||
(i for i in range(len(out) - 1, -1, -1) if out[i].get("role") == "user"),
|
||||
None,
|
||||
)
|
||||
if recovered_user is not None:
|
||||
kept = out[recovered_user:]
|
||||
|
||||
# And keep a legal tool-call boundary at the front.
|
||||
start = find_legal_message_start(kept)
|
||||
if start:
|
||||
kept = kept[start:]
|
||||
out = kept
|
||||
return out
|
||||
|
||||
def clear(self) -> None:
|
||||
@@ -89,26 +131,42 @@ class Session:
|
||||
self.updated_at = datetime.now()
|
||||
|
||||
def retain_recent_legal_suffix(self, max_messages: int) -> None:
|
||||
"""Keep a legal recent suffix, mirroring get_history boundary rules."""
|
||||
"""Keep a legal recent suffix constrained by a hard message cap."""
|
||||
if max_messages <= 0:
|
||||
self.clear()
|
||||
return
|
||||
if len(self.messages) <= max_messages:
|
||||
return
|
||||
|
||||
start_idx = max(0, len(self.messages) - max_messages)
|
||||
retained = list(self.messages[-max_messages:])
|
||||
|
||||
# If the cutoff lands mid-turn, extend backward to the nearest user turn.
|
||||
while start_idx > 0 and self.messages[start_idx].get("role") != "user":
|
||||
start_idx -= 1
|
||||
|
||||
retained = self.messages[start_idx:]
|
||||
# Prefer starting at a user turn when one exists within the tail.
|
||||
first_user = next((i for i, m in enumerate(retained) if m.get("role") == "user"), None)
|
||||
if first_user is not None:
|
||||
retained = retained[first_user:]
|
||||
else:
|
||||
# If the tail is assistant/tool-only, anchor to the latest user in
|
||||
# the full session and take a capped forward window from there.
|
||||
latest_user = next(
|
||||
(i for i in range(len(self.messages) - 1, -1, -1)
|
||||
if self.messages[i].get("role") == "user"),
|
||||
None,
|
||||
)
|
||||
if latest_user is not None:
|
||||
retained = list(self.messages[latest_user: latest_user + max_messages])
|
||||
|
||||
# Mirror get_history(): avoid persisting orphan tool results at the front.
|
||||
start = find_legal_message_start(retained)
|
||||
if start:
|
||||
retained = retained[start:]
|
||||
|
||||
# Hard-cap guarantee: never keep more than max_messages.
|
||||
if len(retained) > max_messages:
|
||||
retained = retained[-max_messages:]
|
||||
start = find_legal_message_start(retained)
|
||||
if start:
|
||||
retained = retained[start:]
|
||||
|
||||
dropped = len(self.messages) - len(retained)
|
||||
self.messages = retained
|
||||
self.last_consolidated = max(0, self.last_consolidated - dropped)
|
||||
|
||||
Reference in New Issue
Block a user