feat(agent): make idle compaction scan interval configurable

Before this change, idle compaction is triggered every 1 second
if the incoming message stream is idle.
When triggered, it enumerates all session files, loads and parses them,
and then checks their expiration.

This becomes too CPU-intensive, especially on low-power devices like Raspberry Pi.
It's unlikely that you actually need to compact every second over the long time.

This change adds a configurable throttling for idle-compaction.

Default behavior is unchanged.
This commit is contained in:
Andrew Khmylov
2026-07-27 02:15:48 +08:00
committed by Xubin Ren
parent 4e2640f2d2
commit 7aab7e8830
4 changed files with 78 additions and 8 deletions
+4 -2
View File
@@ -2158,7 +2158,8 @@ When a user is idle for longer than a configured threshold, nanobot **proactivel
{ {
"agents": { "agents": {
"defaults": { "defaults": {
"idleCompactAfterMinutes": 15 "idleCompactAfterMinutes": 15,
"idleCompactCheckIntervalSeconds": 0
} }
} }
} }
@@ -2167,11 +2168,12 @@ When a user is idle for longer than a configured threshold, nanobot **proactivel
| Option | Default | Description | | Option | Default | Description |
|--------|---------|-------------| |--------|---------|-------------|
| `agents.defaults.idleCompactAfterMinutes` | `15` | Minutes of idle time before auto-compaction starts. Set to `0` to disable. The default is close to a typical LLM KV cache expiry window, so stale sessions get compacted before the user returns. | | `agents.defaults.idleCompactAfterMinutes` | `15` | Minutes of idle time before auto-compaction starts. Set to `0` to disable. The default is close to a typical LLM KV cache expiry window, so stale sessions get compacted before the user returns. |
| `agents.defaults.idleCompactCheckIntervalSeconds` | `0` | Minimum number of seconds between scans for idle sessions. |
`sessionTtlMinutes` remains accepted as a legacy alias for backward compatibility, but `idleCompactAfterMinutes` is the preferred config key going forward. `sessionTtlMinutes` remains accepted as a legacy alias for backward compatibility, but `idleCompactAfterMinutes` is the preferred config key going forward.
How it works: How it works:
1. **Idle detection**: On each idle tick (~1 s), checks all sessions for expiration. 1. **Idle detection**: On each idle tick (~1 s), checks all sessions for expiration, subject to the minimum interval set by `idleCompactCheckIntervalSeconds`.
2. **Background compaction**: Idle sessions summarize the older live prefix via LLM and keep the most recent legal suffix (currently 8 messages). 2. **Background compaction**: Idle sessions summarize the older live prefix via LLM and keep the most recent legal suffix (currently 8 messages).
3. **Summary injection**: When the user returns, the summary is injected as runtime context (one-shot, not persisted) alongside the retained recent suffix. 3. **Summary injection**: When the user returns, the summary is injected as runtime context (one-shot, not persisted) alongside the retained recent suffix.
4. **Restart-safe resume**: The summary is also mirrored into session metadata so it can still be recovered after a process restart. 4. **Restart-safe resume**: The summary is also mirrored into session metadata so it can still be recovered after a process restart.
+17 -5
View File
@@ -297,6 +297,7 @@ class AgentLoop:
runtime_model_publisher: Callable[[str, str | None], None] | None = None, runtime_model_publisher: Callable[[str, str | None], None] | None = None,
restart_mode: str = "auto", restart_mode: str = "auto",
local_trigger_store: Any | None = None, local_trigger_store: Any | None = None,
idle_compact_check_interval_seconds: int = 0,
): ):
from nanobot.config.schema import ToolsConfig from nanobot.config.schema import ToolsConfig
@@ -445,6 +446,8 @@ class AgentLoop:
consolidator=self.consolidator, consolidator=self.consolidator,
session_ttl_minutes=session_ttl_minutes, session_ttl_minutes=session_ttl_minutes,
) )
self._idle_compact_check_interval_s = idle_compact_check_interval_seconds
self._next_idle_compact_check_at = time.monotonic()
if model_preset: if model_preset:
self.set_model_preset(model_preset, publish_update=False) self.set_model_preset(model_preset, publish_update=False)
self._register_default_tools(provider_snapshot_loader=provider_snapshot_loader) self._register_default_tools(provider_snapshot_loader=provider_snapshot_loader)
@@ -500,6 +503,7 @@ class AgentLoop:
unified_session=defaults.unified_session, unified_session=defaults.unified_session,
disabled_skills=defaults.disabled_skills, disabled_skills=defaults.disabled_skills,
session_ttl_minutes=defaults.session_ttl_minutes, session_ttl_minutes=defaults.session_ttl_minutes,
idle_compact_check_interval_seconds=defaults.idle_compact_check_interval_seconds,
consolidation_ratio=defaults.consolidation_ratio, consolidation_ratio=defaults.consolidation_ratio,
tools_config=config.tools, tools_config=config.tools,
model_presets=preset_helpers.configured_model_presets(config), model_presets=preset_helpers.configured_model_presets(config),
@@ -1081,6 +1085,18 @@ class AgentLoop:
logger.error("LLM returned error: {}", (result.final_content or "")[:200]) logger.error("LLM returned error: {}", (result.final_content or "")[:200])
return result.final_content, result.tools_used, result.messages, result.stop_reason, result.had_injections return result.final_content, result.tools_used, result.messages, result.stop_reason, result.had_injections
def _check_expired_sessions_if_due(self) -> None:
"""Scan idle sessions no more often than the configured interval."""
now = time.monotonic()
if now < self._next_idle_compact_check_at:
return
self._next_idle_compact_check_at = now + self._idle_compact_check_interval_s
self.auto_compact.check_expired(
self._schedule_background,
self.runtime_for_session,
active_session_keys=self._pending_queues.keys(),
)
async def run(self) -> None: async def run(self) -> None:
"""Run the agent loop, dispatching messages as tasks to stay responsive to /stop.""" """Run the agent loop, dispatching messages as tasks to stay responsive to /stop."""
self._running = True self._running = True
@@ -1092,11 +1108,7 @@ class AgentLoop:
try: try:
msg = await asyncio.wait_for(self.bus.consume_inbound(), timeout=1.0) msg = await asyncio.wait_for(self.bus.consume_inbound(), timeout=1.0)
except asyncio.TimeoutError: except asyncio.TimeoutError:
self.auto_compact.check_expired( self._check_expired_sessions_if_due()
self._schedule_background,
self.runtime_for_session,
active_session_keys=self._pending_queues.keys(),
)
continue continue
except asyncio.CancelledError: except asyncio.CancelledError:
# Preserve real task cancellation so shutdown can complete cleanly. # Preserve real task cancellation so shutdown can complete cleanly.
+4
View File
@@ -154,6 +154,10 @@ class AgentDefaults(Base):
validation_alias=AliasChoices("idleCompactAfterMinutes", "sessionTtlMinutes"), validation_alias=AliasChoices("idleCompactAfterMinutes", "sessionTtlMinutes"),
serialization_alias="idleCompactAfterMinutes", serialization_alias="idleCompactAfterMinutes",
) # Auto-compact idle threshold in minutes (0 = disabled) ) # Auto-compact idle threshold in minutes (0 = disabled)
idle_compact_check_interval_seconds: int = Field(
default=0,
ge=0,
) # Minimum interval in seconds between scans for idle sessions
consolidation_ratio: float = Field( consolidation_ratio: float = Field(
default=0.5, default=0.5,
ge=0.1, ge=0.1,
+53 -1
View File
@@ -11,7 +11,7 @@ from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.command import CommandContext from nanobot.command import CommandContext
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults, Config
from nanobot.providers.base import LLMResponse from nanobot.providers.base import LLMResponse
@@ -180,12 +180,64 @@ class TestSessionTTLConfig:
assert data["idleCompactAfterMinutes"] == 30 assert data["idleCompactAfterMinutes"] == 30
assert "sessionTtlMinutes" not in data assert "sessionTtlMinutes" not in data
def test_idle_scan_interval_defaults_to_zero(self):
"""The default should preserve a scan on every idle tick."""
defaults = AgentDefaults()
assert defaults.idle_compact_check_interval_seconds == 0
def test_idle_scan_interval_uses_camel_case_config_key(self):
"""The JSON config should use the standard camelCase alias."""
defaults = AgentDefaults.model_validate({"idleCompactCheckIntervalSeconds": 10})
assert defaults.idle_compact_check_interval_seconds == 10
data = defaults.model_dump(mode="json", by_alias=True)
assert data["idleCompactCheckIntervalSeconds"] == 10
def test_session_file_cap_is_internal_constant(self): def test_session_file_cap_is_internal_constant(self):
"""Session file cap should remain an internal constant, not a config field.""" """Session file cap should remain an internal constant, not a config field."""
from nanobot.session.manager import FILE_MAX_MESSAGES from nanobot.session.manager import FILE_MAX_MESSAGES
assert FILE_MAX_MESSAGES == 2000 assert FILE_MAX_MESSAGES == 2000
class TestIdleScanThrottling:
"""Test scheduling of full idle-session scans."""
def test_configured_idle_scan_interval_throttles_checks(self, tmp_path, monkeypatch):
"""The configured interval should reach the loop and gate session scans."""
ticks = iter((1_000.0, 1_000.0, 1_009.999, 1_010.0))
monkeypatch.setattr("nanobot.agent.loop.time.monotonic", lambda: next(ticks))
config = Config.model_validate({
"agents": {
"defaults": {
"workspace": str(tmp_path),
"idleCompactCheckIntervalSeconds": 10,
}
}
})
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
loop = AgentLoop.from_config(config, provider=provider)
loop.auto_compact.check_expired = MagicMock()
loop._check_expired_sessions_if_due()
loop.auto_compact.check_expired.assert_called_once()
loop._check_expired_sessions_if_due()
loop.auto_compact.check_expired.assert_called_once()
loop._check_expired_sessions_if_due()
assert loop.auto_compact.check_expired.call_count == 2
def test_default_idle_scan_interval_checks_every_tick(self, tmp_path, monkeypatch):
"""The zero default should leave each idle tick eligible to scan."""
monkeypatch.setattr("nanobot.agent.loop.time.monotonic", lambda: 1_000.0)
loop = _make_loop(tmp_path)
loop.auto_compact.check_expired = MagicMock()
loop._check_expired_sessions_if_due()
loop._check_expired_sessions_if_due()
assert loop.auto_compact.check_expired.call_count == 2
class TestAgentLoopTTLParam: class TestAgentLoopTTLParam:
"""Test that AutoCompact receives and stores session_ttl_minutes.""" """Test that AutoCompact receives and stores session_ttl_minutes."""