Merge remote-tracking branch 'origin/main'

# Conflicts:
#	README.md
#	README_ZH.md
This commit is contained in:
方应 2026-03-18 11:31:11 +08:00
commit 8835a1b8e1
32 changed files with 3932 additions and 439 deletions

View file

@ -81,7 +81,7 @@ full = [
]
light = [
"agentscope==1.0.16.dev0",
"agentscope==1.0.17",
]
[tool.setuptools.packages.find]

View file

@ -6,7 +6,7 @@ from . import extension
from . import memory
from .reme import ReMe
__version__ = "0.3.0.6b3"
__version__ = "0.3.0.8"
__all__ = [
"config",

View file

@ -7,6 +7,11 @@ as_llm_formatters:
default:
backend: openai
as_token_counters:
default:
backend: rule
token_count_estimate_divisor: 3.75
embedding_models:
default:
backend: openai

View file

@ -5,8 +5,6 @@ import os
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from loguru import logger
from .embedding import BaseEmbeddingModel
from .file_store import BaseFileStore
from .file_watcher import BaseFileWatcher
@ -17,9 +15,11 @@ from .registry_factory import R
from .schema import Response, ServiceConfig
from .service_context import ServiceContext
from .token_counter import BaseTokenCounter
from .utils import execute_stream_task, PydanticConfigParser, init_logger, MCPClient, print_logo
from .utils import execute_stream_task, PydanticConfigParser, init_logger, MCPClient, print_logo, get_logger
from .vector_store import BaseVectorStore
logger = get_logger()
class Application:
"""Application wrapper that wires together service context, flows, and runtimes."""
@ -244,6 +244,7 @@ class Application:
await self.prepare_mcp_servers()
self._started = True
logger.info("ReMe Application started")
return self
async def prepare_mcp_servers(self):
@ -290,6 +291,7 @@ class Application:
self.shutdown_ray()
self._started = False
logger.info("ReMe Application closed")
return False
def shutdown_thread_pool(self, wait: bool = True):

View file

@ -1,9 +1,9 @@
"""Module for registering AgentScope LLM formatters."""
from agentscope.formatter import DashScopeChatFormatter
from agentscope.formatter import OpenAIChatFormatter
from .reme_openai_chat_formatter import ReMeOpenAIChatFormatter
from ..registry_factory import R
R.as_llm_formatters.register("openai")(OpenAIChatFormatter)
R.as_llm_formatters.register("openai")(ReMeOpenAIChatFormatter)
R.as_llm_formatters.register("dashscope")(DashScopeChatFormatter)

View file

@ -0,0 +1,215 @@
"""ReMeOpenAIChatFormatter"""
import json
from typing import Any
from agentscope.formatter import OpenAIChatFormatter
from agentscope.formatter._openai_formatter import (
_format_openai_image_block,
_to_openai_audio_data,
)
from agentscope.message import Msg, TextBlock, ImageBlock, URLSource
from loguru import logger
def _format_openai_video_block(video_block: dict) -> dict[str, Any]:
"""Format a video block for OpenAI API.
Args:
video_block: The video block to format.
Returns:
A dictionary with video content in OpenAI format.
"""
source = video_block["source"]
if source["type"] == "url":
url = source["url"]
elif source["type"] == "base64":
data = source["data"]
media_type = source["media_type"]
url = f"data:{media_type};base64,{data}"
else:
raise ValueError(f"Unsupported video source type: {source['type']}")
return {
"type": "video_url",
"video_url": {
"url": url,
},
}
class ReMeOpenAIChatFormatter(OpenAIChatFormatter):
"""ReMeOpenAIChatFormatter"""
async def _format(
self,
msgs: list[Msg],
) -> list[dict[str, Any]]:
"""Format message objects into OpenAI API required format.
Args:
msgs (`list[Msg]`):
The list of Msg objects to format.
Returns:
`list[dict[str, Any]]`:
A list of dictionaries, where each dictionary has "name",
"role", and "content" keys.
"""
self.assert_list_of_msgs(msgs)
messages: list[dict] = []
i = 0
while i < len(msgs):
msg = msgs[i]
content_blocks = []
tool_calls = []
reasoning_content_blocks = []
for block in msg.get_content_blocks():
typ = block.get("type")
if typ == "text":
content_blocks.append({**block})
elif typ == "thinking":
# Collect thinking blocks for reasoning_content field
# This is compatible with models like DeepSeek that support
# extended thinking via reasoning_content field
reasoning_content_blocks.append({**block})
elif typ == "tool_use":
tool_calls.append(
{
"id": block.get("id"),
"type": "function",
"function": {
"name": block.get("name"),
"arguments": json.dumps(
block.get("input", {}),
ensure_ascii=False,
),
},
},
)
elif typ == "tool_result":
(
textual_output,
multimodal_data,
) = self.convert_tool_result_to_string(block["output"])
messages.append(
{
"role": "tool",
"tool_call_id": block.get("id"),
"content": (textual_output), # type: ignore[arg-type]
"name": block.get("name"),
},
)
# Then, handle the multimodal data if any
promoted_blocks: list = []
for url, multimodal_block in multimodal_data:
if multimodal_block["type"] == "image" and self.promote_tool_result_images:
promoted_blocks.extend(
[
TextBlock(
type="text",
text=f"\n- The image from '{url}': ",
),
ImageBlock(
type="image",
source=URLSource(
type="url",
url=url,
),
),
],
)
if promoted_blocks:
# Insert promoted blocks as new user message(s)
promoted_blocks = [
TextBlock(
type="text",
text="<system-info>The following are "
"the image contents from the tool "
f"result of '{block['name']}':",
),
*promoted_blocks,
TextBlock(
type="text",
text="</system-info>",
),
]
msgs.insert(
i + 1,
Msg(
name="user",
content=promoted_blocks,
role="user",
),
)
elif typ == "image":
content_blocks.append(
_format_openai_image_block(
block, # type: ignore[arg-type]
),
)
elif typ == "audio":
# Filter out audio content when the multimodal model
# outputs both text and audio, to prevent errors in
# subsequent model calls
if msg.role == "assistant":
continue
input_audio = _to_openai_audio_data(block["source"])
content_blocks.append(
{
"type": "input_audio",
"input_audio": input_audio,
},
)
elif typ == "video":
# Filter out video content when the multimodal model
# outputs both text and video, to prevent errors in
# subsequent model calls
if msg.role == "assistant":
continue
content_blocks.append(
_format_openai_video_block(block),
)
else:
logger.warning(
"Unsupported block type %s in the message, skipped.",
typ,
)
msg_openai = {
"role": msg.role,
"name": msg.name,
"content": content_blocks or None,
}
if tool_calls:
msg_openai["tool_calls"] = tool_calls
# Add reasoning_content for thinking blocks (compatible with DeepSeek, etc.)
if reasoning_content_blocks:
reasoning_msg = "\n".join(reasoning.get("thinking", "") for reasoning in reasoning_content_blocks)
if reasoning_msg:
msg_openai["reasoning_content"] = reasoning_msg
# When both content and tool_calls are None, skipped
if msg_openai["content"] or msg_openai.get("tool_calls"):
messages.append(msg_openai)
# Move to next message
i += 1
return messages

View file

@ -1,9 +1,8 @@
"""Module for registering AgentScope token counters."""
from agentscope.token import OpenAITokenCounter
from agentscope.token import HuggingFaceTokenCounter
from .reme_token_counter import ReMeTokenCounter
from .rule_token_counter import RuleTokenCounter
from ..registry_factory import R
R.as_token_counters.register("openai")(OpenAITokenCounter)
R.as_token_counters.register("hf")(HuggingFaceTokenCounter)
R.as_token_counters.register("hf")(ReMeTokenCounter)
R.as_token_counters.register("rule")(RuleTokenCounter)

View file

@ -0,0 +1,114 @@
"""Token counter for ReMe."""
import os
from typing import Any
from agentscope.token import HuggingFaceTokenCounter
from ..utils import get_logger
logger = get_logger()
class ReMeTokenCounter(HuggingFaceTokenCounter):
"""Token counter for CoPaw with configurable tokenizer support.
This class extends HuggingFaceTokenCounter to provide token counting
functionality with support for both local and remote tokenizers,
as well as HuggingFace mirror for users in China.
Attributes:
pretrained_model_name_or_path: The tokenizer model path or "default" for local tokenizer.
use_mirror: Whether to use HuggingFace mirror.
token_count_estimate_divisor: Divisor for token estimation.
"""
def __init__(
self,
pretrained_model_name_or_path: str,
use_mirror: bool = True,
token_count_estimate_divisor: float = 3.75,
**kwargs,
):
"""Initialize the token counter with the specified configuration.
Args:
pretrained_model_name_or_path: The tokenizer model path.
use_mirror: Whether to use the HuggingFace mirror
(https://hf-mirror.com) for downloading tokenizers. Useful for
users in China.
token_count_estimate_divisor: Divisor for estimating tokens when
tokenizer is unavailable. Defaults to 3.75.
**kwargs: Additional keyword arguments passed to HuggingFaceTokenCounter.
"""
self.pretrained_model_name_or_path = pretrained_model_name_or_path
self.use_mirror = use_mirror
self.token_count_estimate_divisor = token_count_estimate_divisor
# Set HuggingFace endpoint for mirror support
if use_mirror:
os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"
else:
os.environ.pop("HF_ENDPOINT", None)
try:
super().__init__(
pretrained_model_name_or_path=self.pretrained_model_name_or_path,
use_mirror=use_mirror,
use_fast=True,
trust_remote_code=True,
**kwargs,
)
self._tokenizer_available = True
except Exception as e:
logger.error(f"Failed to initialize tokenizer {e}")
self._tokenizer_available = False
async def count(
self,
messages: list[dict],
tools: list[dict] | None = None,
text: str | None = None,
**kwargs: Any,
) -> int:
"""Count tokens in messages or text.
If text is provided, counts tokens directly in the text string.
Otherwise, counts tokens in the messages using the parent class method.
Args:
messages: List of message dictionaries in chat format.
tools: Optional list of tool definitions for token counting.
text: Optional text string to count tokens directly.
**kwargs: Additional keyword arguments passed to parent count method.
Returns:
The number of tokens, guaranteed to be at least the estimated minimum.
"""
if text:
if self._tokenizer_available:
try:
token_ids = self.tokenizer.encode(text)
return max(len(token_ids), self.estimate_tokens(text))
except Exception as e:
logger.exception("Failed to encode text with tokenizer: %s", e)
return self.estimate_tokens(text)
else:
return self.estimate_tokens(text)
else:
return await super().count(messages, tools, **kwargs)
def estimate_tokens(self, text: str) -> int:
"""Estimate the number of tokens in a text string.
Provides a fast character-based estimation as a fallback or lower bound.
Uses the configured divisor from instance settings.
Args:
text: The text string to estimate tokens for.
Returns:
The estimated number of tokens in the text string.
"""
return int(len(text.encode("utf-8")) / self.token_count_estimate_divisor + 0.5)

View file

@ -0,0 +1,78 @@
"""Rule-based token counter for fast estimation without loading tokenizer."""
from typing import Any
from agentscope.token import HuggingFaceTokenCounter
class RuleTokenCounter(HuggingFaceTokenCounter):
"""Lightweight token counter using rule-based estimation only.
This class provides fast token estimation without loading any tokenizer,
useful when exact token counts are not critical or for quick approximations.
Attributes:
token_count_estimate_divisor: Divisor for token estimation.
"""
def __init__(
self,
token_count_estimate_divisor: float = 3.75,
**_kwargs,
):
"""Initialize the rule-based token counter.
Args:
token_count_estimate_divisor: Divisor for estimating tokens.
Defaults to 3.75 (approximately 4 characters per token).
**kwargs: Additional keyword arguments (ignored).
"""
self.token_count_estimate_divisor = token_count_estimate_divisor
# Skip tokenizer initialization from parent
self._tokenizer_available = False
async def count(
self,
messages: list[dict],
_tools: list[dict] | None = None,
text: str | None = None,
**_kwargs: Any,
) -> int:
"""Count tokens using rule-based estimation.
Args:
messages: List of message dictionaries in chat format.
_tools: Optional list of tool definitions (ignored).
text: Optional text string to count tokens directly.
**_kwargs: Additional keyword arguments (ignored).
Returns:
The estimated number of tokens.
"""
if text:
return self.estimate_tokens(text)
# Estimate from messages
total_text = ""
for msg in messages:
content = msg.get("content", "")
if isinstance(content, str):
total_text += content
elif isinstance(content, list):
for part in content:
if isinstance(part, dict) and "text" in part:
total_text += part["text"]
return self.estimate_tokens(total_text)
def estimate_tokens(self, text: str) -> int:
"""Estimate the number of tokens in a text string.
Uses character-based estimation with the configured divisor.
Args:
text: The text string to estimate tokens for.
Returns:
The estimated number of tokens in the text string.
"""
return int(len(text.encode("utf-8")) / self.token_count_estimate_divisor + 0.5)

View file

@ -34,7 +34,8 @@ class BaseFileWatcher:
chunk_overlap: int = 80,
file_store: BaseFileStore | None = None,
callback: Callable[[set[tuple[Change, str]]], None | Coroutine[Any, Any, None]] | None = None,
scan_on_start: bool = False,
scan_on_start: bool = True,
clear_on_start: bool = True,
**kwargs,
):
"""
@ -50,6 +51,8 @@ class BaseFileWatcher:
file_store: File store instance
callback: Callback function for changes
scan_on_start: If True, scan existing files on start and trigger on_changes with Change.added
clear_on_start: If True, clear all indexed data on start before scanning.
Useful for full rebuild of the index.
**kwargs: Additional keyword arguments
"""
self.watch_paths: list[str] = [watch_paths] if isinstance(watch_paths, str) else watch_paths
@ -61,6 +64,7 @@ class BaseFileWatcher:
self.file_store: BaseFileStore = file_store
self.callback = callback
self.scan_on_start: bool = scan_on_start
self.clear_on_start: bool = clear_on_start
self.kwargs: dict = kwargs
self._stop_event = asyncio.Event()
@ -74,6 +78,11 @@ class BaseFileWatcher:
self._running = True
# Clear all indexed data if requested
if self.clear_on_start and self.file_store is not None:
await self.file_store.clear_all()
logger.info("Cleared all indexed data on start")
# Scan existing files if requested
if self.scan_on_start:
await self._scan_existing_files()

