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
+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."""