diff --git a/docs/configuration.md b/docs/configuration.md index 0c94d010..04699b2a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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. diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index eab6f92e..84b1f8d7 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -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. diff --git a/nanobot/config/schema.py b/nanobot/config/schema.py index 8d264ce6..452c5406 100644 --- a/nanobot/config/schema.py +++ b/nanobot/config/schema.py @@ -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, diff --git a/tests/agent/test_auto_compact.py b/tests/agent/test_auto_compact.py index ce0f2205..af27ab6e 100644 --- a/tests/agent/test_auto_compact.py +++ b/tests/agent/test_auto_compact.py @@ -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."""