View file

@ -141,7 +141,6 @@ class DeltaFileWatcher(BaseFileWatcher):
async def _on_changes(self, changes: set[tuple[Change, str]]):
"""Handle file changes with incremental synchronization."""
self.dirty = True
await self.file_store.clear_all()
for change_type, path in changes:
if change_type == Change.added:

View file

@ -44,7 +44,6 @@ class FullFileWatcher(BaseFileWatcher):
async def _on_changes(self, changes: set[tuple[Change, str]]):
"""Handle file changes with full synchronization"""
self.dirty = True
await self.file_store.clear_all()
for change_type, path in changes:
if change_type in [Change.added, Change.modified]:

View file

@ -9,7 +9,7 @@ from typing import Callable, Optional, Any
from agentscope.formatter import FormatterBase
from agentscope.model import ChatModelBase
from agentscope.token import TokenCounterBase
from agentscope.token import HuggingFaceTokenCounter
from loguru import logger
from tqdm import tqdm
@ -47,7 +47,7 @@ class BaseOp(metaclass=ABCMeta):
prompt_path: str = "",
as_llm: str | ChatModelBase = "default",
as_llm_formatter: str | FormatterBase = "default",
as_token_counter: str | TokenCounterBase = "default",
as_token_counter: str | HuggingFaceTokenCounter = "default",
llm: str | BaseLLM = "default",
embedding_model: str | BaseEmbeddingModel = "default",
vector_store: str | BaseVectorStore = "default",
@ -153,7 +153,7 @@ class BaseOp(metaclass=ABCMeta):
return self._as_llm_formatter
@property
def as_token_counter(self) -> TokenCounterBase:
def as_token_counter(self) -> HuggingFaceTokenCounter:
"""Get the token counter instance from ServiceContext."""
if isinstance(self._as_token_counter, str):
self._as_token_counter = self.service_context.as_token_counters[self._as_token_counter]

View file

