refactor(mem_agent): optimize agent execution and enhance evaluation pipeline

This commit is contained in:
jinli.yl 2026-01-19 00:46:52 +08:00
parent 08b771b6c4
commit 74c1386a69
17 changed files with 374 additions and 237 deletions

View file

@ -1,11 +1,9 @@
"""
Compute Question Answering statistics from eval_reme_simple_v4.py results.
This script processes the output from eval_reme_simple_v4.py and computes
comprehensive QA metrics.
Usage:
python bench/halumem/compute_qa_stats_v4.py --results_file bench_results/reme_simple_v4/eval_results.jsonl
python bench/halumem/compute_qa_stats_v4.py --tmp_dir bench_results/reme_simple_v4/tmp
"""
import json
@ -13,8 +11,6 @@ import os
from pathlib import Path
from typing import Any
from loguru import logger
def compute_qa_metrics(qa_records: list[dict]) -> dict[str, Any]:
"""Compute question answering metrics."""
@ -31,51 +27,37 @@ def compute_qa_metrics(qa_records: list[dict]) -> dict[str, Any]:
"qa_num": 0
}
correct = 0
hallucination = 0
omission = 0
valid = 0
correct = hallucination = omission = valid = 0
for qa in qa_records:
result_type = qa.get("result_type", "")
if result_type in ["Correct", "Hallucination", "Omission"]:
if result_type == "Correct":
correct += 1
valid += 1
elif result_type == "Hallucination":
hallucination += 1
valid += 1
elif result_type == "Omission":
omission += 1
valid += 1
if result_type == "Correct":
correct += 1
elif result_type == "Hallucination":
hallucination += 1
elif result_type == "Omission":
omission += 1
metrics = {
"correct_qa_ratio(all)": correct / total,
"hallucination_qa_ratio(all)": hallucination / total,
"omission_qa_ratio(all)": omission / total,
"correct_qa_ratio(valid)": correct / valid if valid > 0 else 0,
"hallucination_qa_ratio(valid)": hallucination / valid if valid > 0 else 0,
"omission_qa_ratio(valid)": omission / valid if valid > 0 else 0,
"qa_valid_num": valid,
"qa_num": total
}
if valid > 0:
metrics.update({
"correct_qa_ratio(valid)": correct / valid,
"hallucination_qa_ratio(valid)": hallucination / valid,
"omission_qa_ratio(valid)": omission / valid
})
else:
metrics.update({
"correct_qa_ratio(valid)": 0,
"hallucination_qa_ratio(valid)": 0,
"omission_qa_ratio(valid)": 0
})
return metrics
def compute_time_metrics(results_file: str) -> dict[str, float]:
"""Compute timing metrics from evaluation results."""
add_duration = 0
search_duration = 0
add_duration = search_duration = 0
with open(results_file, "r", encoding="utf-8") as f:
for line in f:
@ -85,12 +67,10 @@ def compute_time_metrics(results_file: str) -> dict[str, float]:
for session in user_data.get("sessions", []):
add_duration += session.get("add_dialogue_duration_ms", 0)
eval_results = session.get("evaluation_results", {})
for qa in eval_results.get("question_answering_records", []):
search_duration += qa.get("search_duration_ms", 0)
# Convert to minutes
return {
"add_dialogue_duration_time": add_duration / 1000 / 60,
"search_memory_duration_time": search_duration / 1000 / 60,
@ -98,36 +78,27 @@ def compute_time_metrics(results_file: str) -> dict[str, float]:
}
def load_from_tmp_dir(tmp_dir: str) -> tuple[str, list[dict]]:
def load_from_tmp_dir(tmp_dir: str) -> str:
"""Load data from tmp directory and generate eval_results.jsonl file."""
tmp_path = Path(tmp_dir)
parent_dir = tmp_path.parent
eval_results_file = parent_dir / "eval_results.jsonl"
eval_results_file = tmp_path.parent / "eval_results.jsonl"
print(f"\n📁 Loading data from tmp directory: {tmp_dir}")
print(f"📝 Will generate: {eval_results_file}")
print(f"\n📁 Loading from: {tmp_dir}")
print(f"📝 Generating: {eval_results_file}")
# Collect all user directories
user_dirs = [d for d in tmp_path.iterdir() if d.is_dir()]
print(f" Found {len(user_dirs)} user directories")
print(f" Found {len(user_dirs)} users")
users_data = []
for user_dir in user_dirs:
user_name = user_dir.name
# Load all session files for this user (sorted by session number)
session_files = sorted(
[f for f in user_dir.iterdir()
if f.name.startswith("session_") and f.suffix == ".json"],
key=lambda f: int(f.stem.split("_")[1]) # Sort by session number
[f for f in user_dir.iterdir() if f.name.startswith("session_") and f.suffix == ".json"],
key=lambda f: int(f.stem.split("_")[1])
)
if not session_files:
print(f" ⚠️ No session files found for user: {user_name}")
continue
# Load first session to get user metadata
with open(session_files[0], "r", encoding="utf-8") as f:
first_session = json.load(f)
@ -137,52 +108,46 @@ def load_from_tmp_dir(tmp_dir: str) -> tuple[str, list[dict]]:
"sessions": []
}
# Load all sessions
for session_file in session_files:
with open(session_file, "r", encoding="utf-8") as f:
session_data = json.load(f)
# Remove redundant user metadata
session_data.pop("uuid", None)
session_data.pop("user_name", None)
user_data["sessions"].append(session_data)
users_data.append(user_data)
print(f" Loaded user {user_name}: {len(session_files)} sessions")
print(f" {user_dir.name}: {len(session_files)} sessions")
# Write to eval_results.jsonl
with open(eval_results_file, "w", encoding="utf-8") as f:
for user_data in users_data:
f.write(json.dumps(user_data, ensure_ascii=False) + "\n")
print(f" ✅ Generated: {eval_results_file}")
return str(eval_results_file), users_data
return str(eval_results_file)
def main(input_path: str):
"""Main function to compute statistics from eval results."""
if not os.path.exists(input_path):
logger.error(f"Input path not found: {input_path}")
print(f"❌ Error: Path not found: {input_path}")
return
print("\n" + "=" * 80)
print("COMPUTING QUESTION ANSWERING STATISTICS - REME V4")
print("REME V4 - QUESTION ANSWERING STATISTICS")
print("=" * 80)
# Determine if input is a directory (tmp) or file (eval_results.jsonl)
# Load or generate eval_results.jsonl
if os.path.isdir(input_path):
results_file, users_data = load_from_tmp_dir(input_path)
results_file = load_from_tmp_dir(input_path)
else:
results_file = input_path
users_data = None
print(f"\n📁 Using existing results file: {results_file}")
print(f"\n📁 Using: {results_file}")
# Collect all QA records with metadata
# Collect QA records with metadata
qa_records = []
qa_records_with_metadata = [] # Store records with user/session/question info
user_count = 0
session_count = 0
qa_with_metadata = []
user_count = session_count = 0
with open(results_file, "r", encoding="utf-8") as f:
for line in f:
@ -192,38 +157,38 @@ def main(input_path: str):
user_count += 1
user_name = user_data.get("user_name", "Unknown")
valid_session_idx = 0 # Track the index of valid (non-skipped) sessions
valid_session_idx = 0
for original_idx, session in enumerate(user_data.get("sessions", [])):
if session.get("is_generated_qa_session"):
continue
session_count += 1
eval_results = session.get("evaluation_results", {})
session_qa_records = eval_results.get("question_answering_records", [])
# Add records with metadata
for qa_idx, qa in enumerate(session_qa_records):
for qa_idx, qa in enumerate(eval_results.get("question_answering_records", [])):
qa_records.append(qa)
qa_records_with_metadata.append({
qa_with_metadata.append({
"user_name": user_name,
"session_idx": valid_session_idx,
"original_session_idx": original_idx,
"question_idx": qa_idx,
"qa_record": qa
})
valid_session_idx += 1
print(f"\n📊 Data loaded:")
print(f"\n📊 Data Summary:")
print(f" Users: {user_count}")
print(f" Sessions: {session_count}")
print(f" QA Records: {len(qa_records)}")
# Compute metrics
print("\n🔄 Computing metrics...")
qa_metrics = compute_qa_metrics(qa_records)
time_metrics = compute_time_metrics(results_file)
# Save results
output_dir = Path(results_file).parent
report_file = output_dir / "reme_eval_stat_result.json"
final_results = {
"overall_score": {
"question_answering": qa_metrics,
@ -232,22 +197,16 @@ def main(input_path: str):
"question_answering_records": qa_records
}
# Save final report
output_dir = Path(results_file).parent
report_file = output_dir / "reme_eval_stat_result.json"
with open(report_file, "w", encoding="utf-8") as f:
json.dump(final_results, f, ensure_ascii=False, indent=4)
print(f"\nStatistics saved to: {report_file}")
print(f"\n✅ Results saved to: {report_file}")
# Print summary
# Print metrics
print("\n" + "=" * 80)
print("EVALUATION SUMMARY - REME V4")
print("📊 QUESTION ANSWERING METRICS")
print("=" * 80)
print("\n📊 Question Answering:")
print(f" Correct (all): {qa_metrics['correct_qa_ratio(all)']:.4f}")
print(f"\n Correct (all): {qa_metrics['correct_qa_ratio(all)']:.4f}")
print(f" Hallucination (all): {qa_metrics['hallucination_qa_ratio(all)']:.4f}")
print(f" Omission (all): {qa_metrics['omission_qa_ratio(all)']:.4f}")
print(f" Correct (valid): {qa_metrics['correct_qa_ratio(valid)']:.4f}")
@ -255,42 +214,56 @@ def main(input_path: str):
print(f" Omission (valid): {qa_metrics['omission_qa_ratio(valid)']:.4f}")
print(f" Valid/Total: {qa_metrics['qa_valid_num']}/{qa_metrics['qa_num']}")
print(f"\n⏱️ Time Metrics:")
print(f"\n⏱️ TIME METRICS")
print(f" Memory Addition: {time_metrics['add_dialogue_duration_time']:.2f} min")
print(f" Memory Search: {time_metrics['search_memory_duration_time']:.2f} min")
print(f" Total: {time_metrics['total_duration_time']:.2f} min")
# Print non-Correct QA records
# Print error records
print("\n" + "=" * 80)
print("NON-CORRECT QA RECORDS")
print("❌ ERROR RECORDS (Non-Correct)")
print("=" * 80)
non_correct_records = [
record for record in qa_records_with_metadata
if record["qa_record"].get("result_type") not in ["Correct", ""]
]
error_records = [r for r in qa_with_metadata if r["qa_record"].get("result_type") not in ["Correct", ""]]
if non_correct_records:
print(f"\nFound {len(non_correct_records)} non-correct records:\n")
for record in non_correct_records:
user_name = record["user_name"]
session_idx = record["session_idx"]
original_idx = record["original_session_idx"]
question_idx = record["question_idx"]
qa = record["qa_record"]
result_type = qa.get("result_type", "Unknown")
question = qa.get("question", "N/A")
answer = qa.get("answer", "N/A")
print(f"👤 User: {user_name}")
print(f"📅 Session: {original_idx} (valid session index: {session_idx})")
print(f"❓ Question #{question_idx}")
print(f"🏷️ Result Type: {result_type}")
print(f"💬 Question: {question}")
print(f"💡 Answer: {answer}")
print("-" * 80)
if not error_records:
print("\n✅ All QA records are correct!")
else:
print("\n✅ All QA records are Correct!")
print(f"\nFound {len(error_records)} error records:\n")
for idx, record in enumerate(error_records, 1):
qa = record["qa_record"]
print(f"\n{'' * 80}")
print(f"❌ ERROR #{idx}")
print(f"{'' * 80}")
print(f"👤 User: {record['user_name']}")
print(f"📅 Session: {record['session_idx']} | Question: {record['question_idx']}")
print(f"🏷️ Result Type: {qa.get('result_type', 'Unknown')}")
print(f"\n❓ Question:")
print(f" {qa.get('question', 'N/A')}")
print(f"\n✅ Expected Answer:")
print(f" {qa.get('answer', 'N/A')}")
print(f"\n🤖 System Response:")
print(f" {qa.get('system_response', 'N/A')}")
print(f"\n💭 Reasoning:")
reason = qa.get('question_answering_reasoning', 'N/A')
# Wrap long reasoning text
if len(reason) > 80:
words = reason.split()
lines = []
current_line = " "
for word in words:
if len(current_line) + len(word) + 1 <= 80:
current_line += word + " "
else:
lines.append(current_line.rstrip())
current_line = " " + word + " "
if current_line.strip():
lines.append(current_line.rstrip())
print("\n".join(lines))
else:
print(f" {reason}")
print("\n" + "=" * 80)
@ -298,30 +271,15 @@ def main(input_path: str):
if __name__ == "__main__":
import argparse
parser = argparse.ArgumentParser(
description="Compute QA statistics from eval_reme_simple_v4.py results"
)
parser.add_argument(
"--results_file",
type=str,
required=False,
help="Path to eval_results.jsonl file (e.g., bench_results/reme_simple_v4/eval_results.jsonl)"
)
parser.add_argument(
"--tmp_dir",
type=str,
required=False,
help="Path to tmp directory (e.g., bench_results/reme_simple_v4/tmp)"
)
parser = argparse.ArgumentParser(description="Compute QA statistics from eval_reme_simple_v4.py results")
parser.add_argument("--results_file", type=str, help="Path to eval_results.jsonl file")
parser.add_argument("--tmp_dir", type=str, help="Path to tmp directory")
args = parser.parse_args()
# Determine input path
if args.tmp_dir:
input_path = args.tmp_dir
main(input_path=args.tmp_dir)
elif args.results_file:
input_path = args.results_file
main(input_path=args.results_file)
else:
parser.error("Either --results_file or --tmp_dir must be provided")
main(input_path=input_path)

View file

@ -26,7 +26,7 @@ from typing import Any
from loguru import logger
from eval_tools import evaluation_for_question2
from eval_tools import evaluation_for_question2, answer_question_with_memories
from reme_ai.core.enumeration import MemoryType
from reme_ai.core.schema import MemoryNode
from reme_ai.reme import ReMe
@ -239,23 +239,35 @@ class MemoryProcessor:
query: str,
user_id: str,
top_k: int = 20
) -> tuple[str, list, float]:
) -> tuple[dict, list, float]:
"""
Search memory using ReMe and return response.
Search memory using ReMe and return structured answer with reasoning.
Returns:
tuple: (response, agent_messages, duration_ms)
tuple: (answer_dict, agent_messages, duration_ms)
answer_dict contains: {"reasoning": str, "answer": str, "memories": str}
"""
start = time.time()
response, agent_messages, success = await self.reme.retrieve_v4(
# Retrieve memories from ReMe
memories_response, agent_messages, success = await self.reme.retrieve_v4(
query=query,
user_id=user_id,
top_k=top_k
)
# Use LLM to generate structured answer from memories
answer_result = await answer_question_with_memories(
question=query,
memories=memories_response,
user_id=user_id
)
# Add original memories to the result
answer_result["memories"] = memories_response
duration_ms = (time.time() - start) * 1000
return response, agent_messages, duration_ms
return answer_result, agent_messages, duration_ms
# ==================== Evaluation ====================
@ -279,19 +291,24 @@ class QuestionAnsweringEvaluator:
results = []
for qa in questions:
response, agent_messages, duration_ms = await self.memory_processor.search_memory(
answer_dict, agent_messages, duration_ms = await self.memory_processor.search_memory(
query=qa["question"],
user_id=user_name,
top_k=self.top_k
)
# Extract answer and reasoning from the structured response
system_answer = answer_dict.get("answer", "")
system_reasoning = answer_dict.get("reasoning", "")
retrieved_memories = answer_dict.get("memories", "")
# Evaluate response
evidence_text = "\n".join([e["memory_content"] for e in qa["evidence"]])
eval_result = await evaluation_for_question2(
qa["question"],
qa["answer"],
evidence_text,
response,
system_answer,
formatted_dialogue
)
@ -300,7 +317,9 @@ class QuestionAnsweringEvaluator:
**qa,
"uuid": uuid,
"session_id": session_id,
"system_response": response,
"system_response": system_answer,
"system_reasoning": system_reasoning,
"retrieved_memories": retrieved_memories,
"retrieve_messages": [m.model_dump() for m in agent_messages],
"search_duration_ms": duration_ms,
"result_type": eval_result.get("evaluation_result"),

View file

@ -131,3 +131,40 @@ async def evaluation_for_question2(
result = await llm_request_for_json(prompt)
return result
async def answer_question_with_memories(
question: str,
memories: str,
user_id: str = None,
):
"""
Answer a question using retrieved memories with PROMPT_MEMZERO_JSON template.
Args:
question: The question to answer
memories: The retrieved memories (formatted as context)
user_id: Optional user ID for context formatting
Returns:
dict with 'reasoning' and 'answer' fields
"""
# Format context with memories
if user_id:
context = _PROMPTS["TEMPLATE_MEMOS"].format(
user_id=user_id,
memories=memories
)
else:
context = f"Memories:\n{memories}"
# Use PROMPT_MEMZERO_JSON template for structured JSON response
prompt = _PROMPTS["PROMPT_MEMZERO_JSON"].format(
context=context,
question=question
)
# result = await llm_request_for_json(prompt, model_name="qwen3-max")
result = await llm_request_for_json(prompt, model_name="qwen3-30b-a3b-instruct-2507")
return result

View file

@ -2,6 +2,30 @@ TEMPLATE_MEMOS: |
Memories for user {user_id}:
{memories}
PROMPT_MEMZERO_JSON: |
# CONTEXT:
{context}
# CONTEXT PRIORITY:
When the context contains information from multiple sources, follow this strict priority order:
1. **Historical Dialogue** (highest priority) - Direct conversation content
2. **Extracted Memories** (medium priority) - Summarized memory points
3. **User Profile** (lowest priority) - General user information
# Question:
{question}
# OUTPUT FORMAT:
Do not hallucinate; strictly answer the user's question based on the content of the CONTEXT.
Please provide your response in the following JSON format:
```json
{{
"reasoning": "reasoning content",
"answer": "Provide a detailed answer"
}}
```
PROMPT_MEMZERO: |
You are an intelligent memory assistant tasked with retrieving accurate information from conversation memories.

View file

@ -17,6 +17,7 @@ llm:
backend: openai
model_name: qwen3-30b-a3b-instruct-2507
max_concurrency: 20
temperature: 0.0001
qwen3_max_instruct:
backend: openai

View file

@ -22,7 +22,7 @@ class BaseMemoryAgent(BaseOp, metaclass=ABCMeta):
tools: list[BaseMemoryTool],
add_think_tool: bool = False, # only for instruct model
tool_call_interval: float = 0,
max_steps: int = 20,
max_steps: int = 8,
**kwargs,
):
tools = tools or []
@ -35,10 +35,11 @@ class BaseMemoryAgent(BaseOp, metaclass=ABCMeta):
self.max_steps: int = max_steps
self.messages: list[Message] = []
self.tool_messages: list[Message] = []
self.success: bool = True
self.retrieved_nodes: list[MemoryNode] = []
self.memory_nodes: list[MemoryNode | str] = []
self.meta_info: str = ""
def _build_tool_call(self) -> ToolCall:
return ToolCall(
@ -97,35 +98,37 @@ class BaseMemoryAgent(BaseOp, metaclass=ABCMeta):
"""Builds and returns the initial messages for the agent."""
return self.get_messages()
async def _reasoning_step(self, messages: list[Message], step: int, **kwargs) -> tuple[Message, bool]:
async def _reasoning_step(self, messages: list[Message], step: int, stage: str = "", **kwargs) -> tuple[Message, bool]:
assistant_message: Message = await self.llm.chat(
messages=messages,
tools=[t.tool_call for t in self.tools],
**kwargs,
)
messages.append(assistant_message)
stage_prefix = f"-{stage}" if stage else ""
logger.info(
f"[{self.__class__.__name__}] "
f"[{self.__class__.__name__}{stage_prefix}] "
f"step{step + 1}.assistant={assistant_message.simple_dump(enable_json_dump=True)}",
)
should_act = bool(assistant_message.tool_calls)
return assistant_message, should_act
async def _acting_step(self, assistant_message: Message, step: int, **kwargs) -> list[Message]:
async def _acting_step(self, assistant_message: Message, step: int, stage: str = "", **kwargs) -> list[Message]:
if not assistant_message.tool_calls:
return []
tool_list: list[BaseMemoryTool] = []
tool_result_messages: list[Message] = []
tool_dict = {t.tool_call.name: t for t in self.tools}
stage_prefix = f"-{stage}" if stage else ""
for j, tool_call in enumerate(assistant_message.tool_calls):
if tool_call.name not in tool_dict:
logger.warning(f"[{self.__class__.__name__}] unknown tool_call.name={tool_call.name}")
logger.warning(f"[{self.__class__.__name__}{stage_prefix}] unknown tool_call.name={tool_call.name}")
continue
logger.info(
f"[{self.__class__.__name__}] step{step + 1}.{j} "
f"[{self.__class__.__name__}{stage_prefix}] step{step + 1}.{j} "
f"submit tool_calls={tool_call.name} argument={tool_call.arguments}",
)
tool_copy: BaseMemoryTool = tool_dict[tool_call.name].copy()
@ -143,7 +146,7 @@ class BaseMemoryAgent(BaseOp, metaclass=ABCMeta):
self.memory_nodes.extend(op.memory_nodes)
if hasattr(op, "messages") and op.messages:
self.messages.extend(op.messages)
self.tool_messages.extend(op.messages)
tool_result = str(op.output)
tool_message = Message(
@ -152,20 +155,27 @@ class BaseMemoryAgent(BaseOp, metaclass=ABCMeta):
tool_call_id=op.tool_call.id,
)
tool_result_messages.append(tool_message)
logger.info(f"[{self.__class__.__name__}] step{step + 1}.{j} join tool_result={tool_result[:2000]}...\n\n")
# # Collect tool call information to meta_info
# tool_info = f"\n## Tool Call {step + 1}.{j + 1}: {op.tool_call.name}\n"
# tool_info += f"Arguments: {json.dumps(assistant_message.tool_calls[j].argument_dict, ensure_ascii=False)}\n"
# tool_info += f"Result: {tool_result}\n"
self.meta_info += tool_result + "\n"
logger.info(f"[{self.__class__.__name__}{stage_prefix}] step{step + 1}.{j} join tool_result={tool_result[:2000]}...\n\n")
return tool_result_messages
async def react(self, messages: list[Message]):
async def react(self, messages: list[Message], stage: str = ""):
"""Performs reasoning and acting steps until completion or max steps reached."""
success: bool = False
for step in range(self.max_steps):
assistant_message, should_act = await self._reasoning_step(messages, step)
assistant_message, should_act = await self._reasoning_step(messages, step, stage=stage)
if not should_act:
success = True
break
tool_result_messages = await self._acting_step(assistant_message, step)
tool_result_messages = await self._acting_step(assistant_message, step, stage=stage)
messages.extend(tool_result_messages)
return messages, success

View file

@ -9,32 +9,25 @@ class PersonalRetrieverV4(BaseMemoryAgent):
memory_type: MemoryType = MemoryType.PERSONAL
async def build_messages(self) -> list[Message]:
if self.context.get("query"):
context = self.context.query
elif self.context.get("messages"):
context = format_messages(self.context.messages)
else:
context = self.context.query if self.context.get("query") else format_messages(self.context.messages) if self.context.get("messages") else None
if not context:
raise ValueError("input must have either `query` or `messages`")
read_profile_tool = ReadUserProfile()
read_profile_tool = ReadUserProfile(show_ids="history")
await read_profile_tool.call(memory_type=self.memory_type.value, memory_target=self.memory_target)
self.context.user_profile = user_profile = read_profile_tool.output
messages = [
Message(
role=Role.SYSTEM,
content=self.prompt_format(
prompt_name="system_prompt",
memory_type=self.memory_type.value,
memory_target=self.memory_target,
user_profile=read_profile_tool.output,
context=context,
)),
return [
Message(
role=Role.USER,
content=self.get_prompt("user_message"),
),
content=self.prompt_format(
prompt_name="user_message",
memory_type=self.memory_type.value,
memory_target=self.memory_target,
user_profile=user_profile,
context=context,
))
]
return messages
async def _acting_step(self, assistant_message: Message, step: int, **kwargs) -> list[Message]:
return await super()._acting_step(
@ -44,3 +37,16 @@ class PersonalRetrieverV4(BaseMemoryAgent):
memory_target=self.memory_target,
**kwargs,
)
async def execute(self):
"""Execute the retriever and determine success based on output markers."""
await super().execute()
# Check for memory found/not found markers in the output
if self.output:
if "<MEMORY_FOUND>" in self.output:
self.success = True
elif "<MEMORY_NOT_FOUND>" in self.output:
self.success = False
self.meta_info = self.context.user_profile + "\n" + self.meta_info

View file

@ -1,7 +1,7 @@
tool: |
Retrieve relevant personal memories to answer user questions through vector search and history reading.
system_prompt: |
user_message: |
You are a memory agent managing **{memory_type}** memories about **{memory_target}**.
## User Profile
@ -10,28 +10,23 @@ system_prompt: |
## Question
{context}
## Retrieval Strategy
## Task
Search for relevant memories to answer the question above.
**Tool 1: Vector Search (`retrieve_memory`)
- Try at least 3-5 different queries before moving to next tool:
**Tool 1: Vector Search (`retrieve_memory`)**
- Try at least 3-5 different queries:
* Direct question
* Reformulated phrasings
* Entity-focused queries
* Different keyword combinations
- If no results: retry with different time ranges or remove time constraints: [start, end] in YYYYMMDD format
- If no results: retry with different time ranges [start, end] in YYYYMMDD format
* Example: [20200101, 20200102] for 20200101 <= time <= 20200102
* Single-sided: [0, 20200102] or [20200101, 99999999]
**Tool 2: Read Context (`read_history`) - ONLY AFTER Tool 1**
- Use this ONLY after completing multiple retrieve_memory attempts
- Use history_id from retrieved memories to read original conversations
- Prioritize most relevant or recent entries
- Read multiple if needed for complete context
**Response**
- **CRITICAL: Answer ONLY based on retrieved memories. Do NOT hallucinate or infer information not present in the search results.**
- Try multiple angles before giving up
- State "nothing found after thorough search" if nothing found after thorough search
user_message: |
Answer the question using the retrieval strategy.
- If found relevant memories: respond exactly `<MEMORY_FOUND>`
- If no memory found after thorough search: respond exactly `<MEMORY_NOT_FOUND>`

View file

@ -13,17 +13,13 @@ class PersonalSummarizerV4(BaseMemoryAgent):
history_node: MemoryNode = self.context.history_node
messages = [
Message(
role=Role.SYSTEM,
role=Role.USER,
content=self.prompt_format(
prompt_name="system_prompt_phase1",
prompt_name="user_message_phase1",
context=history_node.content,
memory_type=self.memory_type.value,
memory_target=self.memory_target,
)),
Message(
role=Role.USER,
content=self.get_prompt("user_message_phase1"),
),
]
return messages
@ -32,25 +28,22 @@ class PersonalSummarizerV4(BaseMemoryAgent):
history_node: MemoryNode = self.context.history_node
messages = [
Message(
role=Role.SYSTEM,
role=Role.USER,
content=self.prompt_format(
prompt_name="system_prompt_phase2",
prompt_name="user_message_phase2",
context=history_node.content,
memory_type=self.memory_type.value,
memory_target=self.memory_target,
user_profile=user_profile,
)),
Message(
role=Role.USER,
content=self.get_prompt("user_message_phase2"),
),
]
return messages
async def _acting_step(self, assistant_message: Message, step: int, **kwargs) -> list[Message]:
async def _acting_step(self, assistant_message: Message, step: int, stage: str = "", **kwargs) -> list[Message]:
return await super()._acting_step(
assistant_message,
step,
stage=stage,
memory_type=self.memory_type.value,
memory_target=self.memory_target,
history_node=self.history_node,
@ -68,7 +61,7 @@ class PersonalSummarizerV4(BaseMemoryAgent):
)
# Phase 1: AddSummaryMemory
logger.info(f"[{self.__class__.__name__}] Starting Phase 1: AddSummaryMemory")
logger.info(f"[{self.__class__.__name__}-S1] Starting Phase 1: AddSummaryMemory")
# Filter tools for phase 1 (only AddSummaryMemory)
original_tools = self.tools.copy()
@ -77,16 +70,16 @@ class PersonalSummarizerV4(BaseMemoryAgent):
messages_phase1 = await self.build_messages_phase1()
for i, message in enumerate(messages_phase1):
logger.info(
f"[{self.__class__.__name__}] phase1.step0.{i} {message.role} "
f"[{self.__class__.__name__}-S1] phase1.step0.{i} {message.role} "
f"{message.simple_dump(enable_json_dump=True)}",
)
messages_phase1, success_phase1 = await self.react(messages_phase1)
messages_phase1, success_phase1 = await self.react(messages_phase1, stage="S1")
if not success_phase1:
logger.warning(f"[{self.__class__.__name__}] Phase 1 did not complete successfully")
logger.warning(f"[{self.__class__.__name__}-S1] Phase 1 did not complete successfully")
# Phase 2: Read user profile and UpdateUserProfile
logger.info(f"[{self.__class__.__name__}] Starting Phase 2: UpdateUserProfile")
logger.info(f"[{self.__class__.__name__}-S2] Starting Phase 2: UpdateUserProfile")
# Restore original tools and get ReadUserProfile tool
self.tools = original_tools
@ -94,13 +87,17 @@ class PersonalSummarizerV4(BaseMemoryAgent):
user_profile = ""
if read_profile_tool:
# Call ReadUserProfile to load current profile
logger.info(f"[{self.__class__.__name__}] Loading user profile with ReadUserProfile")
await read_profile_tool.call(memory_type=self.memory_type.value, memory_target=self.memory_target)
# Call ReadUserProfile to load current profile (only show profile_id, not history_id)
logger.info(f"[{self.__class__.__name__}-S2] Loading user profile with ReadUserProfile")
await read_profile_tool.call(
memory_type=self.memory_type.value,
memory_target=self.memory_target,
show_ids="profile",
)
user_profile = str(read_profile_tool.output)
logger.info(f"[{self.__class__.__name__}] User profile loaded: {user_profile}...")
logger.info(f"[{self.__class__.__name__}-S2] User profile loaded: {user_profile}...")
else:
logger.warning(f"[{self.__class__.__name__}] ReadUserProfile tool not found")
logger.warning(f"[{self.__class__.__name__}-S2] ReadUserProfile tool not found")
# Filter tools for phase 2 (only UpdateUserProfile)
self.tools = [t for t in self.tools if t.tool_call.name == "update_user_profile"]
@ -108,11 +105,11 @@ class PersonalSummarizerV4(BaseMemoryAgent):
messages_phase2 = await self.build_messages_phase2(user_profile)
for i, message in enumerate(messages_phase2):
logger.info(
f"[{self.__class__.__name__}] phase2.step0.{i} {message.role} "
f"[{self.__class__.__name__}-S2] phase2.step0.{i} {message.role} "
f"{message.simple_dump(enable_json_dump=True)}",
)
messages_phase2, success_phase2 = await self.react(messages_phase2)
messages_phase2, success_phase2 = await self.react(messages_phase2, stage="S2")
# Restore original tools
self.tools = original_tools

View file

@ -2,7 +2,7 @@ tool: |
Extract and update personal memories about the user from conversation context.
Identify preferences, habits, background, relationships, and key facts.
system_prompt_phase1: |
user_message_phase1: |
You are a memory agent managing **{memory_type}** memories about **{memory_target}**.
## Latest Conversation:
@ -17,11 +17,10 @@ system_prompt_phase1: |
Summarize all important information about **{memory_target}**
- Set `conversation_time` (format: 2020-01-01 00:00:00; use 0000-00-00 00:00:00 if unavailable)
user_message_phase1: |
Extract personal memories from the conversation using `AddSummaryMemory`.
# capturing complete contexts with preconditions, causes, and consequences
system_prompt_phase2: |
user_message_phase2: |
You are a memory agent managing **{memory_type}** memories about **{memory_target}**.
## Latest Conversation:
@ -36,10 +35,15 @@ system_prompt_phase2: |
## Task: Update Profile with `UpdateUserProfile`
Synchronize profile/memories with new information from the conversation, including **{memory_target}**' current status:
- `profile_ids_to_delete`: Remove outdated, conflicting, or redundant entries.
- `profiles_to_add`: Add new profiles/memories with `conversation_time`, e.g. `YYYY-MM-DD HH:MM:SS`, {memory_target} did something.
- Maintain profiles that are concise, mutually exclusive, and collectively comprehensive with no information loss.
Synchronize profile with new information from the conversation:
- `profile_ids_to_delete`: Remove conflicting, or redundant entries (array of profile IDs).
- `profiles_to_add`:
- `conversation_time`: Time of conversation (format: `YYYY-MM-DD HH:MM:SS`, e.g., `2024-01-15 14:30:00`)
- `profile_content`: Complete, self-contained profile description with full context
**Profile Requirements**:
- One user profile entry records one dimension of the user portrait, and MUST be complete and self-contained with all necessary context (preconditions, causes, and consequences)
- All profiles MUST be mutually exclusive (non-overlapping) and non-conflicting
- Profiles should collectively be comprehensive with no information loss
user_message_phase2: |
Update user profile using `UpdateUserProfile` based on the conversation and current profile.

View file

@ -11,6 +11,7 @@ class ReMeRetrieverV4(BaseMemoryAgent):
def __init__(self, meta_memories: list[dict] | None = None, **kwargs):
super().__init__(**kwargs)
self.meta_memories: list[dict] = meta_memories or []
self.meta_info_dict: dict[str, str] = {}
async def _read_meta_memories(self) -> str:
from ...mem_tool import ReadMetaMemory
@ -44,10 +45,73 @@ class ReMeRetrieverV4(BaseMemoryAgent):
return messages
async def _acting_step(self, assistant_message: Message, step: int, **kwargs) -> list[Message]:
return await super()._acting_step(
assistant_message,
step,
query=self.context.get("query", ""),
messages=self.context.get("messages", []),
**kwargs,
)
import asyncio
from ...mem_tool.v4 import HandsOff
if not assistant_message.tool_calls:
return []
tool_list: list = []
tool_result_messages: list[Message] = []
tool_dict = {t.tool_call.name: t for t in self.tools}
stage_prefix = ""
# Add required context parameters
kwargs["query"] = self.context.get("query", "")
kwargs["messages"] = self.context.get("messages", [])
for j, tool_call in enumerate(assistant_message.tool_calls):
if tool_call.name not in tool_dict:
logger.warning(f"[{self.__class__.__name__}{stage_prefix}] unknown tool_call.name={tool_call.name}")
continue
logger.info(
f"[{self.__class__.__name__}{stage_prefix}] step{step + 1}.{j} "
f"submit tool_calls={tool_call.name} argument={tool_call.arguments}",
)
tool_copy = tool_dict[tool_call.name].copy()
tool_copy.tool_call.id = tool_call.id
tool_list.append(tool_copy)
kwargs.update(tool_call.argument_dict)
self.submit_async_task(tool_copy.call, retrieved_nodes=self.retrieved_nodes, **kwargs)
if self.tool_call_interval > 0:
await asyncio.sleep(self.tool_call_interval)
await self.join_async_tasks()
for j, op in enumerate(tool_list):
if op.memory_nodes:
self.memory_nodes.extend(op.memory_nodes)
if hasattr(op, "messages") and op.messages:
self.tool_messages.extend(op.messages)
# Collect meta_info_dict from HandsOff tool
if isinstance(op, HandsOff) and hasattr(op, "meta_info_dict"):
self.meta_info_dict.update(op.meta_info_dict)
logger.info(f"Collected meta_info_dict from HandsOff: {len(op.meta_info_dict)} entries")
tool_result = str(op.output)
tool_message = Message(
role=Role.TOOL,
content=tool_result,
tool_call_id=op.tool_call.id,
)
tool_result_messages.append(tool_message)
self.meta_info += tool_result + "\n"
logger.info(f"[{self.__class__.__name__}{stage_prefix}] step{step + 1}.{j} join tool_result={tool_result[:2000]}...\n\n")
return tool_result_messages
async def execute(self):
await super().execute()
# Assemble meta_info_dict into output
if self.meta_info_dict:
output_parts = []
for key, value in self.meta_info_dict.items():
output_parts.append(f"## {key}\n{value}")
self.output = "\n\n".join(output_parts)
logger.info(f"Assembled output from meta_info_dict with {len(self.meta_info_dict)} entries")

View file

@ -20,6 +20,7 @@ class HandsOff(BaseMemoryTool):
self.sub_ops: list[BaseMemoryAgent] = [a for a in self.sub_ops if isinstance(a, BaseMemoryAgent)]
self.messages: list[Message] = []
self.meta_info_dict: dict[str, str] = {}
@property
def memory_agent_dict(self) -> dict[MemoryType, "BaseMemoryAgent"]:
@ -105,6 +106,8 @@ class HandsOff(BaseMemoryTool):
self.memory_nodes.extend(agent.memory_nodes)
if agent.messages:
self.messages.extend(agent.messages)
if agent.meta_info:
self.meta_info_dict[f"{memory_type.value} {memory_target}"] = agent.meta_info
results.append(f"{memory_type.value} {memory_target} agent result: {agent.output}")

View file

@ -34,5 +34,5 @@ class ReadHistory(BaseMemoryTool):
return
memory = MemoryNode.from_vector_node(nodes[0])
self.output = memory.content
self.output = f"### Historical Dialogue\n{memory.content}"
logger.info(f"Successfully read history memory: {history_id}")

View file

@ -1,3 +1,4 @@
from typing import Literal
from loguru import logger
from ..base_memory_tool import BaseMemoryTool
@ -6,10 +7,11 @@ from ...core.schema.memory_node import MemoryNode
class ReadUserProfile(BaseMemoryTool):
def __init__(self, add_memory_type_target: bool = False, **kwargs):
def __init__(self, add_memory_type_target: bool = False, show_ids: Literal["both", "profile", "history", "none"] = "both", **kwargs):
kwargs["enable_multiple"] = False
super().__init__(**kwargs)
self.add_memory_type_target = add_memory_type_target
self.show_ids = show_ids
def _build_tool_description(self) -> str:
return "Read user profile."
@ -37,12 +39,16 @@ class ReadUserProfile(BaseMemoryTool):
"required": [],
}
async def execute(self):
async def execute(self):
# Determine which IDs to show
show_profile_id = self.show_ids in ("both", "profile")
show_history_id = self.show_ids in ("both", "history")
cache_key = f"{self.memory_type}_{self.memory_target}".replace(" ", "_").lower()
cached_data = self.meta_memory.load(cache_key, auto_clean=False)
if not cached_data:
self.output = ""
self.output = "### User Profile\nNo user profile found."
logger.info(f"empty cached_data={cache_key}")
return
@ -51,12 +57,25 @@ class ReadUserProfile(BaseMemoryTool):
memory_formated = []
for node in memory_nodes:
node_formated = f"profile_id={node.memory_id} profile_content={node.content}"
node_formated_parts = []
# Add profile_id if enabled
if show_profile_id:
node_formated_parts.append(f"profile_id={node.memory_id}")
# Always add profile_content
node_formated_parts.append(f"profile_content={node.content}")
# Add conversation_time if available
if "conversation_time" in node.metadata and node.metadata["conversation_time"]:
node_formated += f" conversation_time={node.metadata['conversation_time']}"
if node.ref_memory_id:
node_formated += f" history_id={node.ref_memory_id}"
node_formated_parts.append(f"conversation_time={node.metadata['conversation_time']}")
# Add history_id if enabled and available
if show_history_id and node.ref_memory_id:
node_formated_parts.append(f"history_id={node.ref_memory_id}")
node_formated = " ".join(node_formated_parts)
memory_formated.append(node_formated.strip())
self.output = "\n".join(memory_formated)
self.output = "### User Profile\n" + "\n".join(memory_formated)
logger.info(f"Read {len(memory_formated)} nodes from cache key: {cache_key}")

View file

@ -98,6 +98,6 @@ class RetrieveMemory(BaseMemoryTool):
if node.ref_memory_id:
line += f"history_id={node.ref_memory_id} "
output.append(line.strip())
self.output = "\n".join(output)
self.output = "### Extracted Memories\n" + "\n".join(output)
logger.info(f"Retrieved {len(memory_nodes)} memory_nodes, {len(new_memory_nodes)} new after deduplication")

View file

@ -80,7 +80,7 @@ class UpdateUserProfile(BaseMemoryTool):
memory_target=self.memory_target,
when_to_use="",
content=mem.get("profile_content", ""),
ref_memory_id=self.ref_memory_id,
ref_memory_id=self.history_node.memory_id,
author=self.author,
metadata={"conversation_time": mem.get("conversation_time", "")},
)

View file

@ -458,7 +458,7 @@ class ReMe(Application):
)
await reme_summarizer_v4.call(messages=messages, description=description, **kwargs)
return reme_summarizer_v4.memory_nodes, reme_summarizer_v4.messages, reme_summarizer_v4.success
return reme_summarizer_v4.memory_nodes, reme_summarizer_v4.tool_messages, reme_summarizer_v4.success
else:
raise NotImplementedError
@ -499,7 +499,7 @@ class ReMe(Application):
)
await reme_retriever_v4.call(query=query, messages=messages, description=description, **kwargs)
return reme_retriever_v4.output, reme_retriever_v4.messages, reme_retriever_v4.success
return reme_retriever_v4.output, reme_retriever_v4.tool_messages, reme_retriever_v4.success
else:
raise NotImplementedError