refactor(agent): require runtime for consolidation

This commit is contained in:
chengyongru
2026-07-10 17:54:34 +08:00
committed by Xubin Ren
parent 5bd3d1e0af
commit c9d3e74342
18 changed files with 486 additions and 214 deletions
+13 -5
View File
@@ -12,6 +12,7 @@ from nanobot.session.manager import Session, SessionManager
if TYPE_CHECKING:
from nanobot.agent.memory import Consolidator
from nanobot.utils.llm_runtime import LLMRuntime
class AutoCompact:
@@ -62,8 +63,12 @@ class AutoCompact:
def _is_internal_session(cls, key: str) -> bool:
return key.startswith(cls._INTERNAL_SESSION_PREFIXES)
def check_expired(self, schedule_background: Callable[[Coroutine], None],
active_session_keys: Collection[str] = ()) -> None:
def check_expired(
self,
schedule_background: Callable[[Coroutine], None],
resolve_runtime: Callable[[], LLMRuntime],
active_session_keys: Collection[str] = (),
) -> None:
"""Schedule archival for idle sessions, skipping those with in-flight agent tasks."""
now = datetime.now()
for info in self.sessions.list_sessions():
@@ -74,16 +79,19 @@ class AutoCompact:
continue
updated_at = info.get("updated_at")
if self._is_expired(updated_at, now) and self._has_compactable_idle_tail(key):
runtime = resolve_runtime()
self._archiving.add(key)
schedule_background(self._archive(key))
schedule_background(self._archive(key, runtime=runtime))
async def _archive(self, key: str) -> None:
async def _archive(self, key: str, *, runtime: LLMRuntime) -> None:
if self._is_internal_session(key):
self._archiving.discard(key)
return
try:
summary = await self.consolidator.compact_idle_session(
key, self._RECENT_SUFFIX_MESSAGES,
key,
runtime=runtime,
max_suffix=self._RECENT_SUFFIX_MESSAGES,
)
if summary and summary != "(nothing)":
session = self.sessions.get_or_create(key)
+11 -6
View File
@@ -388,13 +388,9 @@ class AgentLoop:
)
self.consolidator = Consolidator(
store=self.context.memory,
provider=provider,
model=self.model,
sessions=self.sessions,
context_window_tokens=self.context_window_tokens,
build_messages=self.context.build_messages,
get_tool_definitions=self.tools.get_definitions,
max_completion_tokens=provider.generation.max_tokens,
consolidation_ratio=consolidation_ratio,
unified_session=unified_session,
)
@@ -492,7 +488,6 @@ class AgentLoop:
self.provider = provider
self.model = model
self.context_window_tokens = context_window_tokens
self.consolidator.set_provider(provider, model, context_window_tokens)
self._sync_replay_max_messages()
self._provider_signature = snapshot.signature
if publish_update and self._runtime_model_publisher is not None:
@@ -974,6 +969,7 @@ class AgentLoop:
except asyncio.TimeoutError:
self.auto_compact.check_expired(
self._schedule_background,
self.llm_runtime,
active_session_keys=self._pending_queues.keys(),
)
continue
@@ -1272,6 +1268,7 @@ class AgentLoop:
await self.consolidator.maybe_consolidate_by_tokens(
session,
runtime=runtime,
replay_max_messages=replay_max_messages_for_context(
runtime.context_window_tokens
),
@@ -1328,6 +1325,7 @@ class AgentLoop:
self._schedule_background(
self.consolidator.maybe_consolidate_by_tokens(
session,
runtime=runtime,
replay_max_messages=replay_max_messages_for_context(
runtime.context_window_tokens
),
@@ -1539,7 +1537,12 @@ class AgentLoop:
async def _state_command(self, ctx: TurnContext) -> str:
raw = ctx.msg.content.strip()
cmd_ctx = CommandContext(
msg=ctx.msg, session=ctx.session, key=ctx.session_key, raw=raw, loop=self
msg=ctx.msg,
session=ctx.session,
key=ctx.session_key,
raw=raw,
loop=self,
runtime=ctx.runtime,
)
result = await self.commands.dispatch(cmd_ctx)
if result is not None:
@@ -1568,6 +1571,7 @@ class AgentLoop:
if not ctx.ephemeral:
await self.consolidator.maybe_consolidate_by_tokens(
ctx.session,
runtime=ctx.runtime,
replay_max_messages=replay_max_messages,
)
if message_tool := self.tools.get("message"):
@@ -1673,6 +1677,7 @@ class AgentLoop:
self._schedule_background(
self.consolidator.maybe_consolidate_by_tokens(
ctx.session,
runtime=ctx.runtime,
replay_max_messages=replay_max_messages_for_context(
ctx.runtime.context_window_tokens
),
+43 -36
View File
@@ -30,8 +30,8 @@ from nanobot.utils.helpers import (
from nanobot.utils.prompt_templates import render_template
if TYPE_CHECKING:
from nanobot.providers.base import LLMProvider
from nanobot.session.manager import SessionManager
from nanobot.utils.llm_runtime import LLMRuntime
# ---------------------------------------------------------------------------
# MemoryStore — pure file I/O layer
@@ -742,22 +742,14 @@ class Consolidator:
def __init__(
self,
store: MemoryStore,
provider: LLMProvider,
model: str,
sessions: SessionManager,
context_window_tokens: int,
build_messages: Callable[..., list[dict[str, Any]]],
get_tool_definitions: Callable[[], list[dict[str, Any]]],
max_completion_tokens: int = 4096,
consolidation_ratio: float = 0.5,
unified_session: bool = False,
):
self.store = store
self.provider = provider
self.model = model
self.sessions = sessions
self.context_window_tokens = context_window_tokens
self.max_completion_tokens = max_completion_tokens
self.consolidation_ratio = consolidation_ratio
self.unified_session = unified_session
self._build_messages = build_messages
@@ -766,17 +758,6 @@ class Consolidator:
weakref.WeakValueDictionary()
)
def set_provider(
self,
provider: LLMProvider,
model: str,
context_window_tokens: int,
) -> None:
self.provider = provider
self.model = model
self.context_window_tokens = context_window_tokens
self.max_completion_tokens = provider.generation.max_tokens
def get_lock(self, session_key: str) -> asyncio.Lock:
"""Return the shared consolidation lock for one session."""
return self._locks.setdefault(session_key, asyncio.Lock())
@@ -854,6 +835,8 @@ class Consolidator:
self,
session: Session,
replay_max_messages: int | None,
*,
runtime: LLMRuntime,
) -> str | None:
"""Archive messages that would be hidden by the replay message window."""
end_idx = self._replay_overflow_boundary(session, replay_max_messages)
@@ -868,7 +851,11 @@ class Consolidator:
len(chunk),
replay_max_messages,
)
summary = await self.archive(chunk, session_key=session.key)
summary = await self.archive(
chunk,
runtime=runtime,
session_key=session.key,
)
session.last_consolidated = end_idx
self.sessions.save(session)
return summary
@@ -884,6 +871,8 @@ class Consolidator:
def estimate_session_prompt_tokens(
self,
session: Session,
*,
runtime: LLMRuntime,
) -> tuple[int, str]:
"""Estimate prompt size from the full unconsolidated session tail."""
history = self._full_unconsolidated_history(session)
@@ -903,20 +892,23 @@ class Consolidator:
unified_session=self.unified_session,
)
return estimate_prompt_tokens_chain(
self.provider,
self.model,
runtime.provider,
runtime.model,
probe_messages,
self._get_tool_definitions(),
)
@property
def _input_token_budget(self) -> int:
def _input_token_budget(self, runtime: LLMRuntime) -> int:
"""Available input token budget for consolidation LLM."""
return self.context_window_tokens - self.max_completion_tokens - self._SAFETY_BUFFER
return (
runtime.context_window_tokens
- runtime.generation.max_tokens
- self._SAFETY_BUFFER
)
def _truncate_to_token_budget(self, text: str) -> str:
def _truncate_to_token_budget(self, text: str, *, runtime: LLMRuntime) -> str:
"""Truncate text so it fits within the consolidation LLM's token budget."""
budget = self._input_token_budget
budget = self._input_token_budget(runtime)
if budget <= 0:
return truncate_text(text, _RAW_ARCHIVE_MAX_CHARS)
return truncate_text_to_tokens(text, budget)
@@ -925,6 +917,7 @@ class Consolidator:
self,
messages: list[dict],
*,
runtime: LLMRuntime,
session_key: str | None = None,
summary_messages: list[dict] | None = None,
) -> str | None:
@@ -942,9 +935,9 @@ class Consolidator:
messages_to_summarize = summary_messages if summary_messages is not None else messages
try:
formatted = MemoryStore._format_messages(messages_to_summarize)
formatted = self._truncate_to_token_budget(formatted)
response = await self.provider.chat_with_retry(
model=self.model,
formatted = self._truncate_to_token_budget(formatted, runtime=runtime)
response = await runtime.provider.chat_with_retry(
model=runtime.model,
messages=[
{
"role": "system",
@@ -957,6 +950,9 @@ class Consolidator:
],
tools=None,
tool_choice=None,
temperature=runtime.generation.temperature,
max_tokens=runtime.generation.max_tokens,
reasoning_effort=runtime.generation.reasoning_effort,
)
if response.finish_reason == "error":
raise RuntimeError(f"LLM returned error: {response.content}")
@@ -976,6 +972,7 @@ class Consolidator:
self,
session: Session,
*,
runtime: LLMRuntime,
replay_max_messages: int | None = None,
) -> None:
"""Loop: archive old messages until prompt fits within safe budget.
@@ -983,7 +980,7 @@ class Consolidator:
The budget reserves space for completion tokens and a safety buffer
so the LLM request never exceeds the context window.
"""
if self.context_window_tokens <= 0:
if runtime.context_window_tokens <= 0:
return
lock = self.get_lock(session.key)
@@ -995,15 +992,17 @@ class Consolidator:
if not session.messages:
return
budget = self._input_token_budget
budget = self._input_token_budget(runtime)
target = int(budget * self.consolidation_ratio)
last_summary = await self._consolidate_replay_overflow(
session,
replay_max_messages,
runtime=runtime,
)
try:
estimated, source = self.estimate_session_prompt_tokens(
session,
runtime=runtime,
)
except Exception:
logger.exception("Token estimation failed for {}", session.key)
@@ -1017,7 +1016,7 @@ class Consolidator:
"Token consolidation idle {}: {}/{} via {}, msgs={}",
session.key,
estimated,
self.context_window_tokens,
runtime.context_window_tokens,
source,
unconsolidated_count,
)
@@ -1048,11 +1047,15 @@ class Consolidator:
round_num,
session.key,
estimated,
self.context_window_tokens,
runtime.context_window_tokens,
source,
len(chunk),
)
summary = await self.archive(chunk, session_key=session.key)
summary = await self.archive(
chunk,
runtime=runtime,
session_key=session.key,
)
# Advance the cursor either way: on success the chunk was
# summarized; on failure archive() already raw-archived it as
# a breadcrumb. Re-archiving the same chunk on the next call
@@ -1069,6 +1072,7 @@ class Consolidator:
try:
estimated, source = self.estimate_session_prompt_tokens(
session,
runtime=runtime,
)
except Exception:
logger.exception("Token estimation failed for {}", session.key)
@@ -1084,6 +1088,8 @@ class Consolidator:
async def compact_idle_session(
self,
session_key: str,
*,
runtime: LLMRuntime,
max_suffix: int = 8,
) -> str | None:
"""Hard-truncate an idle session under the consolidation lock.
@@ -1126,6 +1132,7 @@ class Consolidator:
# the messages that are no longer kept in the live session.
summary = await self.archive(
messages_to_remove,
runtime=runtime,
session_key=session_key,
summary_messages=messages_to_summarize,
)
+16 -7
View File
@@ -227,9 +227,13 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
"""Build an outbound status message for a session."""
loop = ctx.loop
session = ctx.session or loop.sessions.get_or_create(ctx.key)
runtime = ctx.runtime or loop.llm_runtime()
ctx_est = 0
with suppress(Exception):
ctx_est, _ = loop.consolidator.estimate_session_prompt_tokens(session)
ctx_est, _ = loop.consolidator.estimate_session_prompt_tokens(
session,
runtime=runtime,
)
if ctx_est <= 0:
ctx_est = loop._last_usage.get("prompt_tokens", 0)
@@ -253,16 +257,14 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
channel=ctx.msg.channel,
chat_id=ctx.msg.chat_id,
content=build_status_content(
version=__version__, model=loop.model,
version=__version__, model=runtime.model,
start_time=loop._start_time, last_usage=loop._last_usage,
context_window_tokens=loop.context_window_tokens,
context_window_tokens=runtime.context_window_tokens,
session_msg_count=len(session.get_history(max_messages=0)),
context_tokens_estimate=ctx_est,
search_usage_text=search_usage_text,
active_task_count=task_count,
max_completion_tokens=getattr(
getattr(loop.provider, "generation", None), "max_tokens", 8192
),
max_completion_tokens=runtime.generation.max_tokens,
),
metadata={**dict(ctx.msg.metadata or {}), "render_as": "text"},
)
@@ -278,7 +280,14 @@ async def cmd_new(ctx: CommandContext) -> OutboundMessage:
loop.sessions.save(session)
loop.sessions.invalidate(session.key)
if snapshot:
loop._schedule_background(loop.consolidator.archive(snapshot, session_key=ctx.key))
runtime = ctx.runtime or loop.llm_runtime()
loop._schedule_background(
loop.consolidator.archive(
snapshot,
runtime=runtime,
session_key=ctx.key,
)
)
return OutboundMessage(
channel=ctx.msg.channel, chat_id=ctx.msg.chat_id,
content="New session started.",
+2
View File
@@ -9,6 +9,7 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable
if TYPE_CHECKING:
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.session.manager import Session
from nanobot.utils.llm_runtime import LLMRuntime
Handler = Callable[["CommandContext"], Awaitable["OutboundMessage | None"]]
_BOT_SUFFIX_RE = re.compile(r"^[A-Za-z0-9_]+$")
@@ -43,6 +44,7 @@ class CommandContext:
raw: str
args: str = ""
loop: Any = None
runtime: LLMRuntime | None = None
class CommandRouter:
+8 -1
View File
@@ -13,6 +13,7 @@ from nanobot.sdk.types import (
snapshot_from_payload,
snapshot_from_session,
)
from nanobot.session.manager import replay_max_messages_for_context
if TYPE_CHECKING:
from nanobot.agent.loop import AgentLoop
@@ -151,15 +152,21 @@ class RuntimeClient:
async def compact_session(self, session_key: str) -> SessionSnapshot:
"""Run token/replay-window consolidation for one session."""
session = self._loop.sessions.get_or_create(session_key)
runtime = self._loop.llm_runtime()
await self._loop.consolidator.maybe_consolidate_by_tokens(
session,
replay_max_messages=self._loop._max_messages,
runtime=runtime,
replay_max_messages=replay_max_messages_for_context(
runtime.context_window_tokens
),
)
return snapshot_from_session(self._loop.sessions.get_or_create(session_key))
async def compact_idle_session(self, session_key: str, *, max_suffix: int = 8) -> str | None:
"""Run idle-session compaction for one session and return the summary."""
runtime = self._loop.llm_runtime()
return await self._loop.consolidator.compact_idle_session(
session_key,
runtime=runtime,
max_suffix=max_suffix,
)