-
Notifications
You must be signed in to change notification settings - Fork 2
Fix main-branch context compression baseline regressions #211
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
Changes from 2 commits
cb8dd55
56f6e57
a6aff31
73b04b2
e528148
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -112,6 +112,11 @@ def __init__( | |
| tool_gateway: IToolGateway, | ||
| plan_provider: IToolProvider | None = None, | ||
| max_tool_rounds: int = 10, | ||
| auto_compress: bool = False, | ||
| compress_trigger_ratio: float = 0.9, | ||
| compress_target_ratio: float = 0.75, | ||
| compress_max_messages: int | None = None, | ||
| compress_strategy: str = "dedup_then_truncate", | ||
| agent_channel: AgentChannel | None = None, | ||
| ) -> None: | ||
| super().__init__(name, agent_channel=agent_channel) | ||
|
|
@@ -120,6 +125,19 @@ def __init__( | |
| self._context = context | ||
| self._tool_gateway = tool_gateway | ||
| self._plan_provider = plan_provider | ||
| self._auto_compress = bool(auto_compress) | ||
| self._compress_trigger_ratio = _clamp_ratio(compress_trigger_ratio, default=0.9) | ||
| self._compress_target_ratio = _clamp_ratio(compress_target_ratio, default=0.75) | ||
| self._compress_max_messages = ( | ||
| compress_max_messages | ||
| if isinstance(compress_max_messages, int) and compress_max_messages > 0 | ||
| else None | ||
| ) | ||
| self._compress_strategy = ( | ||
| compress_strategy.strip() | ||
| if isinstance(compress_strategy, str) and compress_strategy.strip() | ||
| else "dedup_then_truncate" | ||
| ) | ||
| self._context.set_tool_gateway(self._tool_gateway) | ||
|
|
||
| # 运行时检测是否可以启用 SmartContext 能力 | ||
|
|
@@ -136,7 +154,7 @@ def plan_provider(self) -> IToolProvider | None: | |
|
|
||
| async def execute( | ||
| self, | ||
| task: Message, | ||
| task: Message | str, | ||
| *, | ||
| transport: AgentChannel | None = None, | ||
| ) -> RunResult: | ||
|
|
@@ -147,12 +165,13 @@ async def execute( | |
|
|
||
| async def _execute_basic( | ||
| self, | ||
| task: Message, | ||
| task: Message | str, | ||
| *, | ||
| transport: AgentChannel | None = None, | ||
| ) -> RunResult: | ||
| """原始基础 ReAct 循环实现。""" | ||
| self._context.stm_add(task) | ||
| user_message = task if isinstance(task, Message) else Message(role="user", text=task) | ||
| self._context.stm_add(user_message) | ||
|
|
||
| gateway = self._tool_gateway | ||
|
|
||
|
|
@@ -164,6 +183,9 @@ async def _execute_basic( | |
| print(f"[{self.name}] Round {round_idx + 1}/{self._max_tool_rounds}: 调用模型中...", flush=True) | ||
| assembled = await self._context.assemble_for_model() | ||
| messages = self._build_model_messages(assembled) | ||
| if self._maybe_auto_compress(messages): | ||
| assembled = await self._context.assemble_for_model() | ||
| messages = self._build_model_messages(assembled) | ||
|
|
||
| model_input = ModelInput( | ||
| messages=messages, | ||
|
|
@@ -329,7 +351,7 @@ async def _execute_basic( | |
|
|
||
| async def _execute_with_smart_context( | ||
| self, | ||
| task: Message, | ||
| task: Message | str, | ||
| *, | ||
| transport: AgentChannel | None = None, | ||
| ) -> RunResult: | ||
|
|
@@ -344,7 +366,7 @@ async def _execute_with_smart_context( | |
| return await self._execute_basic(task, transport=transport) | ||
|
|
||
| _ = transport | ||
| source_user_message = task | ||
| source_user_message = task if isinstance(task, Message) else Message(role="user", text=task) | ||
| user_message = Message( | ||
| role=source_user_message.role, | ||
| kind=source_user_message.kind, | ||
|
|
@@ -405,6 +427,26 @@ async def _execute_with_smart_context( | |
| messages.append(self._next_round_reflection_prompt) | ||
| self._next_round_reflection_prompt = None | ||
|
|
||
| if self._maybe_auto_compress(messages): | ||
| assembled = await self._context.assemble_for_model() | ||
| messages = list(assembled.messages) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
In the SmartContext path, the transient reflection prompt is appended and then cleared before this branch runs; when auto-compression triggers, you rebuild Useful? React with 👍 / 👎.
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handled in When SmartContext auto-compression rebuilds the assembled messages, the saved one-shot reflection prompt is now appended again before model invocation. Added a regression in |
||
| prompt_def = getattr(assembled, "sys_prompt", None) | ||
| sys_prompt_message = ( | ||
| Message( | ||
| role=prompt_def.role, | ||
| text=prompt_def.content, | ||
| name=prompt_def.name, | ||
| metadata=dict(prompt_def.metadata), | ||
| mark=MessageMark.IMMUTABLE, | ||
| id="sys_prompt", | ||
| ) | ||
| if prompt_def is not None | ||
| else None | ||
| ) | ||
| messages = self._context.order_messages_for_llm(messages, sys_prompt_message) | ||
| if injected_reflection_prompt is not None: | ||
| messages.append(injected_reflection_prompt) | ||
|
|
||
| # Inject critical_block from plan_provider (maintained by plan tools) | ||
| # Disabled: skip injection to observe plan agent behavior without it | ||
| if False and self._plan_provider is not None: | ||
|
|
@@ -608,6 +650,38 @@ def _build_model_messages(self, assembled: Any) -> list[Message]: | |
| ) | ||
| return messages | ||
|
|
||
| def _maybe_auto_compress(self, model_messages: list[Message]) -> bool: | ||
| """Auto-compress context before model invocation when token estimate is near budget.""" | ||
| if not self._auto_compress: | ||
| return False | ||
| max_tokens = self._context.budget.max_tokens | ||
| if max_tokens is None or max_tokens <= 0: | ||
| return False | ||
|
|
||
| estimated_tokens = _estimate_messages_tokens(model_messages) | ||
| trigger_tokens = max(1, int(max_tokens * self._compress_trigger_ratio)) | ||
| if estimated_tokens < trigger_tokens: | ||
| return False | ||
|
|
||
| stm_messages = self._context.stm_get() | ||
| if not stm_messages: | ||
| return False | ||
|
|
||
| max_messages = self._compress_max_messages | ||
| if max_messages is None: | ||
| max_messages = max(1, int(len(stm_messages) * self._compress_target_ratio)) | ||
| if max_messages >= len(stm_messages): | ||
| max_messages = max(1, len(stm_messages) - 1) | ||
|
|
||
| target_tokens = max(1, int(max_tokens * self._compress_target_ratio)) | ||
| self._context.compress( | ||
| strategy=self._compress_strategy, | ||
| max_messages=max_messages, | ||
| target_tokens=target_tokens, | ||
| tool_pair_safe=True, | ||
| ) | ||
| return True | ||
|
|
||
| async def _emit_terminal_transport_message( | ||
| self, | ||
| *, | ||
|
|
@@ -749,4 +823,25 @@ def _tool_calls_signature(tool_calls: list[dict[str, Any]]) -> tuple[str, ...]: | |
| return tuple(signature) | ||
|
|
||
|
|
||
| def _estimate_messages_tokens(messages: list[Message]) -> int: | ||
| total = 0 | ||
| for message in messages: | ||
| content = (message.text or "").strip() | ||
| attachment_tokens = len(message.attachments) * 32 | ||
| total += max(1, len(content) // 4) + attachment_tokens + 8 | ||
| return total | ||
|
|
||
|
|
||
| def _clamp_ratio(value: Any, *, default: float) -> float: | ||
| try: | ||
| ratio = float(value) | ||
| except (TypeError, ValueError): | ||
| return default | ||
| if not math.isfinite(ratio) or ratio <= 0: | ||
| return default | ||
| if ratio > 1: | ||
| return 1.0 | ||
| return ratio | ||
|
|
||
|
|
||
| __all__ = ["ReactAgent"] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,8 @@ | ||
| """Compression utilities for context and memories. | ||
|
|
||
| - MovingCompressor: 移动窗口式 STM 压缩(LLM 摘要),见 moving_compression。 | ||
| """ | ||
| """Compression utilities for context and memories.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from .core import compress_context, compress_context_llm_summary | ||
| from .moving_compression import MovingCompressor | ||
|
|
||
| __all__ = ["MovingCompressor"] | ||
|
|
||
| __all__ = ["compress_context", "compress_context_llm_summary", "MovingCompressor"] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When
auto_compressis enabled and the token trigger fires, this branch callsassemble_for_model()a second time in the same round; becauseContext.assemble_for_model()always runsmoving_compressor.prune(...)when a moving compressor is attached, the same request can be pruned twice before one model call. In configurations that enable both moving compression and auto compression, this can over-compress STM (or trigger duplicate expensive summary work) and change model inputs unexpectedly.Useful? React with 👍 / 👎.