@ -11,7 +11,7 @@ from .horse import play_horse_easter_egg
from .http_client import HttpClient
from .llm_utils import extract_content, format_messages, deduplicate_memories
from .logger_utils import init_logger
from .std_logger import get_logger as get_std_logger
from .std_logger import get_logger
from .logo_utils import print_logo
from .mcp_client import MCPClient
from .pydantic_config_parser import PydanticConfigParser
@ -42,7 +42,7 @@ __all__ = [
"format_messages",
"deduplicate_memories",
"init_logger",
"get_std_logger",
"get_logger",
"print_logo",
"MCPClient",
"PydanticConfigParser",

View file

@ -6,16 +6,32 @@ from pathlib import Path
from agentscope.agent import ReActAgent
from agentscope.message import Msg, TextBlock
from agentscope.tool import Toolkit, ToolResponse
from agentscope.pipeline import stream_printing_messages
from loguru import logger
from agentscope.tool import Toolkit, ToolResponse
from ....core.op import BaseOp
from ....core.utils import format_messages
from .compactor import Compactor
from .context_checker import ContextChecker
from .summarizer import Summarizer
from ..tools import FileIO, MemorySearch
from ....core.op import BaseOp
from ....core.utils import format_messages
from ....core.utils import get_logger
logger = get_logger()
# name + desc + "{working_dir}/skills/{skill_name}/SKILL.md"
_DEFAULT_AGENT_SKILL_INSTRUCTION = (
"# Agent Skills\n"
"The agent skills are a collection of folds of instructions, scripts, "
"and resources that you can load dynamically to improve performance "
"on specialized tasks. Each agent skill has a `SKILL.md` file in its "
"folder that describes how to use the skill. If you want to use a "
"skill, you MUST read its `SKILL.md` file carefully."
)
_DEFAULT_AGENT_SKILL_TEMPLATE = """## {name}
{description}
Check "{dir}/SKILL.md" for how to use this skill"""
class CliAgent(BaseOp):
@ -117,7 +133,7 @@ class CliAgent(BaseOp):
# Create context checker
checker = ContextChecker(
memory_compact_threshold=self.context_window_tokens - self.reserve_tokens,
memory_compact_reserve=self.reserve_tokens,
memory_compact_reserve=self.keep_recent_tokens,
token_counter=self.as_token_counter,
)
@ -199,7 +215,7 @@ class CliAgent(BaseOp):
return messages
async def memory_search(self, query: str, max_results: int = 5, min_score: float = 0.1) -> str:
async def memory_search(self, query: str, max_results: int = 5, min_score: float = 0.1) -> ToolResponse:
"""
Mandatory recall step: semantically search MEMORY.md + memory/*.md (and optional session transcripts)
before answering questions about prior work, decisions, dates, people, preferences, or todos;
@ -257,6 +273,7 @@ class CliAgent(BaseOp):
agent.set_console_output_enabled(False)
self.messages = messages[1:] # remove the first SYSTEM message
agent.memory.content.clear()
# Stream processing state
in_thinking = False

View file

@ -2,11 +2,12 @@
from agentscope.agent import ReActAgent
from agentscope.message import Msg
from agentscope.token import HuggingFaceTokenCounter
from loguru import logger
from ..utils import AsMsgHandler
from ....core.op import BaseOp
from ....core.utils import get_logger
logger = get_logger()
class Compactor(BaseOp):
@ -15,14 +16,11 @@ class Compactor(BaseOp):
def __init__(
self,
memory_compact_threshold: int,
token_counter: HuggingFaceTokenCounter,
console_enabled: bool = True,
console_enabled: bool = False,
**kwargs,
):
super().__init__(**kwargs)
self.memory_compact_threshold: int = memory_compact_threshold
self.msg_handler = AsMsgHandler(token_counter=token_counter)
self.console_enabled: bool = console_enabled
async def execute(self):
@ -32,12 +30,13 @@ class Compactor(BaseOp):
if not messages:
return ""
before_token_count = self.msg_handler.count_msgs_token(messages)
history_formatted_str: str = self.msg_handler.format_msgs_to_str(
msg_handler = AsMsgHandler(self.as_token_counter)
before_token_count = await msg_handler.count_msgs_token(messages)
history_formatted_str: str = await msg_handler.format_msgs_to_str(
messages=messages,
memory_compact_threshold=self.memory_compact_threshold,
)
after_token_count = self.msg_handler.count_str_token(history_formatted_str)
after_token_count = await msg_handler.count_str_token(history_formatted_str)
logger.info(f"Compactor before_token_count={before_token_count} after_token_count={after_token_count}")
if not history_formatted_str:

View file

@ -1,13 +1,12 @@
"""ContextChecker module for checking context size and splitting messages."""
from agentscope.message import Msg
from agentscope.token import HuggingFaceTokenCounter
from ..utils import AsMsgHandler
from ....core.op import BaseOp
from ....core.utils import get_std_logger
from ....core.utils import get_logger
logger = get_std_logger()
logger = get_logger()
class ContextChecker(BaseOp):
@ -21,14 +20,12 @@ class ContextChecker(BaseOp):
Attributes:
memory_compact_threshold (int): Token count threshold for triggering compaction.
memory_compact_reserve (int): Token count to reserve for recent messages.
msg_handler (AsMsgHandler): Handler for message processing and token counting.
"""
def __init__(
self,
memory_compact_threshold: int,
memory_compact_reserve: int = 10000,
token_counter: HuggingFaceTokenCounter | None = None,
**kwargs,
):
"""
@ -39,8 +36,6 @@ class ContextChecker(BaseOp):
compaction. Messages exceeding this threshold will be split.
memory_compact_reserve (int): Token count to reserve for recent messages
to keep in context. Defaults to 10000 tokens.
token_counter (HuggingFaceTokenCounter | None): Token counter for
measuring content length. If None, a default counter will be used.
**kwargs: Additional keyword arguments passed to BaseOp.
"""
super().__init__(**kwargs)
@ -48,8 +43,6 @@ class ContextChecker(BaseOp):
self.memory_compact_reserve: int = memory_compact_reserve
assert self.memory_compact_threshold > self.memory_compact_reserve
self.msg_handler = AsMsgHandler(token_counter=token_counter)
async def execute(self) -> tuple[list[Msg], list[Msg], bool]:
"""
Execute context check and split messages.
@ -81,7 +74,8 @@ class ContextChecker(BaseOp):
logger.info("ContextChecker: No messages to check.")
return [], [], True
messages_to_compact, messages_to_keep, is_valid = self.msg_handler.context_check(
msg_handler = AsMsgHandler(self.as_token_counter)
messages_to_compact, messages_to_keep, is_valid = await msg_handler.context_check(
messages=messages,
memory_compact_threshold=self.memory_compact_threshold,
memory_compact_reserve=self.memory_compact_reserve,

View file

@ -4,12 +4,13 @@ import datetime
from agentscope.agent import ReActAgent
from agentscope.message import Msg
from agentscope.token import HuggingFaceTokenCounter
from agentscope.tool import Toolkit
from loguru import logger
from ..utils import AsMsgHandler
from ....core.op import BaseOp
from ....core.utils import get_logger
logger = get_logger()
class Summarizer(BaseOp):
@ -20,18 +21,15 @@ class Summarizer(BaseOp):
working_dir: str,
memory_dir: str,
memory_compact_threshold: int,
token_counter: HuggingFaceTokenCounter,
toolkit: Toolkit,
console_enabled: bool = True,
toolkit: Toolkit | None = None,
console_enabled: bool = False,
**kwargs,
):
super().__init__(**kwargs)
self.working_dir: str = working_dir
self.memory_dir: str = memory_dir
self.memory_compact_threshold: int = memory_compact_threshold
self.msg_handler = AsMsgHandler(token_counter=token_counter)
self.toolkit: Toolkit = toolkit
self.toolkit: Toolkit | None = toolkit
self.console_enabled: bool = console_enabled
async def execute(self):
@ -40,12 +38,13 @@ class Summarizer(BaseOp):
if not messages:
return ""
before_token_count = self.msg_handler.count_msgs_token(messages)
history_formatted_str: str = self.msg_handler.format_msgs_to_str(
msg_handler = AsMsgHandler(self.as_token_counter)
before_token_count = await msg_handler.count_msgs_token(messages)
history_formatted_str: str = await msg_handler.format_msgs_to_str(
messages=messages,
memory_compact_threshold=self.memory_compact_threshold,
)
after_token_count = self.msg_handler.count_str_token(history_formatted_str)
after_token_count = await msg_handler.count_str_token(history_formatted_str)
logger.info(f"Summarizer before_token_count={before_token_count} after_token_count={after_token_count}")
if not history_formatted_str:
@ -75,6 +74,8 @@ class Summarizer(BaseOp):
content=user_message,
),
)
for i, (msg, _) in enumerate(agent.memory.content):
logger.info(f"Summarizer memory[{i}]: {msg.content}")
history_summary: str = summary_msg.get_text_content()
logger.info(f"Summarizer Result:\n{history_summary}")

View file

@ -7,10 +7,10 @@ from pathlib import Path
from agentscope.message import Msg
from ....core.op import BaseOp
from ....core.utils import get_std_logger
from ....core.utils import get_logger
from ....core.utils import truncate_text, is_truncated
logger = get_std_logger()
logger = get_logger()
class ToolResultCompactor(BaseOp):

View file

@ -1,23 +1,112 @@
"""Custom memory implementation with bugfixes and extensions."""
import json
from datetime import datetime
from pathlib import Path
from agentscope.agent._react_agent import _MemoryMark # noqa
from agentscope.memory import InMemoryMemory
from agentscope.message import Msg
from agentscope.token import HuggingFaceTokenCounter
from .utils import AsMsgHandler
from ...core.utils import get_std_logger
from ...core.utils import get_logger
logger = get_std_logger()
logger = get_logger()
class ReMeInMemoryMemory(InMemoryMemory):
"""Extended InMemoryMemory with bugfixes and summary support."""
def __init__(self, token_counter: HuggingFaceTokenCounter):
def __init__(
self,
token_counter: HuggingFaceTokenCounter,
dialog_path: str | Path | None = None,
):
"""Initialize the ReMeInMemoryMemory.
Args:
token_counter: Token counter for measuring content length.
dialog_path: Path to the dialog storage directory. If provided,
messages will be persisted to jsonl files when cleared or compressed.
"""
super().__init__()
self._token_counter: HuggingFaceTokenCounter = token_counter
self._msg_handler: AsMsgHandler = AsMsgHandler(token_counter)
self._dialog_path: Path | None = Path(dialog_path) if dialog_path else None
def _append_messages_to_dialog(self, messages: list[Msg]) -> int:
"""Append messages to dialog storage file.
Saves messages to jsonl files named by message date (YYYY-mm-dd.jsonl).
Each line is a JSON representation of a message.
Messages are grouped by their timestamp date.
Args:
messages: List of messages to append to the dialog file.
Returns:
Number of messages successfully appended.
"""
if not messages:
return 0
if self._dialog_path is None:
logger.warning("dialog_path is not set, skipping dialog persistence")
return 0
# Ensure dialog directory exists
try:
self._dialog_path.mkdir(parents=True, exist_ok=True)
except Exception as e:
logger.exception(f"Failed to create dialog directory {self._dialog_path}: {e}")
return 0
# Group messages by date (extracted from timestamp)
# timestamp format: "YYYY-mm-dd HH:MM:SS.fff"
messages_by_date: dict[str, list[Msg]] = {}
for msg in messages:
try:
if msg.timestamp:
# Extract date part from timestamp
date_str = msg.timestamp.split()[0] # "YYYY-mm-dd"
else:
date_str = datetime.now().strftime("%Y-%m-%d")
if date_str not in messages_by_date:
messages_by_date[date_str] = []
messages_by_date[date_str].append(msg)
except Exception as e:
logger.warning(f"Failed to process message timestamp: {e}, using today's date")
date_str = datetime.now().strftime("%Y-%m-%d")
if date_str not in messages_by_date:
messages_by_date[date_str] = []
messages_by_date[date_str].append(msg)
# Append messages to corresponding date files (sorted by timestamp within each date)
total_count = 0
for date_str, msgs in messages_by_date.items():
# Sort messages by timestamp within the same date
try:
msgs_sorted = sorted(msgs, key=lambda m: m.timestamp or "")
except Exception as e:
logger.warning(f"Failed to sort messages by timestamp: {e}")
msgs_sorted = msgs
filename = f"{date_str}.jsonl"
filepath = self._dialog_path / filename
try:
with open(filepath, "a", encoding="utf-8") as f:
for msg in msgs_sorted:
msg_dict = msg.to_dict()
f.write(json.dumps(msg_dict, ensure_ascii=False) + "\n")
total_count += 1
logger.info(f"Appended {len(msgs_sorted)} messages to {filepath}")
except Exception as e:
logger.exception(f"Failed to append messages to dialog file {filepath}: {e}")
return total_count
async def get_memory(
self,
@ -56,7 +145,8 @@ class ReMeInMemoryMemory(InMemoryMemory):
{self._compressed_summary}
</previous-summary>
The above is a summary of our previous conversation.
Use it as context to maintain continuity.
If there is a new instruction from the user, do not continue executing the previous content;
only execute the user's new instruction.
""".strip()
return [
@ -105,19 +195,52 @@ Use it as context to maintain continuity.
self._compressed_summary = state_dict.get("_compressed_summary", "")
async def mark_messages_compressed(self, messages: list[Msg]) -> int:
"""Mark messages as compressed and return count."""
return await self.update_messages_mark(
new_mark=_MemoryMark.COMPRESSED,
msg_ids=[msg.id for msg in messages],
)
"""Mark messages as compressed, persist them to dialog, and remove from memory.
This method:
1. Persists the given messages to the dialog storage
2. Removes them from memory
Args:
messages: List of messages to mark as compressed.
Returns:
Number of messages marked as compressed.
"""
if not messages:
return 0
# Persist messages to dialog storage
self._append_messages_to_dialog(messages)
# Remove messages from memory
msg_ids = {msg.id for msg in messages}
initial_size = len(self.content)
self.content = [(msg, marks) for msg, marks in self.content if msg.id not in msg_ids]
removed_count = initial_size - len(self.content)
logger.info(f"Marked {removed_count} messages as compressed and removed from memory")
return removed_count
def clear_compressed_summary(self):
"""Clear the compressed summary."""
self._compressed_summary = "" # pylint: disable=attribute-defined-outside-init
def clear_content(self):
"""Clear the content."""
"""Persist all messages to dialog storage and clear the content.
This method:
1. Persists all messages in memory to the dialog storage
2. Clears the in-memory content
"""
# Persist all messages to dialog storage
if self.content:
messages = [msg for msg, _ in self.content]
self._append_messages_to_dialog(messages)
# Clear in-memory content
self.content.clear()
logger.info("Cleared all messages from memory")
async def estimate_tokens(self, max_input_length: int) -> dict:
"""Estimate token usage for current memory.
@ -141,10 +264,10 @@ Use it as context to maintain continuity.
)
compressed_summary = self.get_compressed_summary()
compressed_summary_tokens = self._msg_handler.count_str_token(compressed_summary)
compressed_summary_tokens = await self._msg_handler.count_str_token(compressed_summary)
# Build per-message token details using AsMsgHandler
messages_detail = [self._msg_handler.stat_message(msg) for msg in messages]
messages_detail = [await self._msg_handler.stat_message(msg) for msg in messages]
# Calculate total message tokens from stats
messages_tokens = sum(stat.total_tokens for stat in messages_detail)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,248 @@
# -*- coding: utf-8 -*-
"""Build role snapshot + refs from Playwright aria_snapshot."""
import re
from typing import Any
INTERACTIVE_ROLES = frozenset(
{
"button",
"link",
"textbox",
"checkbox",
"radio",
"combobox",
"listbox",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"option",
"searchbox",
"slider",
"spinbutton",
"switch",
"tab",
"treeitem",
},
)
CONTENT_ROLES = frozenset(
{
"heading",
"cell",
"gridcell",
"columnheader",
"rowheader",
"listitem",
"article",
"region",
"main",
"navigation",
},
)
STRUCTURAL_ROLES = frozenset(
{
"generic",
"group",
"list",
"table",
"row",
"rowgroup",
"grid",
"treegrid",
"menu",
"menubar",
"toolbar",
"tablist",
"tree",
"directory",
"document",
"application",
"presentation",
"none",
},
)
def _get_indent_level(line: str) -> int:
m = re.match(r"^(\s*)", line)
return int(len(m.group(1)) / 2) if m else 0
def _create_tracker() -> dict[str, Any]:
counts: dict[str, int] = {}
refs_by_key: dict[str, list[str]] = {}
def get_key(role: str, name: str | None) -> str:
return f"{role}:{name or ''}"
def get_next_index(role: str, name: str | None) -> int:
key = get_key(role, name)
current = counts.get(key, 0)
counts[key] = current + 1
return current
def track_ref(role: str, name: str | None, ref: str) -> None:
key = get_key(role, name)
refs_by_key.setdefault(key, []).append(ref)
def get_duplicate_keys() -> set[str]:
return {k for k, refs in refs_by_key.items() if len(refs) > 1}
return {
"get_next_index": get_next_index,
"track_ref": track_ref,
"get_duplicate_keys": get_duplicate_keys,
"get_key": get_key,
}
def _remove_nth_from_non_duplicates(
refs: dict[str, dict],
tracker: dict,
) -> None:
dup_keys = tracker["get_duplicate_keys"]()
for _, data in list(refs.items()):
key = tracker["get_key"](data["role"], data.get("name"))
if key not in dup_keys and "nth" in data:
del data["nth"]
def _compact_tree(tree: str) -> str:
lines = tree.split("\n")
result = []
for i, line in enumerate(lines):
if "[ref=" in line:
result.append(line)
continue
if ":" in line and not line.rstrip().endswith(":"):
result.append(line)
continue
current_indent = _get_indent_level(line)
has_relevant = False
for j in range(i + 1, len(lines)):
if _get_indent_level(lines[j]) <= current_indent:
break
if "[ref=" in lines[j]:
has_relevant = True
break
if has_relevant:
result.append(line)
return "\n".join(result)
def _process_line( # pylint: disable=too-many-return-statements
line: str,
refs: dict[str, dict],
options: dict[str, Any],
tracker: dict,
next_ref: Any,
) -> str | None:
depth = _get_indent_level(line)
max_depth_val = options.get("maxDepth")
if max_depth_val is not None and depth > max_depth_val:
return None
m = re.match(r'^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$', line)
if not m:
return None if options.get("interactive") else line
prefix, role_raw, name, suffix = m.groups()
if role_raw.startswith("/"):
return None if options.get("interactive") else line
role = role_raw.lower()
is_interactive = role in INTERACTIVE_ROLES
is_content = role in CONTENT_ROLES
is_structural = role in STRUCTURAL_ROLES
if options.get("interactive") and not is_interactive:
return None
if options.get("compact") and is_structural and not name:
return None
should_have_ref = is_interactive or (is_content and name)
if not should_have_ref:
return line
ref = next_ref()
nth = tracker["get_next_index"](role, name)
tracker["track_ref"](role, name, ref)
refs[ref] = {"role": role, "name": name, "nth": nth}
enhanced = f"{prefix}{role_raw}"
if name:
enhanced += f' "{name}"'
enhanced += f" [ref={ref}]"
if nth is not None and nth > 0:
enhanced += f" [nth={nth}]"
if suffix:
enhanced += suffix
return enhanced
def build_role_snapshot_from_aria(
aria_snapshot: str,
*,
interactive: bool = False,
compact: bool = False,
max_depth: int | None = None,
) -> tuple[str, dict[str, dict]]:
"""Build snapshot + refs from Playwright locator.aria_snapshot() output."""
options = {
"interactive": interactive,
"compact": compact,
"maxDepth": max_depth,
}
lines = aria_snapshot.split("\n")
refs: dict[str, dict] = {}
tracker = _create_tracker()
counter = [0]
def next_ref() -> str:
counter[0] += 1
return f"e{counter[0]}"
if options.get("interactive"):
result_lines = []
for line in lines:
depth = _get_indent_level(line)
max_d = options.get("maxDepth")
if max_d is not None and depth > max_d:
continue
m = re.match(r'^(\s*-\s*)(\w+)(?:\s+"([^"]*)")?(.*)$', line)
if not m:
continue
_, role_raw, name, suffix = m.groups()
if role_raw.startswith("/"):
continue
role = role_raw.lower()
if role not in INTERACTIVE_ROLES:
continue
ref = next_ref()
nth = tracker["get_next_index"](role, name)
tracker["track_ref"](role, name, ref)
refs[ref] = {"role": role, "name": name, "nth": nth}
enhanced = f"- {role_raw}"
if name:
enhanced += f' "{name}"'
enhanced += f" [ref={ref}]"
if nth is not None and nth > 0:
enhanced += f" [nth={nth}]"
if "[" in suffix:
enhanced += suffix
result_lines.append(enhanced)
_remove_nth_from_non_duplicates(refs, tracker)
snapshot = "\n".join(result_lines) or "(no interactive elements)"
return snapshot, refs
result_lines = []
for line in lines:
processed = _process_line(line, refs, options, tracker, next_ref)
if processed is not None:
result_lines.append(processed)
_remove_nth_from_non_duplicates(refs, tracker)
tree = "\n".join(result_lines) or "(empty)"
snapshot = _compact_tree(tree) if options.get("compact") else tree
return snapshot, refs

View file

@ -3,12 +3,15 @@
import os
from pathlib import Path
from loguru import logger
from ....core import RuntimeContext
from ....core.op import BaseTool
from ....core.schema import ToolCall
from ....core.utils import get_logger
logger = get_logger()
class MemoryGet(BaseTool):
"""Read specific snippets from memory files."""
@ -109,5 +112,5 @@ class MemoryGet(BaseTool):
except Exception as e:
# Return error message to LLM instead of raising
error_msg = f"{self.__class__.__name__} failed: {str(e)}"
logger.error(error_msg)
logger.exception(error_msg)
return await self.after_execute(error_msg)

View file

@ -2,12 +2,14 @@
import json
from loguru import logger
from ....core.enumeration import MemorySource
from ....core.op import BaseTool
from ....core.runtime_context import RuntimeContext
from ....core.schema import ToolCall
from ....core.utils import get_logger
logger = get_logger()
class MemorySearch(BaseTool):
@ -108,5 +110,5 @@ class MemorySearch(BaseTool):
except Exception as e:
# Return error message to LLM instead of raising
error_msg = f"{self.__class__.__name__} failed: {str(e)}"
logger.error(error_msg)
logger.exception(error_msg)
return await self.after_execute(error_msg)

View file

@ -6,9 +6,9 @@ from agentscope.message import Msg
from agentscope.token import HuggingFaceTokenCounter
from ....core.schema import AsMsgStat, AsBlockStat
from ....core.utils import get_std_logger
from ....core.utils import get_logger
logger = get_std_logger()
logger = get_logger()
class AsMsgHandler:
@ -17,7 +17,7 @@ class AsMsgHandler:
def __init__(self, token_counter: HuggingFaceTokenCounter):
self._token_counter = token_counter
def count_str_token(self, text: str) -> int:
async def count_str_token(self, text: str) -> int:
"""Count tokens in a string.
Args:
@ -30,19 +30,19 @@ class AsMsgHandler:
return 0
try:
token_ids = self._token_counter.tokenizer.encode(text)
token_count = len(token_ids)
token_count = await self._token_counter.count(messages=[], text=text)
assert token_count > 0, "Invalid token count"
return token_count
except Exception as e:
estimated_tokens = len(text.encode("utf-8")) // 4
estimated_tokens = int(len(text.encode("utf-8")) / 3.75)
logger.warning(f"Failed to count string tokens: {text}, e={e}")
return estimated_tokens
def _format_tool_result_output(self, output: str | list[dict]) -> tuple[str, int]:
async def _format_tool_result_output(self, output: str | list[dict]) -> tuple[str, int]:
"""Convert tool result output to string."""
if isinstance(output, str):
return output, self.count_str_token(output)
return output, await self.count_str_token(output)
textual_parts = []
total_token_count = 0
@ -50,8 +50,7 @@ class AsMsgHandler:
try:
if not isinstance(block, dict) or "type" not in block:
logger.warning(
"Invalid block: %s, expected a dict with 'type' key, skipped.",
block,
f"Invalid block: {block}, expected a dict with 'type' key, skipped.",
)
continue
@ -59,7 +58,7 @@ class AsMsgHandler:
if block_type == "text":
textual_parts.append(block.get("text", ""))
total_token_count += self.count_str_token(textual_parts[-1])
total_token_count += await self.count_str_token(textual_parts[-1])
elif block_type in ["image", "audio", "video"]:
source = block.get("source", {})
@ -68,31 +67,28 @@ class AsMsgHandler:
total_token_count += len(data) // 4 if data else 10
else:
url = source.get("url", "")
total_token_count += self.count_str_token(url) if url else 10
total_token_count += await self.count_str_token(url) if url else 10
textual_parts.append(f"[{block_type}] {url}")
elif block_type == "file":
file_path = block.get("path", "") or block.get("url", "")
file_name = block.get("name", file_path)
textual_parts.append(f"[file] {file_name}: {file_path}")
total_token_count += self.count_str_token(file_path)
total_token_count += await self.count_str_token(file_path)
else:
logger.warning(
"Unsupported block type '%s' in tool result, skipped.",
block_type,
f"Unsupported block type '{block_type}' in tool result, skipped.",
)
except Exception as e:
logger.warning(
"Failed to process block %s: %s, skipped.",
block,
e,
f"Failed to process block {block}: {e}, skipped.",
)
return "\n".join(textual_parts), total_token_count
def stat_message(self, message: Msg) -> AsMsgStat:
async def stat_message(self, message: Msg) -> AsMsgStat:
"""Analyze a message and generate block statistics."""
blocks = []
if isinstance(message.content, str):
@ -100,7 +96,7 @@ class AsMsgHandler:
AsBlockStat(
block_type="text",
text=message.content,
token_count=self.count_str_token(message.content),
token_count=await self.count_str_token(message.content),
),
)
return AsMsgStat(
@ -111,25 +107,12 @@ class AsMsgHandler:
metadata=message.metadata or {},
)
if not isinstance(message.content, list):
logger.warning(
"Unexpected message.content type %s, expected str or list, returning empty stat.",
type(message.content),
)
return AsMsgStat(
name=message.name or message.role,
role=message.role,
content=blocks,
timestamp=message.timestamp or "",
metadata=message.metadata or {},
)
for block in message.content:
block_type = block.get("type", "unknown")
if block_type == "text":
text = block.get("text", "")
token_count = self.count_str_token(text)
token_count = await self.count_str_token(text)
blocks.append(
AsBlockStat(
block_type=block_type,
@ -140,7 +123,7 @@ class AsMsgHandler:
elif block_type == "thinking":
thinking = block.get("thinking", "")
token_count = self.count_str_token(thinking)
token_count = await self.count_str_token(thinking)
blocks.append(
AsBlockStat(
block_type=block_type,
@ -156,7 +139,7 @@ class AsMsgHandler:
data = source.get("data", "")
token_count = len(data) // 4 if data else 10
else:
token_count = self.count_str_token(url) if url else 10
token_count = await self.count_str_token(url) if url else 10
blocks.append(
AsBlockStat(
block_type=block_type,
@ -173,7 +156,7 @@ class AsMsgHandler:
input_str = json.dumps(tool_input, ensure_ascii=False)
except (TypeError, ValueError):
input_str = str(tool_input)
token_count = self.count_str_token(tool_name + input_str)
token_count = await self.count_str_token(tool_name + input_str)
blocks.append(
AsBlockStat(
block_type=block_type,
@ -187,7 +170,7 @@ class AsMsgHandler:
elif block_type == "tool_result":
tool_name = block.get("name", "")
output = block.get("output", "")
formatted_output, token_count = self._format_tool_result_output(output)
formatted_output, token_count = await self._format_tool_result_output(output)
blocks.append(
AsBlockStat(
block_type=block_type,
@ -199,7 +182,7 @@ class AsMsgHandler:
)
else:
logger.warning("Unsupported block type %s, skipped.", block_type)
logger.warning(f"Unsupported block type {block_type}, skipped.")
return AsMsgStat(
name=message.name or message.role,
@ -209,11 +192,15 @@ class AsMsgHandler:
metadata=message.metadata or {},
)
def count_msgs_token(self, messages: list[Msg]) -> int:
async def count_msgs_token(self, messages: list[Msg]) -> int:
"""Count total token count of a list of messages."""
return sum(self.stat_message(msg).total_tokens for msg in messages)
total = 0
for msg in messages:
stat = await self.stat_message(msg)
total += stat.total_tokens
return total
def format_msgs_to_str(
async def format_msgs_to_str(
self,
messages: list[Msg],
memory_compact_threshold: int,
@ -236,25 +223,22 @@ class AsMsgHandler:
total_token_count = 0
for i in range(len(messages) - 1, -1, -1):
stat = self.stat_message(messages[i])
stat = await self.stat_message(messages[i])
formatted_content = stat.format(include_thinking=include_thinking)
content_token_count = self.count_str_token(formatted_content)
content_token_count = await self.count_str_token(formatted_content)
is_latest = i == len(messages) - 1
if not is_latest and total_token_count + content_token_count > memory_compact_threshold:
logger.info(
"Skipping older messages: adding %d tokens would exceed threshold %d (current: %d)",
content_token_count,
memory_compact_threshold,
total_token_count,
f"Skipping older messages: adding {content_token_count} tokens would exceed threshold "
f"{memory_compact_threshold} (current: {total_token_count})",
)
break
if is_latest and content_token_count > memory_compact_threshold:
logger.warning(
"Latest message alone (%d tokens) exceeds threshold %d, including it anyway.",
content_token_count,
memory_compact_threshold,
f"Latest message alone ({content_token_count} tokens) exceeds threshold "
f"{memory_compact_threshold}, including it anyway.",
)
formatted_parts.append(formatted_content)
@ -286,7 +270,7 @@ class AsMsgHandler:
return tool_use_ids == tool_result_ids
def context_check(
async def context_check(
self,
messages: list[Msg],
memory_compact_threshold: int,
@ -315,7 +299,7 @@ class AsMsgHandler:
msg_stats: list[tuple[Msg, AsMsgStat]] = []
total_tokens = 0
for msg in messages:
stat = self.stat_message(msg)
stat = await self.stat_message(msg)
msg_stats.append((msg, stat))
total_tokens += stat.total_tokens
@ -354,11 +338,8 @@ class AsMsgHandler:
# Check if adding this message would exceed reserve limit
if accumulated_tokens + stat.total_tokens > memory_compact_reserve:
logger.info(
"Context check: adding message %d with %d tokens would exceed reserve %d (current: %d)",
i,
stat.total_tokens,
memory_compact_reserve,
accumulated_tokens,
f"Context check: adding message {i} with {stat.total_tokens} tokens would exceed reserve "
f"{memory_compact_reserve} (current: {accumulated_tokens})",
)
break
@ -383,11 +364,8 @@ class AsMsgHandler:
# Check if we can fit this message plus its dependencies within reserve
if accumulated_tokens + stat.total_tokens + extra_tokens > memory_compact_reserve:
logger.info(
"Context check: message %d requires %d extra tokens for tool_use dependencies, "
"total would exceed reserve %d",
i,
extra_tokens,
memory_compact_reserve,
f"Context check: message {i} requires {extra_tokens} extra tokens for tool_use dependencies, "
f"total would exceed reserve {memory_compact_reserve}",
)
break
@ -410,16 +388,11 @@ class AsMsgHandler:
tools_aligned = self.validate_tool_ids_alignment(messages_to_keep)
logger.info(
"Context check result: %d messages to compact, %d messages to keep, "
"total tokens: %d, threshold: %d, reserve: %d, kept tokens: %d, "
"tools_aligned: %s",
len(messages_to_compact),
len(messages_to_keep),
total_tokens,
memory_compact_threshold,
memory_compact_reserve,
accumulated_tokens,
tools_aligned,
f"Context check result: {len(messages_to_compact)} messages to compact, "
f"{len(messages_to_keep)} messages to keep, "
f"total tokens: {total_tokens}, threshold: {memory_compact_threshold}, "
f"reserve: {memory_compact_reserve}, kept tokens: {accumulated_tokens}, "
f"tools_aligned: {tools_aligned}",
)
return messages_to_compact, messages_to_keep, tools_aligned

View file

@ -25,7 +25,7 @@ from agentscope.tool import Toolkit, ToolResponse
from .config import ReMeConfigParser
from .core import Application
from .core.utils import get_hf_token_counter, get_std_logger
from .core.utils import get_logger
from .memory.file_based import ReMeInMemoryMemory
from .memory.file_based.components import (
Compactor,
@ -36,7 +36,7 @@ from .memory.file_based.components import (
from .memory.file_based.tools import FileIO, MemorySearch
from .memory.file_based.utils import AsMsgHandler
logger = get_std_logger()
logger = get_logger()
class ReMeLight(Application):
@ -58,6 +58,7 @@ class ReMeLight(Application):
working_path (Path): Absolute path to the working directory.
memory_path (Path): Path to the memory storage directory.
tool_result_path (Path): Path to the tool result storage directory.
dialog_path (Path): Path to the dialog storage directory for raw conversation records.
vector_weight (float): Weight for vector search in hybrid search (0-1).
candidate_multiplier (float): Multiplier for candidate retrieval count.
tool_result_threshold (int): Character threshold for tool result compaction.
@ -120,6 +121,7 @@ class ReMeLight(Application):
- {working_dir}/ - Root working directory
- {working_dir}/memory/ - Memory storage files
- {working_dir}/tool_result/ - Compacted tool result files
- {working_dir}/dialog/ - Raw conversation records
"""
# Initialize working directory structure
self.working_path = Path(working_dir).absolute()
@ -128,6 +130,8 @@ class ReMeLight(Application):
self.memory_path.mkdir(parents=True, exist_ok=True)
self.tool_result_path = self.working_path / "tool_result"
self.tool_result_path.mkdir(parents=True, exist_ok=True)
self.dialog_path = self.working_path / "dialog"
self.dialog_path.mkdir(parents=True, exist_ok=True)
self.vector_weight: float = vector_weight
self.candidate_multiplier: float = candidate_multiplier
@ -283,7 +287,7 @@ class ReMeLight(Application):
messages: list[Msg],
memory_compact_threshold: int,
memory_compact_reserve: int = 10000,
token_counter: HuggingFaceTokenCounter | None = None,
as_token_counter: str | HuggingFaceTokenCounter = "default",
) -> tuple[list[Msg], list[Msg], bool]:
"""
Check context size and determine if compaction is needed.
@ -298,8 +302,7 @@ class ReMeLight(Application):
compaction. Messages exceeding this threshold will be split.
memory_compact_reserve (int): Token count to reserve for recent messages
to keep in context. Defaults to 10000 tokens.
token_counter (HuggingFaceTokenCounter | None): Token counter for
measuring message length. If None, uses default HuggingFace counter.
as_token_counter (str | HuggingFaceTokenCounter): The token counter to use.
Returns:
tuple[list[Msg], list[Msg], bool]: A tuple containing:
@ -315,13 +318,10 @@ class ReMeLight(Application):
- is_valid=False indicates tool_use and tool_result are misaligned.
"""
try:
if token_counter is None:
token_counter = get_hf_token_counter()
checker = ContextChecker(
memory_compact_threshold=memory_compact_threshold,
memory_compact_reserve=memory_compact_reserve,
token_counter=token_counter,
as_token_counter=as_token_counter,
)
return await checker.call(
@ -338,7 +338,7 @@ class ReMeLight(Application):
messages: list[Msg],
as_llm: str | ChatModelBase = "default",
as_llm_formatter: str | FormatterBase = "default",
token_counter: HuggingFaceTokenCounter | None = None,
as_token_counter: str | HuggingFaceTokenCounter = "default",
language: str = "zh",
max_input_length: float = 128 * 1024,
compact_ratio: float = 0.7,
@ -357,8 +357,8 @@ class ReMeLight(Application):
to use for summarization. Defaults to "default".
as_llm_formatter (str | FormatterBase): Formatter for the language model.
Defaults to "default".
token_counter (HuggingFaceTokenCounter | None): Token counter for
measuring message length. If None, uses default HuggingFace counter.
as_token_counter (str | HuggingFaceTokenCounter): Token counter for
measuring message length. Defaults to "default".
language (str): Language for the summary output. "zh" for Chinese,
any other value for English. Defaults to "zh".
max_input_length (float): Maximum input length in tokens for the model.
@ -373,14 +373,11 @@ class ReMeLight(Application):
an error occurred during compaction.
"""
try:
if token_counter is None:
token_counter = get_hf_token_counter()
compactor = Compactor(
memory_compact_threshold=self.calculate_memory_compact_threshold(max_input_length, compact_ratio),
token_counter=token_counter,
as_llm=as_llm,
as_llm_formatter=as_llm_formatter,
as_token_counter=as_token_counter,
language=language if language == "zh" else "",
)
@ -400,7 +397,7 @@ class ReMeLight(Application):
messages: list[Msg],
as_llm: str | ChatModelBase = "default",
as_llm_formatter: str | FormatterBase = "default",
token_counter: HuggingFaceTokenCounter | None = None,
as_token_counter: str | HuggingFaceTokenCounter = "default",
toolkit: Toolkit | None = None,
language: str = "zh",
max_input_length: float = 128 * 1024,
@ -419,8 +416,8 @@ class ReMeLight(Application):
for summarization. Defaults to "default".
as_llm_formatter (str | FormatterBase): Formatter for the language model.
Defaults to "default".
token_counter (HuggingFaceTokenCounter | None): Token counter for
measuring message length. If None, uses default HuggingFace counter.
as_token_counter (str | HuggingFaceTokenCounter): Token counter for
measuring message length. Defaults to "default".
toolkit (Toolkit | None): Toolkit with file operations for persisting
summaries. If None, creates a default toolkit with read/write/edit.
language (str): Language for the summary output. "zh" for Chinese,
@ -438,9 +435,6 @@ class ReMeLight(Application):
using the provided or default toolkit.
"""
try:
if token_counter is None:
token_counter = get_hf_token_counter()
if toolkit is None:
toolkit = Toolkit()
file_io = FileIO(working_dir=str(self.working_path))
@ -452,10 +446,10 @@ class ReMeLight(Application):
working_dir=str(self.working_path),
memory_dir=str(self.memory_path),
memory_compact_threshold=self.calculate_memory_compact_threshold(max_input_length, compact_ratio),
token_counter=token_counter,
toolkit=toolkit,
as_llm=as_llm,
as_llm_formatter=as_llm_formatter,
as_token_counter=as_token_counter,
language=language if language == "zh" else "",
)
@ -479,7 +473,7 @@ class ReMeLight(Application):
Supported arguments include:
- as_llm: Language model identifier or instance
- as_llm_formatter: Formatter for the language model
- token_counter: Token counter instance
- as_token_counter: Token counter instance
- toolkit: Toolkit for file operations
- language: Output language ("zh" or other)
- max_input_length: Maximum input token length
@ -509,6 +503,16 @@ class ReMeLight(Application):
task = asyncio.create_task(self.summary_memory(messages=messages, **kwargs))
self.summary_tasks.append(task)
@property
def default_as_token_counter(self) -> HuggingFaceTokenCounter:
"""
Get the default token counter for the memory.
Returns:
HuggingFaceTokenCounter: The default token counter instance.
"""
return self.service_context.as_token_counters["default"]
async def pre_reasoning_hook(
self,
messages: list[Msg],
@ -516,7 +520,7 @@ class ReMeLight(Application):
compressed_summary: str = "",
as_llm: str | ChatModelBase = "default",
as_llm_formatter: str | FormatterBase = "default",
token_counter: HuggingFaceTokenCounter | None = None,
as_token_counter: str | HuggingFaceTokenCounter = "default",
toolkit: Toolkit | None = None,
language: str = "zh",
max_input_length: float = 128 * 1024,
@ -542,8 +546,8 @@ class ReMeLight(Application):
Defaults to "default".
as_llm_formatter (str | FormatterBase): Formatter for the language model.
Defaults to "default".
token_counter (HuggingFaceTokenCounter | None): Token counter for
measuring content length. If None, uses default counter.
as_token_counter (str | HuggingFaceTokenCounter): Token counter for
measuring content length. Defaults to "default".
toolkit (Toolkit | None): Toolkit for file operations in summarization.
Defaults to None.
language (str): Language for generated summaries. Defaults to "zh".
@ -568,13 +572,10 @@ class ReMeLight(Application):
- Tool results in recent messages (keep_n) are not compacted
- Returns original messages unchanged if no compaction is needed
"""
if token_counter is None:
token_counter = get_hf_token_counter()
msg_handler = AsMsgHandler(self.default_as_token_counter)
msg_handler = AsMsgHandler(token_counter=token_counter)
system_token_count = msg_handler.count_str_token(system_prompt)
compressed_token_count = msg_handler.count_str_token(compressed_summary)
system_token_count = await msg_handler.count_str_token(system_prompt)
compressed_token_count = await msg_handler.count_str_token(compressed_summary)
memory_compact_threshold = self.calculate_memory_compact_threshold(max_input_length, compact_ratio)
left_compact_threshold = memory_compact_threshold - (system_token_count + compressed_token_count)
logger.info(f"Left compact threshold: {left_compact_threshold}")
@ -587,7 +588,7 @@ class ReMeLight(Application):
messages=messages,
memory_compact_threshold=left_compact_threshold,
memory_compact_reserve=memory_compact_reserve,
token_counter=token_counter,
as_token_counter=as_token_counter,
)
if not messages_to_compact:
@ -601,7 +602,7 @@ class ReMeLight(Application):
messages=messages_to_compact,
as_llm=as_llm,
as_llm_formatter=as_llm_formatter,
token_counter=token_counter,
as_token_counter=as_token_counter,
toolkit=toolkit,
language=language,
max_input_length=max_input_length,
@ -612,7 +613,7 @@ class ReMeLight(Application):
messages=messages_to_compact,
as_llm=as_llm,
as_llm_formatter=as_llm_formatter,
token_counter=token_counter,
as_token_counter=as_token_counter,
language=language,
max_input_length=max_input_length,
compact_ratio=compact_ratio,
@ -755,28 +756,31 @@ class ReMeLight(Application):
],
)
@staticmethod
def get_in_memory_memory(token_counter: HuggingFaceTokenCounter | None = None):
def get_in_memory_memory(self, as_token_counter: HuggingFaceTokenCounter | None = None):
"""
Create and return an in-memory memory instance.
Factory method to create a ReMeInMemoryMemory instance configured with
the specified token counter. This memory instance stores data in RAM
without persistence, suitable for temporary or session-based storage.
the specified token counter. This memory instance stores messages in RAM
during the session, and automatically persists them to dialog_path when
messages are compressed or cleared.
Args:
token_counter (HuggingFaceTokenCounter | None): Token counter for
measuring content length in the memory. If None, creates a
default HuggingFace token counter.
as_token_counter (HuggingFaceTokenCounter): Token counter for
measuring content length in the memory.
Returns:
ReMeInMemoryMemory: A new in-memory memory instance ready for use.
The instance is configured with self.dialog_path for persistence.
Example:
>>> memory = ReMeLight.get_in_memory_memory()
>>> # Use memory for temporary storage during a session
Note:
- Messages are stored in RAM during active session
- When messages are compressed via mark_messages_compressed(), they
are persisted to {dialog_path}/{date}.jsonl files
- When clear_content() is called, all messages are persisted before
clearing from memory
"""
if token_counter is None:
token_counter = get_hf_token_counter()
return ReMeInMemoryMemory(token_counter=token_counter)
return ReMeInMemoryMemory(
token_counter=as_token_counter or self.default_as_token_counter,
dialog_path=str(self.dialog_path),
)

View file

@ -9,10 +9,10 @@ from test_utils import (
get_token_counter,
)
from reme.core.utils import get_std_logger
from reme.core.utils import get_logger
from reme.memory.file_based.components import Compactor
logger = get_std_logger()
logger = get_logger()
# ANSI 颜色码
@ -96,7 +96,7 @@ def create_compactor():
"""Create a Compactor instance for testing."""
return Compactor(
memory_compact_threshold=4000,
token_counter=get_token_counter(),
as_token_counter=get_token_counter(),
as_llm=get_dash_chat_model(),
as_llm_formatter=get_formatter(),
language="zh",
@ -282,7 +282,7 @@ def test_low_threshold():
"""Test compaction with low memory threshold."""
compactor = Compactor(
memory_compact_threshold=500,
token_counter=get_token_counter(),
as_token_counter=get_token_counter(),
as_llm=get_dash_chat_model(),
as_llm_formatter=get_formatter(),
)
@ -305,7 +305,7 @@ def test_high_threshold():
"""Test compaction with high memory threshold."""
compactor = Compactor(
memory_compact_threshold=10000,
token_counter=get_token_counter(),
as_token_counter=get_token_counter(),
as_llm=get_dash_chat_model(),
as_llm_formatter=get_formatter(),
)

View file

@ -1,12 +1,14 @@
"""Tests for AsMsgHandler.context_check method."""
import asyncio
from agentscope.message import Msg
from test_utils import get_token_counter
from reme.core.utils import get_std_logger
from reme.core.utils import get_logger
from reme.memory.file_based.utils import AsMsgHandler
logger = get_std_logger()
logger = get_logger()
# ANSI color codes
@ -78,7 +80,7 @@ def verify_context_check_invariants(
AssertionError: If any invariant is violated
"""
# Calculate total tokens of original messages
total_tokens = sum(handler.stat_message(m).total_tokens for m in messages)
total_tokens = sum(asyncio.run(handler.stat_message(m)).total_tokens for m in messages)
# 1. Threshold requirement check
if total_tokens <= memory_compact_threshold:
@ -93,7 +95,7 @@ def verify_context_check_invariants(
)
# 2. Reserve requirement check
kept_tokens = sum(handler.stat_message(m).total_tokens for m in to_keep)
kept_tokens = sum(asyncio.run(handler.stat_message(m)).total_tokens for m in to_keep)
assert kept_tokens <= memory_compact_reserve or len(to_keep) == 0, (
f"[{test_name}] Reserve violation: kept_tokens ({kept_tokens}) > " f"reserve ({memory_compact_reserve})"
)
@ -220,10 +222,12 @@ def test_empty_messages():
handler = create_handler()
messages = []
threshold, reserve = 1000, 500
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
),
)
assert not to_compact, f"Expected empty compact list, got: {to_compact}"
assert to_keep == [], f"Expected empty keep list, got: {to_keep}"
@ -240,10 +244,12 @@ def test_below_threshold_returns_all():
create_user_msg("How are you?"),
]
threshold, reserve = 10000, 5000
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Very high threshold
memory_compact_reserve=reserve,
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Very high threshold
memory_compact_reserve=reserve,
),
)
assert not to_compact, f"Expected empty compact list, got: {len(to_compact)}"
assert len(to_keep) == 3, f"Expected 3 messages to keep, got: {len(to_keep)}"
@ -271,10 +277,12 @@ def test_above_threshold_triggers_compaction():
create_assistant_msg("Fourth message " * 100),
]
threshold, reserve = 100, 200
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Low threshold to trigger compaction
memory_compact_reserve=reserve,
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Low threshold to trigger compaction
memory_compact_reserve=reserve,
),
)
# Should have some messages compacted and some kept
assert len(to_compact) + len(to_keep) == len(messages), "Total messages should match"
@ -302,10 +310,12 @@ def test_message_order_preserved():
create_user_msg("Fifth " * 10),
]
threshold, reserve = 100, 150
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Low threshold
memory_compact_reserve=reserve,
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Low threshold
memory_compact_reserve=reserve,
),
)
# Check order preservation - compact messages should appear first in original
all_messages = to_compact + to_keep
@ -333,10 +343,12 @@ def test_single_message_below_threshold():
handler = create_handler()
messages = [create_user_msg("Short message")]
threshold, reserve = 1000, 500
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
),
)
assert not to_compact, "Should not compact single message below threshold"
assert len(to_keep) == 1, "Should keep the single message"
@ -358,10 +370,12 @@ def test_single_message_above_threshold():
long_content = "Very long message " * 1000
messages = [create_user_msg(long_content)]
threshold, reserve = 10, 5
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Very low threshold
memory_compact_reserve=reserve, # Even lower reserve
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Very low threshold
memory_compact_reserve=reserve, # Even lower reserve
),
)
# Message exceeds both threshold and reserve, so it's compacted
assert len(to_compact) == 1, "Single large message should be compacted"
@ -386,10 +400,12 @@ def test_reserve_zero():
create_assistant_msg("Hi there!"),
]
threshold, reserve = 1, 0
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Trigger compaction
memory_compact_reserve=reserve, # Zero reserve
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Trigger compaction
memory_compact_reserve=reserve, # Zero reserve
),
)
# All messages should be compacted since reserve is 0
assert len(to_compact) == 2, f"All messages should be compacted, got {len(to_compact)}"
@ -403,10 +419,12 @@ def test_threshold_zero():
handler = create_handler()
messages = [create_user_msg("A")] # Minimal message
threshold, reserve = 0, 1000
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Zero threshold - always triggers
memory_compact_reserve=reserve,
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Zero threshold - always triggers
memory_compact_reserve=reserve,
),
)
# Even minimal message triggers compaction with threshold=0
# But reserve is high so it should be kept
@ -421,15 +439,17 @@ def test_exact_threshold_boundary():
messages = [create_user_msg("Test message")]
# Get exact token count
stat = handler.stat_message(messages[0])
stat = asyncio.run(handler.stat_message(messages[0]))
exact_tokens = stat.total_tokens
threshold, reserve = exact_tokens, exact_tokens
# Test at exact boundary
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Exactly at boundary
memory_compact_reserve=reserve,
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Exactly at boundary
memory_compact_reserve=reserve,
),
)
# At exact boundary (<=), should not trigger compaction
assert not to_compact, "Should not compact at exact boundary"
@ -454,10 +474,12 @@ def test_reserve_larger_than_threshold():
create_assistant_msg("Message two " * 20),
]
threshold, reserve = 50, 10000
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Low threshold
memory_compact_reserve=reserve, # High reserve
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Low threshold
memory_compact_reserve=reserve, # High reserve
),
)
# Compaction triggered but reserve can hold everything
# Total messages should be preserved
@ -489,10 +511,12 @@ def test_tool_use_result_paired():
create_assistant_msg("The tool returned results"),
]
threshold, reserve = 50, 1000
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Trigger compaction
memory_compact_reserve=reserve, # Enough for tool pair
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Trigger compaction
memory_compact_reserve=reserve, # Enough for tool pair
),
)
# If tool_result is kept, tool_use should also be kept
@ -522,10 +546,12 @@ def test_tool_use_without_result():
create_assistant_msg("Something happened"),
]
threshold, reserve = 10, 1000
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
),
)
# Should not crash, just process normally
assert len(to_compact) + len(to_keep) == 3
@ -550,10 +576,12 @@ def test_tool_result_without_use():
create_assistant_msg("Got it"),
]
threshold, reserve = 10, 1000
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
),
)
# Should not crash even with orphan tool_result
assert len(to_compact) + len(to_keep) == 3
@ -583,10 +611,12 @@ def test_multiple_tool_pairs():
create_assistant_msg("All done"),
]
threshold, reserve = 50, 500
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
),
)
# Verify tool pairs integrity - for each kept tool_result, its tool_use should be kept
@ -630,10 +660,12 @@ def test_tool_dependency_causes_extra_inclusion():
create_assistant_msg("End"), # Small
]
threshold, reserve = 100, 500
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Trigger compaction
memory_compact_reserve=reserve, # Medium reserve
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Trigger compaction
memory_compact_reserve=reserve, # Medium reserve
),
)
# Check pair integrity
@ -671,10 +703,12 @@ def test_tool_dependency_exceeds_reserve():
create_assistant_msg("Last message"),
]
threshold, reserve = 10, 100
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Trigger compaction
memory_compact_reserve=reserve, # Small reserve - can't fit the pair
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Trigger compaction
memory_compact_reserve=reserve, # Small reserve - can't fit the pair
),
)
# The tool pair is too large, so it should be excluded or partially handled
@ -715,10 +749,12 @@ def test_interleaved_tool_pairs():
create_assistant_msg("Both done"),
]
threshold, reserve = 50, 1000
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
),
)
# Verify pair integrity for interleaved pairs
@ -756,10 +792,12 @@ def test_message_with_empty_content():
create_assistant_msg("Response"),
]
threshold, reserve = 1000, 500
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
),
)
assert len(to_compact) + len(to_keep) == 2
verify_context_check_invariants(
@ -782,10 +820,12 @@ def test_message_with_whitespace_only():
create_assistant_msg("Response"),
]
threshold, reserve = 1000, 500
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
),
)
assert len(to_compact) + len(to_keep) == 2
verify_context_check_invariants(
@ -806,10 +846,12 @@ def test_very_long_single_message():
huge_content = "x" * 100000 # Very long
messages = [create_user_msg(huge_content)]
threshold, reserve = 100, 1000
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
),
)
# Single huge message - either kept alone or compacted
assert len(to_compact) + len(to_keep) == 1
@ -830,10 +872,12 @@ def test_many_small_messages():
handler = create_handler()
messages = [create_user_msg(f"Msg {i}") for i in range(100)]
threshold, reserve = 100, 200
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Low threshold
memory_compact_reserve=reserve,
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Low threshold
memory_compact_reserve=reserve,
),
)
# Should compact older messages and keep recent ones
assert len(to_compact) + len(to_keep) == 100
@ -859,10 +903,12 @@ def test_unicode_content():
create_user_msg("日本語テスト 🇯🇵"),
]
threshold, reserve = 1000, 500
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
),
)
assert len(to_compact) + len(to_keep) == 3
verify_context_check_invariants(handler, messages, to_compact, to_keep, threshold, reserve, "test_unicode_content")
@ -877,10 +923,12 @@ def test_special_characters_content():
create_assistant_msg("More: \n\r\t\0 nulls and newlines"),
]
threshold, reserve = 1000, 500
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
),
)
assert len(to_compact) + len(to_keep) == 2
verify_context_check_invariants(
@ -909,13 +957,15 @@ def test_all_messages_fit_exactly_in_reserve():
]
# Calculate total tokens
total = sum(handler.stat_message(m).total_tokens for m in messages)
total = sum(asyncio.run(handler.stat_message(m)).total_tokens for m in messages)
threshold, reserve = total - 1, total
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Just below total to trigger
memory_compact_reserve=reserve, # Exactly fits all
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Just below total to trigger
memory_compact_reserve=reserve, # Exactly fits all
),
)
# All should be kept since reserve can hold everything
assert len(to_keep) == 2, f"All messages should fit in reserve, got {len(to_keep)}"
@ -941,14 +991,16 @@ def test_first_message_only_compacted():
]
# Calculate tokens to set appropriate reserve
small_msg_tokens = handler.stat_message(messages[1]).total_tokens
tiny_msg_tokens = handler.stat_message(messages[2]).total_tokens
small_msg_tokens = asyncio.run(handler.stat_message(messages[1])).total_tokens
tiny_msg_tokens = asyncio.run(handler.stat_message(messages[2])).total_tokens
threshold, reserve = 50, small_msg_tokens + tiny_msg_tokens + 10
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Low to trigger
memory_compact_reserve=reserve, # Fits last 2
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Low to trigger
memory_compact_reserve=reserve, # Fits last 2
),
)
assert len(to_compact) >= 1, "At least first message should be compacted"
@ -973,13 +1025,15 @@ def test_last_message_only_kept():
create_user_msg("Tiny"), # Only this fits
]
tiny_tokens = handler.stat_message(messages[2]).total_tokens
tiny_tokens = asyncio.run(handler.stat_message(messages[2])).total_tokens
threshold, reserve = 10, tiny_tokens + 5
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve, # Only fits last message
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve, # Only fits last message
),
)
if len(to_keep) == 1:
@ -1005,10 +1059,12 @@ def test_all_messages_compacted():
create_assistant_msg("Large message " * 100),
]
threshold, reserve = 10, 1
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Trigger compaction
memory_compact_reserve=reserve, # Too small for anything
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold, # Trigger compaction
memory_compact_reserve=reserve, # Too small for anything
),
)
assert len(to_compact) == 2, "All messages should be compacted"
assert len(to_keep) == 0, "No messages should be kept"
@ -1039,10 +1095,12 @@ def test_system_message():
create_assistant_msg("Hi there!"),
]
threshold, reserve = 1000, 500
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
),
)
assert len(to_compact) + len(to_keep) == 3
verify_context_check_invariants(handler, messages, to_compact, to_keep, threshold, reserve, "test_system_message")
@ -1061,10 +1119,12 @@ def test_mixed_roles():
Msg(name="helper", role="assistant", content="Another assistant message"),
]
threshold, reserve = 1000, 500
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
),
)
assert len(to_compact) + len(to_keep) == 5
verify_context_check_invariants(handler, messages, to_compact, to_keep, threshold, reserve, "test_mixed_roles")
@ -1085,10 +1145,12 @@ def test_tool_use_with_empty_id():
create_assistant_msg("Done"),
]
threshold, reserve = 10, 1000
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
),
)
# Should handle gracefully
assert len(to_compact) + len(to_keep) == 3
@ -1113,10 +1175,12 @@ def test_tool_result_with_empty_id():
create_assistant_msg("Noted"),
]
threshold, reserve = 10, 1000
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
),
)
# Should handle gracefully
assert len(to_compact) + len(to_keep) == 3
@ -1142,10 +1206,12 @@ def test_duplicate_tool_ids():
create_tool_result_msg("call_dup", "tool_b", "Result B"),
]
threshold, reserve = 10, 1000
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
),
)
# Should not crash with duplicate IDs
assert len(to_compact) + len(to_keep) == 4
@ -1181,10 +1247,12 @@ def test_message_with_multiple_tool_blocks():
create_tool_result_msg("call_3", "tool3", "Result 3"),
]
threshold, reserve = 10, 2000
to_compact, to_keep, _ = handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
to_compact, to_keep, _ = asyncio.run(
handler.context_check(
messages=messages,
memory_compact_threshold=threshold,
memory_compact_reserve=reserve,
),
)
assert len(to_compact) + len(to_keep) == 5
verify_context_check_invariants(

View file

@ -2,15 +2,16 @@
# pylint: disable=W0212
import asyncio
import sys
from agentscope.message import Msg
from test_utils import get_token_counter
from reme.core.utils import get_std_logger
from reme.core.utils import get_logger
from reme.memory.file_based.utils import AsMsgHandler
logger = get_std_logger()
logger = get_logger()
# ANSI 颜色码
@ -87,7 +88,7 @@ def verify_result_within_threshold(
# Calculate tokens of messages that were included in the result
included_tokens = 0
for msg in msgs:
stat = handler.stat_message(msg)
stat = asyncio.run(handler.stat_message(msg))
# Check if this message's content appears in the result
_ = stat.format(include_thinking=True) # Use True to check all content
# Simple heuristic: if the message content is in result, count its tokens
@ -98,10 +99,10 @@ def verify_result_within_threshold(
if block_type == "text" and block.get("text", "") in result:
msg_included = True
break
if block_type == "tool_use" and f"tool_call={block.get('name', '')}" in result:
if block_type == "tool_use" and f"<tool_use>{block.get('name', '')}" in result:
msg_included = True
break
if block_type == "tool_result" and f"tool_result={block.get('name', '')}" in result:
if block_type == "tool_result" and f"<tool_result>{block.get('name', '')}" in result:
msg_included = True
break
@ -216,7 +217,7 @@ def test_format_msgs_to_str_empty_list():
handler = create_handler()
threshold = 4000
msgs = []
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
assert result == "", f"Expected empty string for empty list, got: {result}"
verify_result_within_threshold(handler, result, threshold, "empty_list", msgs)
print_pass("test_format_msgs_to_str_empty_list")
@ -227,7 +228,7 @@ def test_format_msgs_to_str_single_message():
handler = create_handler()
threshold = 4000
msgs = [create_user_msg("Hello, how are you?")]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
assert "user:" in result, f"Expected 'user:' in result, got: {result}"
assert "Hello, how are you?" in result, f"Expected content in result, got: {result}"
@ -245,7 +246,7 @@ def test_format_msgs_to_str_multiple_messages():
create_user_msg("Tell me more."),
create_assistant_msg("Python is known for its readability."),
]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
assert "What is Python?" in result
assert "Python is a programming language." in result
@ -264,7 +265,7 @@ def test_format_msgs_to_str_message_order():
create_assistant_msg("Second message"),
create_user_msg("Third message"),
]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
# Find positions of each message
first_pos = result.find("First message")
@ -283,9 +284,9 @@ def test_format_msgs_to_str_with_tool_use():
handler = create_handler()
threshold = 4000
msgs = [create_tool_use_msg("read_file", {"path": "/test.txt"})]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
assert "tool_call=read_file" in result, f"Expected tool_call in result, got: {result}"
assert "<tool_use>read_file" in result, f"Expected tool_use in result, got: {result}"
verify_result_within_threshold(handler, result, threshold, "with_tool_use", msgs)
print_pass("test_format_msgs_to_str_with_tool_use")
@ -295,9 +296,9 @@ def test_format_msgs_to_str_with_tool_result():
handler = create_handler()
threshold = 4000
msgs = [create_tool_result_msg("read_file", "file content here")]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
assert "tool_result=read_file" in result, f"Expected tool_result in result, got: {result}"
assert "<tool_result>read_file" in result, f"Expected tool_result in result, got: {result}"
verify_result_within_threshold(handler, result, threshold, "with_tool_result", msgs)
print_pass("test_format_msgs_to_str_with_tool_result")
@ -307,9 +308,9 @@ def test_format_msgs_to_str_with_image():
handler = create_handler()
threshold = 4000
msgs = [create_image_msg("https://example.com/image.png")]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
assert "[image]" in result, f"Expected '[image]' in result, got: {result}"
assert "<image>" in result, f"Expected '<image>' in result, got: {result}"
verify_result_within_threshold(handler, result, threshold, "with_image", msgs)
print_pass("test_format_msgs_to_str_with_image")
@ -324,11 +325,11 @@ def test_format_msgs_to_str_conversation_flow():
create_tool_result_msg("read_file", "File content here"),
create_assistant_msg("The file contains: File content here"),
]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
assert "user:" in result
assert "tool_call=read_file" in result
assert "tool_result=read_file" in result
assert "<tool_use>read_file" in result
assert "<tool_result>read_file" in result
assert "assistant:" in result
verify_result_within_threshold(handler, result, threshold, "conversation_flow", msgs)
print_pass("test_format_msgs_to_str_conversation_flow")
@ -342,7 +343,7 @@ def test_format_msgs_to_str_thinking_excluded_by_default():
handler = create_handler()
threshold = 4000
msgs = [create_thinking_msg("Let me think about this...", "Here is my response")]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold, include_thinking=False)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold, include_thinking=False))
assert "Let me think about this" not in result, f"Thinking content should be excluded, got: {result}"
assert "Here is my response" in result, f"Text content should be included, got: {result}"
@ -355,7 +356,7 @@ def test_format_msgs_to_str_thinking_included():
handler = create_handler()
threshold = 4000
msgs = [create_thinking_msg("Let me think about this...", "Here is my response")]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold, include_thinking=True)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold, include_thinking=True))
assert "Let me think about this" in result, f"Thinking content should be included, got: {result}"
assert "<thinking>" in result, f"Expected thinking tag in result, got: {result}"
@ -370,16 +371,20 @@ def test_format_msgs_to_str_thinking_only_message():
msgs = [create_thinking_msg("Deep thoughts here")]
# With include_thinking=False
result_no_thinking = handler.format_msgs_to_str(
msgs,
memory_compact_threshold=threshold,
include_thinking=False,
result_no_thinking = asyncio.run(
handler.format_msgs_to_str(
msgs,
memory_compact_threshold=threshold,
include_thinking=False,
),
)
# With include_thinking=True
result_with_thinking = handler.format_msgs_to_str(
msgs,
memory_compact_threshold=threshold,
include_thinking=True,
result_with_thinking = asyncio.run(
handler.format_msgs_to_str(
msgs,
memory_compact_threshold=threshold,
include_thinking=True,
),
)
assert "Deep thoughts here" not in result_no_thinking
@ -401,7 +406,7 @@ def test_format_msgs_to_str_all_within_threshold():
create_assistant_msg("Short message 2"),
create_user_msg("Short message 3"),
]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
assert "Short message 1" in result
assert "Short message 2" in result
@ -419,7 +424,7 @@ def test_format_msgs_to_str_exceeds_threshold_truncate_older():
msgs.append(create_user_msg(f"Question {i}: " + "x" * 100))
msgs.append(create_assistant_msg(f"Answer {i}: " + "y" * 100))
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
# The newest messages should be present
assert (
@ -440,7 +445,7 @@ def test_format_msgs_to_str_single_message_exceeds_threshold():
msgs = [create_user_msg(long_text)]
# With very low threshold, even a single message won't fit
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
# The message should be skipped entirely since it exceeds threshold
assert result == "" or len(result) > 0, "Result should be empty or contain truncated content"
@ -457,7 +462,7 @@ def test_format_msgs_to_str_first_message_exceeds_threshold():
create_assistant_msg("Short response"), # New, short message
]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
# Newer message should be present
assert "Short response" in result, f"Expected newer message in result, got: {result}"
@ -466,15 +471,16 @@ def test_format_msgs_to_str_first_message_exceeds_threshold():
def test_format_msgs_to_str_threshold_zero():
"""Test with threshold of zero - no messages should be included."""
"""Test with threshold of zero - latest message is still included."""
handler = create_handler()
threshold = 0
msgs = [create_user_msg("Test message")]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
assert result == "", f"Expected empty string with zero threshold, got: {result}"
verify_result_within_threshold(handler, result, threshold, "threshold_zero", msgs)
# Latest message is included even with zero threshold (implementation behavior)
assert "Test message" in result, f"Expected message in result, got: {result}"
# Skip verify_result_within_threshold since latest message is always included
print_pass("test_format_msgs_to_str_threshold_zero")
@ -483,12 +489,12 @@ def test_format_msgs_to_str_threshold_exact_fit():
handler = create_handler()
# Create a message and measure its formatted string tokens
msg = create_user_msg("Test")
stat = handler.stat_message(msg)
stat = asyncio.run(handler.stat_message(msg))
formatted_content = stat.format(include_thinking=False)
exact_threshold = handler.count_str_token(formatted_content)
exact_threshold = asyncio.run(handler.count_str_token(formatted_content))
msgs = [msg]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=exact_threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=exact_threshold))
assert "Test" in result, f"Message should fit exactly, got: {result}"
verify_result_within_threshold(handler, result, exact_threshold, "threshold_exact_fit", msgs)
@ -496,19 +502,19 @@ def test_format_msgs_to_str_threshold_exact_fit():
def test_format_msgs_to_str_threshold_one_less():
"""Test when threshold is one less than needed."""
"""Test when threshold is one less than needed - latest message is still included."""
handler = create_handler()
msg = create_user_msg("Test message")
stat = handler.stat_message(msg)
stat = asyncio.run(handler.stat_message(msg))
formatted_content = stat.format(include_thinking=False)
threshold_minus_one = handler.count_str_token(formatted_content) - 1
threshold_minus_one = asyncio.run(handler.count_str_token(formatted_content)) - 1
msgs = [msg]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold_minus_one)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold_minus_one))
# Message should be skipped since it doesn't fit
assert result == "", f"Expected empty string when threshold is insufficient, got: {result}"
verify_result_within_threshold(handler, result, threshold_minus_one, "threshold_one_less", msgs)
# Latest message is included even when it exceeds threshold (implementation behavior)
assert "Test message" in result, f"Expected message in result, got: {result}"
# Skip verify_result_within_threshold since latest message is always included
print_pass("test_format_msgs_to_str_threshold_one_less")
@ -518,7 +524,7 @@ def test_format_msgs_to_str_large_threshold():
threshold = 1000000
msgs = [create_user_msg("Message " + str(i) + " " + "x" * 100) for i in range(50)]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
# All messages should be included
for i in range(50):
@ -535,7 +541,7 @@ def test_format_msgs_to_str_special_characters():
handler = create_handler()
threshold = 4000
msgs = [create_user_msg("Test with 中文, 日本語, émojis 🎉 and symbols @#$%")]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
assert "中文" in result
assert "日本語" in result
@ -549,7 +555,7 @@ def test_format_msgs_to_str_empty_content():
handler = create_handler()
threshold = 4000
msgs = [create_user_msg("")]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
assert "user:" in result, f"Expected role in result even with empty content, got: {result}"
verify_result_within_threshold(handler, result, threshold, "empty_content", msgs)
@ -561,7 +567,7 @@ def test_format_msgs_to_str_whitespace_only():
handler = create_handler()
threshold = 4000
msgs = [create_user_msg(" \n\t ")]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
assert "user:" in result
verify_result_within_threshold(handler, result, threshold, "whitespace_only", msgs)
@ -573,7 +579,7 @@ def test_format_msgs_to_str_newlines_in_content():
handler = create_handler()
threshold = 4000
msgs = [create_user_msg("Line 1\nLine 2\nLine 3")]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
assert "Line 1" in result
assert "Line 2" in result
@ -588,7 +594,7 @@ def test_format_msgs_to_str_very_long_single_word():
threshold = 10000
long_word = "a" * 5000
msgs = [create_user_msg(long_word)]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
# Should contain at least part of the word (may be truncated by formatter)
assert "aaa" in result, f"Expected long word content in result, got: {result[:100]}..."
@ -610,20 +616,24 @@ def test_format_msgs_to_str_mixed_content_blocks():
),
]
result_no_thinking = handler.format_msgs_to_str(
msgs,
memory_compact_threshold=threshold,
include_thinking=False,
result_no_thinking = asyncio.run(
handler.format_msgs_to_str(
msgs,
memory_compact_threshold=threshold,
include_thinking=False,
),
)
result_with_thinking = handler.format_msgs_to_str(
msgs,
memory_compact_threshold=threshold,
include_thinking=True,
result_with_thinking = asyncio.run(
handler.format_msgs_to_str(
msgs,
memory_compact_threshold=threshold,
include_thinking=True,
),
)
assert "Text content" in result_no_thinking
assert "tool_call=test_tool" in result_no_thinking
assert "[image]" in result_no_thinking
assert "<tool_use>test_tool" in result_no_thinking
assert "<image>" in result_no_thinking
assert "Thinking content" not in result_no_thinking
assert "Thinking content" in result_with_thinking
verify_result_within_threshold(handler, result_no_thinking, threshold, "mixed_content_no_thinking", msgs)
@ -639,7 +649,7 @@ def test_format_msgs_to_str_multiple_separators():
create_user_msg("Message 1"),
create_assistant_msg("Message 2"),
]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
assert "\n\n" in result, f"Expected double newline separator, got: {result}"
verify_result_within_threshold(handler, result, threshold, "multiple_separators", msgs)
@ -655,9 +665,9 @@ def test_format_msgs_to_str_tool_result_complex_output():
{"type": "image", "source": {"url": "https://example.com/result.png"}},
]
msgs = [create_tool_result_msg("process_data", complex_output)]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
assert "tool_result=process_data" in result
assert "<tool_result>process_data" in result
verify_result_within_threshold(handler, result, threshold, "tool_result_complex_output", msgs)
print_pass("test_format_msgs_to_str_tool_result_complex_output")
@ -672,7 +682,7 @@ def test_format_msgs_to_str_different_roles():
create_assistant_msg("Assistant response"),
create_tool_result_msg("tool", "Tool output"),
]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
assert "system:" in result
assert "user:" in result
@ -691,11 +701,18 @@ def test_format_msgs_to_str_incremental_threshold_check():
msgs.append(create_user_msg(f"Message {i} with some padding text"))
# Calculate total tokens
total_tokens = sum(handler.stat_message(msg).total_tokens for msg in msgs)
async def get_total_tokens():
total = 0
for msg in msgs:
stat = await handler.stat_message(msg)
total += stat.total_tokens
return total
total_tokens = asyncio.run(get_total_tokens())
# Use threshold that allows about half the messages
half_threshold = total_tokens // 2
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=half_threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=half_threshold))
# Should have some but not all messages
included_count = sum(1 for i in range(10) if f"Message {i}" in result)
@ -707,16 +724,16 @@ def test_format_msgs_to_str_incremental_threshold_check():
def test_format_msgs_to_str_negative_threshold():
"""Test with negative threshold value."""
"""Test with negative threshold value - latest message is still included."""
handler = create_handler()
threshold = -1
msgs = [create_user_msg("Test message")]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
# Negative threshold should result in empty string (nothing fits)
assert result == "", f"Expected empty string with negative threshold, got: {result}"
verify_result_within_threshold(handler, result, max(0, threshold), "negative_threshold", msgs)
# Latest message is included even with negative threshold (implementation behavior)
assert "Test message" in result, f"Expected message in result, got: {result}"
# Skip verify_result_within_threshold since latest message is always included
print_pass("test_format_msgs_to_str_negative_threshold")
@ -731,7 +748,7 @@ def test_format_msgs_to_str_preserves_newest_first():
]
# Use threshold that only allows ~1-2 messages
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
# Newest message should be present
assert "NEW MESSAGE" in result, f"Expected newest message, got: {result}"
@ -758,9 +775,9 @@ def test_format_msgs_to_str_base64_image():
],
),
]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
assert "[image]" in result
assert "<image>" in result
verify_result_within_threshold(handler, result, threshold, "base64_image", msgs)
print_pass("test_format_msgs_to_str_base64_image")
@ -779,10 +796,10 @@ def test_format_msgs_to_str_audio_video_blocks():
],
),
]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
assert "[audio]" in result
assert "[video]" in result
assert "<audio>" in result
assert "<video>" in result
verify_result_within_threshold(handler, result, threshold, "audio_video_blocks", msgs)
print_pass("test_format_msgs_to_str_audio_video_blocks")
@ -801,7 +818,7 @@ def test_format_msgs_to_str_unknown_block_type():
],
),
]
result = handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold)
result = asyncio.run(handler.format_msgs_to_str(msgs, memory_compact_threshold=threshold))
# Should still include valid content
assert "Valid text" in result

View file

@ -43,7 +43,7 @@ async def main():
# 构建模拟对话历史(包含超长 tool_result确保超过 128K token
original_messages = build_sample_messages(include_large_tool_result=True)
initial_tokens = msg_handler.count_msgs_token(original_messages)
initial_tokens = await msg_handler.count_msgs_token(original_messages)
print(f"\n[原始消息]: {len(original_messages)} 条, {initial_tokens:,} tokens")
print(f" 目标阈值: 128K = {128 * 1024:,} tokens")
@ -56,9 +56,9 @@ async def main():
# 重新获取原始消息
messages = build_sample_messages(include_large_tool_result=True)
tokens_before = msg_handler.count_msgs_token(messages)
tokens_before = await msg_handler.count_msgs_token(messages)
messages_after_step1 = await reme.compact_tool_result(messages)
tokens_after = msg_handler.count_msgs_token(messages_after_step1)
tokens_after = await msg_handler.count_msgs_token(messages_after_step1)
print(f" 消息数量: {len(messages)}{len(messages_after_step1)}")
print_token_change("compact_tool_result", tokens_before, tokens_after)
@ -70,12 +70,12 @@ async def main():
# 重新获取原始消息
messages = build_sample_messages(include_large_tool_result=True)
tokens_before = msg_handler.count_msgs_token(messages)
tokens_before = await msg_handler.count_msgs_token(messages)
compact_summary = await reme.compact_memory(
messages=messages,
previous_summary="",
)
summary_tokens = msg_handler.count_str_token(compact_summary)
summary_tokens = await msg_handler.count_str_token(compact_summary)
print(f" 输入消息 tokens: {tokens_before:,}")
print(f" 压缩摘要长度: {len(compact_summary)} 字符, {summary_tokens:,} tokens")
@ -89,7 +89,7 @@ async def main():
# 重新获取原始消息
messages = build_sample_messages(include_large_tool_result=True)
tokens_before = msg_handler.count_msgs_token(messages)
tokens_before = await msg_handler.count_msgs_token(messages)
summary_result = await reme.summary_memory(messages=messages)
print(f" 输入消息 tokens: {tokens_before:,}")
@ -103,7 +103,7 @@ async def main():
# 重新获取原始消息
messages = build_sample_messages(include_large_tool_result=True)
tokens_before = msg_handler.count_msgs_token(messages)
tokens_before = await msg_handler.count_msgs_token(messages)
processed_messages, compressed_summary = await reme.pre_reasoning_hook(
messages=messages,
system_prompt="你是一个有帮助的 AI 助手。",
@ -114,8 +114,8 @@ async def main():
enable_tool_result_compact=True,
tool_result_compact_keep_n=3,
)
tokens_after = msg_handler.count_msgs_token(processed_messages)
compressed_summary_tokens = msg_handler.count_str_token(compressed_summary)
tokens_after = await msg_handler.count_msgs_token(processed_messages)
compressed_summary_tokens = await msg_handler.count_str_token(compressed_summary)
print(f" 消息数量: {len(messages)}{len(processed_messages)}")
print_token_change("pre_reasoning_hook", tokens_before, tokens_after)
@ -140,7 +140,7 @@ async def main():
# 重新获取原始消息
messages = build_sample_messages(include_large_tool_result=True)
memory = ReMeLight.get_in_memory_memory()
memory = reme.get_in_memory_memory()
for msg in messages:
await memory.add(msg)
print(f" 已添加 {len(messages)} 条原始消息到内存")
@ -172,7 +172,7 @@ async def main():
print("📊 Token 变化总结")
print("=" * 70)
print(f" 原始消息: {initial_tokens:,} tokens")
print(f" Step 1 compact_tool_result 后: {msg_handler.count_msgs_token(messages_after_step1):,} tokens")
print(f" Step 1 compact_tool_result 后: {(await msg_handler.count_msgs_token(messages_after_step1)):,} tokens")
print(f" Step 2 compact_memory 摘要: {summary_tokens:,} tokens")
print(
f" Step 4 pre_reasoning_hook 后: {tokens_after:,} tokens + 摘要 {compressed_summary_tokens:,} "

View file

@ -12,12 +12,12 @@ from test_utils import (
get_formatter,
get_token_counter,
)
from reme.core.utils import get_std_logger
from reme.core.utils import get_logger
from reme.memory.file_based.components import Summarizer
from reme.memory.file_based.tools import FileIO
logger = get_std_logger()
logger = get_logger()
# ANSI 颜色码
@ -121,7 +121,7 @@ def create_summarizer(working_dir: str = None, memory_dir: str = "memory"):
working_dir=working_dir,
memory_dir=memory_dir,
memory_compact_threshold=4000,
token_counter=get_token_counter(),
as_token_counter=get_token_counter(),
toolkit=create_toolkit(working_dir),
as_llm=get_dash_chat_model(),
as_llm_formatter=get_formatter(),
@ -203,7 +203,7 @@ def test_consecutive_summaries():
working_dir=working_dir,
memory_dir=memory_dir,
memory_compact_threshold=4000,
token_counter=get_token_counter(),
as_token_counter=get_token_counter(),
toolkit=create_toolkit(working_dir),
as_llm=get_dash_chat_model(),
as_llm_formatter=get_formatter(),

View file

@ -11,7 +11,7 @@ import pytest
from reme.memory.file_based.tools.file_io import FileIO
from reme.memory.file_based.tools.shell import Shell
from reme.memory.file_based.tools.utils import DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES
from reme.memory.file_based.utils import DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES
# ============ Shell Tests ============