Repository navigation
Expand file tree
/
Copy pathengine.py
More file actions
5284 lines (4951 loc) · 237 KB
/
Copy pathengine.py
File metadata and controls
5284 lines (4951 loc) · 237 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""LCM Engine — Lossless Context Management.
Implements the ContextEngine ABC. Replaces the built-in ContextCompressor
with a DAG-based summarization system that preserves every message.
"""
import copy
import hashlib
import json
import logging
import os
import re
import sqlite3
import threading
import time
from pathlib import Path
from typing import Any, Dict, List, Optional
from agent.context_engine import ContextEngine
from .codex_routing import (
_codex_oauth_context_cap,
_is_codex_gpt55_route,
)
from .config import LCMConfig
from .dag import SummaryDAG, SummaryNode
from .diagnostics import _enforce_state_db_containment
from .engine_registry import (
_ACTIVE_ENGINE_REGISTRY_LOCK,
_ACTIVE_ENGINES_BY_CONVERSATION_ID,
_ACTIVE_ENGINES_BY_SESSION_ID,
_remove_registry_entries_for_engine,
resolve_active_lcm_engine, # noqa: F401 (re-exported: hosts import it from .engine)
)
from .escalation import (
SummaryCircuitBreaker,
SummarySpendGuard,
summarize_with_escalation,
)
from .externalize import (
build_transcript_gc_placeholder,
extract_externalized_ref,
find_externalized_payload_for_message,
find_externalized_tool_result_content_for_call,
is_externalized_placeholder,
load_externalized_payload,
maybe_externalize_tool_output,
)
from .extraction import (
extract_before_compaction,
sanitize_pre_compaction_content,
sanitize_pre_compaction_tool_arguments,
strip_injected_context_blocks,
)
from .ingest_protection import (
_expected_persisted_output_chars,
_has_lossy_sensitive_redaction,
_is_hermes_persisted_output_marker,
_persisted_output_inline_preview_sha256,
_persisted_output_preview_prefix_digest,
_persisted_output_saved_path,
assistant_output_quarantine_reason,
extract_all_externalized_payload_refs,
extract_ingest_externalized_refs,
protect_inline_payloads_in_text,
protect_messages_for_ingest,
quarantine_suspicious_assistant_messages,
recover_hermes_persisted_output_with_file_stat,
redact_sensitive_text,
redact_sensitive_value,
restore_ingest_payload_placeholders,
sensitive_pattern_status,
)
from .runtime_identity import (
_PLUGIN_ROOT,
_git_runtime_identity,
_plugin_metadata,
)
from .schemas import (
LCM_DESCRIBE,
LCM_DOCTOR,
LCM_EXPAND,
LCM_EXPAND_QUERY,
LCM_GREP,
LCM_INSPECT,
LCM_LOAD_SESSION,
LCM_STATUS,
)
from .sanitize import (
_clean_active_assistant_message,
_should_drop_active_assistant_message,
)
from .session_patterns import (
build_session_match_keys,
compile_session_patterns,
matches_session_pattern,
)
from .message_analysis import (
_is_synthetic_assistant_noise,
_matched_tool_call_ids,
_tool_call_id,
)
from .message_patterns import compile_message_patterns, matches_message_pattern
from .aux_session import AuxiliarySessionMixin
from .placeholder_ledger import PlaceholderLedgerMixin
from .reconcile import ReconcileMixin, _PRESERVED_OBJECTIVE_CONTEXT_PREFIX
from .compaction import CompactionMixin
from .reset_state import ResetStateMixin
from .bypass import BypassMixin
from .lifecycle_state import LifecycleStateStore
from .message_content import (
normalize_content_value,
stored_text_content_for_pattern_matching,
text_content_for_pattern_matching,
)
from .sqlite_util import (
_is_sqlite_locked_error,
_temporary_sqlite_busy_timeout,
)
from .store import MessageStore
from .tokens import count_message_tokens, count_messages_tokens, count_tokens
from . import tools as lcm_tools
logger = logging.getLogger(__name__)
_SESSION_END_BUSY_TIMEOUT_MS = 50
_CODEX_GPT55_COMPACTION_THRESHOLD = 0.85
# Auto-focus topic derivation: infer a compact focus hint from the most recent
# real user turns so that summarization can prioritise current user intent.
# Mirrors Hermes upstream fix/compression-auto-focus-topic (#44687 branch).
_AUTO_FOCUS_MAX_TURNS = 3
_AUTO_FOCUS_TURN_MAX_CHARS = 260
_AUTO_FOCUS_MAX_CHARS = 700
_PRESERVED_TODO_CONTEXT_PREFIX = "[Your active task list was preserved across context compression]"
_LCM_MESSAGE_PREFIX_FINGERPRINT_LIMIT = 8
class LCMEngine(CompactionMixin, ResetStateMixin, ReconcileMixin, AuxiliarySessionMixin, PlaceholderLedgerMixin, BypassMixin, ContextEngine):
"""Lossless Context Management engine.
Automatic LCM compaction is routine background maintenance. Hosts that
support user-visible compaction status opt-outs should keep successful
automatic LCM passes silent unless the user explicitly asks for diagnostics.
Architecture:
1. Every message is persisted verbatim in an immutable MessageStore
2. When context pressure builds, older messages outside the fresh tail
are summarized into leaf nodes (D0) in a SummaryDAG
3. When enough nodes accumulate at a depth, they're condensed into
higher-depth nodes (D1, D2, ...)
4. The agent gets tools (lcm_grep, lcm_load_session, lcm_describe,
lcm_expand) to search and drill into compacted history
5. Active context = system prompt + DAG summaries + fresh tail
"""
def __init__(self, config: LCMConfig | None = None,
hermes_home: str = ""):
self._config = config or LCMConfig.from_env()
self._hermes_home = hermes_home
db_path = self._resolve_db_path(hermes_home)
self._bind_storage(db_path, hermes_home)
self._session_id: str = ""
self._session_platform: str = ""
# Tracks the most recent non-ignored, non-stateless binding so that
# user-facing tools (lcm_status, lcm_grep default scope, lcm_describe,
# lcm_expand_query, lcm_doctor) keep showing the foreground session
# even while a side-channel session (cron, debug) temporarily owns the
# engine's _session_id binding. Updated alongside _session_id only
# when _refresh_session_filters classifies the new session as a real
# foreground (neither ignored nor stateless). Read via the
# `current_session_id` / `current_session_platform` properties and
# `current_session_ignored` / `current_session_stateless` /
# `side_channel_active` companion predicates.
self._foreground_session_id: str = ""
self._foreground_session_platform: str = ""
self._foreground_conversation_id: str = ""
self._foreground_rebind_session_id: str = ""
self._foreground_rebind_previous_session_id: str = ""
self._foreground_rebind_previous_platform: str = ""
self._foreground_rebind_previous_conversation_id: str = ""
self._foreground_rebind_parent_session_id: str = ""
self._conversation_id: str = ""
self._session_match_keys: list[str] = []
self._session_ignored = False
self._session_stateless = False
self._compiled_ignore_session_patterns = compile_session_patterns(
self._config.ignore_session_patterns
)
self._compiled_stateless_session_patterns = compile_session_patterns(
self._config.stateless_session_patterns
)
self._compiled_ignore_message_patterns = compile_message_patterns(
self._config.ignore_message_patterns
)
self._ignored_message_count: int = 0
# Raw messages permanently dropped because they matched
# ignore_message_patterns. These are NOT persisted anywhere, so an
# over-broad operator pattern silently discards substantive turns from
# the "lossless" store. Count + log them so the loss is at least
# visible; full lossless retention (store with ignored=1) is a larger
# follow-up that touches cursor reconciliation and FTS.
self._ignore_pattern_dropped_count: int = 0
# Track which store_ids have been ingested into the DAG
self._last_compacted_store_id: int = 0
# Cursor: index in the current messages list up to which all
# messages have been persisted. After compress() shortens the
# list, the cursor resets to len(compressed) so that only
# genuinely new messages (appended after compaction) get ingested.
# The cursor is process-local; existing sessions rebound after a
# gateway restart reconcile it against the durable store on the
# next ingest.
self._ingest_cursor: int = 0
self._ingest_cursor_needs_reconcile = False
self._last_ingest_reconciliation: Dict[str, Any] = {
"action": "none",
"reason": "not run",
}
# State required by ContextEngine ABC and run_agent.py compatibility
self.model = ""
self.base_url = ""
self.api_key = ""
self.provider = ""
self.api_mode = ""
self.raw_context_length = 0
self.context_length = 0
self.effective_context_length_cap: int | None = None
self.effective_context_length_reason = ""
self._context_length_source = ""
self._update_model_pending_session_start = False
self.threshold_tokens = 0
self.context_threshold = self._config.context_threshold
self.threshold_percent = self.context_threshold
self._context_threshold_source = (
self._config.config_sources.get("context_threshold", "manual_or_default")
if getattr(self._config, "config_sources", None)
else "manual_or_default"
)
self._context_threshold_autoraised: dict[str, float] | None = None
self.last_prompt_tokens = 0
self.last_completion_tokens = 0
self.last_total_tokens = 0
self.last_input_tokens = 0
self.last_output_tokens = 0
self.last_cache_read_tokens = 0
self.last_cache_write_tokens = 0
self.last_reasoning_tokens = 0
self.cache_metrics_available = False
self.compression_count = 0
# Wall-clock of the last leaf compaction (ms); surfaced via telemetry only.
self._last_compaction_duration_ms = 0.0
# run_agent.py reads these for preflight checks
self.protect_first_n = 3
self.protect_last_n = self._config.fresh_tail_count
# run_agent.py reads these for context probing
self._context_probed = False
self._context_probe_persistable = False
# Host compatibility: LCM treats successful automatic compaction as
# silent maintenance. Manual /lcm diagnostics and warning/error paths
# remain explicit.
self.emit_automatic_compaction_status = False
self.quiet_mode = True
self.summary_model = self._config.summary_model
self._summary_circuit_breaker = SummaryCircuitBreaker(
failure_threshold=self._config.summary_circuit_breaker_failure_threshold,
cooldown_seconds=self._config.summary_circuit_breaker_cooldown_seconds,
)
# Summary spend guard: process-local sliding window so a loop that
# keeps succeeding cannot burn auxiliary-model budget without bound. When
# tripped, escalation falls back to deterministic L3 truncation. Set
# summary_spend_max_calls=0 to disable.
self._summary_spend_guard = SummarySpendGuard(
max_calls=int(self._config.summary_spend_max_calls),
window_seconds=float(self._config.summary_spend_window_seconds),
backoff_seconds=float(self._config.summary_spend_backoff_seconds),
)
self._last_overflow_recovery_failed = False
self._last_condensation_suppressed_reason = ""
self._last_compression_status = "idle"
self._last_compression_noop_reason = ""
# Ingest-failure tracking. The core promise is that nothing is ever
# lost, but a swallowed persistence error (disk full, DB locked,
# corruption) silently breaks it: the turn continues while messages
# exist only in the volatile host list. Surface it instead of hiding
# it in a debug log so get_status()/doctor can escalate. Store-scoped,
# not session-scoped, so it is not cleared on session reset.
self._ingest_failure_count = 0
self._consecutive_ingest_failures = 0
self._last_ingest_error = ""
self._last_ingest_error_time: float = 0
# Cooldown timestamp to prevent compression cascade after boundary skip.
# Set when skip-carry-over path is taken in _continue_compression_boundary.
self._last_boundary_skip_time: float = 0
# Temporary source window used only while compress() assembles context.
# _assemble_context also serves tests and recovery paths directly, so
# keep anchoring opt-in rather than changing its public behavior.
self._pending_context_anchor_messages: Optional[List[Dict[str, Any]]] = None
self._current_compress_store_ids_by_message_id: dict[int, int] = {}
self._current_compress_placeholder_identity_counts: dict[tuple[str, str, str, str], int] = {}
self._last_active_replay_source_identities: list[tuple[Any, ...]] = []
self._last_active_replay_messages: list[Dict[str, Any]] = []
self._generated_ignored_active_replay_placeholder_message_ids: set[int] = set()
self._logged_filter_config = False
self._pending_reset_session_id: str = ""
self._pending_reset_conversation_id: str = ""
self._pending_reset_frontier_store_id: int = 0
self._compression_boundary_ingest_pending = False
self._compression_boundary_active_placeholder_digest_budget: dict[str, int] = {}
self._compression_boundary_active_placeholder_digest_ordinals: dict[str, set[int]] = {}
self._compression_boundary_stored_placeholder_digest_counts: dict[str, int] = {}
self._thread_context = threading.local()
self._auxiliary_session_ids: set[str] = set()
self._auxiliary_lineage_session_ids: set[str] = set()
self._auxiliary_last_prompt_tokens: dict[str, int] = {}
self._auxiliary_session_generations: dict[str, int] = {}
self._auxiliary_generation_tokens: dict[int, tuple[Any, int]] = {}
self._auxiliary_next_generation_token = 0
self._auxiliary_direct_end_guard_session_ids: set[str] = set()
self._auxiliary_handoff_parent_session_ids: dict[str, str] = {}
self._auxiliary_retired_session_generations: dict[str, set[int]] = {}
self._auxiliary_foreground_reused_session_ids: set[str] = set()
self._lcm_bypass_lineage_session_ids: set[str] = set()
self._lcm_bypass_lineage_platforms: dict[str, set[str]] = {}
self._lcm_non_bypass_platforms: dict[str, set[str]] = {}
self._lcm_session_last_platform: dict[str, str] = {}
self._lcm_session_last_normal_platform: dict[str, str] = {}
self._lcm_session_last_bypassed: dict[str, bool] = {}
self._lcm_session_last_conversation_id: dict[str, str] = {}
self._lcm_session_last_normal_conversation_id: dict[str, str] = {}
self._lcm_bypass_message_prefix_fingerprints: dict[
str, list[tuple[list[str], bool]]
] = {}
self._lcm_normal_message_prefix_fingerprints: dict[tuple[str, str], list[str]] = {}
self._lcm_current_start_allows_bypass_lineage = False
self._auxiliary_session_lock = threading.RLock()
self._host_fallback_compressor: Any = None
self._host_fallback_session_id = ""
self._host_fallback_import_warning_logged = False
def clone_for_agent(self) -> "LCMEngine":
"""Return a fresh runtime engine for one AIAgent instance.
Hermes registers plugin context engines process-wide, while gateway
runtimes may keep multiple cached AIAgent instances alive at once
(different platforms, chats, cron jobs, etc.). LCM stores mutable
session binding and ingest cursor state on the engine object itself, so
sharing one registered instance across agents can let one conversation
rebind another conversation's raw-message ingest and lifecycle state.
The clone shares the same durable SQLite database path/configuration,
but gets independent session/cursor/lifecycle runtime state. Runtime
model and context-window metadata is copied so the clone is immediately
budget-aware even before a compatible Hermes host calls update_model().
"""
clone = type(self)(
config=copy.deepcopy(self._config),
hermes_home=self._hermes_home,
)
clone.model = self.model
clone.base_url = self.base_url
clone.api_key = self.api_key
clone.provider = self.provider
clone.api_mode = self.api_mode
if self._context_length_source:
clone._set_context_length(
self.raw_context_length,
source=self._context_length_source,
model=self.model,
provider=self.provider,
)
elif self.raw_context_length or self.context_length:
clone._set_context_length(
self.raw_context_length or self.context_length,
source="clone_for_agent",
model=self.model,
provider=self.provider,
)
# ``update_model()`` authority is a per-runtime lifecycle edge, not
# durable metadata. Compatible hosts call update_model() on the clone
# before binding it; hosts that bind only through on_session_start()
# must still be able to replace the copied prototype route.
clone._update_model_pending_session_start = False
clone._lcm_current_start_allows_bypass_lineage = False
return clone
def __deepcopy__(self, memo: dict[int, object]) -> "LCMEngine":
"""Copy the plugin runtime without pickling SQLite-backed helpers.
Hermes core may deepcopy plugin context engines while creating isolated
AIAgent instances. A default object deepcopy walks into MessageStore,
SummaryDAG, and LifecycleStateStore sqlite3.Connection handles, which
cannot be pickled. LCM already exposes clone_for_agent() as the safe
boundary: share durable configuration/database path, but allocate fresh
per-agent runtime/storage helper objects.
"""
clone = self.clone_for_agent()
memo[id(self)] = clone
return clone
def _resolve_db_path(self, hermes_home: str = "") -> Path:
"""Resolve the SQLite path for the active Hermes profile/home."""
if self._config.database_path:
return Path(self._config.database_path)
if hermes_home:
return Path(hermes_home) / "lcm.db"
return Path.home() / ".hermes" / "lcm.db"
def _bind_storage(self, db_path: str | Path, hermes_home: str = "") -> None:
"""Bind store/DAG/lifecycle helpers to one SQLite database."""
self._store = MessageStore(
db_path,
ingest_protection_config=self._config,
hermes_home=hermes_home,
)
self._dag = SummaryDAG(db_path)
self._lifecycle = LifecycleStateStore(db_path)
def _close_storage(self) -> None:
"""Best-effort close of currently bound SQLite helpers."""
for attr in ("_store", "_dag", "_lifecycle"):
helper = getattr(self, attr, None)
close = getattr(helper, "close", None)
if callable(close):
try:
close()
except Exception:
logger.debug("LCM failed closing %s during profile rebind", attr, exc_info=True)
def _reset_profile_runtime_state(self) -> None:
"""Clear process-local session state that cannot cross profile homes."""
self._unregister_active_engine_binding()
self._session_id = ""
self._session_platform = ""
self._foreground_session_id = ""
self._foreground_session_platform = ""
self._foreground_conversation_id = ""
self._clear_foreground_rebind_candidate()
self._conversation_id = ""
self._session_match_keys = []
self._session_ignored = False
self._session_stateless = False
self._clear_pending_reset_boundary()
self._compression_boundary_ingest_pending = False
self._compression_boundary_active_placeholder_digest_budget = {}
self._compression_boundary_active_placeholder_digest_ordinals = {}
self._compression_boundary_stored_placeholder_digest_counts = {}
with self._auxiliary_session_lock:
self._auxiliary_session_ids.clear()
self._auxiliary_lineage_session_ids.clear()
self._auxiliary_last_prompt_tokens.clear()
self._auxiliary_session_generations.clear()
self._auxiliary_generation_tokens.clear()
self._auxiliary_next_generation_token = 0
self._auxiliary_direct_end_guard_session_ids.clear()
self._auxiliary_handoff_parent_session_ids.clear()
self._auxiliary_retired_session_generations.clear()
self._auxiliary_foreground_reused_session_ids.clear()
self._lcm_bypass_lineage_session_ids.clear()
self._lcm_bypass_lineage_platforms.clear()
self._lcm_non_bypass_platforms.clear()
self._lcm_session_last_platform.clear()
self._lcm_session_last_normal_platform.clear()
self._lcm_session_last_bypassed.clear()
self._lcm_session_last_conversation_id.clear()
self._lcm_session_last_normal_conversation_id.clear()
self._lcm_bypass_message_prefix_fingerprints.clear()
self._lcm_normal_message_prefix_fingerprints.clear()
self._lcm_current_start_allows_bypass_lineage = False
self._host_fallback_compressor = None
self._host_fallback_session_id = ""
self._host_fallback_import_warning_logged = False
self._clear_thread_context_stateless()
self._reset_session_scoped_runtime_state()
def _rebind_storage_for_home(self, hermes_home: str = "") -> bool:
"""Switch SQLite-backed state when a reused engine serves another profile.
Hermes core passes the active ``hermes_home`` on session start. Older
Hermes versions may still reuse the same plugin/context-engine object
after ``HERMES_HOME`` changes, so the plugin must not assume the store
captured during ``register()`` is still correct.
"""
if not hermes_home:
return False
if self._config.database_path:
current_home = str(self._hermes_home or "")
current_store_home = str(getattr(getattr(self, "_store", None), "_hermes_home", "") or "")
if current_home == str(hermes_home) and current_store_home == str(hermes_home):
return False
self._hermes_home = hermes_home
store = getattr(self, "_store", None)
if store is not None:
store._hermes_home = hermes_home
self._reset_profile_runtime_state()
logger.info("LCM rebound Hermes home for configured database path %s", hermes_home)
return True
db_path = self._resolve_db_path(hermes_home)
current_db = Path(getattr(getattr(self, "_store", None), "db_path", ""))
if current_db == db_path and str(self._hermes_home or "") == str(hermes_home):
return False
self._close_storage()
self._hermes_home = hermes_home
self._bind_storage(db_path, hermes_home)
self._reset_profile_runtime_state()
logger.info("LCM rebound storage for Hermes home %s", hermes_home)
return True
def _runtime_context_threshold(
self,
*,
model: str | None = None,
provider: str | None = None,
) -> tuple[float, str, dict[str, float] | None]:
configured = float(self._config.context_threshold)
source = (
self._config.config_sources.get("context_threshold", "manual_or_default")
if getattr(self._config, "config_sources", None)
else "manual_or_default"
)
explicit_lcm_override = source in {
"env:LCM_CONTEXT_THRESHOLD",
"config_yaml:lcm.context_threshold",
}
route_model = self.model if model is None else model
route_provider = self.provider if provider is None else provider
if (
_is_codex_gpt55_route(route_model, route_provider)
and self._config.codex_gpt55_autoraise_enabled
and not explicit_lcm_override
and configured < _CODEX_GPT55_COMPACTION_THRESHOLD
):
return (
_CODEX_GPT55_COMPACTION_THRESHOLD,
"codex_gpt55_autoraise",
{"from": configured, "to": _CODEX_GPT55_COMPACTION_THRESHOLD},
)
return configured, source, None
def _effective_context_length(
self,
raw_context_length: int,
*,
model: str | None = None,
provider: str | None = None,
) -> tuple[int, int | None, str]:
route_model = self.model if model is None else model
route_provider = self.provider if provider is None else provider
cap = _codex_oauth_context_cap(route_model, route_provider)
if cap is not None and raw_context_length > cap:
return (
cap,
cap,
"codex_oauth_context_cap",
)
return raw_context_length, None, ""
def _effective_threshold_tokens(self, context_threshold_tokens: int) -> int:
"""Return the host-visible preflight trigger token count.
Hermes core uses ``threshold_tokens`` as a cheap gate before it pays for
the full request estimate that includes system prompt and tool schemas.
LCM can enforce a stricter active-context assembly cap than the normal
context-threshold value, so expose the stricter cap here; otherwise a
tool/schema-heavy request can skip host preflight entirely.
"""
assembly_cap = self._effective_assembly_token_cap()
if assembly_cap is not None and assembly_cap > 0:
if context_threshold_tokens > 0:
return min(context_threshold_tokens, assembly_cap)
return assembly_cap
return context_threshold_tokens
def _set_context_length(
self,
context_length: Any,
*,
source: str,
model: str | None = None,
provider: str | None = None,
) -> bool:
try:
parsed_context_length = int(context_length)
except (TypeError, ValueError):
logger.debug("LCM ignored invalid %s context_length: %r", source, context_length)
return False
if parsed_context_length <= 0:
logger.debug(
"LCM cleared non-positive %s context_length: %r",
source,
context_length,
)
self.raw_context_length = 0
self.context_length = 0
self.effective_context_length_cap = None
self.effective_context_length_reason = ""
self._context_length_source = source
self.threshold_tokens = 0
self.context_threshold, self._context_threshold_source, self._context_threshold_autoraised = (
self._runtime_context_threshold(model=model, provider=provider)
)
self.threshold_percent = self.context_threshold
return True
self.raw_context_length = parsed_context_length
effective_context_length, cap, reason = self._effective_context_length(
parsed_context_length,
model=model,
provider=provider,
)
self.context_length = effective_context_length
self.effective_context_length_cap = cap
self.effective_context_length_reason = reason
self._context_length_source = source
self.context_threshold, self._context_threshold_source, self._context_threshold_autoraised = (
self._runtime_context_threshold(model=model, provider=provider)
)
self.threshold_percent = self.context_threshold
context_threshold_tokens = int(
effective_context_length * self.context_threshold
)
self.threshold_tokens = self._effective_threshold_tokens(
context_threshold_tokens
)
return True
def _session_metadata_matches_active_runtime(
self,
kwargs: Dict[str, Any],
*,
ignore_empty_optional: bool = False,
) -> bool:
if "model" in kwargs and str(kwargs.get("model") or "") != self.model:
return False
for key in ("provider", "base_url", "api_key", "api_mode"):
if key not in kwargs:
continue
incoming = str(kwargs.get(key) or "")
if ignore_empty_optional and not incoming:
continue
if incoming != str(getattr(self, key, "") or ""):
return False
return True
@property
def name(self) -> str:
return "lcm"
@property
def last_compression_status(self) -> str:
"""Public status for the most recent compression/preflight attempt.
Host runtimes use this to distinguish a real compaction boundary from
an LCM no-op (for example, when request pressure is high but all
compactable raw backlog is protected by the fresh tail).
"""
return self._last_compression_status
@property
def last_compression_noop_reason(self) -> str:
"""Human-readable reason for the latest no-op compression decision."""
return self._last_compression_noop_reason
@property
def last_compression_was_noop(self) -> bool:
"""Whether the most recent compression/preflight decision was a no-op."""
return self._last_compression_status == "noop"
def _mark_preflight_compression_requested(self) -> bool:
"""Record that preflight found work and clear any stale no-op reason."""
self._last_compression_status = "pending"
self._last_compression_noop_reason = ""
return True
@property
def bound_session_id(self) -> str:
"""Session id this engine is actively servicing for ingest/lifecycle.
This differs from ``current_session_id`` while a side-channel session is
bound but operator-facing tools should keep showing the foreground
session. Host lifecycle hooks must compare against this value before
deciding whether a post-turn ingest needs to rebind the engine.
"""
return self._session_id
@property
def current_session_id(self) -> str:
"""User-facing "current session" id surfaced by LCM tools.
Returns the most recent foreground binding (the last session id that
``_refresh_session_filters`` classified as neither ignored nor
stateless). Falls back to ``_session_id`` when no foreground has
ever been bound, so unattended cron-only or stateless-only processes
remain observable via ``lcm_status``.
Lifecycle paths (compress, ingest, on_session_end, etc.) must keep
reading ``_session_id`` directly because those paths must follow the
binding the engine is actually servicing. Only tool-surface code
paths that report a "current session" view to operators should read
this property.
"""
return self._foreground_session_id or self._session_id
@property
def current_session_platform(self) -> str:
"""Platform string paired with ``current_session_id``."""
if self._foreground_session_id:
return self._foreground_session_platform
return self._session_platform
@property
def current_conversation_id(self) -> str:
"""Conversation id paired with ``current_session_id``."""
if self._foreground_session_id:
return self._foreground_conversation_id
return self._conversation_id
@property
def side_channel_active(self) -> bool:
"""True when an ignored or stateless session has temporarily rebound
``_session_id`` while a real foreground binding still exists.
Operators reading lcm_status during this window see the foreground
session id and counts (because tools read ``current_session_id``)
but the engine itself is servicing the side channel. This predicate
lets diagnostic surfaces (lcm_status, /lcm command) make the
divergence explicit without recomputing the underlying invariant.
"""
return bool(self._foreground_session_id) and self._foreground_session_id != self._session_id
@property
def current_session_ignored(self) -> bool:
"""``_session_ignored`` reported for ``current_session_id``.
When a side channel is in flight the foreground is by definition
non-ignored; otherwise this is the bound session's ignore flag.
"""
if self.side_channel_active:
return False
return self._session_ignored
@property
def current_session_stateless(self) -> bool:
"""``_session_stateless`` reported for ``current_session_id``.
When a side channel is in flight the foreground is by definition
non-stateless; otherwise this is the bound session's stateless flag.
"""
if self.side_channel_active:
return False
return self._session_stateless
# -- ContextEngine required methods ------------------------------------
def update_from_response(self, usage: Dict[str, Any]) -> None:
if self._thread_context_stateless():
auxiliary_session_id = self._thread_context_session_id()
if auxiliary_session_id:
prompt_tokens = int(usage.get("prompt_tokens", 0) or 0)
caller_generation = self._in_process_auxiliary_caller_generation(
auxiliary_session_id
)
with self._auxiliary_session_lock:
if auxiliary_session_id not in self._auxiliary_session_ids:
return
if self._auxiliary_generation_is_retired(
auxiliary_session_id,
caller_generation,
):
return
active_generation = self._auxiliary_session_generations.get(
auxiliary_session_id
)
if active_generation is None and caller_generation:
expected_parent = self._auxiliary_handoff_parent_session_ids.get(
auxiliary_session_id
)
if expected_parent and self._in_process_parent_session_id(
{},
session_id=auxiliary_session_id,
include_explicit=False,
) != expected_parent:
return
if auxiliary_session_id in self._auxiliary_last_prompt_tokens:
self._auxiliary_direct_end_guard_session_ids.add(
auxiliary_session_id
)
self._auxiliary_last_prompt_tokens.pop(auxiliary_session_id, None)
if self._host_fallback_session_id == auxiliary_session_id:
self._end_host_fallback_compressor_for_session(
auxiliary_session_id,
[],
current_session_bypasses=True,
)
self._auxiliary_session_generations[
auxiliary_session_id
] = caller_generation
active_generation = caller_generation
stack = self._thread_context_auxiliary_stack()
stack_marks_current_session = bool(
active_generation is not None
and caller_generation == 0
and stack
and stack[-1] == auxiliary_session_id
)
generation_matches = (
caller_generation == 0
if active_generation is None
else caller_generation == active_generation or stack_marks_current_session
)
if generation_matches:
self._auxiliary_last_prompt_tokens[auxiliary_session_id] = prompt_tokens
return
self.last_prompt_tokens = int(usage.get("prompt_tokens", 0) or 0)
self.last_completion_tokens = int(usage.get("completion_tokens", 0) or 0)
self.last_total_tokens = int(usage.get("total_tokens", 0) or 0)
cache_keys = {"cache_read_tokens", "cache_write_tokens"}
self.cache_metrics_available = any(key in usage for key in cache_keys)
self.last_input_tokens = int(usage.get("input_tokens", self.last_prompt_tokens) or 0)
self.last_output_tokens = int(
usage.get("output_tokens", self.last_completion_tokens) or 0
)
self.last_cache_read_tokens = int(usage.get("cache_read_tokens", 0) or 0)
self.last_cache_write_tokens = int(usage.get("cache_write_tokens", 0) or 0)
self.last_reasoning_tokens = int(usage.get("reasoning_tokens", 0) or 0)
self._record_turn_compaction_telemetry()
@property
def cache_read_ratio(self) -> float:
if self.last_prompt_tokens <= 0:
return 0.0
return self.last_cache_read_tokens / self.last_prompt_tokens
def _record_turn_compaction_telemetry(self) -> None:
"""Persist a per-conversation compaction-telemetry snapshot for this turn.
Best-effort and diagnostic only: any failure is logged at debug and never
affects the turn. Turns with no token or cache signal are skipped so idle
turns do not churn the record. The since-compaction accumulators reset off
the monotonic ``compression_count`` (which also drops to 0 on a session
reset) rather than instrumenting the compaction hot path.
"""
conversation_id = self._conversation_id
if not conversation_id:
return
prompt_tokens = self.last_prompt_tokens
cache_read = self.last_cache_read_tokens
cache_write = self.last_cache_write_tokens
if (
prompt_tokens <= 0
and cache_read <= 0
and cache_write <= 0
and not self.cache_metrics_available
):
return
try:
existing = self._store.read_compaction_telemetry(conversation_id) or {}
if cache_read > 0 or cache_write > 0:
cache_state = "hot"
elif self.cache_metrics_available:
cache_state = "cold"
else:
cache_state = "unknown"
cold_streak = int(existing.get("consecutive_cold_observations", 0) or 0)
if cache_state == "hot":
cold_streak = 0
elif cache_state == "cold":
cold_streak += 1
prev_count = int(existing.get("compression_count_at_record", 0) or 0)
compacted = self.compression_count > prev_count
rebaselined = self.compression_count != prev_count # compaction or session reset
if rebaselined:
turns_since = 0
peak_tokens_since = prompt_tokens
else:
turns_since = int(existing.get("turns_since_leaf_compaction", 0) or 0) + 1
peak_tokens_since = max(
int(existing.get("peak_prompt_tokens_since_leaf_compaction", 0) or 0),
prompt_tokens,
)
total_compactions = int(existing.get("total_compactions", 0) or 0)
if compacted:
total_compactions += self.compression_count - prev_count
last_leaf_compaction_at = time.time()
last_compaction_duration_ms = round(self._last_compaction_duration_ms, 3)
else:
last_leaf_compaction_at = existing.get("last_leaf_compaction_at")
last_compaction_duration_ms = existing.get("last_compaction_duration_ms")
record = dict(existing)
record.update({
"conversation_id": conversation_id,
"last_observed_prompt_tokens": prompt_tokens,
"last_observed_cache_read": cache_read,
"last_observed_cache_write": cache_write,
"cache_state": cache_state,
"consecutive_cold_observations": cold_streak,
"turns_since_leaf_compaction": turns_since,
"peak_prompt_tokens_since_leaf_compaction": peak_tokens_since,
# Reserved carry-forward field; no live 'medium'/'high' computation yet.
"activity_band": existing.get("activity_band", "low"),
"provider": self.provider or existing.get("provider"),
"model": self.model or existing.get("model"),
"last_api_call_at": time.time(),
"last_leaf_compaction_at": last_leaf_compaction_at,
"last_compaction_duration_ms": last_compaction_duration_ms,
"total_compactions": total_compactions,
"compression_count_at_record": self.compression_count,
})
if cache_state == "hot":
record["last_cache_hit_at"] = time.time()
self._store.write_compaction_telemetry(conversation_id, record)
except Exception:
logger.debug("LCM compaction telemetry update failed", exc_info=True)
def _compression_boundary_cooldown_active(self) -> bool:
"""Return true while a boundary skip is in its short no-compress window."""
if self._last_boundary_skip_time <= 0:
return False
elapsed = time.time() - self._last_boundary_skip_time
if elapsed < 60:
logger.debug(
"LCM compression cooldown active: %.1f seconds since boundary skip",
elapsed,
)
return True
self._last_boundary_skip_time = 0
return False
def _record_ingest_success(self) -> None:
self._consecutive_ingest_failures = 0
def _record_ingest_failure(self, where: str, error: Exception) -> None:
"""Track a swallowed ingest error so it is operator-visible.
Escalates to error level once failures are consecutive: a single
transient lock is a warning, but a sustained inability to persist
means the lossless guarantee is broken and must not stay hidden.
"""
self._ingest_failure_count += 1
self._consecutive_ingest_failures += 1
self._last_ingest_error = f"{type(error).__name__}: {error}"
self._last_ingest_error_time = time.time()
message = "LCM ingest failed (%s): %s [consecutive=%d, total=%d]"
args = (
where,
error,
self._consecutive_ingest_failures,
self._ingest_failure_count,
)
if self._consecutive_ingest_failures >= 3:
logger.error(message, *args)
else:
logger.warning(message, *args)
def _clear_foreground_rebind_candidate(self) -> None:
self._foreground_rebind_session_id = ""
self._foreground_rebind_previous_session_id = ""
self._foreground_rebind_previous_platform = ""
self._foreground_rebind_previous_conversation_id = ""
self._foreground_rebind_parent_session_id = ""
def _clear_foreground_rebind_candidate_if_bound_session_confirmed(self) -> None:
if self._foreground_rebind_session_id == self._session_id:
self._clear_foreground_rebind_candidate()
def _session_has_foreground_branch_marker(
self,
session_id: str,
parent_session_id: str,
) -> bool:
"""Return True when Hermes state.db records ``session_id`` as a real branch.
An un-ingested foreground branch and a provisional late-marked auxiliary
child can both expose the same in-process parent id before either has
durable LCM rows. The canonical host signal for real foreground branches
is the ``sessions.model_config._branched_from`` marker in state.db; use
it to decide whether a displaced un-ingested candidate is safe to restore.
"""
session_id = str(session_id or "")
parent_session_id = str(parent_session_id or "")
if not session_id or not parent_session_id:
return False
path = self._state_db_path()
if not path.exists():
return False
try:
uri = path.resolve().as_uri() + "?mode=ro"
conn = sqlite3.connect(uri, uri=True)
try:
row = conn.execute(