File size: 21,990 Bytes
fbd5681
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Abstract base class for pluggable context engines.



A context engine controls how conversation context is managed when

approaching the model's token limit. The built-in ContextCompressor

is the default implementation. Third-party engines (e.g. LCM) can

replace it via the plugin system or by being placed in the

``plugins/context_engine/<name>/`` directory.



Selection is config-driven: ``context.engine`` in config.yaml.

Default is ``"compressor"`` (the built-in). Only one engine is active.



The engine is responsible for:

  - Deciding when compaction should fire

  - Performing compaction (summarization, DAG construction, etc.)

  - Optionally exposing tools the agent can call (e.g. lcm_grep)

  - Tracking token usage from API responses



Lifecycle:

  1. Engine is instantiated and registered (plugin register() or default)

  2. on_session_start() called when a conversation begins

  3. update_from_response() called after each API response with usage data

  4. should_compress() checked after each turn

  5. compress() called when should_compress() returns True

  6. on_session_end() called at real session boundaries (CLI exit, /reset,

     gateway session expiry) β€” NOT per-turn

"""

from abc import ABC, abstractmethod
from typing import Any, Dict, List, Optional

from agent.redact import redact_sensitive_text


MEMORY_CONTEXT_MAX_CHARS = 6_000
_MEMORY_CONTEXT_HEAD_CHARS = 4_000
_MEMORY_CONTEXT_TAIL_CHARS = 1_500
_MEMORY_CONTEXT_TRUNCATION_MARKER = "\n...[memory provider context truncated]...\n"


def sanitize_memory_context(memory_context: str) -> str:
    """Prepare provider context for a context-engine/LLM egress boundary."""
    sanitized = redact_sensitive_text(
        memory_context.strip(),
        force=True,
        redact_url_credentials=True,
    )
    if len(sanitized) <= MEMORY_CONTEXT_MAX_CHARS:
        return sanitized
    return (
        sanitized[:_MEMORY_CONTEXT_HEAD_CHARS]
        + _MEMORY_CONTEXT_TRUNCATION_MARKER
        + sanitized[-_MEMORY_CONTEXT_TAIL_CHARS:]
    )


def automatic_compaction_status_message(

    engine: Any,

    *,

    phase: str,

    default_message: str,

    **context: Any,

) -> str | None:
    """Resolve host-visible status for an automatic compaction event.



    Engines can suppress routine automatic status with

    ``emit_automatic_compaction_status = False`` or customize it by defining

    ``get_automatic_compaction_status_message(...)``. Empty strings and

    ``None`` mean "do not emit a lifecycle status".

    """
    if not getattr(engine, "emit_automatic_compaction_status", True):
        return None

    formatter = getattr(engine, "get_automatic_compaction_status_message", None)
    if callable(formatter):
        message = formatter(
            phase=phase,
            default_message=default_message,
            **context,
        )
    else:
        message = default_message

    if message is None:
        return None
    message = str(message).strip()
    return message or None


class ContextEngine(ABC):
    """Base class all context engines must implement."""

    # -- Identity ----------------------------------------------------------

    @property
    @abstractmethod
    def name(self) -> str:
        """Short identifier (e.g. 'compressor', 'lcm')."""

    # -- Token state (read by run_agent.py for display/logging) ------------
    #
    # Engines MUST maintain these. run_agent.py reads them directly.

    last_prompt_tokens: int = 0
    last_completion_tokens: int = 0
    last_total_tokens: int = 0
    threshold_tokens: int = 0
    context_length: int = 0
    compression_count: int = 0

    # -- Compaction parameters (read by run_agent.py for preflight) --------
    #
    # These control the preflight compression check.  Subclasses may
    # override via __init__ or property; defaults are sensible for most
    # engines.
    #
    # protect_first_n semantics (since PR #13754): count of non-system head
    # messages always preserved verbatim, IN ADDITION to the system prompt
    # which is always implicitly protected.  Default 3 keeps the
    # historical "system + first 3 non-system messages" head shape.

    threshold_percent: float = 0.75
    protect_first_n: int = 3
    protect_last_n: int = 6

    # User-visible lifecycle status for automatic host-triggered compaction.
    # Alternative engines that treat compaction as routine background
    # maintenance can set this false to keep successful automatic passes silent;
    # warnings, errors, and explicit manual commands should still surface.
    emit_automatic_compaction_status: bool = True

    # -- Core interface ----------------------------------------------------

    @abstractmethod
    def update_from_response(self, usage: Dict[str, Any]) -> None:
        """Update tracked token usage from an API response.



        Called after every LLM call with a normalized usage dict. The legacy

        keys ``prompt_tokens``, ``completion_tokens``, and ``total_tokens``

        are always present. Newer hosts also include canonical buckets:

        ``input_tokens``, ``output_tokens``, ``cache_read_tokens``,

        ``cache_write_tokens``, and ``reasoning_tokens``. Engines should

        treat those fields as optional for compatibility with older hosts.

        """

    @abstractmethod
    def should_compress(self, prompt_tokens: int = None) -> bool:
        """Return True if compaction should fire this turn."""

    def should_compress_info(self, prompt_tokens: int = None) -> "tuple[bool, str | None]":
        """Return ``(should_compress, reason)``.



        The base implementation is backward-compatible: engines that only

        implement ``should_compress`` get ``(should_compress(prompt_tokens),

        None)``. Concrete engines with richer block reasons (e.g. a

        summary-LLM cooldown or an anti-thrashing guard) override this to

        surface a human-readable reason so callers can warn the user instead

        of silently skipping compression. Added for the silent-overflow

        warning fix (#62625) so plugin engines don't raise AttributeError.

        """
        return self.should_compress(prompt_tokens), None

    @abstractmethod
    def compress(

        self,

        messages: List[Dict[str, Any]],

        current_tokens: Optional[int] = None,

        focus_topic: Optional[str] = None,

        force: bool = False,

        memory_context: str = "",

    ) -> List[Dict[str, Any]]:
        """Compact the message list and return the new message list.



        This is the main entry point. The engine receives the full message

        list and returns a (possibly shorter) list that fits within the

        context budget. The implementation is free to summarize, build a

        DAG, or do anything else β€” as long as the returned list is a valid

        OpenAI-format message sequence.



        Args:

            focus_topic: Optional topic string from manual ``/compress <focus>``.

                Engines that support guided compression should prioritise

                preserving information related to this topic.  Engines that

                don't support it may simply ignore this argument.

            force: Whether a user-requested compression should bypass an

                engine-owned cooldown. Engines without cooldowns may ignore it.

            memory_context: Text returned by memory providers immediately before

                compaction. Summarizing engines should include non-empty text in

                their handoff prompt. Older engines may omit this parameter; the

                host filters unsupported optional arguments by signature.

        """

    # -- Optional: proactive tool-result prune -----------------------------

    def prune_tool_results_only(

        self,

        messages: List[Dict[str, Any]],

        current_tokens: int | None = None,

    ) -> tuple[List[Dict[str, Any]], int]:
        """Deterministically trim old tool-result payloads without an LLM call.



        Runs on a low, cost-oriented trigger independent of ``should_compress``

        so large-window engines can reclaim re-sent tool output long before full

        compaction would fire. Returns ``(messages, n_pruned)``.



        Default is a safe no-op: the list is returned unchanged with ``0``

        pruned. Engines that don't implement a cheap prune β€” and any engine that

        predates this hook β€” inherit this default, so the agent loop's

        post-tool-call prune path never raises ``AttributeError`` on them. The

        built-in ContextCompressor overrides this with the real implementation.

        """
        return messages, 0

    # -- Optional: per-turn context selection (distinct from compression) --

    def select_context(

        self,

        request_messages: List[Dict[str, Any]],

        *,

        conversation_messages: List[Dict[str, Any]] = None,

        incoming_message: Dict[str, Any] = None,

        budget_tokens: int = 0,

    ) -> List[Dict[str, Any]]:
        """Optionally choose/replace the context for THIS request, pre-generation.



        Called every turn after the request message list is assembled and

        before it is dispatched to the provider β€” independent of

        ``should_compress()``. This lets an engine *select* which context

        enters the prompt (retrieval, topic routing, role/branch switching)

        rather than *shrink* context that is already there. The two verbs are

        orthogonal:



          - ``compress()``      : context is too long  -> make it shorter.

          - ``select_context()``: this turn belongs to a different context

                                  -> use that one instead.



        Without this hook, engines that need per-turn access to the message

        list have to force ``should_compress()`` to return ``True`` so that

        ``compress()`` is invoked every turn purely as a callback β€” which

        conflates selection with compression and degrades behaviour when the

        engine's backend is unavailable. ``select_context()`` removes the need

        for that workaround.



        The returned list is request-only: it replaces the messages sent to

        the provider for this single call and MUST NOT be treated as persisted

        transcript state. The conversation history in the session DB is left

        untouched, so nothing leaks across turns. Return ``None`` to leave the

        request unchanged.



        Unlike the ``pre_llm_call`` plugin hook (which appends to the user

        message and intentionally never rewrites the list, to preserve the

        cache prefix), ``select_context()`` may *replace* the message list.



        Ordering / cache contract: the host runs this hook **before** prompt

        cache-control and **before** every request sanitizer (orphaned-tool

        cleanup, thinking-only/role normalization, whitespace/JSON

        normalization). So (a) whatever the hook returns still passes through

        the same validation as any request β€” a malformed replacement cannot

        reach the provider β€” and (b) prompt-cache stability (an AGENTS.md

        invariant) is preserved: the default no-op leaves the request

        byte-identical, so cache behaviour is unchanged for the built-in

        compressor and any non-implementing engine. An engine that *does*

        replace the list changes its own cache prefix by definition; that is

        the engine's concern, and cache-control breakpoints are re-derived on

        the selected list. The hook is evaluated per provider request (so it

        re-runs on retries within a turn), consistent with "select the context

        for THIS request".



        Args:

            request_messages: The assembled request message list (system

                prompt + history + any ephemeral prefill), in OpenAI format.

            conversation_messages: The unmodified persisted conversation

                history, for reference only (do not mutate).

            incoming_message: The current turn's user message, if available.

            budget_tokens: The active model's context length, or 0 if unknown.



        Default returns ``None`` (no-op) β€” zero impact on the built-in

        compressor or any existing engine.

        """
        return None

    def on_turn_complete(

        self,

        messages: List[Dict[str, Any]],

        usage: Dict[str, Any] = None,

        **kwargs: Any,

    ) -> None:
        """Observe a finished user turn (post-turn ingestion / observation).



        Called from the standard turn-finalization path once the assistant/tool

        loop completes, with the finalized in-memory transcript snapshot. This

        is the complement to ``select_context()``: selection happens *before*

        the request, while observation happens *after* the turn. It lets an

        engine ingest, index, summarize, or update routing / topic / session

        state from what actually happened β€” so the next ``select_context()``

        can act on it.



        Coverage: this fires from the normal finalization seam. Some abnormal

        early-return paths in the loop (e.g. a content-policy block or a

        provider terminal failure) persist and return without routing through

        finalization, and therefore do not currently emit this hook. Treat it

        as a best-effort post-turn observation for completed turns, not a

        guaranteed callback for every possible early exit; unifying all

        terminal paths behind one finalization seam is a separate follow-up.



        Together the two hooks remove the need to abuse ``should_compress()`` /

        ``compress()`` as a generic per-turn callback just to observe history,

        and they cover the case where a turn finishes and there may be no next

        request from which to infer the previous turn.



        ``messages`` is a shallow copy and should be treated as read-only:

        return values are ignored and this hook must not rely on transcript

        mutation for persistence. ``kwargs`` may include ``turn_id``,

        ``task_id``, ``api_call_count``, ``interrupted``, ``failed``, and

        ``turn_exit_reason``.



        ``usage`` carries the completed turn's canonical token usage (the same

        dict shape passed to ``update_from_response`` β€” ``prompt_tokens`` /

        ``completion_tokens`` / ``total_tokens`` plus the canonical

        ``input_tokens`` / ``output_tokens`` / ``cache_read_tokens`` /

        ``cache_write_tokens`` / ``reasoning_tokens`` buckets) so an engine can

        weigh how large/expensive the selected context actually was when

        deciding the next ``select_context()``. It is ``None`` on finalized

        turns that never reached a provider response (e.g. interrupt); engines

        must treat it as optional.



        Default is a no-op.

        """
        return None

    # -- Optional: pre-flight check ----------------------------------------

    def should_compress_preflight(self, messages: List[Dict[str, Any]]) -> bool:
        """Quick rough check before the API call (no real token count yet).



        Default returns False (skip pre-flight). Override if your engine

        can do a cheap estimate.

        """
        return False

    def should_defer_preflight_to_real_usage(self, rough_tokens: int) -> bool:
        """Return True when preflight should trust recent real usage instead.



        Built-in compression uses this to avoid re-compacting from known-noisy

        rough estimates after a compressed request has already fit. Third-party

        engines can ignore it safely.

        """
        return False

    def get_automatic_compaction_status_message(

        self,

        *,

        phase: str,

        default_message: str,

        **context: Any,

    ) -> str | None:
        """Return user-visible status for automatic host-triggered compaction.



        Return ``None`` to suppress successful automatic lifecycle status for

        this compaction event. ``phase`` identifies the host call site (for

        example ``"preflight"`` or ``"compress"``). ``context`` contains

        best-effort fields such as ``approx_tokens`` and ``threshold_tokens``.



        This hook does not control warning/error messages or explicit manual

        commands such as ``/compress``.

        """
        if not self.emit_automatic_compaction_status:
            return None
        return default_message

    # -- Optional: manual /compress preflight ------------------------------

    def has_content_to_compress(self, messages: List[Dict[str, Any]]) -> bool:
        """Quick check: is there anything in ``messages`` that can be compacted?



        Used by the gateway ``/compress`` command as a preflight guard β€”

        returning False lets the gateway report "nothing to compress yet"

        without making an LLM call.



        Default returns True (always attempt).  Engines with a cheap way

        to introspect their own head/tail boundaries should override this

        to return False when the transcript is still entirely protected.

        """
        return True

    # -- Optional: session lifecycle ---------------------------------------

    def on_session_start(self, session_id: str, **kwargs) -> None:
        """Called when a new conversation session begins.



        Use this to load persisted state (DAG, store) for the session.

        kwargs may include hermes_home, platform, model, etc.

        """

    def on_session_end(self, session_id: str, messages: List[Dict[str, Any]]) -> None:
        """Called at real session boundaries (CLI exit, /reset, gateway expiry).



        Use this to flush state, close DB connections, etc.

        NOT called per-turn β€” only when the session truly ends.

        """

    def on_session_reset(self) -> None:
        """Called on /new or /reset. Reset per-session state.



        Default resets compression_count and token tracking.

        """
        self.last_prompt_tokens = 0
        self.last_completion_tokens = 0
        self.last_total_tokens = 0
        self.compression_count = 0

    # -- Optional: tools ---------------------------------------------------

    def get_tool_schemas(self) -> List[Dict[str, Any]]:
        """Return tool schemas this engine provides to the agent.



        Default returns empty list (no tools). LCM would return schemas

        for lcm_grep, lcm_describe, lcm_expand here.

        """
        return []

    def handle_tool_call(self, name: str, args: Dict[str, Any], **kwargs) -> str:
        """Handle a tool call from the agent.



        Only called for tool names returned by get_tool_schemas().

        Must return a JSON string.



        kwargs may include:

          messages: the current in-memory message list (for live ingestion)

        """
        import json
        return json.dumps({"error": f"Unknown context engine tool: {name}"})

    # -- Optional: status / display ----------------------------------------

    def get_status(self) -> Dict[str, Any]:
        """Return status dict for display/logging.



        Default returns the standard fields run_agent.py expects.

        """
        # Clamp the -1 "compression just ran, awaiting real usage" sentinel
        # (set by conversation_compression) to 0 so status readers don't see a
        # raw -1 or a negative usage_percent on the transitional turn. Mirrors
        # the CLI/gateway status-bar paths (cli.py, tui_gateway/server.py).
        last_prompt = self.last_prompt_tokens if self.last_prompt_tokens > 0 else 0
        return {
            "last_prompt_tokens": last_prompt,
            "threshold_tokens": self.threshold_tokens,
            "context_length": self.context_length,
            "usage_percent": (
                min(100, last_prompt / self.context_length * 100)
                if self.context_length else 0
            ),
            "compression_count": self.compression_count,
        }

    # -- Optional: model switch support ------------------------------------

    def update_model(

        self,

        model: str,

        context_length: int,

        base_url: str = "",

        api_key: str = "",

        provider: str = "",

        api_mode: str = "",

    ) -> None:
        """Called when the user switches models or on fallback activation.



        Default updates context_length and recalculates threshold_tokens

        from threshold_percent. Override if your engine needs more

        (e.g. recalculate DAG budgets, switch summary models).

        """
        self.context_length = context_length
        # Apply per-model threshold overrides if set (longest substring match).
        # Falls back to _config_threshold_percent (the raw config value) when
        # no override matches. Plugin engines that override update_model() can
        # call resolve_model_threshold() for the same logic.
        from agent.context_compressor import resolve_model_threshold
        if not hasattr(self, "_config_threshold_percent"):
            # Snapshot the pre-override percent ONCE so repeated model
            # switches fall back to the engine's configured value, not the
            # previous model's override.
            self._config_threshold_percent = self.threshold_percent
        self._base_threshold_percent = resolve_model_threshold(
            model, getattr(self, "model_thresholds", {}),
            self._config_threshold_percent,
        )
        self.threshold_percent = self._base_threshold_percent
        self.threshold_tokens = int(context_length * self.threshold_percent)