-
-
Notifications
You must be signed in to change notification settings - Fork 2.7k
refactor: redesign context config with orthogonal model #9441
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Rail1bc
wants to merge
11
commits into
AstrBotDevs:master
Choose a base branch
from
Rail1bc:pr/1-core-integration
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+1,661
−372
Open
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
1f68ead
refactor(agent): redesign context management config with orthogonal t…
Rail1bc 076bfae
feat(agent): wire new context config through MainAgentBuildConfig and…
Rail1bc 3fd8e34
feat(i18n): update context_management field translations across all l…
Rail1bc 1d032bf
test(agent): add tests for new context config model
Rail1bc f319b83
fix(i18n): restore ru-RU upstream formatting and replace with context…
Rail1bc 7c0c543
fix: update tool_loop test mocks to accept new max_context_tokens par…
Rail1bc 58a9e02
docs: clarify retention constraint docstring in ContextConfig
Rail1bc 0dc0158
fix: address review comments — dead code, migration consistency, test…
Rail1bc 2bf7581
refactor: make _token_guard_exceeded a pure predicate, centralize gua…
Rail1bc c2c0b5c
style: apply ruff formatting to manager.py
Rail1bc 6ca26f0
fix: address review — migration docs, CancelledError, non-numeric tes…
Rail1bc File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,121 +1,215 @@ | ||
| import asyncio | ||
| import math | ||
|
|
||
| from astrbot import logger | ||
|
|
||
| from ..message import Message | ||
| from .compressor import LLMSummaryCompressor, TruncateByTurnsCompressor | ||
| from .compressor import LLMSummaryCompressor | ||
| from .config import ContextConfig | ||
| from .round_utils import split_into_rounds | ||
| from .token_counter import EstimateTokenCounter | ||
| from .truncator import ContextTruncator | ||
|
|
||
|
|
||
| class ContextManager: | ||
| """Context compression manager.""" | ||
| """Context compression manager — orthogonal trigger/disposal model.""" | ||
|
|
||
| def __init__( | ||
| self, | ||
| config: ContextConfig, | ||
| ) -> None: | ||
| """Initialize the context manager. | ||
|
|
||
| There are two strategies to handle context limit reached: | ||
| 1. Truncate by turns: remove older messages by turns. | ||
| 2. LLM-based compression: use LLM to summarize old messages. | ||
|
|
||
| Args: | ||
| config: The context configuration. | ||
| """ | ||
| self.config = config | ||
|
|
||
| self.token_counter = config.custom_token_counter or EstimateTokenCounter() | ||
| self.truncator = ContextTruncator() | ||
|
|
||
| # Build compressors on demand. Summary compressor only when a provider is | ||
| # available; discard is handled directly via truncator, not a compressor. | ||
| self._summary_compressor = None | ||
| self._unity_compressor = None | ||
| if config.custom_compressor: | ||
| self.compressor = config.custom_compressor | ||
| elif config.llm_compress_provider: | ||
| self.compressor = LLMSummaryCompressor( | ||
| provider=config.llm_compress_provider, | ||
| keep_recent_ratio=config.llm_compress_keep_recent_ratio, | ||
| instruction_text=config.llm_compress_instruction, | ||
| token_counter=self.token_counter, | ||
| ) | ||
| self._unity_compressor = config.custom_compressor | ||
| else: | ||
| self.compressor = TruncateByTurnsCompressor( | ||
| truncate_turns=config.truncate_turns | ||
| if config.summary_provider: | ||
| self._summary_compressor = LLMSummaryCompressor( | ||
| provider=config.summary_provider, | ||
| keep_recent_ratio=config.retain_percentage, | ||
| instruction_text=config.summary_prompt, | ||
| compression_threshold=config.token_guard_threshold, | ||
| token_counter=self.token_counter, | ||
| ) | ||
|
|
||
| # -- helpers ---------------------------------------------------------- | ||
|
|
||
| def _count_turns(self, messages: list[Message]) -> int: | ||
| """Count the number of conversation turns (non-system rounds).""" | ||
| rounds = split_into_rounds(messages) | ||
| # Filter out rounds that are exclusively system messages | ||
| return sum( | ||
| 1 | ||
| for rnd in rounds | ||
| if any((isinstance(m, Message) and m.role != "system") for m in rnd) | ||
| ) | ||
|
|
||
| def _compute_discard_limit(self, total_turns: int) -> int: | ||
| """Maximum number of turns that discard may remove, given retention.""" | ||
| method = self.config.retention_method | ||
| if method == "turns": | ||
| return max(0, total_turns - self.config.retain_turns) | ||
| if method == "percentage": | ||
| min_keep = math.ceil(total_turns * self.config.retain_percentage) | ||
| return max(0, total_turns - min_keep) | ||
| # "null" — no lower bound | ||
| return total_turns | ||
|
|
||
| async def _try_summary(self, messages: list[Message]) -> list[Message] | None: | ||
| """Attempt LLM summary compression. Returns compressed messages or None.""" | ||
| if not self.config.enable_summary or self._summary_compressor is None: | ||
| return None | ||
| try: | ||
| result = await self._summary_compressor(messages) | ||
| if result is None or result is messages: | ||
| return None # compressor chose not to compress | ||
| if len(result) >= len(messages): | ||
| return None # no effective reduction | ||
| return result | ||
| except asyncio.CancelledError: | ||
| raise | ||
| except Exception: | ||
| logger.warning( | ||
|
sourcery-ai[bot] marked this conversation as resolved.
|
||
| "LLM summary compression failed, falling back.", exc_info=True | ||
| ) | ||
| return None | ||
|
|
||
| def _try_discard( | ||
| self, messages: list[Message], total_turns: int | ||
| ) -> list[Message] | None: | ||
| """Discard oldest turns, bounded by retention.""" | ||
| if not self.config.enable_discard: | ||
| return None | ||
| max_discardable = self._compute_discard_limit(total_turns) | ||
| if max_discardable <= 0: | ||
| return None # retention prevents any discard | ||
| requested = min(self.config.discard_turns, max_discardable) | ||
| return self.truncator.truncate_by_dropping_oldest_turns( | ||
| messages, | ||
| drop_turns=requested, | ||
| ) | ||
|
|
||
| async def process( | ||
| self, messages: list[Message], trusted_token_usage: int = 0 | ||
| ) -> list[Message]: | ||
| """Process the messages. | ||
| def _token_guard_exceeded(self, tokens: int, max_context_tokens: int) -> bool: | ||
|
sourcery-ai[bot] marked this conversation as resolved.
|
||
| """Pure predicate: return True if tokens exceed the configured threshold.""" | ||
| if tokens <= 0 or max_context_tokens <= 0: | ||
| return False | ||
| return (tokens / max_context_tokens) > self.config.token_guard_threshold | ||
|
|
||
| Args: | ||
| messages: The original message list. | ||
| def _triggers_fired( | ||
| self, | ||
| total_turns: int, | ||
| current_tokens: int, | ||
| guard_max_tokens: int, | ||
| ) -> bool: | ||
| """Return True if any trigger condition is met. | ||
|
|
||
| Returns: | ||
| The processed message list. | ||
| Args: | ||
| total_turns: Current conversation turn count. | ||
| current_tokens: Current token count. | ||
| guard_max_tokens: Resolved guard window (0 = token guard disabled). | ||
| """ | ||
| try: | ||
| result = messages | ||
| if self.config.enable_turn_limit and total_turns > self.config.max_turns: | ||
| return True | ||
|
|
||
| # 1. 基于轮次的截断 (Enforce max turns) | ||
| if self.config.enforce_max_turns != -1: | ||
| result = self.truncator.truncate_by_turns( | ||
| result, | ||
| keep_most_recent_turns=self.config.enforce_max_turns, | ||
| drop_turns=self.config.truncate_turns, | ||
| ) | ||
| if ( | ||
| self.config.enable_token_guard | ||
| and guard_max_tokens | ||
| and self._token_guard_exceeded(current_tokens, guard_max_tokens) | ||
| ): | ||
| return True | ||
|
|
||
| # 2. 基于 token 的压缩 | ||
| if self.config.max_context_tokens > 0: | ||
| total_tokens = self.token_counter.count_tokens( | ||
| result, trusted_token_usage | ||
| ) | ||
| return False | ||
|
|
||
| if self.compressor.should_compress( | ||
| result, total_tokens, self.config.max_context_tokens | ||
| ): | ||
| result = await self._run_compression(result, total_tokens) | ||
| async def _select_disposal( | ||
| self, | ||
| messages: list[Message], | ||
| total_turns: int, | ||
| ) -> list[Message]: | ||
| """Apply disposal strategy: custom > summary > discard.""" | ||
| if self._unity_compressor is not None: | ||
| return await self._unity_compressor(messages) | ||
|
|
||
| return result | ||
| except Exception as e: | ||
| logger.error(f"Error during context processing: {e}", exc_info=True) | ||
| return messages | ||
| compressed = await self._try_summary(messages) | ||
| if compressed is not None: | ||
| return compressed | ||
|
|
||
| discarded = self._try_discard(messages, total_turns=total_turns) | ||
| if discarded is not None: | ||
| return discarded | ||
|
|
||
| async def _run_compression( | ||
| self, messages: list[Message], prev_tokens: int | ||
| logger.warning( | ||
| "Context disposal triggered but both summary and discard " | ||
| "are unavailable or disabled. No compression applied.", | ||
| ) | ||
| return messages | ||
|
|
||
| # -- main entry point ------------------------------------------------- | ||
|
|
||
| async def process( | ||
| self, | ||
| messages: list[Message], | ||
| trusted_token_usage: int = 0, | ||
| max_context_tokens: int = 0, | ||
| ) -> list[Message]: | ||
| """ | ||
| Compress/truncate the messages. | ||
| """Process messages through the orthogonal trigger/disposal pipeline. | ||
|
|
||
| Args: | ||
| messages: The original message list. | ||
| prev_tokens: The token count before compression. | ||
| trusted_token_usage: External token count hint (e.g. from conversation stats). | ||
| max_context_tokens: The model's context window size (provider-level value). | ||
|
|
||
| Returns: | ||
| The compressed/truncated message list. | ||
| The processed message list. | ||
| """ | ||
| logger.debug("Compress triggered, starting compression...") | ||
|
|
||
| messages = await self.compressor(messages) | ||
|
|
||
| # double check | ||
| tokens_after_summary = self.token_counter.count_tokens(messages) | ||
|
|
||
| # calculate compress rate | ||
| compress_rate = (tokens_after_summary / self.config.max_context_tokens) * 100 | ||
| logger.info( | ||
| f"Compress completed." | ||
| f" {prev_tokens} -> {tokens_after_summary} tokens," | ||
| f" compression rate: {compress_rate:.2f}%.", | ||
| ) | ||
| try: | ||
| result = messages | ||
|
|
||
| # last check | ||
| if self.compressor.should_compress( | ||
| messages, tokens_after_summary, self.config.max_context_tokens | ||
| ): | ||
| logger.info( | ||
| "Context still exceeds max tokens after compression, applying halving truncation..." | ||
| current_tokens = self.token_counter.count_tokens( | ||
| result, trusted_token_usage | ||
| ) | ||
| # still need compress, truncate by half | ||
| messages = self.truncator.truncate_by_halving(messages) | ||
| total_turns = self._count_turns(result) | ||
|
|
||
| # Resolve token guard: validate config and precompute guard window. | ||
| guard_max_tokens = 0 | ||
| if self.config.enable_token_guard: | ||
| if max_context_tokens <= 0: | ||
| if not getattr(self, "_token_guard_warning_emitted", False): | ||
| logger.warning( | ||
| "Token guard is enabled but max_context_tokens is %s. " | ||
| "Token guarding is effectively disabled. " | ||
| "Set max_context_tokens in the provider config to enable it.", | ||
| max_context_tokens, | ||
| ) | ||
| self._token_guard_warning_emitted = True | ||
| else: | ||
| guard_max_tokens = max_context_tokens | ||
|
|
||
| if not self._triggers_fired(total_turns, current_tokens, guard_max_tokens): | ||
| return result | ||
|
|
||
| # Disposal entry: custom > summary > discard (fallthrough) | ||
| result = await self._select_disposal(result, total_turns) | ||
|
|
||
| # Double-check halving — only when token guard is active | ||
| tokens_after = self.token_counter.count_tokens(result, trusted_token_usage) | ||
| if guard_max_tokens and self._token_guard_exceeded( | ||
| tokens_after, guard_max_tokens | ||
| ): | ||
| logger.info( | ||
| "Context still exceeds token guard threshold after disposal, " | ||
| "applying halving truncation (unconstrained by retention).", | ||
| ) | ||
| result = self.truncator.truncate_by_halving(result) | ||
|
|
||
| return messages | ||
| return result | ||
| except asyncio.CancelledError: | ||
| raise | ||
| except Exception: | ||
| logger.error("Error during context processing.", exc_info=True) | ||
| return messages | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.