mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-10-06 02:48:22 +00:00
Merge pull request #37 from agentscope-ai/dev_1119
feat(context): implement context offload operations for token management
This commit is contained in:
commit
1852390422
13 changed files with 559 additions and 489 deletions
|
|
@ -8,6 +8,7 @@ os.environ["FLOW_APP_NAME"] = "ReMe"
|
|||
from . import agent # noqa: E402
|
||||
from . import config # noqa: E402
|
||||
from . import constants # noqa: E402
|
||||
from . import context # noqa: E402
|
||||
from . import enumeration # noqa: E402
|
||||
from . import retrieve # noqa: E402
|
||||
from . import schema # noqa: E402
|
||||
|
|
@ -21,6 +22,7 @@ __all__ = [
|
|||
"agent",
|
||||
"config",
|
||||
"constants",
|
||||
"context",
|
||||
"enumeration",
|
||||
"retrieve",
|
||||
"schema",
|
||||
|
|
|
|||
|
|
@ -156,6 +156,83 @@ flow:
|
|||
description: "user query"
|
||||
required: true
|
||||
|
||||
context_offload:
|
||||
flow_content: ContextOffloadOp() >> BatchWriteFileOp()
|
||||
description: "Manages context window limits by compacting tool messages and compressing conversation history. First compacts large tool messages by storing full content in external files, then applies LLM-based compression if compaction ratio exceeds threshold. This helps reduce token usage while preserving important information."
|
||||
input_schema:
|
||||
messages:
|
||||
type: array
|
||||
description: "List of conversation messages to process for context offloading"
|
||||
required: true
|
||||
context_manage_mode:
|
||||
type: string
|
||||
description: "Context management mode: 'compact' only applies compaction to tool messages, 'compress' only applies LLM-based compression, 'auto' applies compaction first then compression if compaction ratio exceeds threshold. Defaults to 'auto'."
|
||||
required: false
|
||||
enum: ["compact", "compress", "auto"]
|
||||
max_total_tokens:
|
||||
type: integer
|
||||
description: "Maximum token count threshold for triggering compression/compaction. For compaction, this is the total token count threshold. For compression, this excludes keep_recent_count messages and system messages. Defaults to 20000."
|
||||
required: false
|
||||
max_tool_message_tokens:
|
||||
type: integer
|
||||
description: "Maximum token count per tool message before compaction is applied. Tool messages exceeding this threshold will have their full content stored in external files with only a preview kept in context. Defaults to 2000."
|
||||
required: false
|
||||
group_token_threshold:
|
||||
type: integer
|
||||
description: "Maximum token count per compression group when using LLM-based compression. If None or 0, all messages are compressed in a single group. Messages exceeding this threshold individually will form their own group. Only used in 'compress' or 'auto' mode."
|
||||
required: false
|
||||
keep_recent_count:
|
||||
type: integer
|
||||
description: "Number of recent messages to preserve without compression or compaction. These messages remain unchanged to maintain conversation context. Defaults to 1 for compaction and 2 for compression."
|
||||
required: false
|
||||
store_dir:
|
||||
type: string
|
||||
description: "Directory path for storing offloaded message content. Full tool message content and compressed message groups are saved as files in this directory. Required for compaction and compression operations."
|
||||
required: false
|
||||
chat_id:
|
||||
type: string
|
||||
description: "Unique identifier for the chat session, used for file naming when storing compressed message groups. If not provided, a UUID will be generated automatically."
|
||||
required: false
|
||||
|
||||
context_offload_for_agentscope:
|
||||
flow_content: ContextOffloadOp()
|
||||
description: "Context offload operation for AgentScope integration. Manages context window limits by compacting tool messages and compressing conversation history without batch file writing. Same functionality as context_offload but without the BatchWriteFileOp step."
|
||||
input_schema:
|
||||
messages:
|
||||
type: array
|
||||
description: "List of conversation messages to process for context offloading"
|
||||
required: true
|
||||
context_manage_mode:
|
||||
type: string
|
||||
description: "Context management mode: 'compact' only applies compaction to tool messages, 'compress' only applies LLM-based compression, 'auto' applies compaction first then compression if compaction ratio exceeds threshold. Defaults to 'auto'."
|
||||
required: false
|
||||
enum: ["compact", "compress", "auto"]
|
||||
max_total_tokens:
|
||||
type: integer
|
||||
description: "Maximum token count threshold for triggering compression/compaction. For compaction, this is the total token count threshold. For compression, this excludes keep_recent_count messages and system messages. Defaults to 20000."
|
||||
required: false
|
||||
max_tool_message_tokens:
|
||||
type: integer
|
||||
description: "Maximum token count per tool message before compaction is applied. Tool messages exceeding this threshold will have their full content stored in external files with only a preview kept in context. Defaults to 2000."
|
||||
required: false
|
||||
group_token_threshold:
|
||||
type: integer
|
||||
description: "Maximum token count per compression group when using LLM-based compression. If None or 0, all messages are compressed in a single group. Messages exceeding this threshold individually will form their own group. Only used in 'compress' or 'auto' mode."
|
||||
required: false
|
||||
keep_recent_count:
|
||||
type: integer
|
||||
description: "Number of recent messages to preserve without compression or compaction. These messages remain unchanged to maintain conversation context. Defaults to 1 for compaction and 2 for compression."
|
||||
required: false
|
||||
store_dir:
|
||||
type: string
|
||||
description: "Directory path for storing offloaded message content. Full tool message content and compressed message groups are saved as files in this directory. Required for compaction and compression operations."
|
||||
required: false
|
||||
chat_id:
|
||||
type: string
|
||||
description: "Unique identifier for the chat session, used for file naming when storing compressed message groups. If not provided, a UUID will be generated automatically."
|
||||
required: false
|
||||
|
||||
|
||||
llm:
|
||||
default:
|
||||
backend: openai_compatible
|
||||
|
|
@ -164,9 +241,7 @@ llm:
|
|||
temperature: 0.6
|
||||
token_count: # Optional
|
||||
model_name: Qwen/Qwen3-30B-A3B-Instruct-2507
|
||||
backend: hf
|
||||
params:
|
||||
use_mirror: true
|
||||
backend: base
|
||||
|
||||
qwen3_30b_instruct:
|
||||
backend: openai_compatible
|
||||
|
|
|
|||
|
|
@ -0,0 +1,14 @@
|
|||
"""Context management module for ReMe framework.
|
||||
|
||||
This module provides submodules for different types of context management operations:
|
||||
- file_tool: File-related operations for reading, writing, and searching files
|
||||
- offload: Context offload operations for reducing token usage and managing context windows
|
||||
"""
|
||||
|
||||
from . import file_tool
|
||||
from . import offload
|
||||
|
||||
__all__ = [
|
||||
"file_tool",
|
||||
"offload",
|
||||
]
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
"""Context offload package for ReMe framework.
|
||||
|
||||
This package provides context management operations that can be used in LLM-powered flows
|
||||
to reduce token usage and manage context window limits. It includes ready-to-use operations for:
|
||||
|
||||
- ContextCompactOp: Compact tool messages by storing full content in external files
|
||||
- ContextCompressOp: Compress conversation history using LLM to generate concise summaries
|
||||
- ContextOffloadOp: Orchestrate compaction and compression to reduce token usage
|
||||
"""
|
||||
|
||||
from .context_compact_op import ContextCompactOp
|
||||
from .context_compress_op import ContextCompressOp
|
||||
from .context_offload_op import ContextOffloadOp
|
||||
|
||||
__all__ = [
|
||||
"ContextCompactOp",
|
||||
"ContextCompressOp",
|
||||
"ContextOffloadOp",
|
||||
]
|
||||
|
|
@ -6,9 +6,7 @@ their full content in external files and keeping only previews in the context.
|
|||
This helps manage context window limits while preserving important information.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from uuid import uuid4
|
||||
|
||||
from flowllm.core.context import C
|
||||
|
|
@ -28,36 +26,6 @@ class ContextCompactOp(BaseAsyncOp):
|
|||
This helps manage context window limits while preserving recent tool messages.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
all_token_threshold: int = 20000,
|
||||
tool_token_threshold: int = 2000,
|
||||
tool_left_char_len: int = 100,
|
||||
keep_recent: int = 1,
|
||||
storage_path: str = "./",
|
||||
exclude_tools: List[str] = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Initialize the context compaction operation.
|
||||
|
||||
Args:
|
||||
all_token_threshold: Maximum total token count before compaction is triggered.
|
||||
tool_token_threshold: Maximum token count for a single tool message before it's compressed.
|
||||
tool_left_char_len: Number of characters to keep in the compressed tool message preview.
|
||||
keep_recent: Number of recent tool messages to keep uncompressed.
|
||||
storage_path: Directory path where compressed tool message contents will be stored.
|
||||
exclude_tools: List of tool names to exclude from compaction (not currently used).
|
||||
**kwargs: Additional arguments passed to the base class.
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self.all_token_threshold: int = all_token_threshold
|
||||
self.tool_token_threshold: int = tool_token_threshold
|
||||
self.tool_left_char_len: int = tool_left_char_len
|
||||
self.keep_recent: int = keep_recent
|
||||
self.storage_path: Path = Path(storage_path)
|
||||
self.exclude_tools: List[str] = exclude_tools
|
||||
|
||||
async def async_execute(self):
|
||||
"""
|
||||
Execute the context compaction operation.
|
||||
|
|
@ -70,41 +38,44 @@ class ContextCompactOp(BaseAsyncOp):
|
|||
- Storing full content in external files
|
||||
- Preserving recent tool messages
|
||||
"""
|
||||
# Get configuration from context
|
||||
max_total_tokens: int = self.context.get("max_total_tokens", 20000)
|
||||
max_tool_message_tokens: int = self.context.get("max_tool_message_tokens", 2000)
|
||||
preview_char_length: int = self.context.get("preview_char_length", 100)
|
||||
keep_recent_count: int = self.context.get("keep_recent_count", 1)
|
||||
store_dir: Path = Path(self.context.get("store_dir", ""))
|
||||
|
||||
assert max_total_tokens > 0, "max_total_tokens must be greater than 0"
|
||||
assert max_tool_message_tokens > 0, "max_tool_message_tokens must be greater than 0"
|
||||
assert preview_char_length >= 0, "preview_char_length must be greater than 0"
|
||||
assert keep_recent_count > 0, "keep_recent_count must be greater than 0"
|
||||
|
||||
# Convert context messages to Message objects
|
||||
messages = [Message(**x) for x in self.context.messages]
|
||||
messages_to_compress = [x for x in messages if x.role is not Role.SYSTEM][:-keep_recent_count]
|
||||
|
||||
# If nothing to compress after filtering, return original messages
|
||||
if not messages_to_compress:
|
||||
self.context.response.answer = self.context.messages
|
||||
logger.info("No messages to compress after filtering, returning original messages")
|
||||
return
|
||||
|
||||
logger.info(f"{len(messages_to_compress)} messages remaining for compression check")
|
||||
|
||||
# Calculate total token count
|
||||
token_cnt: int = self.token_count(messages)
|
||||
logger.info(f"Context compaction check: total token count={token_cnt}, threshold={self.all_token_threshold}")
|
||||
compact_token_cnt: int = self.token_count(messages_to_compress)
|
||||
logger.info(f"Context compaction check: total token count={compact_token_cnt}, threshold={max_total_tokens}")
|
||||
|
||||
# If token count is within threshold, no compaction needed
|
||||
if token_cnt <= self.all_token_threshold:
|
||||
if compact_token_cnt <= max_total_tokens:
|
||||
self.context.response.answer = self.context.messages
|
||||
logger.info(
|
||||
f"Token count ({token_cnt}) is within threshold ({self.all_token_threshold}), no compaction needed",
|
||||
)
|
||||
logger.info(f"Token count ({compact_token_cnt}) is within ({max_total_tokens}), no compaction needed")
|
||||
return
|
||||
|
||||
# Filter tool messages for processing
|
||||
tool_messages = [x for x in messages if x.role is Role.TOOL]
|
||||
tool_messages = [x for x in messages_to_compress if x.role is Role.TOOL]
|
||||
|
||||
# If there are too few tool messages, no compaction needed
|
||||
if len(tool_messages) <= self.keep_recent:
|
||||
self.context.response.answer = self.context.messages
|
||||
logger.info(
|
||||
f"Tool message count ({len(tool_messages)}) is less than or "
|
||||
f"equal to keep_recent ({self.keep_recent}), no compaction needed",
|
||||
)
|
||||
return
|
||||
|
||||
# Exclude recent tool messages from compaction (keep them intact)
|
||||
tool_messages = tool_messages[: -self.keep_recent]
|
||||
logger.info(
|
||||
f"Processing {len(tool_messages)} tool messages for "
|
||||
f"compaction (keeping {self.keep_recent} recent messages)",
|
||||
)
|
||||
|
||||
# Dictionary to store file paths and their compressed content (for potential batch writing)
|
||||
# Dictionary to store file paths and their full content (for potential batch writing)
|
||||
write_file_dict = {}
|
||||
|
||||
# Process each tool message
|
||||
|
|
@ -113,33 +84,56 @@ class ContextCompactOp(BaseAsyncOp):
|
|||
tool_token_cnt = self.token_count([tool_message])
|
||||
|
||||
# Skip if token count is within threshold
|
||||
if tool_token_cnt <= self.tool_token_threshold:
|
||||
if tool_token_cnt <= max_tool_message_tokens:
|
||||
logger.info(
|
||||
f"Skipping tool message (tool_call_id={tool_message.tool_call_id}): "
|
||||
f"token count ({tool_token_cnt}) is within threshold ({self.tool_token_threshold})",
|
||||
f"token count ({tool_token_cnt}) is within threshold ({max_tool_message_tokens})",
|
||||
)
|
||||
continue
|
||||
|
||||
# Create compressed preview of the tool message content
|
||||
compact_result = tool_message.content[: self.tool_left_char_len] + "..."
|
||||
# Save original full content before modifying
|
||||
original_content = tool_message.content
|
||||
|
||||
# Generate file name from tool_call_id or create a unique identifier
|
||||
file_name = tool_message.tool_call_id or uuid4().hex
|
||||
path = self.storage_path / f"{file_name}.txt"
|
||||
store_path = store_dir / f"{file_name}.txt"
|
||||
|
||||
# Store the mapping for potential batch writing
|
||||
write_file_dict[str(path)] = compact_result
|
||||
# Store the full content for batch writing
|
||||
write_file_dict[store_path.as_posix()] = original_content
|
||||
|
||||
# Create compressed preview of the tool message content
|
||||
compact_result = original_content[:preview_char_length] + "..."
|
||||
|
||||
# Log the compaction action
|
||||
logger.info(
|
||||
f"Compacting tool message (tool_call_id={tool_message.tool_call_id}): "
|
||||
f"token count={tool_token_cnt}, saving full content to {path}",
|
||||
f"token count={tool_token_cnt}, saving full content to {store_path}",
|
||||
)
|
||||
|
||||
# Update tool message content with preview and file reference
|
||||
compact_result += f" (detailed result is stored in {path})"
|
||||
compact_result += f" (detailed result is stored in {store_path})"
|
||||
tool_message.content = compact_result
|
||||
|
||||
# Store write_file_dict in context for potential batch writing
|
||||
if write_file_dict:
|
||||
self.context.write_file_dict = write_file_dict
|
||||
|
||||
# Return the compacted messages as JSON
|
||||
self.context.response.answer = json.dumps([x.simple_dump() for x in messages], ensure_ascii=False, indent=2)
|
||||
self.context.response.answer = [x.simple_dump() for x in messages]
|
||||
self.context.response.metadata["write_file_dict"] = write_file_dict
|
||||
|
||||
logger.info(f"Context compaction completed: {len(write_file_dict)} tool messages were compacted")
|
||||
|
||||
async def async_default_execute(self, e: Exception = None, **_kwargs):
|
||||
"""Handle execution errors by returning original messages.
|
||||
|
||||
This method is called when an exception occurs during async_execute. It preserves
|
||||
the original messages and marks the operation as unsuccessful.
|
||||
|
||||
Args:
|
||||
e: The exception that occurred during execution, if any.
|
||||
**_kwargs: Additional keyword arguments (unused but required by interface).
|
||||
"""
|
||||
self.context.response.answer = self.context.messages
|
||||
self.context.response.success = False
|
||||
self.context.response.metadata["error"] = str(e)
|
||||
|
|
|
|||
|
|
@ -4,22 +4,29 @@ Context compression module for reducing token usage in conversation contexts usi
|
|||
This module provides functionality to compress conversation history by using a language
|
||||
model to generate concise summaries of older messages while preserving recent messages.
|
||||
This helps manage context window limits while maintaining conversation coherence.
|
||||
|
||||
The compression process:
|
||||
1. Identifies messages that exceed token thresholds
|
||||
2. Splits messages into groups if needed
|
||||
3. Uses LLM to generate compressed summaries of older message groups
|
||||
4. Stores original messages to files for potential retrieval
|
||||
5. Appends compressed summaries to the system message while preserving recent messages
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from typing import List, Tuple
|
||||
from uuid import uuid4
|
||||
|
||||
from flowllm.core.context import C
|
||||
from flowllm.core.enumeration import Role
|
||||
from flowllm.core.op import BaseAsyncOp
|
||||
from flowllm.core.schema import Message
|
||||
from flowllm.core.utils import extract_content
|
||||
from loguru import logger
|
||||
|
||||
from reme_ai.utils import merge_messages_content
|
||||
from reme_ai.utils.op_utils import extract_xml_tag_content
|
||||
|
||||
|
||||
@C.register_op()
|
||||
class ContextCompressOp(BaseAsyncOp):
|
||||
|
|
@ -29,90 +36,46 @@ class ContextCompressOp(BaseAsyncOp):
|
|||
When the total token count exceeds the threshold, this operation uses a language
|
||||
model to compress older messages into a concise summary while keeping recent
|
||||
messages intact. This preserves conversation context while reducing token usage.
|
||||
|
||||
Attributes:
|
||||
file_path: Path to the operation file, used for configuration.
|
||||
|
||||
Context Parameters:
|
||||
max_total_tokens (int): Maximum token count threshold for compression.
|
||||
Defaults to 20000. Does not include keep_recent_count messages or system messages.
|
||||
group_token_threshold (int, optional): Maximum token count per compression group.
|
||||
If None or 0, all messages are compressed in a single group.
|
||||
keep_recent_count (int): Number of recent messages to preserve without compression.
|
||||
Defaults to 2. Must be non-negative.
|
||||
chat_id (str): Unique identifier for the chat session, used for file naming.
|
||||
Defaults to a generated UUID if not provided.
|
||||
"""
|
||||
|
||||
file_path: str = __file__
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
all_token_threshold: int = 20000,
|
||||
keep_recent: int = 5,
|
||||
storage_path: str = "./compressed_contexts",
|
||||
micro_summary_token_threshold: int = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""
|
||||
Initialize the context compression operation.
|
||||
def get_store_path(self, name: str) -> Path:
|
||||
"""Get the storage path for a given file name.
|
||||
|
||||
Args:
|
||||
all_token_threshold: Maximum total token count before compression is triggered.
|
||||
keep_recent: Number of recent messages to keep uncompressed.
|
||||
storage_path: Directory path where original messages will be stored for traceability.
|
||||
micro_summary_token_threshold: Token threshold for each compression group.
|
||||
If set, messages will be split into groups of this size and compressed separately.
|
||||
If None, all messages will be compressed together.
|
||||
**kwargs: Additional arguments passed to the base class.
|
||||
|
||||
Note:
|
||||
System messages are NEVER compressed to preserve important system instructions.
|
||||
"""
|
||||
super().__init__(**kwargs)
|
||||
self.all_token_threshold: int = all_token_threshold
|
||||
self.keep_recent: int = keep_recent
|
||||
self.storage_path: Path = Path(storage_path)
|
||||
self.micro_summary_token_threshold: int = micro_summary_token_threshold
|
||||
|
||||
assert (
|
||||
micro_summary_token_threshold is None or micro_summary_token_threshold > 0
|
||||
), "Micro summary token threshold must be greater than 0"
|
||||
|
||||
# Create storage directory if it doesn't exist
|
||||
self.storage_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _save_original_messages(self, messages: List[Message]) -> str:
|
||||
"""Save original messages to file for traceability.
|
||||
|
||||
Args:
|
||||
messages: List of messages to save
|
||||
name: Name of the file to store.
|
||||
|
||||
Returns:
|
||||
Path to the saved file
|
||||
Path object representing the full path to the storage location.
|
||||
"""
|
||||
# Generate unique filename with timestamp
|
||||
file_name = f"context_{uuid4().hex}.txt"
|
||||
file_path = self.storage_path / file_name
|
||||
return Path(self.context.store_dir) / name
|
||||
|
||||
# Convert messages to serializable format
|
||||
messages_data = [
|
||||
{
|
||||
"role": msg.role.value if hasattr(msg.role, "value") else str(msg.role),
|
||||
"content": msg.content,
|
||||
"name": getattr(msg, "name", None),
|
||||
"tool_call_id": getattr(msg, "tool_call_id", None),
|
||||
}
|
||||
for msg in messages
|
||||
]
|
||||
|
||||
# Save to file
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(messages_data, f, ensure_ascii=False, indent=2)
|
||||
|
||||
logger.info(f"Saved {len(messages)} original messages to {file_path}")
|
||||
return str(file_path)
|
||||
|
||||
def _split_messages_by_token_threshold(
|
||||
self,
|
||||
messages: List[Message],
|
||||
token_threshold: int,
|
||||
) -> List[List[Message]]:
|
||||
def _split_messages_by_token_threshold(self, messages: List[Message], token_threshold: int) -> List[List[Message]]:
|
||||
"""Split messages into groups based on token threshold.
|
||||
|
||||
Messages are grouped such that each group's token count does not exceed the threshold,
|
||||
except when a single message exceeds the threshold, in which case it forms its own group.
|
||||
|
||||
Args:
|
||||
messages: List of messages to split
|
||||
token_threshold: Maximum token count for each group
|
||||
token_threshold: Maximum token count for each group (may be exceeded by single messages)
|
||||
|
||||
Returns:
|
||||
List of message groups, each within the token threshold
|
||||
List of message groups, where each group attempts to stay within the token threshold
|
||||
"""
|
||||
if not messages:
|
||||
return []
|
||||
|
|
@ -146,112 +109,43 @@ class ContextCompressOp(BaseAsyncOp):
|
|||
if current_group:
|
||||
groups.append(current_group)
|
||||
|
||||
logger.info(
|
||||
f"Split {len(messages)} messages into {len(groups)} groups " f"with token threshold {token_threshold}",
|
||||
)
|
||||
logger.info(f"Split {len(messages)} messages into {len(groups)} groups with token threshold {token_threshold}")
|
||||
return groups
|
||||
|
||||
@staticmethod
|
||||
def _extract_xml_fragments(text: str) -> str:
|
||||
"""
|
||||
Extract XML fragments from text, removing scratchpad elements.
|
||||
|
||||
Scans text to extract complete and parseable top-level XML fragments,
|
||||
excluding <scratchpad> elements. If state_snapshot XML is found, returns it;
|
||||
otherwise returns the original text.
|
||||
|
||||
Args:
|
||||
text: Input text potentially containing XML fragments
|
||||
|
||||
Returns:
|
||||
Extracted XML content or original text
|
||||
"""
|
||||
try:
|
||||
# Remove scratchpad elements
|
||||
new_text = re.sub(r"<scratchpad>.*?</scratchpad>", "", text, flags=re.S | re.I)
|
||||
# Extract balanced XML tags
|
||||
extract_xml = [m[0] for m in re.findall(r"(<(\w+)[^>]*>(?:[^<]|<(?!/\2))*</\2>)", new_text)]
|
||||
|
||||
# Validate XML parsing
|
||||
valid_xml = []
|
||||
for xml_str in extract_xml:
|
||||
try:
|
||||
ET.fromstring(xml_str)
|
||||
valid_xml.append(xml_str)
|
||||
except ET.ParseError:
|
||||
continue
|
||||
|
||||
# Return state_snapshot if found, otherwise original text
|
||||
if valid_xml and any("<state_snapshot>" in xml for xml in valid_xml):
|
||||
return next(xml for xml in valid_xml if "<state_snapshot>" in xml)
|
||||
elif valid_xml:
|
||||
return valid_xml[0]
|
||||
else:
|
||||
return text
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to extract XML fragments: {e}. Returning original text.")
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def _format_messages_for_compression(messages: List[Message]) -> str:
|
||||
"""Format messages into a readable text for compression.
|
||||
|
||||
Args:
|
||||
messages: List of messages to format
|
||||
|
||||
Returns:
|
||||
Formatted string representation of messages
|
||||
"""
|
||||
lines = []
|
||||
for i, msg in enumerate(messages, 1):
|
||||
role_name = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||||
lines.append(f"[Message {i} - {role_name}]")
|
||||
lines.append(msg.content)
|
||||
lines.append("") # Empty line between messages
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _compress_messages_with_llm(self, messages_to_compress: List[Message]) -> str:
|
||||
"""Use LLM to compress messages into a concise summary.
|
||||
"""Compress a list of messages using LLM to generate a summary.
|
||||
|
||||
This method formats the messages into a prompt, sends it to the LLM, and extracts
|
||||
the compressed state snapshot from the response. The LLM response is expected to
|
||||
contain XML tags for scratchpad and state_snapshot.
|
||||
|
||||
Args:
|
||||
messages_to_compress: List of messages to compress
|
||||
messages_to_compress: List of Message objects to compress into a summary.
|
||||
|
||||
Returns:
|
||||
Compressed summary text
|
||||
"""
|
||||
# Format messages for the prompt
|
||||
formatted_messages = self._format_messages_for_compression(messages_to_compress)
|
||||
Compressed summary string extracted from the LLM response. Returns empty string
|
||||
if LLM returns None or if state_snapshot cannot be extracted.
|
||||
|
||||
# Create prompt for compression
|
||||
Note:
|
||||
If state_snapshot extraction fails, the full content is used as fallback.
|
||||
"""
|
||||
prompt = self.prompt_format(
|
||||
prompt_name="compress_context_prompt",
|
||||
messages_content=formatted_messages,
|
||||
"compress_context_prompt",
|
||||
messages_content=merge_messages_content(messages_to_compress),
|
||||
)
|
||||
|
||||
def parse_compressed_result(message: Message) -> str:
|
||||
"""Parse LLM response to extract compressed content.
|
||||
|
||||
Args:
|
||||
message: LLM response message
|
||||
|
||||
Returns:
|
||||
Compressed content string
|
||||
"""
|
||||
content = message.content.strip()
|
||||
# Try to extract content from txt code block
|
||||
compressed = extract_content(content, "txt")
|
||||
|
||||
# If no code block found, use the raw content
|
||||
if not compressed:
|
||||
compressed = content
|
||||
scratchpad = extract_xml_tag_content(content, "scratchpad")
|
||||
state_snapshot = extract_xml_tag_content(content, "state_snapshot")
|
||||
logger.info(f"Parsed scratchpad: \n{scratchpad} \nstate_snapshot: \n{state_snapshot}")
|
||||
|
||||
logger.info(
|
||||
f"Compressed {len(messages_to_compress)} messages into "
|
||||
f"{len(compressed)} characters (reduction: "
|
||||
f"{len(formatted_messages)} -> {len(compressed)})",
|
||||
)
|
||||
return compressed
|
||||
if state_snapshot is None:
|
||||
logger.warning("Failed to extract state_snapshot from LLM response, using full content as fallback")
|
||||
return content
|
||||
|
||||
return state_snapshot
|
||||
|
||||
# Call LLM to generate compressed summary
|
||||
result = await self.llm.achat(
|
||||
|
|
@ -259,158 +153,81 @@ class ContextCompressOp(BaseAsyncOp):
|
|||
callback_fn=parse_compressed_result,
|
||||
)
|
||||
|
||||
if result is None:
|
||||
logger.error("LLM returned None, using empty string as fallback")
|
||||
return ""
|
||||
|
||||
return result
|
||||
|
||||
async def _compress_with_micro_groups(
|
||||
async def _compress_with_groups(
|
||||
self,
|
||||
messages_to_compress: List[Message],
|
||||
system_messages: List[Message],
|
||||
recent_messages: List[Message],
|
||||
) -> List[Message]:
|
||||
"""Compress messages by splitting into groups and compressing each separately.
|
||||
system_message: Message,
|
||||
message_groups: List[List[Message]],
|
||||
) -> Tuple[dict, list]:
|
||||
"""Compress multiple message groups and prepare them for storage.
|
||||
|
||||
This method processes each message group, compresses it using LLM, and determines
|
||||
whether compression is beneficial. If compression reduces token count, the original
|
||||
messages are saved to files and compressed summaries are appended to the system
|
||||
message. Otherwise, original messages are preserved in the return list.
|
||||
|
||||
Args:
|
||||
messages_to_compress: Messages to be compressed
|
||||
system_messages: System messages to preserve
|
||||
recent_messages: Recent messages to keep uncompressed
|
||||
system_message: The system message to append compressed summaries to.
|
||||
message_groups: List of message groups, where each group is a list of Message
|
||||
objects to be compressed together.
|
||||
|
||||
Returns:
|
||||
List of new messages after compression
|
||||
A tuple containing:
|
||||
- write_file_dict: Dictionary mapping file paths to JSON-serialized message
|
||||
strings for messages that were successfully compressed.
|
||||
- return_messages: List of Message objects including the modified system
|
||||
message with compressed summaries and any messages that couldn't be
|
||||
compressed or didn't benefit from compression.
|
||||
"""
|
||||
# Split messages into groups based on micro threshold
|
||||
message_groups = self._split_messages_by_token_threshold(
|
||||
messages_to_compress,
|
||||
self.micro_summary_token_threshold,
|
||||
)
|
||||
write_file_dict = {}
|
||||
return_messages = []
|
||||
chat_id: str = self.context.get("chat_id", uuid4().hex)
|
||||
|
||||
# Compress each group separately
|
||||
compressed_messages = []
|
||||
total_original_tokens = 0
|
||||
total_compressed_tokens = 0
|
||||
# Create a copy of system_message to avoid modifying the original
|
||||
system_message_copy = Message(role=system_message.role, content=system_message.content)
|
||||
|
||||
for group_idx, group in enumerate(message_groups, 1):
|
||||
# Calculate original token count for this group
|
||||
group_original_tokens = self.token_count(group)
|
||||
total_original_tokens += group_original_tokens
|
||||
for g_idx, messages in enumerate(message_groups):
|
||||
group_original_tokens = self.token_count(messages)
|
||||
messages_str = json.dumps([x.simple_dump() for x in messages], ensure_ascii=False, indent=2)
|
||||
store_path = Path(self.context.get("store_dir", "")) / f"{chat_id}_{g_idx}.json"
|
||||
|
||||
# Save original messages for this group
|
||||
group_file_path = self._save_original_messages(group)
|
||||
logger.info(f"Compress {g_idx}/{len(message_groups)} ({len(messages)}, {group_original_tokens} tokens)")
|
||||
group_summary = await self._compress_messages_with_llm(messages)
|
||||
|
||||
# Compress this group
|
||||
logger.info(
|
||||
f"Compressing group {group_idx}/{len(message_groups)} "
|
||||
f"({len(group)} messages, {group_original_tokens} tokens)",
|
||||
if not group_summary:
|
||||
logger.warning(f"Group {g_idx} compression returned empty summary, using original messages.")
|
||||
return_messages.extend(messages)
|
||||
continue
|
||||
|
||||
compress_content = (
|
||||
f"[Compressed conversation history - Part {g_idx}/{len(message_groups)}]\n{group_summary}\n\n"
|
||||
f"(Original {len(messages)} messages are stored in: {store_path.as_posix()})\n"
|
||||
)
|
||||
group_summary = await self._compress_messages_with_llm(group)
|
||||
group_summary = self._extract_xml_fragments(group_summary)
|
||||
|
||||
# Create compressed message for this group
|
||||
compressed_message = Message(
|
||||
role=Role.SYSTEM,
|
||||
content=(
|
||||
f"[Compressed conversation history - Part {group_idx}/{len(message_groups)}]\n"
|
||||
f"{group_summary}\n\n"
|
||||
f"(Original {len(group)} messages are stored in: {group_file_path})"
|
||||
),
|
||||
)
|
||||
|
||||
# Check if compression actually reduced tokens for this group
|
||||
compressed_tokens = self.token_count([compressed_message])
|
||||
compressed_tokens = self.token_count([Message(content=compress_content)])
|
||||
|
||||
if compressed_tokens >= group_original_tokens:
|
||||
logger.warning(
|
||||
f"Group {group_idx} compression did not reduce tokens: "
|
||||
f"Group {g_idx} compression did not reduce tokens: "
|
||||
f"{group_original_tokens} -> {compressed_tokens}. Using original messages.",
|
||||
)
|
||||
return None
|
||||
return_messages.extend(messages)
|
||||
else:
|
||||
system_message_copy.content += compress_content + "\n\n"
|
||||
write_file_dict[store_path.as_posix()] = messages_str
|
||||
logger.info(
|
||||
f"Group {g_idx} compression successful: "
|
||||
f"{group_original_tokens} -> {compressed_tokens} tokens "
|
||||
f"(reduction: {group_original_tokens - compressed_tokens} tokens, "
|
||||
f"{100 * (1 - compressed_tokens / group_original_tokens):.1f}%)",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Group {group_idx} compression successful: "
|
||||
f"{group_original_tokens} -> {compressed_tokens} tokens "
|
||||
f"(reduction: {group_original_tokens - compressed_tokens} tokens, "
|
||||
f"{100 * (1 - compressed_tokens / group_original_tokens):.1f}%)",
|
||||
)
|
||||
compressed_messages.append(compressed_message)
|
||||
total_compressed_tokens += compressed_tokens
|
||||
|
||||
# Construct new message list: system messages + all compressed messages + recent messages
|
||||
new_messages = system_messages + compressed_messages + recent_messages
|
||||
|
||||
logger.info(
|
||||
f"Context compression completed using micro-compression: "
|
||||
f"{len(messages_to_compress) + len(system_messages) + len(recent_messages)} messages -> "
|
||||
f"{len(new_messages)} messages ({len(message_groups)} compressed groups), "
|
||||
f"total tokens: {total_original_tokens} -> {total_compressed_tokens}",
|
||||
)
|
||||
|
||||
return new_messages
|
||||
|
||||
async def _compress_all_together(
|
||||
self,
|
||||
messages_to_compress: List[Message],
|
||||
system_messages: List[Message],
|
||||
recent_messages: List[Message],
|
||||
) -> List[Message]:
|
||||
"""Compress all messages together into a single summary.
|
||||
|
||||
Args:
|
||||
messages_to_compress: Messages to be compressed
|
||||
system_messages: System messages to preserve
|
||||
recent_messages: Recent messages to keep uncompressed
|
||||
|
||||
Returns:
|
||||
List of new messages after compression
|
||||
"""
|
||||
# Calculate original token count
|
||||
original_tokens = self.token_count(messages_to_compress)
|
||||
|
||||
# Save original messages to file for traceability
|
||||
original_file_path = self._save_original_messages(messages_to_compress)
|
||||
|
||||
# Use LLM to compress messages
|
||||
logger.info(
|
||||
f"Starting LLM compression of {len(messages_to_compress)} messages "
|
||||
f"({original_tokens} tokens), keeping {len(recent_messages)} recent messages",
|
||||
)
|
||||
compressed_summary = await self._compress_messages_with_llm(messages_to_compress)
|
||||
compressed_summary = self._extract_xml_fragments(compressed_summary)
|
||||
|
||||
# Create a new system message with the compressed content and file reference
|
||||
compressed_message = Message(
|
||||
role=Role.SYSTEM,
|
||||
content=(
|
||||
f"[Compressed conversation history]\n"
|
||||
f"{compressed_summary}\n\n"
|
||||
f"(Original {len(messages_to_compress)} messages are stored in: {original_file_path})"
|
||||
),
|
||||
)
|
||||
|
||||
# Check if compression actually reduced tokens
|
||||
compressed_tokens = self.token_count([compressed_message])
|
||||
|
||||
if compressed_tokens >= original_tokens:
|
||||
logger.warning(
|
||||
f"Compression did not reduce tokens: {original_tokens} -> {compressed_tokens}. "
|
||||
f"Returning original messages.",
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
f"Compression successful: {original_tokens} -> {compressed_tokens} tokens "
|
||||
f"(reduction: {original_tokens - compressed_tokens} tokens, "
|
||||
f"{100 * (1 - compressed_tokens / original_tokens):.1f}%)",
|
||||
)
|
||||
|
||||
# Construct new message list: system messages + compressed message + recent messages
|
||||
new_messages = system_messages + [compressed_message] + recent_messages
|
||||
|
||||
logger.info(
|
||||
f"Context compression completed: "
|
||||
f"{len(messages_to_compress) + len(system_messages) + len(recent_messages)} messages -> "
|
||||
f"{len(new_messages)} messages",
|
||||
)
|
||||
|
||||
return new_messages
|
||||
return_messages = [system_message_copy] + return_messages
|
||||
return write_file_dict, return_messages
|
||||
|
||||
async def async_execute(self):
|
||||
"""
|
||||
|
|
@ -423,31 +240,33 @@ class ContextCompressOp(BaseAsyncOp):
|
|||
4. Otherwise, uses LLM to compress older messages by:
|
||||
- Saving original messages to file
|
||||
- Generating a concise summary of older messages
|
||||
- Replacing older messages with a single summary message
|
||||
- Appending compressed summaries to the system message
|
||||
- Preserving messages that couldn't be compressed or didn't benefit from compression
|
||||
"""
|
||||
# Get configuration from context
|
||||
# Note: max_total_tokens does not include keep_recent_count messages or system messages
|
||||
max_total_tokens: int = self.context.get("max_total_tokens", 20000)
|
||||
group_token_threshold: int = self.context.get("group_token_threshold", None)
|
||||
keep_recent_count: int = self.context.get("keep_recent_count", 2)
|
||||
|
||||
assert max_total_tokens > 0, "max_total_tokens must be positive"
|
||||
assert keep_recent_count >= 0, "keep_recent_count must be non-negative"
|
||||
|
||||
# Convert context messages to Message objects
|
||||
messages = [Message(**x) for x in self.context.messages]
|
||||
|
||||
# Check if we have enough messages to compress
|
||||
if len(messages) <= self.keep_recent:
|
||||
self.context.response.answer = self.context.messages
|
||||
logger.info(
|
||||
f"Message count ({len(messages)}) is less than or "
|
||||
f"equal to keep_recent ({self.keep_recent}), no compression needed",
|
||||
)
|
||||
return
|
||||
# Extract system message (should be exactly one)
|
||||
system_message = [x for x in messages if x.role is Role.SYSTEM]
|
||||
assert len(system_message) <= 1, f"Expected at most one system message, got {len(system_message)}"
|
||||
|
||||
# Split messages into those to compress and those to keep
|
||||
messages_to_compress = messages[: -self.keep_recent]
|
||||
recent_messages = messages[-self.keep_recent :]
|
||||
if len(system_message) == 0:
|
||||
system_message = Message(role=Role.SYSTEM, content="")
|
||||
else:
|
||||
system_message = system_message[0]
|
||||
|
||||
# Always filter out system messages (system messages are never compressed)
|
||||
system_messages = [m for m in messages_to_compress if m.role is Role.SYSTEM]
|
||||
messages_to_compress = [m for m in messages_to_compress if m.role is not Role.SYSTEM]
|
||||
logger.info(
|
||||
f"Excluding {len(system_messages)} system messages from compression, "
|
||||
f"{len(messages_to_compress)} messages remaining for compression check",
|
||||
)
|
||||
messages_without_system = [x for x in messages if x.role is not Role.SYSTEM]
|
||||
messages_to_compress = messages_without_system[:-keep_recent_count]
|
||||
recent_messages = messages_without_system[-keep_recent_count:]
|
||||
|
||||
# If nothing to compress after filtering, return original messages
|
||||
if not messages_to_compress:
|
||||
|
|
@ -455,40 +274,42 @@ class ContextCompressOp(BaseAsyncOp):
|
|||
logger.info("No messages to compress after filtering, returning original messages")
|
||||
return
|
||||
|
||||
logger.info(f"{len(messages_to_compress)} messages remaining for compression check")
|
||||
|
||||
# Calculate token count of messages to compress (only the content that will be compressed)
|
||||
compress_token_cnt: int = self.token_count(messages_to_compress)
|
||||
logger.info(
|
||||
f"Context compression check: messages_to_compress token count={compress_token_cnt}, "
|
||||
f"threshold={self.all_token_threshold}",
|
||||
)
|
||||
logger.info(f"Context compression check: token count={compress_token_cnt} threshold={max_total_tokens}")
|
||||
|
||||
# If token count is within threshold, no compression needed
|
||||
if compress_token_cnt <= self.all_token_threshold:
|
||||
if compress_token_cnt <= max_total_tokens:
|
||||
self.context.response.answer = self.context.messages
|
||||
logger.info(
|
||||
f"Messages to compress token count ({compress_token_cnt}) is within threshold "
|
||||
f"({self.all_token_threshold}), no compression needed",
|
||||
)
|
||||
logger.info(f"messages_to_compress ({compress_token_cnt}) is within threshold ({max_total_tokens})")
|
||||
return
|
||||
|
||||
# Determine whether to use micro-compression (split into groups) or compress all together
|
||||
if self.micro_summary_token_threshold is not None and self.micro_summary_token_threshold > 0:
|
||||
new_messages = await self._compress_with_micro_groups(
|
||||
messages_to_compress,
|
||||
system_messages,
|
||||
recent_messages,
|
||||
)
|
||||
if group_token_threshold is not None and group_token_threshold > 0:
|
||||
message_groups = self._split_messages_by_token_threshold(messages_to_compress, group_token_threshold)
|
||||
else:
|
||||
new_messages = await self._compress_all_together(
|
||||
messages_to_compress,
|
||||
system_messages,
|
||||
recent_messages,
|
||||
)
|
||||
message_groups = [messages_to_compress]
|
||||
|
||||
# If compression failed (returned None), use original messages
|
||||
if new_messages is None:
|
||||
self.context.response.answer = self.context.messages
|
||||
return
|
||||
write_file_dict, return_messages = await self._compress_with_groups(system_message, message_groups)
|
||||
|
||||
# Return the compressed messages as JSON
|
||||
self.context.response.answer = json.dumps([x.model_dump() for x in new_messages], ensure_ascii=False, indent=2)
|
||||
# Store write_file_dict in context for potential batch writing
|
||||
if write_file_dict:
|
||||
self.context.write_file_dict = write_file_dict
|
||||
|
||||
self.context.response.answer = [x.simple_dump() for x in (return_messages + recent_messages)]
|
||||
self.context.response.metadata["write_file_dict"] = write_file_dict
|
||||
|
||||
async def async_default_execute(self, e: Exception = None, **_kwargs):
|
||||
"""Handle execution errors by returning original messages.
|
||||
|
||||
This method is called when an exception occurs during async_execute. It preserves
|
||||
the original messages and marks the operation as unsuccessful.
|
||||
|
||||
Args:
|
||||
e: The exception that occurred during execution, if any.
|
||||
**_kwargs: Additional keyword arguments (unused but required by interface).
|
||||
"""
|
||||
self.context.response.answer = self.context.messages
|
||||
self.context.response.success = False
|
||||
self.context.response.metadata["error"] = str(e)
|
||||
|
|
|
|||
|
|
@ -1,58 +1,13 @@
|
|||
compress_context_prompt_zh: |
|
||||
你是将扮演一个功能为“将内部聊天记录总结为给定结构”的组件。
|
||||
|
||||
当会话历史记录变得太大时,将调用您将整个历史记录提取为简洁的结构化XML快照。这个快照非常重要,因为它会成为Agent对过去内容**唯一**的记忆。后续的对话将Agent仅基于此snapshot恢复其工作。所有重要的细节、计划、错误和用户指令都必须保留。
|
||||
|
||||
首先,你在个人的<scratchpad>思考整个历史内容。检查用户的总体目标、代理的操作、工具输出、文件修改以及任何未解决的问题。找出对未来行动至关重要的每一条信息。
|
||||
|
||||
在你推理完成后,生成最终的<state_snapshot> XML对象。信息要非常密集。不要省略任何不重要的对话填充。
|
||||
|
||||
结构必须如下:
|
||||
|
||||
<state_snapshot>
|
||||
<overall_goal>
|
||||
<!-- 用一个简洁的句子描述用户的高层次目标。 -->
|
||||
<!-- 示例:‘重构身份验证服务以使用新的JWT库。\’ -->
|
||||
</overall_goal>
|
||||
|
||||
<key_knowledge>
|
||||
<!-- 基于对话历史和与用户的交互,必须记住的重要事实、惯例和约束。使用列表项。 -->
|
||||
<!-- Example:
|
||||
- Build Command: `npm run build`
|
||||
- Testing: Tests are run with `npm test`. Test files must end in `.test.ts`.
|
||||
- API Endpoint: The primary API endpoint is `https://api.example.com/v2`.
|
||||
-->
|
||||
</key_knowledge>
|
||||
|
||||
<recent_actions>
|
||||
<!-- 最近几个重要行为动作及其结果的总结。重点是事实。 -->
|
||||
<!-- Example:
|
||||
- Ran `grep 'old_function'` which returned 3 results in 2 files.
|
||||
- Ran `npm run test`, which failed due to a snapshot mismatch in `UserProfile.test.ts`.
|
||||
- Ran `ls -F static/` and discovered image assets are stored as `.webp`.
|
||||
-->
|
||||
</recent_actions>
|
||||
|
||||
</state_snapshot>
|
||||
|
||||
下面是需要压缩的对话历史记录:
|
||||
|
||||
’‘’
|
||||
{messages_content}
|
||||
’‘’
|
||||
|
||||
首先,您将在一个私有的<scratchpad>中考虑整个历史。审查用户的总体目标。请你一定要使用中文来回答和压缩。
|
||||
|
||||
|
||||
compress_context_prompt: |
|
||||
Here is the conversation history that needs to be compressed:
|
||||
’’’
|
||||
{messages_content}
|
||||
’’’
|
||||
|
||||
You are the component that summarizes internal chat history into a given structure.
|
||||
|
||||
When the conversation history grows too large, you will be invoked to distill the entire history into a concise, structured XML snapshot. This snapshot is CRITICAL, as it will become the agent's *only* memory of the past. The agent will resume its work based solely on this snapshot. All crucial details, plans, errors, and user directives MUST be preserved.
|
||||
|
||||
First, you will think through the entire history in a private <scratchpad>. Review the user's overall goal, the agent's actions, tool outputs, file modifications, and any unresolved questions. Identify every piece of information that is essential for future actions.
|
||||
|
||||
After your reasoning is complete, generate the final <state_snapshot> XML object. Be incredibly dense with information. Omit any irrelevant conversational filler.
|
||||
|
||||
The structure MUST be as follows:
|
||||
|
||||
<state_snapshot>
|
||||
|
|
@ -80,8 +35,43 @@ compress_context_prompt: |
|
|||
</recent_actions>
|
||||
</state_snapshot>
|
||||
|
||||
Here is the conversation history that needs to be compressed:
|
||||
’’’
|
||||
First, you will think through the entire history in a private <scratchpad>. Then, generate the <state_snapshot>.
|
||||
|
||||
compress_context_prompt_zh: |
|
||||
以下是需要压缩的对话历史:
|
||||
'''
|
||||
{messages_content}
|
||||
’’’
|
||||
First, you will think through the entire history in a private <scratchpad>. Review the user's overall goal, the agent'
|
||||
'''
|
||||
|
||||
你是负责将内部聊天历史总结为给定结构的组件。
|
||||
当对话历史变得过长时,你将被调用来将整个历史提炼成一个简洁、结构化的 XML 快照。这个快照至关重要,因为它将成为智能体对过去的*唯一*记忆。智能体将仅基于这个快照继续工作。所有关键细节、计划、错误和用户指令都必须被保留。
|
||||
首先,你将在私有的 <scratchpad> 中思考整个历史。回顾用户的总体目标、智能体的行动、工具输出、文件修改以及任何未解决的问题。识别对未来行动至关重要的每一条信息。
|
||||
完成推理后,生成最终的 <state_snapshot> XML 对象。信息要极其密集。省略任何无关的对话填充内容。
|
||||
结构必须如下:
|
||||
|
||||
<state_snapshot>
|
||||
<overall_goal>
|
||||
<!-- 用一句话简洁地描述用户的高层目标。 -->
|
||||
<!-- 示例:"重构认证服务以使用新的 JWT 库。" -->
|
||||
</overall_goal>
|
||||
|
||||
<key_knowledge>
|
||||
<!-- 基于对话历史和与用户的交互,智能体必须记住的关键事实、约定和约束。使用项目符号。 -->
|
||||
<!-- 示例:
|
||||
- 构建命令:`npm run build`
|
||||
- 测试:使用 `npm test` 运行测试。测试文件必须以 `.test.ts` 结尾。
|
||||
- API 端点:主要 API 端点是 `https://api.example.com/v2`。
|
||||
-->
|
||||
</key_knowledge>
|
||||
|
||||
<recent_actions>
|
||||
<!-- 最近几次重要智能体行动及其结果的摘要。关注事实。 -->
|
||||
<!-- 示例:
|
||||
- 运行了 `grep 'old_function'`,在 2 个文件中返回了 3 个结果。
|
||||
- 运行了 `npm run test`,由于 `UserProfile.test.ts` 中的快照不匹配而失败。
|
||||
- 运行了 `ls -F static/`,发现图片资源存储为 `.webp` 格式。
|
||||
-->
|
||||
</recent_actions>
|
||||
</state_snapshot>
|
||||
|
||||
首先,你将在私有的 <scratchpad> 中思考整个历史。然后,生成 <state_snapshot>。
|
||||
107
reme_ai/context/offload/context_offload_op.py
Normal file
107
reme_ai/context/offload/context_offload_op.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"""
|
||||
Context offload module for managing context window limits through compaction and compression.
|
||||
|
||||
This module provides a high-level operation that orchestrates context compaction and compression
|
||||
to reduce token usage. It first attempts to compact tool messages, and if the compaction ratio
|
||||
is not sufficient, it applies LLM-based compression to further reduce token count.
|
||||
|
||||
The offload process:
|
||||
1. Compacts tool messages by storing full content in external files
|
||||
2. Evaluates the compaction effectiveness by comparing token counts
|
||||
3. If compaction ratio exceeds threshold, applies LLM-based compression
|
||||
"""
|
||||
|
||||
from flowllm.core.context import C
|
||||
from flowllm.core.op import BaseAsyncOp
|
||||
from flowllm.core.schema import Message
|
||||
from loguru import logger
|
||||
|
||||
from reme_ai.enumeration import ContextManageEnum
|
||||
|
||||
|
||||
@C.register_op()
|
||||
class ContextOffloadOp(BaseAsyncOp):
|
||||
"""
|
||||
Context offload operation that orchestrates compaction and compression to reduce token usage.
|
||||
|
||||
This operation combines context compaction and compression strategies to manage context
|
||||
window limits. It first applies compaction to tool messages, then evaluates the effectiveness.
|
||||
If the compaction ratio (compressed tokens / original tokens) exceeds a threshold, it
|
||||
applies additional LLM-based compression to further reduce token count.
|
||||
|
||||
Context Parameters:
|
||||
context_manage_mode (ContextManageEnum): The context management mode to use.
|
||||
- COMPACT: Only applies context compaction to tool messages.
|
||||
- COMPRESS: Only applies LLM-based compression to messages.
|
||||
- AUTO: Applies compaction first, then compression if compaction ratio exceeds threshold.
|
||||
Defaults to AUTO.
|
||||
compact_ratio_threshold (float): Threshold for compaction ratio above which compression
|
||||
is applied. Only used in AUTO mode. Defaults to 0.75. If the ratio of compressed
|
||||
tokens to original tokens exceeds this value, compression will be triggered.
|
||||
"""
|
||||
|
||||
async def async_execute(self):
|
||||
"""
|
||||
Execute the context offload operation.
|
||||
|
||||
The operation behavior depends on the context_manage_mode:
|
||||
- COMPACT: Only applies context compaction to reduce token usage in tool messages.
|
||||
- COMPRESS: Only applies LLM-based compression to generate concise summaries.
|
||||
- AUTO: Applies compaction first, then compression if compaction ratio exceeds threshold.
|
||||
|
||||
The compaction operation stores full tool message content in external files and
|
||||
keeps only previews in the context. The compression operation uses LLM to generate
|
||||
concise summaries of older messages.
|
||||
"""
|
||||
from .context_compact_op import ContextCompactOp
|
||||
from .context_compress_op import ContextCompressOp
|
||||
|
||||
# Get the context management mode from context, default to AUTO
|
||||
context_manage_mode = self.context.get("context_manage_mode", ContextManageEnum.AUTO)
|
||||
if isinstance(context_manage_mode, str):
|
||||
context_manage_mode = ContextManageEnum(context_manage_mode)
|
||||
|
||||
context_compact_op = ContextCompactOp()
|
||||
context_compress_op = ContextCompressOp()
|
||||
|
||||
if context_manage_mode == ContextManageEnum.COMPACT:
|
||||
# Only apply compaction
|
||||
logger.info("Context management mode: COMPACT")
|
||||
await context_compact_op.async_call(context=self.context)
|
||||
elif context_manage_mode == ContextManageEnum.COMPRESS:
|
||||
# Only apply compression
|
||||
logger.info("Context management mode: COMPRESS")
|
||||
await context_compress_op.async_call(context=self.context)
|
||||
elif context_manage_mode == ContextManageEnum.AUTO:
|
||||
# Apply compaction first, then compression if needed
|
||||
logger.info("Context management mode: AUTO")
|
||||
await context_compact_op.async_call(context=self.context)
|
||||
|
||||
origin_messages = [Message(**x) for x in self.context.messages]
|
||||
origin_token_cnt = self.token_count(origin_messages)
|
||||
|
||||
result_messages = [Message(**x) for x in self.context.response.answer]
|
||||
answer_token_cnt = self.token_count(result_messages)
|
||||
|
||||
compact_ratio = answer_token_cnt / origin_token_cnt
|
||||
|
||||
compact_ratio_threshold: float = self.context.get("compact_ratio_threshold", 0.75)
|
||||
if compact_ratio > compact_ratio_threshold:
|
||||
logger.info(f"Compact ratio {compact_ratio:.2f} > {compact_ratio_threshold:.2f}, compress answer")
|
||||
await context_compress_op.async_call(context=self.context)
|
||||
else:
|
||||
raise ValueError(f"Unknown context management mode: {context_manage_mode}")
|
||||
|
||||
async def async_default_execute(self, e: Exception = None, **_kwargs):
|
||||
"""Handle execution errors by returning original messages.
|
||||
|
||||
This method is called when an exception occurs during async_execute. It preserves
|
||||
the original messages and marks the operation as unsuccessful.
|
||||
|
||||
Args:
|
||||
e: The exception that occurred during execution, if any.
|
||||
**_kwargs: Additional keyword arguments (unused but required by interface).
|
||||
"""
|
||||
self.context.response.answer = self.context.messages
|
||||
self.context.response.success = False
|
||||
self.context.response.metadata["error"] = str(e)
|
||||
|
|
@ -4,8 +4,10 @@ This module provides enumerations used throughout the ReMe system,
|
|||
including language enumerations and other type definitions.
|
||||
"""
|
||||
|
||||
from reme_ai.enumeration.context_manage_enum import ContextManageEnum
|
||||
from reme_ai.enumeration.language_enum import LanguageEnum
|
||||
|
||||
__all__ = [
|
||||
"ContextManageEnum",
|
||||
"LanguageEnum",
|
||||
]
|
||||
|
|
|
|||
21
reme_ai/enumeration/context_manage_enum.py
Normal file
21
reme_ai/enumeration/context_manage_enum.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
"""Context management enumeration module.
|
||||
|
||||
This module provides enumerations for context management strategies in the ReMe system.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ContextManageEnum(str, Enum):
|
||||
"""
|
||||
An enumeration representing context management strategies.
|
||||
|
||||
Members:
|
||||
- COMPACT: Represents the compact context management strategy.
|
||||
- COMPRESS: Represents the compress context management strategy.
|
||||
- AUTO: Represents the automatic context management strategy.
|
||||
"""
|
||||
|
||||
COMPACT = "compact"
|
||||
COMPRESS = "compress"
|
||||
AUTO = "auto"
|
||||
|
|
@ -130,3 +130,24 @@ def parse_update_insight_response(response_text: str, language: str = "en") -> s
|
|||
|
||||
logger.warning("No insight content found in response")
|
||||
return ""
|
||||
|
||||
|
||||
def extract_xml_tag_content(text: str, tag_name: str) -> str | None:
|
||||
"""Extract content from XML tag in text.
|
||||
|
||||
Args:
|
||||
text: The text containing XML tags.
|
||||
tag_name: The name of the XML tag to extract (e.g., 'state_snapshot').
|
||||
|
||||
Returns:
|
||||
str: The content inside the XML tag, or None if not found.
|
||||
"""
|
||||
# Use re.DOTALL to make . match newline characters
|
||||
pattern = rf"<{tag_name}>(.*?)</{tag_name}>"
|
||||
match = re.search(pattern, text, re.DOTALL)
|
||||
|
||||
if match:
|
||||
content = match.group(1).strip()
|
||||
return content
|
||||
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -222,12 +222,13 @@ async def main():
|
|||
logger.info("Test 1: Messages below threshold (should skip compression)")
|
||||
logger.info("=" * 60)
|
||||
|
||||
compress_op1 = ContextCompressOp(
|
||||
all_token_threshold=50000, # High threshold, won't trigger
|
||||
keep_recent=3,
|
||||
)
|
||||
compress_op1 = ContextCompressOp()
|
||||
|
||||
await compress_op1.async_call(messages=messages)
|
||||
await compress_op1.async_call(
|
||||
messages=messages,
|
||||
max_total_tokens=50000, # High threshold, won't trigger
|
||||
keep_recent_count=3,
|
||||
)
|
||||
|
||||
result_messages1 = compress_op1.context.response.answer
|
||||
logger.info(f"✓ Result: {len(result_messages1)} messages (unchanged)")
|
||||
|
|
@ -237,14 +238,15 @@ async def main():
|
|||
logger.info("Test 2: Messages above threshold (should compress)")
|
||||
logger.info("=" * 60)
|
||||
|
||||
compress_op2 = ContextCompressOp(
|
||||
all_token_threshold=2000, # Low threshold, will trigger
|
||||
keep_recent=3, # Keep last 3 messages
|
||||
compress_op2 = ContextCompressOp()
|
||||
|
||||
await compress_op2.async_call(
|
||||
messages=messages,
|
||||
max_total_tokens=2000, # Low threshold, will trigger
|
||||
keep_recent_count=3, # Keep last 3 messages
|
||||
compress_system_message=False, # Don't compress system messages
|
||||
)
|
||||
|
||||
await compress_op2.async_call(messages=messages)
|
||||
|
||||
result_messages2 = compress_op2.context.response.answer
|
||||
logger.info(f"✓ Result: {len(result_messages2)} messages (compressed)")
|
||||
|
||||
|
|
@ -260,16 +262,17 @@ async def main():
|
|||
logger.info("Test 3: Messages above micro threshold (should compress)")
|
||||
logger.info("=!" * 30)
|
||||
|
||||
compress_op2 = ContextCompressOp(
|
||||
all_token_threshold=2000, # Low threshold, will trigger
|
||||
keep_recent=2, # Keep last 3 messages
|
||||
compress_op2 = ContextCompressOp()
|
||||
|
||||
await compress_op2.async_call(
|
||||
messages=messages,
|
||||
max_total_tokens=2000, # Low threshold, will trigger
|
||||
keep_recent_count=2, # Keep last 2 messages
|
||||
compress_system_message=False, # Don't compress system messages
|
||||
micro_summary_token_threshold=1500,
|
||||
group_token_threshold=1500,
|
||||
language="zh",
|
||||
)
|
||||
|
||||
await compress_op2.async_call(messages=messages)
|
||||
|
||||
result_messages2 = compress_op2.context.response.answer
|
||||
logger.info(f"✓ Result: {len(result_messages2)} messages (compressed)")
|
||||
|
||||
|
|
|
|||
|
|
@ -59,16 +59,17 @@ async def async_main():
|
|||
]
|
||||
|
||||
# Create op with lower thresholds for testing
|
||||
op = ContextCompactOp(
|
||||
all_token_threshold=1000, # Low threshold to trigger compaction
|
||||
tool_token_threshold=100, # Low threshold to compact tool messages
|
||||
tool_left_char_len=50, # Keep 50 chars in preview
|
||||
keep_recent=1, # Keep 1 recent tool message
|
||||
storage_path="./test_compact_storage",
|
||||
)
|
||||
op = ContextCompactOp()
|
||||
|
||||
# Execute the compaction
|
||||
await op.async_call(messages=[m.model_dump() for m in messages])
|
||||
await op.async_call(
|
||||
messages=[m.model_dump() for m in messages],
|
||||
max_total_tokens=1000, # Low threshold to trigger compaction
|
||||
max_tool_message_tokens=100, # Low threshold to compact tool messages
|
||||
preview_char_length=50, # Keep 50 chars in preview
|
||||
keep_recent_count=1, # Keep 1 recent tool message
|
||||
storage_path="./test_compact_storage",
|
||||
)
|
||||
|
||||
# Print results
|
||||
result = op.context.response.answer
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue