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": {
|
"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
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user