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": {
"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 |
|--------|---------|-------------|
| `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.
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).
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.
+17 -5
View File
@@ -297,6 +297,7 @@ class AgentLoop:
runtime_model_publisher: Callable[[str, str | None], None] | None = None,
restart_mode: str = "auto",
local_trigger_store: Any | None = None,
idle_compact_check_interval_seconds: int = 0,
):
from nanobot.config.schema import ToolsConfig
@@ -445,6 +446,8 @@ class AgentLoop:
consolidator=self.consolidator,
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:
self.set_model_preset(model_preset, publish_update=False)
self._register_default_tools(provider_snapshot_loader=provider_snapshot_loader)
@@ -500,6 +503,7 @@ class AgentLoop:
unified_session=defaults.unified_session,
disabled_skills=defaults.disabled_skills,
session_ttl_minutes=defaults.session_ttl_minutes,
idle_compact_check_interval_seconds=defaults.idle_compact_check_interval_seconds,
consolidation_ratio=defaults.consolidation_ratio,
tools_config=config.tools,
model_presets=preset_helpers.configured_model_presets(config),
@@ -1081,6 +1085,18 @@ class AgentLoop:
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
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:
"""Run the agent loop, dispatching messages as tasks to stay responsive to /stop."""
self._running = True
@@ -1092,11 +1108,7 @@ class AgentLoop:
try:
msg = await asyncio.wait_for(self.bus.consume_inbound(), timeout=1.0)
except asyncio.TimeoutError:
self.auto_compact.check_expired(
self._schedule_background,
self.runtime_for_session,
active_session_keys=self._pending_queues.keys(),
)
self._check_expired_sessions_if_due()
continue
except asyncio.CancelledError:
# Preserve real task cancellation so shutdown can complete cleanly.
+4
View File
@@ -154,6 +154,10 @@ class AgentDefaults(Base):
validation_alias=AliasChoices("idleCompactAfterMinutes", "sessionTtlMinutes"),
serialization_alias="idleCompactAfterMinutes",
) # 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(
default=0.5,
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.queue import MessageBus
from nanobot.command import CommandContext
from nanobot.config.schema import AgentDefaults
from nanobot.config.schema import AgentDefaults, Config
from nanobot.providers.base import LLMResponse
@@ -180,12 +180,64 @@ class TestSessionTTLConfig:
assert data["idleCompactAfterMinutes"] == 30
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):
"""Session file cap should remain an internal constant, not a config field."""
from nanobot.session.manager import FILE_MAX_MESSAGES
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:
"""Test that AutoCompact receives and stores session_ttl_minutes."""