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:
committed by
Xubin Ren
parent
4e2640f2d2
commit
7aab7e8830
@@ -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
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user