Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 100 additions & 5 deletions dare_framework/agent/react_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 能力
Expand All @@ -136,7 +154,7 @@ def plan_provider(self) -> IToolProvider | None:

async def execute(
self,
task: Message,
task: Message | str,
*,
transport: AgentChannel | None = None,
) -> RunResult:
Expand All @@ -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

Expand All @@ -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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid double-pruning context after auto compression

When auto_compress is enabled and the token trigger fires, this branch calls assemble_for_model() a second time in the same round; because Context.assemble_for_model() always runs moving_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 👍 / 👎.

messages = self._build_model_messages(assembled)

model_input = ModelInput(
messages=messages,
Expand Down Expand Up @@ -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:
Expand All @@ -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,
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Re-append injected reflection prompt after recompressing

In the SmartContext path, the transient reflection prompt is appended and then cleared before this branch runs; when auto-compression triggers, you rebuild messages from assemble_for_model() here but never re-append the saved injected_reflection_prompt. As a result, the per-round manage-context guidance silently disappears whenever compression runs, changing agent control flow (e.g., wasting rounds or failing to enforce intended first-step guidance under tighter max_tool_rounds).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Handled in 56f6e57.

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 tests/unit/test_react_agent_gateway_injection.py that forces compression and asserts the manage-context guidance is still present in the model input.

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:
Expand Down Expand Up @@ -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,
*,
Expand Down Expand Up @@ -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"]
9 changes: 3 additions & 6 deletions dare_framework/compression/__init__.py
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"]
Loading
Loading