Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 6 additions & 7 deletions backend/agents/create_agent_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -989,13 +989,12 @@ async def create_agent_config(
description=(
"Store one model-selected and summarized short-term memory extracted only "
"from the conversation between the user and the current agent. Eligible "
"information is limited to user preferences, task goals, action plans and "
"latest progress, or reflections on user feedback and errors. Consider the "
"user question, tool or code execution results, and the final answer. Do not "
"store whole conversations, transient calculations, unverified guesses, "
"duplicates, secrets, or information the user asks to forget. Before every "
"final answer, assess whether an eligible memory was added or updated; if so, "
"calling this tool is mandatory."
"information is limited to process-level observations made during intermediate "
"action steps: user preferences, task goals, action plans and latest progress, "
"or reflections on user feedback and errors. Do not store whole conversations, "
"transient calculations, unverified guesses, duplicates, secrets, or information "
"the user asks to forget. Call this tool only during intermediate action steps, "
"not when generating the final answer."
),
inputs=json.dumps({
"content": {
Expand Down
38 changes: 38 additions & 0 deletions backend/prompts/fa_memory_extraction_en.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: fa_memory_extraction
version: 1
output:
format: memory_items
system: |
You are a memory extraction assistant. Your task is to identify and extract
worth-remembering information from a final answer generated by an AI agent.

Rules:
- Only extract information that is genuinely useful for future conversations
with the same user.
- Eligible categories: user preferences, confirmed facts about the user,
task outcomes and conclusions, lessons learned from errors or feedback,
stable context that may recur.
- Each memory item must be a single, self-contained, concise fact or preference.
- Do NOT extract: transient calculations, intermediate reasoning steps,
temporary task state, greetings, pleasantries, or information already
obvious from the conversation.
- Do NOT invent information that is not present in the provided text.
- If nothing qualifies, output <no-memory/> and nothing else.

Output format:
- Wrap each memory item in <memory-item>...</memory-item> tags.
- Output only the tagged items, no preamble, no explanation, no markdown.
- Example with items:
<memory-item>User prefers Python over JavaScript for backend work</memory-item>
<memory-item>User's project uses PostgreSQL 15 with asyncpg driver</memory-item>
- Example with no items:
<no-memory/>
user: |
Extract worth-remembering memory items from the following final answer.

User query: {user_query}

Final answer:
{final_answer}

Return only <memory-item>...</memory-item> tags or <no-memory/>.
76 changes: 76 additions & 0 deletions backend/services/agent_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
logger = logging.getLogger(__name__)
SAFE_AGENT_STREAM_ERROR_MESSAGE = "Agent execution failed. Please try again later."
_channel_cleanup_tasks: set[asyncio.Task[None]] = set()
_fa_extraction_tasks: set[asyncio.Task] = set()


async def _cleanup_channel_later(conversation_id: int, user_id: str, delay: float = 5.0):
Expand Down Expand Up @@ -1001,6 +1002,7 @@ async def _stream_agent_chunks(
next_unit_index: int = resume_from_unit_index
# Set when the agent run loop finishes successfully.
stream_completed_normally: bool = False
captured_final_answer: Optional[str] = None

# Get or create streaming channel for multi-subscriber support
if channel is None:
Expand Down Expand Up @@ -1169,6 +1171,7 @@ async def _iter_run_chunks():

# Special-case: final_answer also updates message_content
if chunk_type == "final_answer":
captured_final_answer = chunk_content
submit(
update_message_content,
streaming_message_id,
Expand Down Expand Up @@ -1447,6 +1450,79 @@ async def _iter_run_chunks():
# the new layered architecture (agents may only write to
# ``agent.short_term``).

# Post-final-answer memory extraction (fire-and-forget)
if (
captured_final_answer
and memory_ctx is not None
and getattr(getattr(memory_ctx, "user_config", None), "memory_switch", False)
):
try:
from services.fa_memory_extractor import FaMemoryExtractor
from services.memory_backend_adapter import build_memory_service_for_fa_extraction

async def _run_fa_extraction():
try:
from utils.config_utils import tenant_config_manager, get_model_name_from_config
from consts.const import MODEL_CONFIG_MAPPING
from nexent.core.models import OpenAIModel

config = tenant_config_manager.get_model_config(
key=MODEL_CONFIG_MAPPING["llm"], tenant_id=tenant_id
)
if not config:
logger.warning("fa_memory_extraction: no tenant LLM configured, skipping")
return

model = OpenAIModel(
model_id=get_model_name_from_config(config),
api_base=config.get("base_url", ""),
api_key=config.get("api_key", ""),
temperature=0.1,
top_p=0.9,
model_factory=config.get("model_factory"),
ssl_verify=config.get("ssl_verify", True),
display_name=config.get("display_name") or None,
timeout_seconds=config.get("timeout_seconds"),
)

class _ModelAdapter:
"""Adapt synchronous OpenAIModel to async chat interface."""
def __init__(self, model):
self._model = model
async def chat(self, messages):
import asyncio
result = await asyncio.to_thread(self._model.generate, messages)
if hasattr(result, "content"):
return result.content if isinstance(result.content, str) else str(result.content)
return str(result)

memory_service = build_memory_service_for_fa_extraction()
extractor = FaMemoryExtractor(
tenant_id=tenant_id,
user_id=user_id,
agent_id=str(getattr(agent_request, "agent_id", "")),
conversation_id=str(getattr(agent_request, "conversation_id", "")),
memory_service=memory_service,
model_client=_ModelAdapter(model),
)
result = await extractor.extract_and_store(
captured_final_answer,
)
logger.info(
"fa_memory_extraction: tenant=%s user=%s agent=%s items=%d reason=%s",
tenant_id, user_id,
str(getattr(agent_request, "agent_id", "")),
len(result.items), result.reason,
)
except Exception:
logger.exception("fa_memory_extraction: unexpected error")

extraction_task = asyncio.create_task(_run_fa_extraction())
_fa_extraction_tasks.add(extraction_task)
extraction_task.add_done_callback(_fa_extraction_tasks.discard)
except Exception:
logger.exception("fa_memory_extraction: failed to schedule extraction task")


def get_enable_tool_id_by_agent_id(agent_id: int, tenant_id: str):
all_tool_instance = query_all_enabled_tool_instances(
Expand Down
112 changes: 112 additions & 0 deletions backend/services/fa_memory_extractor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""FA-based memory extraction module for extracting and storing memory items from final answers."""

from __future__ import annotations

import logging
import re
from dataclasses import dataclass, field
from pathlib import Path

import yaml
from nexent.memory.models import MemoryLayer, MemoryType

logger = logging.getLogger("fa_memory_extractor")

_PROMPT_PATH = Path(__file__).resolve().parents[1] / "prompts" / "fa_memory_extraction_en.yaml"


@dataclass
class ExtractionResult:
items: list[dict] = field(default_factory=list)
reason: str = ""


class FaMemoryExtractor:
MAX_INPUT_CHARS = 8000

def __init__(
self,
*,
tenant_id: str,
user_id: str,
agent_id: str,
conversation_id: str,
language: str = "en",
memory_service=None,
model_client=None,
):
self.tenant_id = tenant_id
self.user_id = user_id
self.agent_id = agent_id
self.conversation_id = conversation_id
self.language = language
self.memory_service = memory_service
self.model_client = model_client

def _load_prompt(self) -> dict:
with _PROMPT_PATH.open(encoding="utf-8") as stream:
prompt = yaml.safe_load(stream)
return prompt

def _build_messages(self, final_answer: str, user_query: str = "") -> list[dict]:
prompt = self._load_prompt()
user_content = prompt["user"].format(
final_answer=final_answer,
user_query=user_query,
)
return [
{"role": "system", "content": prompt["system"]},
{"role": "user", "content": user_content},
]

@staticmethod
def _parse_items(raw: str) -> list[str]:
if "<no-memory/>" in raw:
return []
matches = re.findall(r"<memory-item>(.*?)</memory-item>", raw, re.DOTALL)
return [m.strip() for m in matches if m.strip()]

async def _store_items(self, items: list[str]) -> list[dict]:
results: list[dict] = []
for item in items:
try:
result = await self.memory_service.store_memory(
content=item,
tenant_id=self.tenant_id,
user_id=self.user_id,
agent_id=self.agent_id,
conversation_id=self.conversation_id,
layer=MemoryLayer.AGENT,
memory_type=MemoryType.SHORT_TERM,
)
results.append({
"content": item,
"memory_id": result.memory_id,
"event": result.event,
})
except Exception:
logger.warning("Failed to store memory item: %s", item, exc_info=True)
return results

async def extract_and_store(self, final_answer_text: str) -> ExtractionResult:
if not final_answer_text or not final_answer_text.strip():
return ExtractionResult(items=[], reason="empty_final_answer")

if not self.model_client:
return ExtractionResult(items=[], reason="no_llm_configured")

truncated = final_answer_text[: self.MAX_INPUT_CHARS]
messages = self._build_messages(truncated)

try:
raw = await self.model_client.chat(messages)
except Exception:
logger.error("LLM call failed during memory extraction", exc_info=True)

Check failure on line 104 in backend/services/fa_memory_extractor.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use "logging.exception()" instead.

See more on https://sonarcloud.io/project/issues?id=ModelEngine-Group_nexent&issues=AaANjViNcTQmOdtgt8oH&open=AaANjViNcTQmOdtgt8oH&pullRequest=3682
return ExtractionResult(items=[], reason="llm_error")

parsed = self._parse_items(raw)
if not parsed:
return ExtractionResult(items=[], reason="no_items")

stored = await self._store_items(parsed)
return ExtractionResult(items=stored, reason="ok")
16 changes: 15 additions & 1 deletion backend/services/memory_backend_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ async def _backend_store_hook(
layer_value = layer_value.value

memory_type_value = payload.get("memory_type")
if isinstance(memory_type_value, MemoryLayer):
if hasattr(memory_type_value, "value"):
memory_type_value = memory_type_value.value

tenant_id = payload["tenant_id"]
Expand Down Expand Up @@ -155,3 +155,17 @@ def build_memory_service_for_dreaming() -> MemoryService:
backend_store=_backend_store_hook,
backend_search=None,
)


def build_memory_service_for_fa_extraction() -> MemoryService:
"""Return a facade for final-answer memory extraction.

Reuses the same backend store hook as StoreMemoryTool. The extraction
pipeline writes agent short-term memory with the same policy constraints.
"""
return MemoryService(
embedding_model=None,
embedding_model_info=None,
backend_store=_backend_store_hook,
backend_search=None,
)
12 changes: 6 additions & 6 deletions backend/utils/memory_tool_prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,17 @@ def build_memory_tool_policy(language: str, tool_names: Iterable[str]) -> str:

if language == "zh":
return """### Memory Tool Policy
- `store_memory` 只存储从用户与当前智能体对话中提取的短期记忆。短期记忆仅包括:用户偏好、任务目标、行动计划与最新进展、针对用户反馈或报错信息的反思总结。
- 提取时综合参考用户提问、工具或代码执行结果、模型最终回答;在输出最终回答前,由你判断、归纳和去重,将单条可复用记忆作为 `content` 入参传给 `store_memory`,然后再输出最终回答;不要传入整段对话。
- 每轮输出最终回答前都必须执行一次记忆价值评估。只要上述任一类短期记忆出现新增或更新,就必须调用 `store_memory`;只有确实没有合格条目时才可跳过,且不得为了调用工具而保存空洞内容
- `store_memory` 只存储从用户与当前智能体对话中提取的短期记忆。短期记忆仅包括:用户偏好、任务目标、行动计划与最新进展、针对用户反馈或报错信息的反思总结。这些都是行动步骤中观察到的过程性信息。
- 仅在中间行动步骤(即调用工具或执行代码的步骤)中调用 `store_memory`。当你发现上述任一类短期记忆出现新增或更新时,由你判断、归纳和去重,将单条可复用记忆作为 `content` 入参传给 `store_memory`;不要传入整段对话。
- 生成最终回答时不要调用 `store_memory`。最终回答中的记忆将由系统在回答交付后自动提取,无需你手动保存
- 系统已在本轮开始前固定检索历史记忆。若候选条目已出现在已提供的记忆上下文或历史工具结果中,不得再次调用 `store_memory`。
- 不要存储临时计算、中间噪声、未验证推测、重复内容或敏感密钥。
- 不要为了展示 Memory 功能而机械调用 `store_memory`。"""

return """### Memory Tool Policy
- `store_memory` stores only short-term memory extracted from the conversation between the user and the current agent. Short-term memory is limited to user preferences, task goals, action plans and latest progress, and reflections on user feedback or errors.
- Consider the user's question, tool or code execution results, and the final answer you have determined. Before emitting that answer, judge, summarize, and deduplicate the information, pass one reusable memory entry as the `content` input, and then emit the final answer; never pass the whole conversation.
- Before every final answer, you must assess whether reusable memory was added or updated. If any eligible category changed, you must call `store_memory`; skip it only when no eligible entry exists, and never store empty content merely to call the tool.
- `store_memory` stores only short-term memory extracted from the conversation between the user and the current agent. Short-term memory is limited to user preferences, task goals, action plans and latest progress, and reflections on user feedback or errors. These are process-level observations made during action steps.
- Call `store_memory` only during intermediate action steps (i.e., steps that invoke tools or execute code). When you observe that any eligible category of short-term memory has been added or updated, judge, summarize, and deduplicate the information, then pass one reusable memory entry as the `content` input; never pass the whole conversation.
- Do NOT call `store_memory` when generating the final answer. Memory from the final answer will be extracted automatically by the system after the answer is delivered.
- The system has already performed fixed memory retrieval before this turn. Do not call `store_memory` when the candidate already appears in the provided memory context or prior tool results.
- Do not store transient calculations, intermediate noise, unverified guesses, duplicates, or secrets.
- Do not call `store_memory` mechanically on every turn."""
Loading
Loading