diff --git a/agentuniverse/agent/action/tool/context_tool/__init__.py b/agentuniverse/agent/action/tool/context_tool/__init__.py new file mode 100644 index 000000000..9a77825a8 --- /dev/null +++ b/agentuniverse/agent/action/tool/context_tool/__init__.py @@ -0,0 +1,7 @@ +# !/usr/bin/env python3 +# -*- coding:utf-8 -*- + +# @Time : 2025/12/7 16:20 +# @Author : pengqingsong.pqs +# @Email : pengqingsong.pqs@antgroup.com +# @FileName: __init__.py.py diff --git a/agentuniverse/agent/action/tool/context_tool/base_context_tool.py b/agentuniverse/agent/action/tool/context_tool/base_context_tool.py new file mode 100644 index 000000000..47d87e8e4 --- /dev/null +++ b/agentuniverse/agent/action/tool/context_tool/base_context_tool.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +# @Time : 2025/12/7 16:20 +# @Author : pengqingsong.pqs +# @Email : pengqingsong.pqs@antgroup.com +# @FileName: base_context_tool.py + +import os +from abc import ABC + +from pydantic import Field + +from agentuniverse.agent.action.tool.tool import Tool +from agentuniverse.base.util.env_util import get_from_env + + +class BaseContextTool(Tool, ABC): + """Context tool base class + + Provides basic functionality for session_id directory management + All context tools inherit from this class + """ + + context_file_rootpath: str = Field( + default_factory=lambda: get_from_env("CONTEXT_FILE_ROOTPATH") if get_from_env("CONTEXT_FILE_ROOTPATH") else "/tmp/agentuniverse_context", + description="Context file root path" + ) + + def _get_session_directory(self, session_id: str) -> str: + """Get directory path for session_id + + Args: + session_id: Session ID + + Returns: + Session directory path + """ + return os.path.join(self.context_file_rootpath, f"session_{session_id}") + + def _ensure_session_directory(self, session_id: str) -> str: + """Ensure session_id directory exists + + Args: + session_id: Session ID + + Returns: + Session directory path + """ + session_dir = self._get_session_directory(session_id) + os.makedirs(session_dir, exist_ok=True) + return session_dir + + def _get_file_path(self, session_id: str, file_name: str) -> str: + """Get full file path + + Args: + session_id: Session ID + file_name: File name + + Returns: + Full file path + """ + # If no extension, default to .md + if '.' not in file_name: + file_name = f"{file_name}.md" + + session_dir = self._ensure_session_directory(session_id) + return os.path.join(session_dir, file_name) + + def _file_exists(self, session_id: str, file_name: str) -> bool: + """Check if file exists + + Args: + session_id: Session ID + file_name: File name + + Returns: + Whether file exists + """ + file_path = self._get_file_path(session_id, file_name) + return os.path.exists(file_path) + + def _read_file_content(self, file_path: str) -> str: + """Read file content + + Args: + file_path: File path + + Returns: + File content + """ + try: + with open(file_path, 'r', encoding='utf-8') as f: + return f.read() + except FileNotFoundError: + raise FileNotFoundError(f"File does not exist: {file_path}") + except Exception as e: + raise Exception(f"File reading failed: {str(e)}") + + def _write_file_content(self, file_path: str, content: str) -> None: + """Write file content + + Args: + file_path: File path + content: Content to write + """ + try: + with open(file_path, 'w', encoding='utf-8') as f: + f.write(content) + except Exception as e: + raise Exception(f"File writing failed: {str(e)}") + + def _get_relative_path(self, session_id: str, file_name: str) -> str: + """Get file path relative to root path + + Args: + session_id: Session ID + file_name: File name + + Returns: + Relative path + """ + if '.' not in file_name: + file_name = f"{file_name}.md" + return f"session_{session_id}/{file_name}" diff --git a/agentuniverse/agent/action/tool/context_tool/config/__init__.py b/agentuniverse/agent/action/tool/context_tool/config/__init__.py new file mode 100644 index 000000000..9a77825a8 --- /dev/null +++ b/agentuniverse/agent/action/tool/context_tool/config/__init__.py @@ -0,0 +1,7 @@ +# !/usr/bin/env python3 +# -*- coding:utf-8 -*- + +# @Time : 2025/12/7 16:20 +# @Author : pengqingsong.pqs +# @Email : pengqingsong.pqs@antgroup.com +# @FileName: __init__.py.py diff --git a/agentuniverse/agent/action/tool/context_tool/config/context_append_to_file.yaml b/agentuniverse/agent/action/tool/context_tool/config/context_append_to_file.yaml new file mode 100644 index 000000000..415cb6fd4 --- /dev/null +++ b/agentuniverse/agent/action/tool/context_tool/config/context_append_to_file.yaml @@ -0,0 +1,34 @@ +name: 'context_append_to_file' +description: | + 该工具可以向指定上下文中的文件末尾追加内容,保留原有内容。 + + 输入参数(JSON格式): + ```json + { + "file_name": "目标文件名(字符串,必需)", + "content": "要追加的内容(字符串,必需)", + "session_id": "会话ID(字符串,必需)" + } + ``` + + 输出参数(JSON格式): + ```json + { + "message": "操作结果消息(字符串)" + } + ``` + + 工具输入示例(JSON格式): + ```json + { + "file_name": "example.md", + "content": "新增内容", + "session_id": "your_session_id" + } + ``` +tool_type: 'func' +input_keys: ['file_name', 'content', 'session_id'] +metadata: + type: 'TOOL' + module: 'agentuniverse.agent.action.tool.context_tool.context_append_to_file' + class: 'ContextAppendToFileTool' diff --git a/agentuniverse/agent/action/tool/context_tool/config/context_create_file.yaml b/agentuniverse/agent/action/tool/context_tool/config/context_create_file.yaml new file mode 100644 index 000000000..be964e4ef --- /dev/null +++ b/agentuniverse/agent/action/tool/context_tool/config/context_create_file.yaml @@ -0,0 +1,35 @@ +name: 'context_create_file' +description: | + 该工具用于创建或更新上下文中的文件。 + + 输入参数(JSON格式): + ```json + { + "file_name": "文件名称(字符串,必需)", + "content": "文件内容(字符串,必需)", + "session_id": "会话ID(字符串,必需)" + } + ``` + + 输出参数(JSON格式): + ```json + { + "message": "操作结果消息(字符串)", + "file_url": "文件相对路径(字符串)" + } + ``` + + 工具输入示例(JSON格式): + ```json + { + "file_name": "example.md", + "content": "文件内容", + "session_id": "your_session_id" + } + ``` +tool_type: 'func' +input_keys: ['file_name', 'content', 'session_id'] +metadata: + type: 'TOOL' + module: 'agentuniverse.agent.action.tool.context_tool.context_create_file' + class: 'ContextCreateFileTool' diff --git a/agentuniverse/agent/action/tool/context_tool/config/context_download_files.yaml b/agentuniverse/agent/action/tool/context_tool/config/context_download_files.yaml new file mode 100644 index 000000000..31aba45c0 --- /dev/null +++ b/agentuniverse/agent/action/tool/context_tool/config/context_download_files.yaml @@ -0,0 +1,39 @@ +name: 'context_download_files' +description: | + 该工具可以获取上下文中文件的访问路径或者地址,提供给用户或者其他工具使用。 + + 输入参数(JSON格式): + ```json + { + "file_name": "文件名称,多个文件用英文逗号分隔(字符串,必需)", + "session_id": "会话ID(字符串,必需)" + } + ``` + + 输出参数(JSON格式): + ```json + { + "file_list": [ + { + "file": "文件名(字符串)", + "fileUrl": "文件相对路径(字符串)", + "message": "下载结果消息(字符串)" + } + ], + "message": "总体操作结果消息(字符串)" + } + ``` + + 工具输入示例(JSON格式): + ```json + { + "file_name": "example.md", + "session_id": "your_session_id" + } + ``` +tool_type: 'func' +input_keys: ['file_name', 'session_id'] +metadata: + type: 'TOOL' + module: 'agentuniverse.agent.action.tool.context_tool.context_download_files' + class: 'ContextDownloadFilesTool' diff --git a/agentuniverse/agent/action/tool/context_tool/config/context_insert.yaml b/agentuniverse/agent/action/tool/context_tool/config/context_insert.yaml new file mode 100644 index 000000000..7a31dcc2c --- /dev/null +++ b/agentuniverse/agent/action/tool/context_tool/config/context_insert.yaml @@ -0,0 +1,37 @@ +name: 'context_insert' +description: | + 该工具可以在指定上下文中的文件的指定行号后插入文本内容。 + + 输入参数(JSON格式): + ```json + { + "file_name": "目标文件名(字符串,必需)", + "insert_text": "插入文本(字符串,必需)", + "session_id": "会话ID(字符串,必需)", + "insert_line": "插入行号,0表示文件开头,不指定则在末尾插入(字符串,可选)" + } + ``` + + 输出参数(JSON格式): + ```json + { + "message": "操作结果消息(字符串)", + "result": "操作结果状态,success/error(字符串)" + } + ``` + + 工具输入示例(JSON格式): + ```json + { + "file_name": "example.md", + "insert_line": "3", + "insert_text": "新增内容", + "session_id": "your_session_id" + } + ``` +tool_type: 'func' +input_keys: ['file_name', 'insert_text', 'session_id', 'insert_line'] +metadata: + type: 'TOOL' + module: 'agentuniverse.agent.action.tool.context_tool.context_insert' + class: 'ContextInsertTool' diff --git a/agentuniverse/agent/action/tool/context_tool/config/context_list_files.yaml b/agentuniverse/agent/action/tool/context_tool/config/context_list_files.yaml new file mode 100644 index 000000000..7ed006c30 --- /dev/null +++ b/agentuniverse/agent/action/tool/context_tool/config/context_list_files.yaml @@ -0,0 +1,31 @@ +name: 'context_list_files' +description: | + 该工具可以列出保存在上下文中的所有文件名称。 + + 输入参数(JSON格式): + ```json + { + "session_id": "会话ID(字符串,必需)" + } + ``` + + 输出参数(JSON格式): + ```json + { + "all_files": "所有文件名,用逗号分隔(字符串)", + "message": "操作结果消息(字符串)" + } + ``` + + 工具输入示例(JSON格式): + ```json + { + "session_id": "your_session_id" + } + ``` +tool_type: 'func' +input_keys: ['session_id'] +metadata: + type: 'TOOL' + module: 'agentuniverse.agent.action.tool.context_tool.context_list_files' + class: 'ContextListFilesTool' diff --git a/agentuniverse/agent/action/tool/context_tool/config/context_read_files.yaml b/agentuniverse/agent/action/tool/context_tool/config/context_read_files.yaml new file mode 100644 index 000000000..ab40853f2 --- /dev/null +++ b/agentuniverse/agent/action/tool/context_tool/config/context_read_files.yaml @@ -0,0 +1,43 @@ +name: 'context_read_files' +description: | + 该工具可以读取上下文中指定名称的文件内容,支持多文件和行范围读取。 + + 输入参数(JSON格式): + ```json + { + "file_name": "文件名称,多个文件用英文逗号分隔(字符串,必需)", + "session_id": "会话ID(字符串,必需)", + "line_range": "阅读指定的行范围,格式如[1, 10](字符串,可选)", + "display_line_numbers": "是否显示行号,true/false(字符串,可选,默认false)" + } + ``` + + 输出参数(JSON格式): + ```json + { + "file_list": [ + { + "file": "文件名(字符串)", + "content": "文件内容(字符串)", + "message": "读取结果消息(字符串)" + } + ], + "message": "总体操作结果消息(字符串)" + } + ``` + + 工具输入示例(JSON格式): + ```json + { + "file_name": "example.md", + "session_id": "your_session_id", + "line_range": "[1, 10]", + "display_line_numbers": "true" + } + ``` +tool_type: 'func' +input_keys: ['file_name', 'session_id'] +metadata: + type: 'TOOL' + module: 'agentuniverse.agent.action.tool.context_tool.context_read_files' + class: 'ContextReadFilesTool' diff --git a/agentuniverse/agent/action/tool/context_tool/config/context_rename_file.yaml b/agentuniverse/agent/action/tool/context_tool/config/context_rename_file.yaml new file mode 100644 index 000000000..9b425d6b6 --- /dev/null +++ b/agentuniverse/agent/action/tool/context_tool/config/context_rename_file.yaml @@ -0,0 +1,34 @@ +name: 'context_rename_file' +description: | + 该工具可以将一个上下文中的文件重命名为新的名称。 + + 输入参数(JSON格式): + ```json + { + "old_file_name": "原文件名(字符串,必需)", + "new_file_name": "新文件名(字符串,必需)", + "session_id": "会话ID(字符串,必需)" + } + ``` + + 输出参数(JSON格式): + ```json + { + "message": "操作结果消息(字符串)" + } + ``` + + 工具输入示例(JSON格式): + ```json + { + "old_file_name": "old.md", + "new_file_name": "new.md", + "session_id": "your_session_id" + } + ``` +tool_type: 'func' +input_keys: ['old_file_name', 'new_file_name', 'session_id'] +metadata: + type: 'TOOL' + module: 'agentuniverse.agent.action.tool.context_tool.context_rename_file' + class: 'ContextRenameFileTool' diff --git a/agentuniverse/agent/action/tool/context_tool/config/context_str_replace.yaml b/agentuniverse/agent/action/tool/context_tool/config/context_str_replace.yaml new file mode 100644 index 000000000..2236c54a4 --- /dev/null +++ b/agentuniverse/agent/action/tool/context_tool/config/context_str_replace.yaml @@ -0,0 +1,37 @@ +name: 'context_str_replace' +description: | + 该工具可以在指定上下文中的文件中进行完全匹配的字符串替换。 + + 输入参数(JSON格式): + ```json + { + "file_name": "文件名称(字符串,必需)", + "old_str": "要查找和替换的字符串(字符串,必需)", + "new_str": "替换后的字符串(字符串,必需)", + "session_id": "会话ID(字符串,必需)" + } + ``` + + 输出参数(JSON格式): + ```json + { + "result": "修改后的文件内容(字符串)", + "message": "操作结果消息(字符串)" + } + ``` + + 工具输入示例(JSON格式): + ```json + { + "file_name": "example.md", + "old_str": "旧文本", + "new_str": "新文本", + "session_id": "your_session_id" + } + ``` +tool_type: 'func' +input_keys: ['file_name', 'old_str', 'new_str', 'session_id'] +metadata: + type: 'TOOL' + module: 'agentuniverse.agent.action.tool.context_tool.context_str_replace' + class: 'ContextStrReplaceTool' diff --git a/agentuniverse/agent/action/tool/context_tool/config/context_toolkit.yaml b/agentuniverse/agent/action/tool/context_tool/config/context_toolkit.yaml new file mode 100644 index 000000000..1f22397a1 --- /dev/null +++ b/agentuniverse/agent/action/tool/context_tool/config/context_toolkit.yaml @@ -0,0 +1,28 @@ +name: 'context_toolkit' +description: | + 这是一个包含所有上下文管理工具的工具包,用于在会话环境中通过文件的形式管理上下文。 + 典型场景如:让不同的子Agent共享相同的环境上下文,或者将数据、代码、搜索内容等长上下文的内容外置到文件中。 + 当有需要的时候通过各种工具进行读取、修改等操作,最终返回给用户对应的内容或者文件地址。 + 包含的工具: + - context_insert: 在指定文件的指定行号后插入文本内容 + - context_read_files: 读取指定名称的文件内容,支持多文件和行范围读取 + - context_str_replace: 在指定文件中进行完全匹配的字符串替换 + - context_append_to_file: 向指定文件末尾追加内容,保留原有内容 + - context_download_files: 下载上下文文件,提供文件访问路径 + - context_list_files: 列出保存在上下文的所有文件名称 + - context_rename_file: 将一个文件重命名为新的名称 + - context_create_file: 创建或更新上下文文件 +include: + - 'context_insert' + - 'context_read_files' + - 'context_str_replace' + - 'context_append_to_file' + - 'context_download_files' + - 'context_list_files' + - 'context_rename_file' + - 'context_create_file' + +metadata: + type: 'TOOLKIT' + module: 'agentuniverse.agent.action.toolkit.toolkit' + class: 'Toolkit' \ No newline at end of file diff --git a/agentuniverse/agent/action/tool/context_tool/context_append_to_file.py b/agentuniverse/agent/action/tool/context_tool/context_append_to_file.py new file mode 100644 index 000000000..3ae6958a1 --- /dev/null +++ b/agentuniverse/agent/action/tool/context_tool/context_append_to_file.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +# @Time : 2025/12/7 16:20 +# @Author : pengqingsong.pqs +# @Email : pengqingsong.pqs@antgroup.com +# @FileName: context_append_to_file.py + +import os +from typing import Dict, Any + +from agentuniverse.agent.action.tool.context_tool.base_context_tool import BaseContextTool + + +class ContextAppendToFileTool(BaseContextTool): + """Context append content tool + + Append content to the end of specified file, preserving existing content + """ + + name: str = "context_append_to_file" + description: str = "Append content to the end of specified file, preserving existing content" + + def execute(self, + file_name: str, + content: str, + session_id: str) -> Dict[str, Any]: + """Execute content append operation + + Args: + file_name: Target file name + content: Content to append + session_id: Session ID + + Returns: + Operation result + """ + try: + file_path = self._get_file_path(session_id, file_name) + + # If file doesn't exist, create new file + if not os.path.exists(file_path): + self._write_file_content(file_path, content) + return { + "message": "File does not exist, created new file and wrote content" + } + + # Read existing content + existing_content = self._read_file_content(file_path) + + # Append new content + if existing_content: + new_content = existing_content + '\n' + content + else: + new_content = content + + # Write updated content + self._write_file_content(file_path, new_content) + + return { + "message": "Content appended successfully" + } + + except Exception as e: + return { + "message": f"Content append failed: {str(e)}" + } diff --git a/agentuniverse/agent/action/tool/context_tool/context_create_file.py b/agentuniverse/agent/action/tool/context_tool/context_create_file.py new file mode 100644 index 000000000..1c93119aa --- /dev/null +++ b/agentuniverse/agent/action/tool/context_tool/context_create_file.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +# @Time : 2025/12/7 16:20 +# @Author : pengqingsong.pqs +# @Email : pengqingsong.pqs@antgroup.com +# @FileName: context_create_file.py + +import os +from typing import Dict, Any + +from agentuniverse.agent.action.tool.context_tool.base_context_tool import BaseContextTool + + +class ContextCreateFileTool(BaseContextTool): + """Context file creation tool + + Used to create or update files in the context. Requires filename and content. + If the file name already exists, it will overwrite and update the existing content. + """ + + name: str = "context_create_file" + description: str = "Create or update context file" + + def execute(self, + file_name: str, + content: str, + session_id: str) -> Dict[str, Any]: + """Execute file creation/update operation + + Args: + file_name: File name + content: File content + session_id: Session ID + + Returns: + Operation result and file URL + """ + try: + file_path = self._get_file_path(session_id, file_name) + + # Check if file already exists + file_exists = os.path.exists(file_path) + + # Write file content (overwrite if file exists) + self._write_file_content(file_path, content) + + # Get relative path as file URL + file_url = self._get_relative_path(session_id, file_name) + + if file_exists: + message = "File already exists, content updated" + else: + message = "File created successfully" + + return { + "message": message, + "file_url": file_url + } + + except Exception as e: + return { + "message": f"File creation/update failed: {str(e)}", + "file_url": "" + } diff --git a/agentuniverse/agent/action/tool/context_tool/context_download_files.py b/agentuniverse/agent/action/tool/context_tool/context_download_files.py new file mode 100644 index 000000000..b5e1142f6 --- /dev/null +++ b/agentuniverse/agent/action/tool/context_tool/context_download_files.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +# @Time : 2025/12/7 16:20 +# @Author : pengqingsong.pqs +# @Email : pengqingsong.pqs@antgroup.com +# @FileName: context_download_files.py + +import os +from typing import Dict, Any + +from agentuniverse.agent.action.tool.context_tool.base_context_tool import BaseContextTool + + +class ContextDownloadFilesTool(BaseContextTool): + """Context file download tool + + If users need to view files, use the download tool and provide file URL to users + """ + + name: str = "context_download_files" + description: str = "Download context files, provide file access paths" + + def execute(self, + file_name: str, + session_id: str) -> Dict[str, Any]: + """Execute file download operation + + Args: + file_name: File name, multiple files separated by comma + session_id: Session ID + + Returns: + File URL list and operation result + """ + try: + file_names = [f.strip() for f in file_name.split(',') if f.strip()] + + if not file_names: + return { + "file_list": [], + "message": "No valid file names provided" + } + + file_list = [] + + for file_name_item in file_names: + file_path = self._get_file_path(session_id, file_name_item) + + if not os.path.exists(file_path): + file_list.append({ + "file": file_name_item, + "fileUrl": "", + "message": "File does not exist" + }) + continue + + # Get relative path as file URL + relative_path = self._get_relative_path(session_id, file_name_item) + + file_list.append({ + "file": file_name_item, + "fileUrl": relative_path, + "message": "Download path obtained successfully" + }) + + return { + "file_list": file_list, + "message": "File download paths obtained successfully" + } + + except Exception as e: + return { + "file_list": [], + "message": f"File download failed: {str(e)}" + } diff --git a/agentuniverse/agent/action/tool/context_tool/context_insert.py b/agentuniverse/agent/action/tool/context_tool/context_insert.py new file mode 100644 index 000000000..edfee1332 --- /dev/null +++ b/agentuniverse/agent/action/tool/context_tool/context_insert.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +# @Time : 2025/12/7 16:20 +# @Author : pengqingsong.pqs +# @Email : pengqingsong.pqs@antgroup.com +# @FileName: context_insert.py + +import os +from typing import Optional, Dict, Any + +from agentuniverse.agent.action.tool.context_tool.base_context_tool import BaseContextTool + + +class ContextInsertTool(BaseContextTool): + """Context insertion tool + + Insert text content after specified line number in a file + """ + + name: str = "context_insert" + description: str = "Insert text content after specified line number in a file" + + def execute(self, + file_name: str, + insert_text: str, + session_id: str, + insert_line: Optional[str] = None) -> Dict[str, Any]: + """Execute insertion operation + + Args: + file_name: Target file name + insert_text: Text to insert + session_id: Session ID + insert_line: Insert line number (optional) + + Returns: + Operation result + """ + try: + file_path = self._get_file_path(session_id, file_name) + + # If file doesn't exist, create new file + if not os.path.exists(file_path): + self._write_file_content(file_path, insert_text) + return { + "message": "File does not exist, created new file and inserted content", + "result": "success" + } + + # Read existing content + content = self._read_file_content(file_path) + lines = content.split('\n') + + # Process insertion line number + if insert_line is None: + # Default insert after last line + lines.append(insert_text) + else: + try: + line_num = int(insert_line) + if line_num < 0: + return { + "message": "Insert line number cannot be negative", + "result": "error" + } + + if line_num == 0: + # Insert at beginning of file + lines.insert(0, insert_text) + elif line_num >= len(lines): + # Line number out of range, insert at end + lines.append(insert_text) + else: + # Insert after specified line + lines.insert(line_num, insert_text) + except ValueError: + return { + "message": "Insert line number must be a valid number", + "result": "error" + } + + # Write updated content + new_content = '\n'.join(lines) + self._write_file_content(file_path, new_content) + + return { + "message": "Content inserted successfully", + "result": "success" + } + + except FileNotFoundError: + return { + "message": f"File does not exist: {file_name}", + "result": "error" + } + except Exception as e: + return { + "message": f"Content insertion failed: {str(e)}", + "result": "error" + } diff --git a/agentuniverse/agent/action/tool/context_tool/context_list_files.py b/agentuniverse/agent/action/tool/context_tool/context_list_files.py new file mode 100644 index 000000000..070da12a8 --- /dev/null +++ b/agentuniverse/agent/action/tool/context_tool/context_list_files.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +# @Time : 2025/12/7 16:20 +# @Author : pengqingsong.pqs +# @Email : pengqingsong.pqs@antgroup.com +# @FileName: context_list_files.py + +import os +from typing import Dict, Any + +from agentuniverse.agent.action.tool.context_tool.base_context_tool import BaseContextTool + + +class ContextListFilesTool(BaseContextTool): + """Context list files tool + + List all file names saved in the context + """ + + name: str = "context_list_files" + description: str = "List all file names saved in the context" + + def execute(self, session_id: str) -> Dict[str, Any]: + """Execute file list operation + + Args: + session_id: Session ID + + Returns: + All file names and operation result + """ + try: + session_dir = self._get_session_directory(session_id) + + if not os.path.exists(session_dir): + return { + "all_files": "", + "message": "Session directory does not exist" + } + + # Get all files in directory + files = [] + for item in os.listdir(session_dir): + item_path = os.path.join(session_dir, item) + if os.path.isfile(item_path): + files.append(item) + + if not files: + return { + "all_files": "", + "message": "No files in this session" + } + + # Sort files by name + files.sort() + all_files_str = ', '.join(files) + + return { + "all_files": all_files_str, + "message": "File list obtained successfully" + } + + except Exception as e: + return { + "all_files": "", + "message": f"Failed to get file list: {str(e)}" + } diff --git a/agentuniverse/agent/action/tool/context_tool/context_read_files.py b/agentuniverse/agent/action/tool/context_tool/context_read_files.py new file mode 100644 index 000000000..5cff8faf0 --- /dev/null +++ b/agentuniverse/agent/action/tool/context_tool/context_read_files.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +# @Time : 2025/12/7 16:20 +# @Author : pengqingsong.pqs +# @Email : pengqingsong.pqs@antgroup.com +# @FileName: context_read_files.py + +import os +import re +from typing import Optional, Dict, Any + +from agentuniverse.agent.action.tool.context_tool.base_context_tool import BaseContextTool + + +class ContextReadFilesTool(BaseContextTool): + """Context file reading tool + + Read file content by specified name, support multiple files and line range reading + """ + + name: str = "context_read_files" + description: str = "Read file content by specified name, support multiple files and line range reading" + + def execute(self, + file_name: str, + session_id: str, + line_range: Optional[str] = '', + display_line_numbers: Optional[str] = "false") -> Dict[str, Any]: + """Execute file reading operation + + Args: + file_name: File name, multiple files separated by comma + session_id: Session ID + line_range: Read specified lines, example: [1, 10] + display_line_numbers: Whether to display line numbers + + Returns: + File content list and operation result + """ + try: + file_names = [f.strip() for f in file_name.split(',') if f.strip()] + + if not file_names: + return { + "file_list": [], + "message": "No valid file names provided" + } + + file_list = [] + + for file_name_item in file_names: + file_path = self._get_file_path(session_id, file_name_item) + + if not os.path.exists(file_path): + file_list.append({ + "file": file_name_item, + "content": "", + "message": "File does not exist" + }) + continue + + # Read file content + content = self._read_file_content(file_path) + lines = content.split('\n') + + # Process line range + if line_range: + try: + # Parse range format [start, end] + match = re.match(r'\[(\d+),\s*(\d+)\]', line_range) + if match: + start = int(match.group(1)) - 1 # Convert to 0-based index + end = int(match.group(2)) + + if start < 0: + start = 0 + if end > len(lines): + end = len(lines) + + if start >= end: + file_list.append({ + "file": file_name_item, + "content": "", + "message": "Invalid line range" + }) + continue + + selected_lines = lines[start:end] + else: + file_list.append({ + "file": file_name_item, + "content": "", + "message": "Invalid range format" + }) + continue + except (ValueError, IndexError): + file_list.append({ + "file": file_name_item, + "content": "", + "message": "Invalid line range" + }) + continue + else: + selected_lines = lines + + # Process line number display + if display_line_numbers and display_line_numbers.lower() == "true": + numbered_lines = [] + start_line = 1 if not line_range else (int(re.match(r'\[(\d+),\s*(\d+)\]', line_range).group(1)) if line_range else 1) + for i, line in enumerate(selected_lines): + numbered_lines.append(f"{start_line + i}: {line}") + file_content = '\n'.join(numbered_lines) + else: + file_content = '\n'.join(selected_lines) + + file_list.append({ + "file": file_name_item, + "content": file_content, + "message": "Read successfully" + }) + + return { + "file_list": file_list, + "message": "File reading successful" + } + + except Exception as e: + return { + "file_list": [], + "message": f"File reading failed: {str(e)}" + } diff --git a/agentuniverse/agent/action/tool/context_tool/context_rename_file.py b/agentuniverse/agent/action/tool/context_tool/context_rename_file.py new file mode 100644 index 000000000..eee23f1a6 --- /dev/null +++ b/agentuniverse/agent/action/tool/context_tool/context_rename_file.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +# @Time : 2025/12/7 16:20 +# @Author : pengqingsong.pqs +# @Email : pengqingsong.pqs@antgroup.com +# @FileName: context_rename_file.py + +import os +from typing import Dict, Any + +from agentuniverse.agent.action.tool.context_tool.base_context_tool import BaseContextTool + + +class ContextRenameFileTool(BaseContextTool): + """Context rename file tool + + Rename a file to a new name + """ + + name: str = "context_rename_file" + description: str = "Rename a file to a new name" + + def execute(self, + old_file_name: str, + new_file_name: str, + session_id: str) -> Dict[str, Any]: + """Execute file rename operation + + Args: + old_file_name: Original file name + new_file_name: New file name + session_id: Session ID + + Returns: + Operation result + """ + try: + old_file_path = self._get_file_path(session_id, old_file_name) + new_file_path = self._get_file_path(session_id, new_file_name) + + if not os.path.exists(old_file_path): + return { + "message": f"Original file does not exist: {old_file_name}" + } + + if os.path.exists(new_file_path): + return { + "message": f"Target file already exists: {new_file_name}" + } + + # Execute rename + os.rename(old_file_path, new_file_path) + + return { + "message": "File renamed successfully" + } + + except Exception as e: + return { + "message": f"File rename failed: {str(e)}" + } diff --git a/agentuniverse/agent/action/tool/context_tool/context_str_replace.py b/agentuniverse/agent/action/tool/context_tool/context_str_replace.py new file mode 100644 index 000000000..e7269898a --- /dev/null +++ b/agentuniverse/agent/action/tool/context_tool/context_str_replace.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +# @Time : 2025/12/7 16:20 +# @Author : pengqingsong.pqs +# @Email : pengqingsong.pqs@antgroup.com +# @FileName: context_str_replace.py + +import os +from typing import Dict, Any + +from agentuniverse.agent.action.tool.context_tool.base_context_tool import BaseContextTool + + +class ContextStrReplaceTool(BaseContextTool): + """Context string replacement tool + + Perform exact string replacement in specified file + """ + + name: str = "context_str_replace" + description: str = "Perform exact string replacement in specified file" + + def execute(self, + file_name: str, + old_str: str, + new_str: str, + session_id: str) -> Dict[str, Any]: + """Execute string replacement operation + + Args: + file_name: File name + old_str: String to find and replace + new_str: Replacement string + session_id: Session ID + + Returns: + Modified content and operation result + """ + try: + file_path = self._get_file_path(session_id, file_name) + + if not os.path.exists(file_path): + return { + "result": "", + "message": f"File does not exist: {file_name}" + } + + # Read file content + content = self._read_file_content(file_path) + + # Check if old string exists + if old_str not in content: + return { + "result": content, + "message": f"No matching string found: {old_str}" + } + + # Execute replacement + new_content = content.replace(old_str, new_str) + + # Write updated content + self._write_file_content(file_path, new_content) + + return { + "result": new_content, + "message": "String replacement successful" + } + + except Exception as e: + return { + "result": "", + "message": f"String replacement failed: {str(e)}" + } diff --git a/agentuniverse/agent/agent.py b/agentuniverse/agent/agent.py index 6d79b1dc6..4f5e5bb49 100644 --- a/agentuniverse/agent/agent.py +++ b/agentuniverse/agent/agent.py @@ -44,7 +44,7 @@ from agentuniverse.base.util.agent_util import process_agent_llm_config from agentuniverse.base.util.common_util import stream_output from agentuniverse.base.util.logging.logging_util import LOGGER -from agentuniverse.base.util.memory_util import generate_messages, get_memory_string +from agentuniverse.base.util.memory_util import generate_messages, get_memory_string, get_long_term_memory_string from agentuniverse.base.util.system_util import process_dict_with_funcs, is_system_builtin from agentuniverse.base.tracing.au_trace_manager import AuTraceManager from agentuniverse.llm.llm import LLM @@ -495,6 +495,23 @@ def get_memory_params(self, agent_input: dict) -> dict: params["type"] = ['input', 'output'] return params + def get_long_term_memory_params(self, agent_input: dict) -> dict: + memory_info = self.agent_model.memory + top_k = self.agent_model.memory.get('long_term_top_k', 20) + agent_id = self.agent_model.info.get('name') + if "agent_id" in memory_info: + agent_id = memory_info.get('agent_id') + params = { + 'agent_id': agent_id, + 'top_k': top_k + } + if agent_input.get('user_id'): + params['user_id'] = agent_input.get('user_id') + if agent_input.get('input'): + params['query'] = agent_input.get('input') + # only search input related info + return params if params['query'] else None + def get_run_config(self, **kwargs) -> dict: llm_name = kwargs.get('llm_name') or self.agent_model.profile.get('llm_model', {}).get('name') callbacks = [InvokeCallbackHandler( @@ -518,6 +535,13 @@ def load_memory(self, memory, agent_input: dict): LOGGER.info(f"Load memory with params: {params}") memory_messages = memory.get(**params) memory_str = get_memory_string(memory_messages, agent_input.get('agent_id')) + if memory.long_term_memory_key: + long_term_memory_params = self.get_long_term_memory_params(agent_input) + if long_term_memory_params: + LOGGER.info(f"Load long term memory with params: {long_term_memory_params}") + long_term_memory_messages = memory.search_long_term_memory(**long_term_memory_params) + long_term_memory_str = get_long_term_memory_string(long_term_memory_messages) + agent_input[memory.long_term_memory_key] = long_term_memory_str else: return "Up to Now, No Chat History" agent_input[memory.memory_key] = memory_str diff --git a/agentuniverse/agent/memory/conversation_memory/memory_storage/chroma_long_term_memory_storage.py b/agentuniverse/agent/memory/conversation_memory/memory_storage/chroma_long_term_memory_storage.py new file mode 100644 index 000000000..078d0363b --- /dev/null +++ b/agentuniverse/agent/memory/conversation_memory/memory_storage/chroma_long_term_memory_storage.py @@ -0,0 +1,562 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +# @Time : 2025/12/03 12:55 +# @Author : pengqingsong.pqs +# @Email : pengqingsong.pqs@antgroup.com +# @FileName: chroma_long_term_memory_storage.py + +import uuid +from datetime import datetime +from urllib.parse import urlparse +from typing import Optional, List, Any + +import chromadb +from pydantic import SkipValidation +from chromadb.config import Settings +from chromadb.api.models.Collection import Collection + +from agentuniverse.agent.action.knowledge.embedding.embedding_manager import EmbeddingManager +from agentuniverse.agent.memory.memory_extract.memory_extract import LongTermMemoryMessage, MemoryCategoryEnum, \ + MemoryOwnerEnum +from agentuniverse.agent.memory.memory_storage.memory_storage import MemoryStorage +from agentuniverse.base.config.component_configer.component_configer import ComponentConfiger +from agentuniverse.base.util.logging.logging_util import LOGGER + + +class ChromaLongTermMemoryStorage(MemoryStorage): + """ChromaDB-based long-term memory storage implementation. + + Attributes: + collection_name (Optional[str]): The name of the ChromaDB collection. + persist_path (Optional[str]): The path to persist the collection. + embedding_model (Optional[str]): The name of the embedding model instance to use. + _collection (SkipValidation[Collection]): The collection object. + """ + collection_name: Optional[str] = 'long_term_memory' + persist_path: Optional[str] = None + embedding_model: Optional[str] = None + _collection: SkipValidation[Collection] = None + + def _initialize_by_component_configer(self, + memory_storage_config: ComponentConfiger) -> 'ChromaLongTermMemoryStorage': + """Initialize the ChromaLongTermMemoryStorage by the ComponentConfiger object. + + Args: + memory_storage_config (ComponentConfiger): A configer contains chroma_memory_storage basic info. + + Returns: + ChromaLongTermMemoryStorage: A ChromaLongTermMemoryStorage instance. + """ + super()._initialize_by_component_configer(memory_storage_config) + if getattr(memory_storage_config, 'collection_name', None): + self.collection_name = memory_storage_config.collection_name + if getattr(memory_storage_config, 'persist_path', None): + self.persist_path = memory_storage_config.persist_path + if getattr(memory_storage_config, 'embedding_model', None): + self.embedding_model = memory_storage_config.embedding_model + return self + + def _init_collection(self) -> Any: + """Initialize the ChromaDB collection. + + Returns: + Any: The ChromaDB client instance. + """ + if self.persist_path.startswith('http') or self.persist_path.startswith('https'): + parsed_url = urlparse(self.persist_path) + settings = Settings( + chroma_api_impl="chromadb.api.fastapi.FastAPI", + chroma_server_host=parsed_url.hostname, + chroma_server_http_port=str(parsed_url.port) + ) + else: + settings = Settings( + is_persistent=True, + persist_directory=self.persist_path + ) + client = chromadb.Client(settings) + self._collection = client.get_or_create_collection(name=self.collection_name) + return client + + def delete(self, session_id: str = None, agent_id: str = None, user_id: str = None, **kwargs) -> None: + """Delete memories from the database. + + Args: + session_id (str, optional): The session id of the memory to delete. + agent_id (str, optional): The agent id of the memory to delete. + user_id (str, optional): The user id of the memory to delete. + **kwargs: Additional parameters including: + ids (List[str]): The list of memory ids to delete. + """ + if self._collection is None: + self._init_collection() + + # Support deletion by IDs + if 'ids' in kwargs and kwargs['ids']: + self._collection.delete(ids=kwargs['ids']) + return + + filters = {} + if session_id is None and agent_id is None and user_id is None: + return + if session_id is not None: + filters['session_id'] = session_id + if agent_id is not None: + filters['agent_id'] = agent_id + if user_id is not None: + filters['user_id'] = user_id + self._collection.delete(where=filters) + + def add(self, message_list: List[LongTermMemoryMessage], session_id: str = None, agent_id: str = None, **kwargs) -> None: + """Batch add messages to the memory database. + + Args: + message_list (List[LongTermMemoryMessage]): The list of messages to add. + session_id (str, optional): The session ID. + agent_id (str, optional): The agent ID. + **kwargs: Additional parameters. + """ + if self._collection is None: + self._init_collection() + if not message_list: + return + + # Generate embeddings for all messages + embeddings = self._generate_embeddings_for_messages(message_list) + + # Prepare batch data + ids, documents, metadatas = self._prepare_batch_data(message_list, session_id, agent_id) + + # Execute batch addition + self._execute_batch_addition(ids, documents, metadatas, embeddings) + + def _generate_embeddings_for_messages(self, message_list: List[LongTermMemoryMessage]) -> List[List[float]]: + """Generate embeddings for messages. + + Args: + message_list (List[LongTermMemoryMessage]): List of messages to process. + + Returns: + List[List[float]]: List of embeddings for each message. + """ + embeddings = [] + + if not self.embedding_model: + # Return empty embeddings for all messages if no embedding model + return None + + # Collect texts that need embedding generation + texts_to_embed = [] + + for message in message_list: + # Always generate embeddings for all messages + texts_to_embed.append(message.content) + + # Batch generate embeddings + if texts_to_embed: + try: + embedding_instance = EmbeddingManager().get_instance_obj(self.embedding_model) + batch_embeddings = embedding_instance.get_embeddings(texts_to_embed, text_type="document") + + # Return the generated embeddings + return batch_embeddings if batch_embeddings else [[] for _ in message_list] + + except Exception as e: + LOGGER.warn(f"Batch embedding generation failed: {e}") + return None + + return None + + def _prepare_batch_data(self, message_list: List[LongTermMemoryMessage], + session_id: str = None, agent_id: str = None) -> tuple: + """Prepare batch data for ChromaDB addition. + + Args: + message_list (List[LongTermMemoryMessage]): List of messages to add. + session_id (str, optional): Session ID. + agent_id (str, optional): Agent ID. + + Returns: + tuple: Tuple of (ids, documents, metadatas). + """ + ids = [] + documents = [] + metadatas = [] + + for message in message_list: + # Generate ID + message_id = message.id if message.id else str(uuid.uuid4()) + ids.append(message_id) + + # Document content + documents.append(message.content) + + # Metadata + metadata = self._build_metadata(message, session_id, agent_id) + metadatas.append(metadata) + + return ids, documents, metadatas + + def _build_metadata(self, message: LongTermMemoryMessage, + session_id: str = None, agent_id: str = None) -> dict: + """Build metadata dictionary for a message. + + Args: + message (LongTermMemoryMessage): The message to build metadata for. + session_id (str, optional): Session ID. + agent_id (str, optional): Agent ID. + + Returns: + dict: Metadata dictionary. + """ + if message.update: + update_metadata = { + 'timestamp': datetime.now().isoformat(), + 'category': message.category if message.category else None, + 'related_role': message.related_role if message.related_role else None, + 'user_id': message.user_id or None, + 'agent_id': message.agent_id or agent_id or None, + 'session_id': message.session_id or session_id or None, + 'confidence': message.confidence or None, + 'tags': ",".join(message.tags) if message.tags else None, + 'created_at': message.created_at.isoformat() if message.created_at else None, + 'updated_at':datetime.now().isoformat() + } + unique_update_metadata = {} + for key, value in update_metadata.items(): + if value is not None: + unique_update_metadata[key] = value + return unique_update_metadata + else: + return { + 'timestamp': datetime.now().isoformat(), + 'category': message.category if message.category else MemoryCategoryEnum.DEFAULT.name, + 'related_role': message.related_role if message.related_role else MemoryOwnerEnum.DEFAULT.name, + 'user_id': message.user_id or '', + 'agent_id': message.agent_id or agent_id or '', + 'session_id': message.session_id or session_id or '', + 'confidence': message.confidence, + 'tags': ",".join(message.tags) if message.tags else "", + 'created_at': message.created_at.isoformat() if message.created_at else datetime.now().isoformat(), + 'updated_at': message.updated_at.isoformat() if message.updated_at else datetime.now().isoformat() + } + + + + def _execute_batch_addition(self, ids: List[str], documents: List[str], + metadatas: List[dict], embeddings: List[List[float]]) -> None: + """Execute batch addition to ChromaDB. + + Args: + ids (List[str]): List of IDs. + documents (List[str]): List of documents. + metadatas (List[dict]): List of metadata. + embeddings (List[List[float]]): List of embeddings (can be empty, ChromaDB will auto-generate). + """ + try: + # ChromaDB can handle empty embeddings by auto-generating them + # We pass embeddings regardless of whether they're empty or not + self._collection.upsert( + ids=ids, + documents=documents, + metadatas=metadatas, + embeddings=embeddings if embeddings else None, # Pass None if empty list + ) + + LOGGER.info(f"Successfully batch added {len(ids)} memories") + + except Exception as e: + LOGGER.error(f"Batch memory addition failed: {e}") + self._fallback_to_single_addition(ids, documents, metadatas, embeddings) + + def _fallback_to_single_addition(self, ids: List[str], documents: List[str], + metadatas: List[dict], embeddings: List[List[float]]) -> None: + """Fallback to single addition when batch addition fails. + + Args: + ids (List[str]): List of IDs. + documents (List[str]): List of documents. + metadatas (List[dict]): List of metadata. + embeddings (List[List[float]]): List of embeddings (can be empty). + """ + LOGGER.info("Attempting to add memories one by one...") + success_count = 0 + + for i in range(len(ids)): + try: + # ChromaDB handles empty embeddings automatically + embedding_to_use = embeddings[i] if i < len(embeddings) else None + self._collection.upsert( + ids=[ids[i]], + documents=[documents[i]], + metadatas=[metadatas[i]], + embeddings=[embedding_to_use] if embedding_to_use else None, + ) + success_count += 1 + except Exception as single_error: + LOGGER.error(f"Failed to add single memory (ID: {ids[i]}): {single_error}") + + LOGGER.info(f"One-by-one addition completed, {success_count}/{len(ids)} successful") + + def to_messages(self, result: dict, sort_by_time: bool = False) -> List[LongTermMemoryMessage]: + """Convert the result from ChromaDB to a list of LongTermMemoryMessage. + + Args: + result (dict): The result from ChromaDB. + sort_by_time (bool, optional): Whether to sort the messages by time. + + Returns: + List[LongTermMemoryMessage]: A list of LongTermMemoryMessage. + """ + message_list = [] + if not result or not result['ids']: + return message_list + try: + if self.is_nested_list(result['ids']): + metadatas = result.get('metadatas', [[]]) + documents = result.get('documents', [[]]) + ids = result.get('ids', [[]]) + message_list = [ + LongTermMemoryMessage( + id=ids[0][i], + content=documents[0][i], + metadata=metadatas[0][i], + category=MemoryCategoryEnum(metadatas[0][i].get('category', MemoryCategoryEnum.DEFAULT.name)) if metadatas[0] else MemoryCategoryEnum.DEFAULT, + related_role=MemoryOwnerEnum(metadatas[0][i].get('related_role', MemoryOwnerEnum.DEFAULT.name)) if metadatas[0] else MemoryOwnerEnum.DEFAULT, + user_id=metadatas[0][i].get('user_id', None) if metadatas[0] else None, + agent_id=metadatas[0][i].get('agent_id', None) if metadatas[0] else None, + session_id=metadatas[0][i].get('session_id', None) if metadatas[0] else None, + tags=self._parse_tags_from_string(metadatas[0][i].get('tags', '')) if metadatas[0] else [], + created_at=datetime.fromisoformat(metadatas[0][i].get('created_at', datetime.now().isoformat())) if metadatas[0] else datetime.now(), + updated_at=datetime.fromisoformat(metadatas[0][i].get('updated_at', datetime.now().isoformat())) if metadatas[0] else datetime.now(), + confidence=metadatas[0][i].get('confidence', 1.0) if metadatas[0] else 1.0 + ) + for i in range(len(result['ids'][0])) + ] + else: + metadatas = result.get('metadatas', []) + documents = result.get('documents', []) + ids = result.get('ids', []) + message_list = [ + LongTermMemoryMessage( + id=ids[i], + content=documents[i], + metadata=metadatas[i], + category=MemoryCategoryEnum(metadatas[i].get('category', MemoryCategoryEnum.DEFAULT.name)) if metadatas[i] else MemoryCategoryEnum.DEFAULT, + related_role=MemoryOwnerEnum(metadatas[i].get('related_role', MemoryOwnerEnum.DEFAULT.name)) if metadatas[i] else MemoryOwnerEnum.DEFAULT, + user_id=metadatas[i].get('user_id', None) if metadatas[i] else None, + agent_id=metadatas[i].get('agent_id', None) if metadatas[i] else None, + session_id=metadatas[i].get('session_id', None) if metadatas[i] else None, + tags=self._parse_tags_from_string(metadatas[i].get('tags', '')) if metadatas[i] else [], + created_at=datetime.fromisoformat(metadatas[i].get('created_at', datetime.now().isoformat())) if metadatas[i] else datetime.now(), + updated_at=datetime.fromisoformat(metadatas[i].get('updated_at', datetime.now().isoformat())) if metadatas[i] else datetime.now(), + confidence=metadatas[i].get('confidence', 1.0) if metadatas[i] else 1.0, + ) + for i in range(len(result['ids'])) + ] + if sort_by_time: + # Order by timestamp ascending + message_list = sorted( + message_list, + key=lambda msg: msg.created_at, + ) + except Exception as e: + LOGGER.error('ChromaMemory.to_messages failed, exception= ' + str(e)) + return message_list + + + def _get_embedding(self, text: str, text_type: str = "document") -> List[float]: + """Get embedding for a text using the configured embedding model. + + Args: + text (str): The text to embed. + text_type (str, optional): Type of text ("document" or "query"). + + Returns: + List[float]: The embedding vector. + + Raises: + ValueError: If no embedding model is configured. + """ + if not self.embedding_model: + raise ValueError("No embedding model configured. Please specify an embedding_model.") + + try: + embedding_instance = EmbeddingManager().get_instance_obj(self.embedding_model) + embeddings = embedding_instance.get_embeddings([text], text_type=text_type) + return embeddings[0] if embeddings else [] + except Exception as e: + # For testing purposes, if embedding manager fails, return empty list + LOGGER.error(f"Failed to get embeddings: {e}") + return [] + + + def get(self, session_id: str = None, agent_id: str = None, top_k: int = 10, + query: str = None, user_id: str = None, tags: List[str] = None, + time_range: tuple = None, **kwargs) -> List[LongTermMemoryMessage]: + """Get messages from the memory database (compatible with base class interface). + + Args: + session_id (str, optional): The session ID. + agent_id (str, optional): The agent ID. + top_k (int, optional): The number of messages to return. + query (str, optional): Query text for similarity search. + user_id (str, optional): User ID for filtering. + tags (List[str], optional): List of tags for filtering. + time_range (tuple, optional): Time range tuple (start_time, end_time). + **kwargs: Additional parameters passed to search method. + + Returns: + List[LongTermMemoryMessage]: List of memory messages. + """ + # Pass all parameters explicitly to the search method + return self.search( + query=query, + top_k=top_k, + session_id=session_id, + agent_id=agent_id, + user_id=user_id, + tags=tags, + time_range=time_range, + **kwargs + ) + + def search(self, query: str = None, top_k: int = 10, session_id: str = None, agent_id: str = None, + user_id: str = None, tags: List[str] = None, time_range: tuple = None, + **kwargs) -> List[LongTermMemoryMessage]: + """Advanced search method for long-term memory with extended parameters. + + Args: + query (str, optional): Query text for similarity search. + top_k (int, optional): Return top k most similar results (only for similarity search). + session_id (str, optional): Session ID. + agent_id (str, optional): Agent ID. + user_id (str, optional): User ID. + tags (List[str], optional): List of tags for filtering. + time_range (tuple, optional): Time range tuple (start_time, end_time). + **kwargs: Other filter conditions. + + Returns: + List[LongTermMemoryMessage]: List of search result memories. + + Raises: + ValueError: Raised when both query and all condition parameters are empty. + """ + # Validation: cannot be empty simultaneously + has_query = query is not None and query.strip() != "" + has_conditions = any([ + agent_id is not None and agent_id.strip() != "", + user_id is not None and user_id.strip() != "", + tags is not None and len(tags) > 0, + time_range is not None, + any(kwargs.values()) + ]) + + if not has_query and not has_conditions: + raise ValueError("Search parameters cannot be empty simultaneously. Please provide query text or at least one search condition (agent_id, user_id, tags, time_range, etc.)") + + if self._collection is None: + self._init_collection() + + # Build filter conditions + filters = {"$and": []} + + # Build conditional filters + if agent_id: + filters["$and"].append({'agent_id': agent_id}) + + if session_id: + filters["$and"].append({'session_id': session_id}) + + if user_id: + filters["$and"].append({'user_id': user_id}) + + if tags: + # Search for memories containing any of the tags + tag_filters = [] + for tag in tags: + # Use string matching instead of list contains + tag_filters.append({'tags': {'$like': f'%{tag}%'}}) + if tag_filters: + filters["$and"].append({'$or': tag_filters}) + if time_range: + start_time, end_time = time_range + if start_time: + filters["$and"].append({'created_at': {'$gte': start_time}}) + if end_time: + filters["$and"].append({'created_at': {'$lte': end_time}}) + + # Handle other filter conditions + for key, value in kwargs.items(): + if key in ['category']: + filters["$and"].append({key: value}) + + # Simplify filters + if len(filters["$and"]) == 1: + filters = filters["$and"][0] + elif not filters["$and"]: + filters = {} + + return self._query_memories(filters, query, top_k) + + def _query_memories(self, filters: dict, query: str, top_k: int) -> List[LongTermMemoryMessage]: + """Execute memory query with filters and optional query text. + + Args: + filters (dict): Filter conditions for the query + query (str): Query text for similarity search + top_k (int): Number of results to return + + Returns: + List[LongTermMemoryMessage]: List of memory messages + """ + try: + if query: + # When query text is provided, use query method for vector search (supports conditional filtering) + embedding = self._get_embedding(query, text_type="query") + if len(embedding) > 0: + results = self._collection.query( + query_embeddings=embedding, + where=filters, + n_results=top_k + ) + else: + results = self._collection.query( + query_texts=[query], + where=filters, + n_results=top_k + ) + messages = self.to_messages(result=results) + return messages + else: + # No query text, only conditional search + results = self._collection.get(where=filters) + messages = self.to_messages(result=results, sort_by_time=True) + # If top_k is specified, limit return count + if top_k > 0: + return messages[:top_k] + return messages + + except Exception as e: + LOGGER.error(f'ChromaMemory.search failed, exception= {str(e)}') + return [] + + def _parse_tags_from_string(self, tags_str: str) -> List[str]: + """Parse comma-separated tags string into a list. + + Args: + tags_str (str): The comma-separated tags string + + Returns: + List[str]: List of parsed tags + """ + if not tags_str: + return [] + # Split string and remove whitespace + return [tag.strip() for tag in tags_str.split(",") if tag.strip()] + + @staticmethod + def is_nested_list(variable: List) -> bool: + """Check if a variable is a nested list.""" + return isinstance(variable, list) and len(variable) > 0 and isinstance(variable[0], list) \ No newline at end of file diff --git a/agentuniverse/agent/memory/memory.py b/agentuniverse/agent/memory/memory.py index 12e0da3bf..889df803a 100644 --- a/agentuniverse/agent/memory/memory.py +++ b/agentuniverse/agent/memory/memory.py @@ -13,6 +13,8 @@ from agentuniverse.agent.memory.enum import MemoryTypeEnum from agentuniverse.agent.memory.memory_compressor.memory_compressor import MemoryCompressor from agentuniverse.agent.memory.memory_compressor.memory_compressor_manager import MemoryCompressorManager +from agentuniverse.agent.memory.memory_extract.memory_extract import MemoryExtract, LongTermMemoryMessage +from agentuniverse.agent.memory.memory_extract.memory_extractor_manager import MemoryExtractorManager from agentuniverse.agent.memory.memory_storage.memory_storage import MemoryStorage from agentuniverse.agent.memory.memory_storage.memory_storage_manager import MemoryStorageManager from agentuniverse.agent.memory.message import Message @@ -32,6 +34,8 @@ class Memory(ComponentBase): description (Optional[str]): The description of the memory class. type (MemoryTypeEnum): The type of the memory class including `long-term` and `short-term`. memory_key (Optional[str]): The name of the memory key in the prompt. + long_term_memory_key (Optional[str]): The name of the long term memory key in the prompt. + long_tern_memory_extractors (Optional[List[str]]): The name list of the memory extractor. max_tokens (int): The maximum number of tokens allowed in the prompt. memory_compressor (Optional[str]): The name of the memory compressor instance. memory_storages (Optional[str]): The name list of the memory storage instances. @@ -42,6 +46,8 @@ class Memory(ComponentBase): description: Optional[str] = None type: MemoryTypeEnum = None memory_key: Optional[str] = 'chat_history' + long_term_memory_key: Optional[str] = None + long_tern_memory_extractors: Optional[List[str]] = None max_tokens: int = 2000 memory_compressor: Optional[str] = None memory_storages: Optional[List[str]] = ['ram_memory_storage'] @@ -58,7 +64,7 @@ def as_langchain(self) -> BaseMemory: """Convert the agentUniverse(aU) memory class to the langchain memory class.""" pass - def add(self, message_list: List[Message], session_id: str = None, agent_id: str = None, + def add(self, message_list: List[Message], session_id: str = None, agent_id: str = None, user_id: str = None, **kwargs) -> None: """Add messages to the memory.""" if not message_list: @@ -67,6 +73,11 @@ def add(self, message_list: List[Message], session_id: str = None, agent_id: str memory_storage: MemoryStorage = MemoryStorageManager().get_instance_obj(storage) if memory_storage: memory_storage.add(message_list, session_id, agent_id, **kwargs) + if self.long_tern_memory_extractors: + for extractor in self.long_tern_memory_extractors: + memory_extractor: MemoryExtract = MemoryExtractorManager().get_instance_obj(extractor) + if memory_extractor: + memory_extractor.extract_and_store(message_list, session_id, agent_id, user_id, **kwargs) def delete(self, session_id: str = None, **kwargs) -> None: """Delete messages from the memory.""" @@ -74,6 +85,11 @@ def delete(self, session_id: str = None, **kwargs) -> None: memory_storage: MemoryStorage = MemoryStorageManager().get_instance_obj(storage) if memory_storage: memory_storage.delete(session_id, **kwargs) + if self.long_tern_memory_extractors: + for extractor in self.long_tern_memory_extractors: + memory_extractor: MemoryExtract = MemoryExtractorManager().get_instance_obj(extractor) + if memory_extractor: + memory_extractor.delete_memory_by_session_id(session_id, **kwargs) def get(self, session_id: str = None, agent_id: str = None, prune: bool = False, **kwargs) -> List[Message]: """Get messages from the memory.""" @@ -85,6 +101,22 @@ def get(self, session_id: str = None, agent_id: str = None, prune: bool = False, return memories return [] + def search_long_term_memory(self, query: str = None, top_k=10, session_id: str = None, agent_id: str = None, user_id: str = None, + tags: List[str] = None, time_range:(str, str) = None) -> List[Message]: + """Get messages from the long term memory.""" + long_term_memory_messages = [] + if self.long_tern_memory_extractors: + for extractor in self.long_tern_memory_extractors: + memory_extractor: MemoryExtract = MemoryExtractorManager().get_instance_obj(extractor) + if memory_extractor: + long_term_memory_messages.extend(memory_extractor.search_memories(query=query, top_k=top_k, + session_id=session_id, + agent_id=agent_id, + user_id=user_id, tags=tags, + time_range=time_range)) + return long_term_memory_messages + + def get_with_no_prune(self, session_id: str = None, agent_id: str = None, **kwargs) -> List[Message]: """Get messages from the memory.""" memory_storage: MemoryStorage = MemoryStorageManager().get_instance_obj(self.memory_retrieval_storage) @@ -173,6 +205,10 @@ def initialize_by_component_configer(self, component_configer: MemoryConfiger) - self.memory_retrieval_storage = self.memory_storages[0] if component_configer.memory_summarize_agent: self.summarize_agent_id = component_configer.memory_summarize_agent + if component_configer.long_tern_memory_extractors: + self.long_tern_memory_extractors = component_configer.long_tern_memory_extractors + if component_configer.long_term_memory_key: + self.long_term_memory_key = component_configer.long_term_memory_key return self def create_copy(self): diff --git a/agentuniverse/agent/memory/memory_extract/__init__.py b/agentuniverse/agent/memory/memory_extract/__init__.py new file mode 100644 index 000000000..59e43f9fd --- /dev/null +++ b/agentuniverse/agent/memory/memory_extract/__init__.py @@ -0,0 +1,7 @@ +# !/usr/bin/env python3 +# -*- coding:utf-8 -*- + +# @Time : 2025/12/03 12:55 +# @Author : pengqingsong.pqs +# @Email : pengqingsong.pqs@antgroup.com +# @FileName: __init__.py.py diff --git a/agentuniverse/agent/memory/memory_extract/default_memory_extract_config.yaml b/agentuniverse/agent/memory/memory_extract/default_memory_extract_config.yaml new file mode 100644 index 000000000..41f490b4f --- /dev/null +++ b/agentuniverse/agent/memory/memory_extract/default_memory_extract_config.yaml @@ -0,0 +1,28 @@ +# 长期记忆配置 +name: "default_text_memory_extractor" +description: "use for text memory extract, extract text memory from input" + +# 基础配置 +enabled: true +top_k: 5 +max_workers: 20 + +# 存储配置 +memory_storage: "chroma_long_term_memory_storage" + +# Prompt配置 +extract_prompt_version: "long_term_memory.facts_extract_cn" +operation_prompt_version: "long_term_memory.memory_operation_cn" + +# 模型配置 +extraction_llm: "deepseek_llm" +operation_llm: "deepseek_llm" + +# 阈值配置 +max_memories_per_user: 1000 +max_memories_per_agent: 1000 + +metadata: + type: 'MEMORY_EXTRACTOR' + module: 'agentuniverse.agent.memory.memory_extract.memory_extract' + class: 'MemoryExtract' \ No newline at end of file diff --git a/agentuniverse/agent/memory/memory_extract/facts_extract_cn_prompt.yaml b/agentuniverse/agent/memory/memory_extract/facts_extract_cn_prompt.yaml new file mode 100644 index 000000000..389e83c29 --- /dev/null +++ b/agentuniverse/agent/memory/memory_extract/facts_extract_cn_prompt.yaml @@ -0,0 +1,65 @@ +summarizer: | + 你是一名个人信息整理助手,专门负责准确存储来自用户和助理消息中的事实。你的主要职责是提取相关信息,并将其整理到不同的类别中,以便在未来的互动中轻松检索和实现个性化服务。以下是需要重点关注的信息类型以及处理输入数据的详细说明。 + + 需要记忆的信息类型: + 1.存储个人偏好:记录用户在饮食、产品、活动、娱乐等各类别中的喜好、厌恶及具体偏好(来自用户)。 + 2.维护重要个人细节:记住姓名、人际关系、重要日期等关键个人信息(来自用户)。 + 3.追踪计划与意图:记录用户分享的即将发生的事件、旅行安排、目标及其他计划(来自用户)。 + 4.记忆活动与服务偏好:回顾用户在餐饮、旅行、爱好及其他服务方面的偏好(来自用户)。 + 5.关注健康与生活习惯偏好:记录饮食限制、健身习惯及其他与健康相关的信息(来自用户)。 + 6.存储职业信息:记住职位、工作习惯、职业目标及其他专业相关信息(来自用户)。 + 7.管理其他杂项信息:记录用户分享的喜爱的书籍、电影、品牌及其他杂项细节(来自用户)。 + 8.记录助理信息:追踪从助理的回复中识别出的行为倾向、常用表达、偏好或模式(来自助理)。 + + + 提取规则: + 1.分别为用户和助理提取信息。 + 2.如果助理表现出明确的行为或模式(例如推荐某个项目、使用特定表达),则将其提取为助理信息。 + 3.如果助理仅回复事实内容,或未分享偏好或行为模式,则不应为助理提取任何信息。 + 4.如果用户提及与其偏好、意图或其他细节相关的内容,则将其记录在用户下。 + 5.如果用户提供模糊、中性或哲学性的信息,则为用户和助理返回空列表。 + 6.要记录助理信息,助理应表达出反映其潜在偏好或模式的特定行为、推荐或细节。 + + 记忆所属角色(related_role)必须为以下之一: + - USER:用户,仅提取用户明确分享的相关个人偏好、计划或其他细节。 + - ASSISTANT:助手,仅当助理提供反映其潜在模式的特定推荐、习惯或表达时,才提取其行为或偏好。 + - DEFAULT:默认,无法区分或者都涉及的事实 + + 信息分类(category)必须为以下之一: + - FACTUAL:事实性信息,如用户是一个老师、用户偏好带emoji的输出,当前业务场景是金融场景等 + - EPISODIC:情景信息,如用户在某个场景下的行为、用户在某个场景下的情绪等 + - SEMANTIC:语义信息,如相关概念及其关联关系的理解 + - EXPERT:专家经验,如专业知识、解决问题的SOP等 + - DEFAULT:其他不属于上面分类的信息 + + 对话内容: + {conversation_text} + + 当前日期:{date} + + 请从对话中提取关键事实信息,并分类,输出格式如下: + {{ + "facts": [ + {{ + "fact": "事实1", + "category": "分类", + "related_role": "关联的角色,USER/ASSISTANT/DEFAULT" + }}, + {{ + "fact": "事实2", + "category": "分类", + "related_role": "关联的角色,USER/ASSISTANT/DEFAULT" + }}, + ... + ] + }} + + 注意: + - 始终清晰区分用户和助理事实。 + - 除非涉及明确的偏好或模式,否则不要包含模糊或哲学性内容。 + - 基于用户和助理的消息创建事实,并按上述格式返回。 + 你的回答: + +metadata: + type: 'PROMPT' + version: 'long_term_memory.facts_extract_cn' diff --git a/agentuniverse/agent/memory/memory_extract/facts_extract_en_prompt.yaml b/agentuniverse/agent/memory/memory_extract/facts_extract_en_prompt.yaml new file mode 100644 index 000000000..adf0f70c0 --- /dev/null +++ b/agentuniverse/agent/memory/memory_extract/facts_extract_en_prompt.yaml @@ -0,0 +1,66 @@ +summarizer: | + You are a personal information organization assistant, specifically responsible for accurately storing facts from user and assistant messages. Your main responsibility is to extract relevant information and organize it into different categories for easy retrieval and personalized service in future interactions. Below are the types of information that need attention and detailed instructions for processing input data. + + Information types to remember: + 1. Store personal preferences: Record user likes, dislikes, and specific preferences in various categories such as diet, products, activities, entertainment, etc. (from user). + 2. Maintain important personal details: Remember key personal information such as names, relationships, important dates, etc. (from user). + 3. Track plans and intentions: Record upcoming events, travel arrangements, goals, and other plans shared by the user (from user). + 4. Remember activity and service preferences: Review user preferences in dining, travel, hobbies, and other services (from user). + 5. Focus on health and lifestyle preferences: Record dietary restrictions, fitness habits, and other health-related information (from user). + 6. Store professional information: Remember positions, work habits, career goals, and other professional-related information (from user). + 7. Manage other miscellaneous information: Record favorite books, movies, brands, and other miscellaneous details shared by the user (from user). + 8. Record assistant information: Track behavioral tendencies, common expressions, preferences, or patterns identified from assistant responses (from assistant). + + Extraction rules: + 1. Extract information separately for users and assistants. + 2. If the assistant demonstrates clear behaviors or patterns (such as recommending certain items, using specific expressions), extract them as assistant information. + 3. If the assistant only replies with factual content or does not share preferences or behavioral patterns, do not extract any information for the assistant. + 4. If the user mentions content related to their preferences, intentions, or other details, record it under the user. + 5. If the user provides vague, neutral, or philosophical information, return empty lists for both user and assistant. + 6. To record assistant information, the assistant should express specific behaviors, recommendations, or details that reflect their underlying preferences or patterns. + + Memory ownership roles (related_role) must be one of the following: + - USER: User, only extract relevant personal preferences, plans, or other details explicitly shared by the user. + - ASSISTANT: Assistant, only extract behaviors or preferences when the assistant provides specific recommendations, habits, or expressions that reflect their underlying patterns. + - DEFAULT: Default, facts that cannot be distinguished or involve both + + Information categories (category) must be one of the following: + - FACTUAL: Factual information, such as the user is a teacher, the user prefers emoji outputs, the current business scenario is financial, etc. + - EPISODIC: Episodic information, such as user behavior in a specific scenario, user emotions in a specific scenario, etc. + - SEMANTIC: Semantic information, such as understanding of related concepts and their relationships + - EXPERT: Expert knowledge, such as professional knowledge, problem-solving SOPs, etc. + - DEFAULT: Other information that does not belong to the above categories + + Note: Search results and code do not belong to facts and do not need to be extracted. + + Conversation content: + {conversation_text} + + Current date: {date} + + Please extract key factual information from the conversation and categorize it, output in the following format: + {{ + "facts": [ + {{ + "fact": "Fact 1", + "category": "Category", + "related_role": "Associated role, USER/ASSISTANT/DEFAULT" + }}, + {{ + "fact": "Fact 2", + "category": "Category", + "related_role": "Associated role, USER/ASSISTANT/DEFAULT" + }}, + ... + ] + }} + + Note: + - Always clearly distinguish between user and assistant facts. + - Do not include vague or philosophical content unless it involves clear preferences or patterns. + - Create facts based on user and assistant messages, and return in the format above. + Your answer: + +metadata: + type: 'PROMPT' + version: 'long_term_memory.facts_extract_en' diff --git a/agentuniverse/agent/memory/memory_extract/memory_extract.py b/agentuniverse/agent/memory/memory_extract/memory_extract.py new file mode 100644 index 000000000..6fdbfc77e --- /dev/null +++ b/agentuniverse/agent/memory/memory_extract/memory_extract.py @@ -0,0 +1,596 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +# @Time : 2025/12/03 13:55 +# @Author : pengqingsong.pqs +# @Email : pengqingsong.pqs@antgroup.com +# @FileName: memory_extract.py + +import asyncio +import json +import queue +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime +from enum import Enum +from typing import Optional, List, Dict, Set +from collections import defaultdict +from threading import Lock +from queue import Queue + +from langchain_core.prompts import PromptTemplate +from pydantic import BaseModel, Field + +from agentuniverse.agent.memory.enum import ChatMessageEnum +from agentuniverse.agent.memory.memory_storage.memory_storage import MemoryStorage +from agentuniverse.agent.memory.memory_storage.memory_storage_manager import MemoryStorageManager +from agentuniverse.agent.memory.message import Message +from agentuniverse.base.component.component_base import ComponentBase +from agentuniverse.base.component.component_enum import ComponentEnum +from agentuniverse.base.config.component_configer.configers.memory_extract_configer import MemoryExtractConfiger +from agentuniverse.base.util.logging.logging_util import LOGGER +from agentuniverse.llm.llm import LLM +from agentuniverse.llm.llm_manager import LLMManager +from agentuniverse.prompt.prompt import Prompt +from agentuniverse.prompt.prompt_manager import PromptManager + + +class MemoryCategoryEnum(Enum): + """Memory category enumeration.""" + FACTUAL = "FACTUAL" # Factual memory + EPISODIC = "EPISODIC" # Episodic memory + SEMANTIC = "SEMANTIC" # Semantic memory + EXPERT = "EXPERT" # Expert experience + DEFAULT = "DEFAULT" # Default category + + +class MemoryOperationEnum(Enum): + """Memory operation enumeration.""" + ADD = "ADD" + UPDATE = "UPDATE" + DELETE = "DELETE" + NONE = "NONE" + +class MemoryOwnerEnum(Enum): + """Memory owner enumeration.""" + USER = "USER" + ASSISTANT = "ASSISTANT" + DEFAULT = "DEFAULT" + + +class LongTermMemoryMessage(Message): + """Memory entity.""" + category: MemoryCategoryEnum + related_role: MemoryOwnerEnum + user_id: Optional[str] = None + agent_id: Optional[str] = None + session_id: Optional[str] = None + tags: List[str] = Field(default_factory=list) + created_at: datetime = Field(default_factory=datetime.now) + updated_at: datetime = Field(default_factory=datetime.now) + confidence: float = Field(default=1.0, ge=0.0, le=1.0) # Memory confidence + update: bool = False + + class Config: + arbitrary_types_allowed = True + use_enum_values = True + + +class MemoryOperation(BaseModel): + """Memory operation.""" + id: Optional[str] = None + text: Optional[str] = None + event: MemoryOperationEnum + category: MemoryCategoryEnum + related_role: MemoryOwnerEnum + old_memory: Optional[str] = None + + class Config: + use_enum_values = True + + +class MemoryOperations(BaseModel): + """Memory operations collection.""" + memory: List[MemoryOperation] = Field(default_factory=list) + + class Config: + use_enum_values = True + + +class MemoryExtract(ComponentBase): + """Memory extraction component. + + Responsible for extracting key information from short-term memory + and storing it in MemoryStorage as long-term memory. + """ + + name: str = "" + description: Optional[str] = None + memory_storage: str = None + extract_prompt_version: str = None + operation_prompt_version: str = None + + # Configuration attributes + enabled: bool = True + top_k: int = 5 + extraction_llm: str = None + operation_llm: str = None + max_workers: int = 20 + + max_memories_per_user: int = 1000 + max_memories_per_agent: int = 1000 + + # Thread pool for asynchronous processing + _executor: Optional[ThreadPoolExecutor] = None + # Session task queues to ensure ordered execution + _session_queues: Dict[str, Queue] = defaultdict(Queue) + _queue_locks: Dict[str, Lock] = defaultdict(Lock) + _queue_processors: Set[str] = set() + + class Config: + arbitrary_types_allowed = True + + def __init__(self, **kwargs): + super().__init__(component_type=ComponentEnum.MEMORY_EXTRACTOR, **kwargs) + self._executor = ThreadPoolExecutor(max_workers=self.max_workers, thread_name_prefix="MemoryExtract") + + def extract_and_store(self, messages: List[Message], session_id: str = None, agent_id: str = None, + user_id: str = None, **kwargs) -> None: + """Extract and store memories from messages. + + Args: + messages (List[Message]): List of messages. + session_id (str, optional): Session ID. + agent_id (str, optional): Agent ID. + user_id (str, optional): User ID. + **kwargs: Additional parameters. + """ + if not self.enabled: + return + + # Use default session if no session_id provided + effective_session_id = session_id or "default_session" + + # Submit task to session-specific queue + self._submit_to_session_queue(messages, effective_session_id, user_id, agent_id) + + def _submit_to_session_queue(self, messages: List[Message], session_id: str, user_id: str, agent_id: str) -> None: + """Submit memory extraction task to session-specific queue. + + Args: + messages (List[Message]): List of messages. + session_id (str): Session ID. + user_id (str): User ID. + agent_id (str): Agent ID. + """ + # Add task to session queue + task_data = (messages, session_id, user_id, agent_id) + self._session_queues[session_id].put(task_data) + + # Start queue processor if not already running + with self._queue_locks[session_id]: + if session_id not in self._queue_processors: + self._queue_processors.add(session_id) + self._executor.submit(self._process_session_queue, session_id) + + def _process_session_queue(self, session_id: str) -> None: + """Process tasks in session queue sequentially. + + Args: + session_id (str): Session ID. + """ + # Session processor idle timeout (5 minutes) + idle_timeout = 300 # 5 minutes in seconds + + try: + last_activity_time = datetime.now() + + while True: + # Calculate remaining timeout + current_time = datetime.now() + time_since_last_activity = (current_time - last_activity_time).total_seconds() + remaining_timeout = max(0, idle_timeout - time_since_last_activity) + + # Get next task from queue with timeout + try: + task_data = self._session_queues[session_id].get(timeout=remaining_timeout) + last_activity_time = datetime.now() # Reset activity timer + except queue.Empty: + # Queue empty for timeout period, exit gracefully + LOGGER.info(f"Session queue processor for session {session_id} exiting due to inactivity") + break + + # Execute the task + try: + messages, session_id, user_id, agent_id = task_data + self._run_extract_and_store(messages, session_id, user_id, agent_id) + except Exception as e: + LOGGER.error(f"Memory extraction task failed for session {session_id}: {e}") + + except Exception as e: + LOGGER.error(f"Session queue processor failed for session {session_id}: {e}") + finally: + # Clean up queue processor + with self._queue_locks[session_id]: + self._queue_processors.discard(session_id) + # Clean up empty queue to avoid memory leaks + if session_id in self._session_queues and self._session_queues[session_id].empty(): + del self._session_queues[session_id] + del self._queue_locks[session_id] + + def _run_extract_and_store(self, messages: List[Message], session_id: str, user_id: str, agent_id: str) -> None: + """Synchronous wrapper method to run async tasks in thread pool.""" + loop = asyncio.new_event_loop() + try: + asyncio.set_event_loop(loop) + loop.run_until_complete(self._extract_and_store_memory(messages, session_id, user_id, agent_id)) + except Exception as e: + LOGGER.error(f"Memory extraction task failed: {e}") + finally: + loop.close() + + async def _extract_and_store_memory(self, messages: List['Message'], + session_id: str, user_id: str, agent_id: str) -> None: + """Extract and store memory.""" + try: + # 1. Extract factual information + facts = await self._extract_facts(messages) + + if not facts: + return + + # 2. Recall related historical memories + facts_query = " ".join([fact.get("fact", "") for fact in facts]) + related_memories = self.search_memories( + query=facts_query, + session_id=session_id, + user_id=user_id, + agent_id=agent_id, + top_k=self.top_k + ) + + if related_memories: + # 3. Let LLM determine memory operations + operations = await self._determine_memory_operations(facts, related_memories) + else: + # If no related historical memories, directly convert facts to add operations + operations = self._convert_facts_to_add_operations(facts) + + if not operations.memory: + return + # 4. Execute memory operations + await self._execute_memory_operations(operations, user_id, agent_id, session_id) + + LOGGER.info(f"Successfully extracted and stored {len(facts)} memories") + + except Exception as e: + LOGGER.error(f"Memory extraction and storage failed: {e}") + + async def _extract_facts(self, messages: List['Message']) -> List[dict]: + """Extract factual information using LLM.""" + + try: + # Build extraction prompt - only use the latest 2 messages + recent_messages = messages[-2:] if len(messages) >= 2 else messages + conversation_text = "\n".join([ + f"{msg.content}" for msg in recent_messages if msg.content + ]) + + # Load prompt from yaml file + prompt_template = self._load_prompt(self.extract_prompt_version) + prompt = prompt_template.format(conversation_text=conversation_text, date=datetime.now().strftime("%Y-%m-%d")) + + llm: LLM = LLMManager().get_instance_obj(self.extraction_llm) + if llm: + result = await self._call_llm(llm, prompt) + return result.get("facts", []) + + return [] + + except Exception as e: + LOGGER.error(f"Memory extraction and storage failed: {e}") + return [] + + async def _call_llm(self, llm: LLM, prompt: str) -> dict: + """Call LLM with prompt and parse JSON response. + + Args: + llm (LLM): The LLM instance to call. + prompt (str): The prompt to send to LLM. + + Returns: + dict: Parsed JSON response from LLM. + """ + messages = [ + { + "role": ChatMessageEnum.USER.value, + "content": prompt, + } + ] + output = llm.call(messages=messages, streaming=False) + result_text = output.text + if result_text.strip().startswith('```json') and result_text.strip().endswith('```'): + result_text = result_text.strip()[7:-3].strip() + elif result_text.strip().startswith('```') and result_text.strip().endswith('```'): + result_text = result_text.strip()[3:-3].strip() + result = json.loads(result_text) + return result + + async def _determine_memory_operations(self, facts: List[dict], + related_memories: List[Message]) -> MemoryOperations: + """Let LLM determine memory operations.""" + try: + # Build related memory text + facts_text = "\n".join([ + f"Fact: {fact.get('fact', '')}, Category: {fact.get('category', '')}, Role: {fact.get('related_role', '')}" + for fact in facts + ]) + + memories_text = "\n".join([ + f"ID: {mem.id}, Content: {mem.content}" for mem in related_memories + ]) + + # Load prompt from yaml file + prompt_template = self._load_prompt(self.operation_prompt_version) + prompt = prompt_template.format(new_facts=facts_text, related_memories=memories_text, date=datetime.now().strftime("%Y-%m-%d")) + + llm: LLM = LLMManager().get_instance_obj(self.operation_llm) + if llm: + result = await self._call_llm(llm, prompt) + return MemoryOperations(**result) + + return MemoryOperations() + + except Exception as e: + LOGGER.error(f"Memory operation determination failed: {e}") + return MemoryOperations() + + async def _execute_memory_operations(self, operations: MemoryOperations, + user_id: str, agent_id: str, session_id: str) -> None: + """Execute memory operations (batch processing).""" + # Group operations + add_memories = [] + update_memories = [] + delete_ids = [] + + for operation in operations.memory: + if operation.event == MemoryOperationEnum.ADD.name: + # Create MemoryEntity for batch addition + memory_agent_id, memory_user_id = await self.determine_memory_related_info(agent_id, operation, user_id) + memory_entity = LongTermMemoryMessage( + content=operation.text, + category=operation.category or MemoryCategoryEnum.DEFAULT.value, + related_role=operation.related_role or MemoryOwnerEnum.DEFAULT.value, + user_id=memory_user_id, + agent_id=memory_agent_id, + session_id=session_id, + created_at=datetime.now(), + updated_at=datetime.now() + ) + add_memories.append(memory_entity) + elif operation.event == MemoryOperationEnum.UPDATE.name: + # Create MemoryEntity for batch update + memory_agent_id, memory_user_id = await self.determine_memory_related_info(agent_id, operation, user_id) + if operation.id and operation.text: + memory_entity = LongTermMemoryMessage( + id=operation.id, + content=operation.text, + category=operation.category, + related_role=operation.related_role, + user_id=memory_user_id, + agent_id=memory_agent_id, + session_id=session_id, + update=True, + updated_at=datetime.now() + ) + update_memories.append(memory_entity) + elif operation.event == MemoryOperationEnum.DELETE.name: + # Collect deletion IDs + if operation.id: + delete_ids.append(operation.id) + + # Batch addition + if add_memories: + await self._upsert_memory(add_memories) + + # Batch update + if update_memories: + await self._upsert_memory(update_memories) + + # Batch deletion + if delete_ids: + await self._delete_memory(delete_ids) + + async def determine_memory_related_info(self, agent_id, operation, user_id): + # Determine user_id and agent_id based on related_role + memory_user_id = None + memory_agent_id = None + if operation.related_role == MemoryOwnerEnum.USER.name: + memory_user_id = user_id + memory_agent_id = None + elif operation.related_role == MemoryOwnerEnum.ASSISTANT.name: + memory_user_id = None + memory_agent_id = agent_id + else: # DEFAULT or other cases + memory_user_id = user_id + memory_agent_id = agent_id + return memory_agent_id, memory_user_id + + async def _delete_memory(self, memory_id: List[str]) -> bool: + """Delete memory.""" + try: + if not self.memory_storage: + LOGGER.warning("MemoryStorage not initialized, cannot delete memory") + return False + + memory_storage: MemoryStorage = MemoryStorageManager().get_instance_obj(self.memory_storage) + if memory_storage: + memory_storage.delete(ids=memory_id) + return True + + except Exception as e: + LOGGER.error(f"Delete memory failed: {e}") + return False + + async def _upsert_memory(self, memories: List[LongTermMemoryMessage]) -> bool: + """Store single memory to MemoryStorage.""" + try: + if not self.memory_storage: + LOGGER.warning("MemoryStorage not initialized, cannot store memory") + return False + + memory_storage: MemoryStorage = MemoryStorageManager().get_instance_obj(self.memory_storage) + if memory_storage: + memory_storage.add(memories) + return True + + except Exception as e: + LOGGER.error(f"Store memory failed: {e}") + return False + + def search_memories(self, query: str = None, top_k: int = 10, session_id: str = None, agent_id: str = None, + user_id: str = None, tags: List[str] = None, time_range: tuple = None) -> List[Message]: + """Search memories. + + Args: + query (str, optional): Query string for embedding retrieval. + top_k (int, optional): Return count. + session_id (str, optional): Session ID. + agent_id (str, optional): Agent ID. + user_id (str, optional): User ID. + tags (List[str], optional): Tags for filtering. + time_range (tuple, optional): Time range. + + Returns: + List[Message]: List of memory entities. + """ + if not self.memory_storage: + LOGGER.warning("MemoryStorage not initialized, cannot store memory") + return [] + + try: + memory_storage: MemoryStorage = MemoryStorageManager().get_instance_obj(self.memory_storage) + # Use the unified get method which now supports all search parameters via kwargs + return memory_storage.get( + session_id=session_id, + agent_id=agent_id, + top_k=top_k, + query=query, + user_id=user_id, + tags=tags, + time_range=time_range + ) + except Exception as e: + LOGGER.error(f"Memory search failed: {e}") + return [] + + async def delete_memory_by_session_id(self, session_id: str = None, **kwargs) -> bool: + """Delete memories by session_id. + + Args: + session_id (str, optional): Session ID. + **kwargs: Additional parameters. + + Returns: + bool: True if deletion was successful, False otherwise. + """ + try: + if not self.memory_storage: + LOGGER.warning("MemoryStorage not initialized, cannot delete memory") + return False + + # Use MemoryStorage for deletion + memory_storage: MemoryStorage = MemoryStorageManager().get_instance_obj(self.memory_storage) + if memory_storage: + memory_storage.delete(session_id=session_id) + + except Exception as e: + LOGGER.error(f"Soft delete memory failed: {e}") + return False + + def _load_prompt(self, prompt_version: str) -> PromptTemplate: + """Load prompt template from yaml file. + + Args: + prompt_version (str): The prompt version identifier. + + Returns: + PromptTemplate: The loaded prompt template. + + Raises: + Exception: If the prompt version is not found. + """ + version_prompt: Prompt = PromptManager().get_instance_obj(prompt_version) + + if version_prompt is None: + raise Exception("The`prompt_version` in profile configuration should be provided.") + return version_prompt.build_simple_prompt().as_langchain() + + def initialize_by_component_configer(self, component_configer: MemoryExtractConfiger) -> 'MemoryExtract': + """Initialize the MemoryExtract by the MemoryExtractConfiger object. + + Args: + component_configer (MemoryExtractConfiger): The ComponentConfiger object. + + Returns: + MemoryExtract: The MemoryExtract object. + """ + memory_extract_config: Optional[MemoryExtractConfiger] = component_configer.load() + + # Set basic attributes + if memory_extract_config.name: + self.name = memory_extract_config.name + if memory_extract_config.description: + self.description = memory_extract_config.description + + # Set configuration attributes + if memory_extract_config.enabled is not None: + self.enabled = memory_extract_config.enabled + if memory_extract_config.top_k is not None: + self.top_k = memory_extract_config.top_k + if memory_extract_config.max_workers is not None: + self.max_workers = memory_extract_config.max_workers + if memory_extract_config.memory_storage: + self.memory_storage = memory_extract_config.memory_storage + if memory_extract_config.extract_prompt_version: + self.extract_prompt_version = memory_extract_config.extract_prompt_version + if memory_extract_config.operation_prompt_version: + self.operation_prompt_version = memory_extract_config.operation_prompt_version + if memory_extract_config.extraction_llm: + self.extraction_llm = memory_extract_config.extraction_llm + if memory_extract_config.operation_llm: + self.operation_llm = memory_extract_config.operation_llm + if memory_extract_config.max_memories_per_user is not None: + self.max_memories_per_user = memory_extract_config.max_memories_per_user + if memory_extract_config.max_memories_per_agent is not None: + self.max_memories_per_agent = memory_extract_config.max_memories_per_agent + + return self + + def _convert_facts_to_add_operations(self, facts: List[dict]) -> MemoryOperations: + """Convert facts to ADD memory operations.""" + operations = MemoryOperations() + + for fact in facts: + # Filter out empty facts + fact_text = fact.get("fact", "").strip() + if not fact_text: + continue + + # Create an add memory operation for each fact + operation = MemoryOperation( + id=None, # Add operation doesn't need ID + text=fact_text, + event=MemoryOperationEnum.ADD, + category=MemoryCategoryEnum(fact.get("category", MemoryCategoryEnum.DEFAULT.name)), + related_role=MemoryOwnerEnum(fact.get("related_role", MemoryOwnerEnum.DEFAULT.name)), + old_memory=None # Add operation doesn't need original memory content + ) + operations.memory.append(operation) + + return operations + + def create_copy(self): + """Create a copy.""" + copied = self.model_copy() + # Since configuration is now direct attributes, no need for separate config copying + return copied \ No newline at end of file diff --git a/agentuniverse/agent/memory/memory_extract/memory_extractor_manager.py b/agentuniverse/agent/memory/memory_extract/memory_extractor_manager.py new file mode 100644 index 000000000..f4cb2428e --- /dev/null +++ b/agentuniverse/agent/memory/memory_extract/memory_extractor_manager.py @@ -0,0 +1,19 @@ +# !/usr/bin/env python3 +# -*- coding:utf-8 -*- + +# @Time : 2025/12/03 13:55 +# @Author : pengqingsong.pqs +# @Email : pengqingsong.pqs@antgroup.com + +from agentuniverse.agent.memory.memory_extract.memory_extract import MemoryExtract +from agentuniverse.base.annotation.singleton import singleton +from agentuniverse.base.component.component_enum import ComponentEnum +from agentuniverse.base.component.component_manager_base import ComponentManagerBase + + +@singleton +class MemoryExtractorManager(ComponentManagerBase[MemoryExtract]): + """A singleton manager class of the MemoryExtract.""" + + def __init__(self): + super().__init__(ComponentEnum.MEMORY_EXTRACTOR) diff --git a/agentuniverse/agent/memory/memory_extract/memory_operation_cn_prompt.yaml b/agentuniverse/agent/memory/memory_extract/memory_operation_cn_prompt.yaml new file mode 100644 index 000000000..da9689d79 --- /dev/null +++ b/agentuniverse/agent/memory/memory_extract/memory_operation_cn_prompt.yaml @@ -0,0 +1,62 @@ +summarizer: | + 你是一个智能记忆管理器,负责控制系统中的记忆。 + 你可以执行四种操作:(1) 向记忆中添加内容,(2) 更新记忆,(3) 从记忆中删除内容,(4) 不做任何更改。 + + 基于以上四种操作,记忆将发生变化。 + 请将新获取的事实与现有记忆进行比较。针对每个新事实,决定是否执行以下操作: + - 添加:将其作为新元素加入记忆 + - 更新:更新现有的记忆元素 + - 删除:删除现有的记忆元素 + - 无操作:不做任何更改(如果事实已存在或无关紧要) + + 以下是选择执行哪种操作的具体指导原则: + 1.添加:如果获取的事实包含记忆中没有的新信息,则必须通过生成新的ID字段来添加它。 + 2.更新:如果获取的事实包含已存在于记忆中的信息,但信息完全不同,则必须更新它。如果获取的事实包含与记忆中现有元素表达相同含义的信息,则保留信息最丰富的事实。 + 3.删除:如果获取的事实包含与记忆中信息相矛盾的内容,则必须删除它。或者,如果指示是删除记忆,则必须删除。请注意,输出中的ID必须仅来自输入ID,不要生成任何新ID。 + 4.无操作:如果获取的事实包含已存在于记忆中的信息,则无需进行任何更改。 + + 基于以下新事实和相关历史记忆,判断需要对记忆进行哪些操作(添加、更新、删除或保持不变)。 + 新事实: + {new_facts} + + 相关历史记忆: + {related_memories} + + 当前日期:{date} + + 记忆所属角色(related_role)必须为以下之一: + - USER:用户,仅提取用户明确分享的相关个人偏好、计划或其他细节。 + - ASSISTANT:助手,仅当助理提供反映其潜在模式的特定推荐、习惯或表达时,才提取其行为或偏好。 + - DEFAULT:默认,无法区分或者都涉及的事实 + + 信息分类(category)必须为以下之一: + - FACTUAL:事实性信息,如用户是一个老师、用户偏好带emoji的输出,当前业务场景是金融场景等 + - EPISODIC:情景信息,如用户在某个场景下的行为、用户在某个场景下的情绪等 + - SEMANTIC:语义信息,如相关概念及其关联关系的理解 + - EXPERT:专家经验,如专业知识、解决问题的SOP等 + - DEFAULT:其他不属于上面分类的信息 + + 操作类型(event)必须为以下之一: + - ADD:添加新的记忆 + - UPDATE:更新现有记忆,此时需要提供id与old_memory字段 + - DELETE:删除现有记忆,此时需要提供id字段 + - NONE:不做任何操作 + + 请始终返回JSON格式的操作指令: + {{ + "memory": [ + {{ + "id": "<记忆ID>", # 对于更新/删除使用现有ID,新增留空 + "text": "<记忆内容>", # 记忆内容 + "category": "<记忆分类>", # FACTUAL/EPISODIC/SEMANTIC/EXPERT/DEFAULT + "related_role": "<关联角色>", # USER/AI/ASSISTANT + "event": "<操作类型>", # ADD/UPDATE/DELETE/NONE + "old_memory": "<原记忆内容>" # 仅UPDATE时需要 + }} + ] + }} + 你的回答: + +metadata: + type: 'PROMPT' + version: 'long_term_memory.memory_operation_cn' diff --git a/agentuniverse/agent/memory/memory_extract/memory_operation_en_prompt.yaml b/agentuniverse/agent/memory/memory_extract/memory_operation_en_prompt.yaml new file mode 100644 index 000000000..a492b1a01 --- /dev/null +++ b/agentuniverse/agent/memory/memory_extract/memory_operation_en_prompt.yaml @@ -0,0 +1,62 @@ +summarizer: | + You are an intelligent memory manager responsible for controlling the memories in the system. + You can perform four operations: (1) Add content to memory, (2) Update memory, (3) Delete content from memory, (4) Do nothing. + + Based on these four operations, memories will change. + Please compare the newly acquired facts with existing memories. For each new fact, decide whether to perform the following operations: + - ADD: Add it as a new element to memory + - UPDATE: Update an existing memory element + - DELETE: Delete an existing memory element + - NONE: Do nothing (if the fact already exists or is irrelevant) + + Here are specific guidelines for choosing which operation to perform: + 1. ADD: If the acquired fact contains new information not present in memory, you must add it by generating a new ID field. + 2. UPDATE: If the acquired fact contains information that already exists in memory but is completely different, you must update it. If the acquired fact contains information that expresses the same meaning as an existing element in memory, keep the most informative fact. + 3. DELETE: If the acquired fact contains content that contradicts information in memory, you must delete it. Or, if the instruction is to delete memory, you must delete it. Note that the ID in the output must only come from input IDs, do not generate any new IDs. + 4. NONE: If the acquired fact contains information that already exists in memory, no changes are needed. + + Based on the following new facts and related historical memories, determine what operations need to be performed on the memories (add, update, delete, or keep unchanged). + New facts: + {new_facts} + + Related historical memories: + {related_memories} + + Current date: {date} + + Memory ownership roles (related_role) must be one of the following: + - USER: User, only extract relevant personal preferences, plans, or other details explicitly shared by the user. + - ASSISTANT: Assistant, only extract behaviors or preferences when the assistant provides specific recommendations, habits, or expressions that reflect their underlying patterns. + - DEFAULT: Default, facts that cannot be distinguished or involve both + + Information categories (category) must be one of the following: + - FACTUAL: Factual information, such as the user is a teacher, the user prefers emoji outputs, the current business scenario is financial, etc. + - EPISODIC: Episodic information, such as user behavior in a specific scenario, user emotions in a specific scenario, etc. + - SEMANTIC: Semantic information, such as understanding of related concepts and their relationships + - EXPERT: Expert knowledge, such as professional knowledge, problem-solving SOPs, etc. + - DEFAULT: Other information that does not belong to the above categories + + Operation types (event) must be one of the following: + - ADD: Add new memory + - UPDATE: Update existing memory, requires providing id and old_memory fields + - DELETE: Delete existing memory, requires providing id field + - NONE: Do nothing + + Please always return operation instructions in JSON format: + {{ + "memory": [ + {{ + "id": "", # Use existing ID for update/delete, leave empty for new + "text": "", # Memory content + "category": "", # FACTUAL/EPISODIC/SEMANTIC/EXPERT/DEFAULT + "related_role": "", # USER/ASSISTANT/DEFAULT + "event": "", # ADD/UPDATE/DELETE/NONE + "old_memory": "" # Only needed for UPDATE + }} + ] + }} + Your answer: + +metadata: + type: 'PROMPT' + version: 'long_term_memory.memory_operation_en' diff --git a/agentuniverse/base/agentuniverse.py b/agentuniverse/base/agentuniverse.py index 8916a91fb..47be1b302 100644 --- a/agentuniverse/base/agentuniverse.py +++ b/agentuniverse/base/agentuniverse.py @@ -58,6 +58,7 @@ def __init__(self): self.__system_default_query_paraphraser_package = ['agentuniverse.agent.action.knowledge.query_paraphraser'] self.__system_default_memory_compressor_package = ['agentuniverse.agent.memory.memory_compressor'] self.__system_default_memory_storage_package = ['agentuniverse.agent.memory.memory_storage'] + self.__system_default_memory_extract_package = ['agentuniverse.agent.memory.memory_extract'] self.__system_default_work_pattern_package = ['agentuniverse.agent.work_pattern'] self.__system_default_log_sink_package = ['agentuniverse.base.util.logging.log_sink.log_sink'] @@ -177,6 +178,8 @@ def __scan_and_register(self, app_configer: AppConfiger): + self.__system_default_query_paraphraser_package) core_memory_compressor_package_list = ((app_configer.core_memory_compressor_package_list or app_configer.core_default_package_list) + self.__system_default_memory_compressor_package) + core_memory_extract_package_list = ((app_configer.core_memory_extract_package_list or app_configer.core_default_package_list) + + self.__system_default_memory_extract_package) core_memory_storage_package_list = ((app_configer.core_memory_storage_package_list or app_configer.core_default_package_list) + self.__system_default_memory_storage_package) core_work_pattern_package_list = ((app_configer.core_work_pattern_package_list or app_configer.core_default_package_list) @@ -209,7 +212,8 @@ def __scan_and_register(self, app_configer: AppConfiger): ComponentEnum.MEMORY_STORAGE: core_memory_storage_package_list, ComponentEnum.WORK_PATTERN: core_work_pattern_package_list, ComponentEnum.LOG_SINK: core_log_sink_package_list, - ComponentEnum.LLM_CHANNEL: core_llm_channel_package_list + ComponentEnum.LLM_CHANNEL: core_llm_channel_package_list, + ComponentEnum.MEMORY_EXTRACTOR: core_memory_extract_package_list } component_configer_list_map = {} diff --git a/agentuniverse/base/component/component_configer_util.py b/agentuniverse/base/component/component_configer_util.py index 062b2fbf2..f0e04dfd0 100644 --- a/agentuniverse/base/component/component_configer_util.py +++ b/agentuniverse/base/component/component_configer_util.py @@ -13,12 +13,14 @@ from agentuniverse.agent.action.toolkit.toolkit_manager import ToolkitManager from agentuniverse.agent.agent_manager import AgentManager from agentuniverse.agent.memory.memory_compressor.memory_compressor_manager import MemoryCompressorManager +from agentuniverse.agent.memory.memory_extract.memory_extractor_manager import MemoryExtractorManager from agentuniverse.agent.memory.memory_manager import MemoryManager from agentuniverse.agent.memory.memory_storage.memory_storage_manager import MemoryStorageManager from agentuniverse.agent.plan.planner.planner_manager import PlannerManager from agentuniverse.agent.work_pattern.work_pattern_manager import WorkPatternManager from agentuniverse.agent_serve.service_manager import ServiceManager from agentuniverse.agent_serve.service_configer import ServiceConfiger +from agentuniverse.base.config.component_configer.configers.memory_extract_configer import MemoryExtractConfiger from agentuniverse.base.config.component_configer.configers.work_pattern_configer import WorkPatternConfiger from agentuniverse.base.config.component_configer.configers.workflow_configer import WorkflowConfiger from agentuniverse.database.sqldb_wrapper_manager import SQLDBWrapperManager @@ -58,6 +60,7 @@ class ComponentConfigerUtil(object): ComponentEnum.TOOL: ToolConfiger, ComponentEnum.TOOLKIT: ComponentConfiger, ComponentEnum.MEMORY: MemoryConfiger, + ComponentEnum.MEMORY_EXTRACTOR: MemoryExtractConfiger, ComponentEnum.SERVICE: ServiceConfiger, ComponentEnum.PROMPT: PromptConfiger, ComponentEnum.SQLDB_WRAPPER: SQLDBWrapperConfiger, @@ -99,6 +102,7 @@ class ComponentConfigerUtil(object): ComponentEnum.WORK_PATTERN: WorkPatternManager, ComponentEnum.LOG_SINK: LogSinkManager, ComponentEnum.LLM_CHANNEL: LLMChannelManager, + ComponentEnum.MEMORY_EXTRACTOR: MemoryExtractorManager, } @classmethod diff --git a/agentuniverse/base/component/component_enum.py b/agentuniverse/base/component/component_enum.py index dd3d9ae47..c7b35e954 100644 --- a/agentuniverse/base/component/component_enum.py +++ b/agentuniverse/base/component/component_enum.py @@ -31,6 +31,7 @@ class ComponentEnum(Enum): QUERY_PARAPHRASER = "QUERY_PARAPHRASER" WORK_PATTERN = "WORK_PATTERN" MEMORY_COMPRESSOR = "MEMORY_COMPRESSOR" + MEMORY_EXTRACTOR = "MEMORY_EXTRACTOR" MEMORY_STORAGE = "MEMORY_STORAGE" LOG_SINK = "LOG_SINK" LLM_CHANNEL = "LLM_CHANNEL" diff --git a/agentuniverse/base/config/application_configer/app_configer.py b/agentuniverse/base/config/application_configer/app_configer.py index 18c153dd3..909aaf3e7 100644 --- a/agentuniverse/base/config/application_configer/app_configer.py +++ b/agentuniverse/base/config/application_configer/app_configer.py @@ -41,6 +41,7 @@ def __init__(self): self.__core_rag_router_package_list: Optional[list[str]] = None self.__core_query_paraphraser_package_list: Optional[list[str]] = None self.__core_memory_compressor_package_list: Optional[list[str]] = None + self.__core_memory_extract_package_list: Optional[list[str]] = None self.__core_memory_storage_package_list: Optional[list[str]] = None self.__core_work_pattern_package_list: Optional[list[str]] = None self.__core_log_sink_package_list: Optional[list[str]] = None @@ -159,6 +160,11 @@ def core_memory_compressor_package_list(self) -> Optional[list[str]]: """Return the memory compressor package list of the core.""" return self.__core_memory_compressor_package_list + @property + def core_memory_extract_package_list(self) -> Optional[list[str]]: + """Return the memory extract package list of the core.""" + return self.__core_memory_extract_package_list + @property def core_memory_storage_package_list(self) -> Optional[list[str]]: """Return the memory storage package list of the core.""" @@ -291,6 +297,7 @@ def load_by_configer(self, configer: Configer) -> 'AppConfiger': self.__core_rag_router_package_list = configer.value.get('CORE_PACKAGE', {}).get('rag_router') self.__core_query_paraphraser_package_list = configer.value.get('CORE_PACKAGE', {}).get('query_paraphraser') self.__core_memory_compressor_package_list = configer.value.get('CORE_PACKAGE', {}).get('memory_compressor') + self.__core_memory_extract_package_list = configer.value.get('CORE_PACKAGE', {}).get('memory_extract') self.__core_memory_storage_package_list = configer.value.get('CORE_PACKAGE', {}).get('memory_storage') self.__core_work_pattern_package_list = configer.value.get('CORE_PACKAGE', {}).get('work_pattern') self.__core_log_sink_package_list = configer.value.get('CORE_PACKAGE', {}).get('log_sink') diff --git a/agentuniverse/base/config/component_configer/configers/memory_configer.py b/agentuniverse/base/config/component_configer/configers/memory_configer.py index c952ec28c..05ec90051 100644 --- a/agentuniverse/base/config/component_configer/configers/memory_configer.py +++ b/agentuniverse/base/config/component_configer/configers/memory_configer.py @@ -24,7 +24,9 @@ def __init__(self, configer: Optional[Configer] = None): self.__max_tokens: Optional[int] = None self.__memory_compressor: Optional[str] = None self.__memory_storages: Optional[List[str]] = None + self.__long_tern_memory_extractors: Optional[List[str]] = None self.__memory_retrieval_storage: Optional[str] = None + self.__long_term_memory_key: Optional[str] = None self.__memory_summarize_agent: Optional[str] = None @property @@ -62,11 +64,21 @@ def memory_storages(self) -> Optional[List[str]]: """Return the storages of the Memory.""" return self.__memory_storages + @property + def long_tern_memory_extractors(self) -> Optional[List[str]]: + """Return the extractors for long term memory extract.""" + return self.__long_tern_memory_extractors + @property def memory_retrieval_storage(self) -> Optional[str]: """Return the retrieval storage of the Memory.""" return self.__memory_retrieval_storage + @property + def long_term_memory_key(self) -> Optional[str]: + """Return the long term memory key.""" + return self.__long_term_memory_key + @property def memory_summarize_agent(self) -> Optional[str]: """Return the summarize agent of the Memory.""" @@ -98,6 +110,8 @@ def load_by_configer(self, configer: Configer) -> 'MemoryConfiger': self.__memory_storages = configer.value.get('memory_storages') self.__memory_retrieval_storage = configer.value.get('memory_retrieval_storage') self.__memory_summarize_agent = configer.value.get('memory_summarize_agent') + self.__long_tern_memory_extractors = configer.value.get('long_tern_memory_extractors') + self.__long_term_memory_key = configer.value.get('long_term_memory_key') except Exception as e: raise Exception(f"Failed to parse the Memory configuration: {e}") return self diff --git a/agentuniverse/base/config/component_configer/configers/memory_extract_configer.py b/agentuniverse/base/config/component_configer/configers/memory_extract_configer.py new file mode 100644 index 000000000..878f83a60 --- /dev/null +++ b/agentuniverse/base/config/component_configer/configers/memory_extract_configer.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +# @Time : 2025/12/03 13:55 +# @Author : pengqingsong.pqs +# @Email : pengqingsong.pqs@antgroup.com +# @FileName: memory_extract_configer.py + +from typing import Optional + +from agentuniverse.base.config.component_configer.component_configer import ComponentConfiger +from agentuniverse.base.config.configer import Configer + + +class MemoryExtractConfiger(ComponentConfiger): + """The MemoryExtractConfiger class, which is used to load and manage the MemoryExtract configuration.""" + + def __init__(self, configer: Optional[Configer] = None): + """Initialize the MemoryExtractConfiger.""" + super().__init__(configer) + self.__name: Optional[str] = None + self.__description: Optional[str] = None + self.__enabled: Optional[bool] = None + self.__top_k: Optional[int] = None + self.__max_workers: Optional[int] = None + self.__memory_storage: Optional[str] = None + self.__extract_prompt_version: Optional[str] = None + self.__operation_prompt_version: Optional[str] = None + self.__embedding_model: Optional[str] = None + self.__extraction_llm: Optional[str] = None + self.__operation_llm: Optional[str] = None + self.__similarity_threshold: Optional[float] = None + self.__max_memories_per_user: Optional[int] = None + self.__max_memories_per_agent: Optional[int] = None + + @property + def name(self) -> Optional[str]: + """Return the name of the MemoryExtract.""" + return self.__name + + @property + def description(self) -> Optional[str]: + """Return the description of the MemoryExtract.""" + return self.__description + + @property + def enabled(self) -> Optional[bool]: + """Return whether the MemoryExtract is enabled.""" + return self.__enabled + + @property + def top_k(self) -> Optional[int]: + """Return the top_k value for search.""" + return self.__top_k + + @property + def max_workers(self) -> Optional[int]: + """Return the max workers for thread pool.""" + return self.__max_workers + + @property + def memory_storage(self) -> Optional[str]: + """Return the memory storage name.""" + return self.__memory_storage + + @property + def extract_prompt_version(self) -> Optional[str]: + """Return the extract prompt version.""" + return self.__extract_prompt_version + + @property + def operation_prompt_version(self) -> Optional[str]: + """Return the operation prompt version.""" + return self.__operation_prompt_version + + @property + def embedding_model(self) -> Optional[str]: + """Return the embedding model name.""" + return self.__embedding_model + + @property + def extraction_llm(self) -> Optional[str]: + """Return the extraction LLM name.""" + return self.__extraction_llm + + @property + def operation_llm(self) -> Optional[str]: + """Return the operation LLM name.""" + return self.__operation_llm + + @property + def similarity_threshold(self) -> Optional[float]: + """Return the similarity threshold.""" + return self.__similarity_threshold + + @property + def max_memories_per_user(self) -> Optional[int]: + """Return the max memories per user.""" + return self.__max_memories_per_user + + @property + def max_memories_per_agent(self) -> Optional[int]: + """Return the max memories per agent.""" + return self.__max_memories_per_agent + + def load(self) -> 'MemoryExtractConfiger': + """Load the configuration by the Configer object. + + Returns: + MemoryExtractConfiger: The MemoryExtractConfiger object. + """ + return self.load_by_configer(self.configer) + + def load_by_configer(self, configer: Configer) -> 'MemoryExtractConfiger': + """Load the configuration by the Configer object. + + Args: + configer (Configer): The Configer object. + + Returns: + MemoryExtractConfiger: The MemoryExtractConfiger object. + + Raises: + Exception: If configuration parsing fails. + """ + super().load_by_configer(configer) + + try: + configer_value: dict = configer.value + self.__name = configer_value.get('name') + self.__description = configer_value.get('description') + self.__enabled = configer_value.get('enabled') + self.__top_k = configer_value.get('top_k') + self.__max_workers = configer_value.get('max_workers') + self.__memory_storage = configer_value.get('memory_storage') + self.__extract_prompt_version = configer_value.get('extract_prompt_version') + self.__operation_prompt_version = configer_value.get('operation_prompt_version') + self.__embedding_model = configer_value.get('embedding_model') + self.__extraction_llm = configer_value.get('extraction_llm') + self.__operation_llm = configer_value.get('operation_llm') + self.__similarity_threshold = configer_value.get('similarity_threshold') + self.__max_memories_per_user = configer_value.get('max_memories_per_user') + self.__max_memories_per_agent = configer_value.get('max_memories_per_agent') + except Exception as e: + raise Exception(f"Failed to parse the MemoryExtract configuration from configer: {e}") + return self diff --git a/agentuniverse/base/util/memory_util.py b/agentuniverse/base/util/memory_util.py index 2346b4876..efb3a4088 100644 --- a/agentuniverse/base/util/memory_util.py +++ b/agentuniverse/base/util/memory_util.py @@ -10,6 +10,7 @@ from langchain_core.chat_history import BaseChatMessageHistory from agentuniverse.agent.memory.enum import ChatMessageEnum +from agentuniverse.agent.memory.memory_extract.memory_extract import MemoryOwnerEnum from agentuniverse.agent.memory.message import Message from agentuniverse.base.context.framework_context_manager import FrameworkContextManager from agentuniverse.llm.llm import LLM @@ -87,6 +88,33 @@ def get_memory_string(messages: List[Message], agent_id=None) -> str: return "\n\n".join(string_messages) +def get_long_term_memory_string(messages: List[Message]) -> str: + """Convert the given messages to a string. + + Args: + messages(List[Message]): The list of long tern messages. + + + Returns: + str: The string representation of the messages. + """ + assistant_memory = [] + user_memory = [] + default_memory = [] + for m in messages: + m_str = f"Time: {m.metadata.get('updated_at')}, content: {m.content}, category:{m.metadata.get('category','DEFAULT')}" + if m.metadata.get('related_role', '') == MemoryOwnerEnum.USER.value: + user_memory.append(m_str) + elif m.metadata.get('related_role', '') == MemoryOwnerEnum.ASSISTANT.value: + assistant_memory.append(m_str) + elif m.metadata.get('related_role', '') == MemoryOwnerEnum.DEFAULT.value: + default_memory.append(m_str) + user_memory_str = "The known long-term memories of the user:" + "\n".join(user_memory) + assistant_memory_str = "The known long-term memories of the assistant:" + "\n".join(assistant_memory) + default_memory_str = "Other known long-term memories" + "\n".join(default_memory) + return f'{user_memory_str}\n\n{assistant_memory_str}\n\n{default_memory_str}' + + def get_memory_tokens(memories: List[Message], llm_name: str = None) -> int: """Get the number of tokens in the given memories. diff --git a/agentuniverse/prompt/prompt.py b/agentuniverse/prompt/prompt.py index 3318e3f7c..879c209b4 100644 --- a/agentuniverse/prompt/prompt.py +++ b/agentuniverse/prompt/prompt.py @@ -50,6 +50,15 @@ def build_prompt(self, agent_prompt_model: AgentPromptModel, prompt_assemble_ord self.input_variables = re.findall(r'\{(.*?)}', self.prompt_template) return self + def build_simple_prompt(self) -> 'Prompt': + """Build the prompt class. + + Returns: + Prompt: The prompt object. + """ + self.input_variables = re.findall(r'\{(.*?)}', self.prompt_template) + return self + def get_instance_code(self) -> str: """Return the prompt version of the current prompt.""" return self.prompt_version diff --git a/examples/sample_apps/react_agent_app/config/config.toml b/examples/sample_apps/react_agent_app/config/config.toml index c5ed803f1..581caaafa 100644 --- a/examples/sample_apps/react_agent_app/config/config.toml +++ b/examples/sample_apps/react_agent_app/config/config.toml @@ -15,6 +15,8 @@ llm = ['react_agent_app.intelligence.agentic.llm'] planner = [] # Scan and register tool components for all paths under this list, with priority over the default. tool = ['react_agent_app.intelligence.agentic.tool'] +# Scan and register toolkit components for all paths under this list, with priority over the default. +toolkit = ['react_agent_app.intelligence.agentic.toolkit'] # Scan and register memory components for all paths under this list, with priority over the default. memory = ['react_agent_app.intelligence.agentic.memory'] # Scan and register service components for all paths under this list, with priority over the default. @@ -37,6 +39,8 @@ query_paraphraser = ['react_agent_app.intelligence.agentic.knowledge.query_parap memory_compressor = ['react_agent_app.intelligence.agentic.memory.memory_compressor'] # Scan and register memory_storage components for all paths under this list, with priority over the default. memory_storage = ['react_agent_app.intelligence.agentic.memory.memory_storage'] +# Scan and register memory_extract components for all paths under this list, with priority over the default. +memory_extract = ['react_agent_app.intelligence.agentic.memory.memory_extract'] [SUB_CONFIG_PATH] # Log config file path, an absolute path or a relative path based on the dir where the current config file is located. diff --git a/examples/sample_apps/react_agent_app/config/custom_key.toml.sample b/examples/sample_apps/react_agent_app/config/custom_key.toml.sample index 8c3a693e9..93efd3ba9 100644 --- a/examples/sample_apps/react_agent_app/config/custom_key.toml.sample +++ b/examples/sample_apps/react_agent_app/config/custom_key.toml.sample @@ -43,4 +43,7 @@ example_key = 'AnExampleKey' #SEARCHAPI_API_KEY='xxxxxx' # ##bing search -#BING_SUBSCRIPTION_KEY='xxxxxx' \ No newline at end of file +#BING_SUBSCRIPTION_KEY='xxxxxx' +# +##context root path +#CONTEXT_FILE_ROOTPATH='/tmp/agentuniverse_context' diff --git a/examples/sample_apps/react_agent_app/intelligence/agentic/agent/agent_instance/react_agent_case/demo_react_agent.yaml b/examples/sample_apps/react_agent_app/intelligence/agentic/agent/agent_instance/react_agent_case/demo_react_agent.yaml index 2b22d90cf..a033dc8de 100644 --- a/examples/sample_apps/react_agent_app/intelligence/agentic/agent/agent_instance/react_agent_case/demo_react_agent.yaml +++ b/examples/sample_apps/react_agent_app/intelligence/agentic/agent/agent_instance/react_agent_case/demo_react_agent.yaml @@ -12,6 +12,8 @@ action: tool: - 'google_search_tool' - 'python_runner' + toolkit: + - 'demo_react_context_toolkit' knowledge: - 'law_knowledge' memory: diff --git a/examples/sample_apps/react_agent_app/intelligence/agentic/llm/deepseek_llm.yaml b/examples/sample_apps/react_agent_app/intelligence/agentic/llm/deepseek_llm.yaml new file mode 100644 index 000000000..31e4d18a3 --- /dev/null +++ b/examples/sample_apps/react_agent_app/intelligence/agentic/llm/deepseek_llm.yaml @@ -0,0 +1,8 @@ +name: 'deepseek_llm' +description: 'default default_deepseek_llm llm with spi' +model_name: 'deepseek-chat' +max_tokens: 8192 +metadata: + type: 'LLM' + module: 'agentuniverse.llm.default.deep_seek_openai_style_llm' + class: 'DefaultDeepSeekLLM' \ No newline at end of file diff --git a/examples/sample_apps/react_agent_app/intelligence/agentic/memory/demo_memory.yaml b/examples/sample_apps/react_agent_app/intelligence/agentic/memory/demo_memory.yaml index f0122e58f..4142d41b7 100644 --- a/examples/sample_apps/react_agent_app/intelligence/agentic/memory/demo_memory.yaml +++ b/examples/sample_apps/react_agent_app/intelligence/agentic/memory/demo_memory.yaml @@ -2,6 +2,7 @@ name: 'demo_memory' description: 'demo memory with chroma storage' type: 'long_term' memory_key: 'chat_history' +long_term_memory_key: 'long_term_memory' max_tokens: 3000 memory_compressor: default_memory_compressor memory_storages: @@ -10,6 +11,11 @@ memory_storages: # you can change to chroma_memory_storage, which will persist the memory into chroma db # for more info please refer to doc - ram_memory_storage +long_tern_memory_extractors: + # here use a text memory extractor, which will extract the memory from text to text + # you can extend the graph extractor to extract the memory from text to graph + # or other multimodality extractor to extract the memory from other material like images, videos. + - text_memory_extractor metadata: type: 'MEMORY' module: 'agentuniverse.agent.memory.memory' diff --git a/examples/sample_apps/react_agent_app/intelligence/agentic/memory/memory_extract/__init__.py b/examples/sample_apps/react_agent_app/intelligence/agentic/memory/memory_extract/__init__.py new file mode 100644 index 000000000..5f73ff3e5 --- /dev/null +++ b/examples/sample_apps/react_agent_app/intelligence/agentic/memory/memory_extract/__init__.py @@ -0,0 +1,7 @@ +# !/usr/bin/env python3 +# -*- coding:utf-8 -*- + +# @Time : 2025/12/11 22:01 +# @Author : pengqingsong.pqs +# @Email : pengqingsong.pqs@antgroup.com +# @FileName: __init__.py.py diff --git a/examples/sample_apps/react_agent_app/intelligence/agentic/memory/memory_extract/demo_memory_extract_config.yaml b/examples/sample_apps/react_agent_app/intelligence/agentic/memory/memory_extract/demo_memory_extract_config.yaml new file mode 100644 index 000000000..145e24362 --- /dev/null +++ b/examples/sample_apps/react_agent_app/intelligence/agentic/memory/memory_extract/demo_memory_extract_config.yaml @@ -0,0 +1,27 @@ +name: "text_memory_extractor" +description: "use for text memory extract" + +# 基础配置 +enabled: true +top_k: 5 +max_workers: 20 + +# 存储配置 +memory_storage: "demo_chroma_long_term_memory_storage" + +# Prompt配置 +extract_prompt_version: "long_term_memory.facts_extract_cn" +operation_prompt_version: "long_term_memory.memory_operation_cn" + +# 模型配置 +extraction_llm: "deepseek_llm" +operation_llm: "deepseek_llm" + +# 阈值配置 +max_memories_per_user: 1000 +max_memories_per_agent: 1000 + +metadata: + type: 'MEMORY_EXTRACTOR' + module: 'agentuniverse.agent.memory.memory_extract.memory_extract' + class: 'MemoryExtract' \ No newline at end of file diff --git a/examples/sample_apps/react_agent_app/intelligence/agentic/memory/memory_storage/chroma_long_term_memory_storage.yaml b/examples/sample_apps/react_agent_app/intelligence/agentic/memory/memory_storage/chroma_long_term_memory_storage.yaml new file mode 100644 index 000000000..ea18f0045 --- /dev/null +++ b/examples/sample_apps/react_agent_app/intelligence/agentic/memory/memory_storage/chroma_long_term_memory_storage.yaml @@ -0,0 +1,9 @@ +name: 'demo_chroma_long_term_memory_storage' +description: 'demo chroma long term memory storage' +collection_name: 'long_term_memory' +persist_path: '../../db/long_term_memory.db' +embedding_model: 'dashscope_embedding' +metadata: + type: 'MEMORY_STORAGE' + module: 'agentuniverse.agent.memory.conversation_memory.memory_storage.chroma_long_term_memory_storage' + class: 'ChromaLongTermMemoryStorage' \ No newline at end of file diff --git a/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/__init__.py b/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/__init__.py new file mode 100644 index 000000000..5f73ff3e5 --- /dev/null +++ b/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/__init__.py @@ -0,0 +1,7 @@ +# !/usr/bin/env python3 +# -*- coding:utf-8 -*- + +# @Time : 2025/12/11 22:01 +# @Author : pengqingsong.pqs +# @Email : pengqingsong.pqs@antgroup.com +# @FileName: __init__.py.py diff --git a/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/context_append_to_file.yaml b/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/context_append_to_file.yaml new file mode 100644 index 000000000..415cb6fd4 --- /dev/null +++ b/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/context_append_to_file.yaml @@ -0,0 +1,34 @@ +name: 'context_append_to_file' +description: | + 该工具可以向指定上下文中的文件末尾追加内容,保留原有内容。 + + 输入参数(JSON格式): + ```json + { + "file_name": "目标文件名(字符串,必需)", + "content": "要追加的内容(字符串,必需)", + "session_id": "会话ID(字符串,必需)" + } + ``` + + 输出参数(JSON格式): + ```json + { + "message": "操作结果消息(字符串)" + } + ``` + + 工具输入示例(JSON格式): + ```json + { + "file_name": "example.md", + "content": "新增内容", + "session_id": "your_session_id" + } + ``` +tool_type: 'func' +input_keys: ['file_name', 'content', 'session_id'] +metadata: + type: 'TOOL' + module: 'agentuniverse.agent.action.tool.context_tool.context_append_to_file' + class: 'ContextAppendToFileTool' diff --git a/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/context_create_file.yaml b/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/context_create_file.yaml new file mode 100644 index 000000000..be964e4ef --- /dev/null +++ b/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/context_create_file.yaml @@ -0,0 +1,35 @@ +name: 'context_create_file' +description: | + 该工具用于创建或更新上下文中的文件。 + + 输入参数(JSON格式): + ```json + { + "file_name": "文件名称(字符串,必需)", + "content": "文件内容(字符串,必需)", + "session_id": "会话ID(字符串,必需)" + } + ``` + + 输出参数(JSON格式): + ```json + { + "message": "操作结果消息(字符串)", + "file_url": "文件相对路径(字符串)" + } + ``` + + 工具输入示例(JSON格式): + ```json + { + "file_name": "example.md", + "content": "文件内容", + "session_id": "your_session_id" + } + ``` +tool_type: 'func' +input_keys: ['file_name', 'content', 'session_id'] +metadata: + type: 'TOOL' + module: 'agentuniverse.agent.action.tool.context_tool.context_create_file' + class: 'ContextCreateFileTool' diff --git a/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/context_download_files.yaml b/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/context_download_files.yaml new file mode 100644 index 000000000..31aba45c0 --- /dev/null +++ b/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/context_download_files.yaml @@ -0,0 +1,39 @@ +name: 'context_download_files' +description: | + 该工具可以获取上下文中文件的访问路径或者地址,提供给用户或者其他工具使用。 + + 输入参数(JSON格式): + ```json + { + "file_name": "文件名称,多个文件用英文逗号分隔(字符串,必需)", + "session_id": "会话ID(字符串,必需)" + } + ``` + + 输出参数(JSON格式): + ```json + { + "file_list": [ + { + "file": "文件名(字符串)", + "fileUrl": "文件相对路径(字符串)", + "message": "下载结果消息(字符串)" + } + ], + "message": "总体操作结果消息(字符串)" + } + ``` + + 工具输入示例(JSON格式): + ```json + { + "file_name": "example.md", + "session_id": "your_session_id" + } + ``` +tool_type: 'func' +input_keys: ['file_name', 'session_id'] +metadata: + type: 'TOOL' + module: 'agentuniverse.agent.action.tool.context_tool.context_download_files' + class: 'ContextDownloadFilesTool' diff --git a/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/context_insert.yaml b/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/context_insert.yaml new file mode 100644 index 000000000..7a31dcc2c --- /dev/null +++ b/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/context_insert.yaml @@ -0,0 +1,37 @@ +name: 'context_insert' +description: | + 该工具可以在指定上下文中的文件的指定行号后插入文本内容。 + + 输入参数(JSON格式): + ```json + { + "file_name": "目标文件名(字符串,必需)", + "insert_text": "插入文本(字符串,必需)", + "session_id": "会话ID(字符串,必需)", + "insert_line": "插入行号,0表示文件开头,不指定则在末尾插入(字符串,可选)" + } + ``` + + 输出参数(JSON格式): + ```json + { + "message": "操作结果消息(字符串)", + "result": "操作结果状态,success/error(字符串)" + } + ``` + + 工具输入示例(JSON格式): + ```json + { + "file_name": "example.md", + "insert_line": "3", + "insert_text": "新增内容", + "session_id": "your_session_id" + } + ``` +tool_type: 'func' +input_keys: ['file_name', 'insert_text', 'session_id', 'insert_line'] +metadata: + type: 'TOOL' + module: 'agentuniverse.agent.action.tool.context_tool.context_insert' + class: 'ContextInsertTool' diff --git a/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/context_list_files.yaml b/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/context_list_files.yaml new file mode 100644 index 000000000..7ed006c30 --- /dev/null +++ b/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/context_list_files.yaml @@ -0,0 +1,31 @@ +name: 'context_list_files' +description: | + 该工具可以列出保存在上下文中的所有文件名称。 + + 输入参数(JSON格式): + ```json + { + "session_id": "会话ID(字符串,必需)" + } + ``` + + 输出参数(JSON格式): + ```json + { + "all_files": "所有文件名,用逗号分隔(字符串)", + "message": "操作结果消息(字符串)" + } + ``` + + 工具输入示例(JSON格式): + ```json + { + "session_id": "your_session_id" + } + ``` +tool_type: 'func' +input_keys: ['session_id'] +metadata: + type: 'TOOL' + module: 'agentuniverse.agent.action.tool.context_tool.context_list_files' + class: 'ContextListFilesTool' diff --git a/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/context_read_files.yaml b/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/context_read_files.yaml new file mode 100644 index 000000000..ab40853f2 --- /dev/null +++ b/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/context_read_files.yaml @@ -0,0 +1,43 @@ +name: 'context_read_files' +description: | + 该工具可以读取上下文中指定名称的文件内容,支持多文件和行范围读取。 + + 输入参数(JSON格式): + ```json + { + "file_name": "文件名称,多个文件用英文逗号分隔(字符串,必需)", + "session_id": "会话ID(字符串,必需)", + "line_range": "阅读指定的行范围,格式如[1, 10](字符串,可选)", + "display_line_numbers": "是否显示行号,true/false(字符串,可选,默认false)" + } + ``` + + 输出参数(JSON格式): + ```json + { + "file_list": [ + { + "file": "文件名(字符串)", + "content": "文件内容(字符串)", + "message": "读取结果消息(字符串)" + } + ], + "message": "总体操作结果消息(字符串)" + } + ``` + + 工具输入示例(JSON格式): + ```json + { + "file_name": "example.md", + "session_id": "your_session_id", + "line_range": "[1, 10]", + "display_line_numbers": "true" + } + ``` +tool_type: 'func' +input_keys: ['file_name', 'session_id'] +metadata: + type: 'TOOL' + module: 'agentuniverse.agent.action.tool.context_tool.context_read_files' + class: 'ContextReadFilesTool' diff --git a/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/context_rename_file.yaml b/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/context_rename_file.yaml new file mode 100644 index 000000000..9b425d6b6 --- /dev/null +++ b/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/context_rename_file.yaml @@ -0,0 +1,34 @@ +name: 'context_rename_file' +description: | + 该工具可以将一个上下文中的文件重命名为新的名称。 + + 输入参数(JSON格式): + ```json + { + "old_file_name": "原文件名(字符串,必需)", + "new_file_name": "新文件名(字符串,必需)", + "session_id": "会话ID(字符串,必需)" + } + ``` + + 输出参数(JSON格式): + ```json + { + "message": "操作结果消息(字符串)" + } + ``` + + 工具输入示例(JSON格式): + ```json + { + "old_file_name": "old.md", + "new_file_name": "new.md", + "session_id": "your_session_id" + } + ``` +tool_type: 'func' +input_keys: ['old_file_name', 'new_file_name', 'session_id'] +metadata: + type: 'TOOL' + module: 'agentuniverse.agent.action.tool.context_tool.context_rename_file' + class: 'ContextRenameFileTool' diff --git a/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/context_str_replace.yaml b/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/context_str_replace.yaml new file mode 100644 index 000000000..2236c54a4 --- /dev/null +++ b/examples/sample_apps/react_agent_app/intelligence/agentic/tool/context/context_str_replace.yaml @@ -0,0 +1,37 @@ +name: 'context_str_replace' +description: | + 该工具可以在指定上下文中的文件中进行完全匹配的字符串替换。 + + 输入参数(JSON格式): + ```json + { + "file_name": "文件名称(字符串,必需)", + "old_str": "要查找和替换的字符串(字符串,必需)", + "new_str": "替换后的字符串(字符串,必需)", + "session_id": "会话ID(字符串,必需)" + } + ``` + + 输出参数(JSON格式): + ```json + { + "result": "修改后的文件内容(字符串)", + "message": "操作结果消息(字符串)" + } + ``` + + 工具输入示例(JSON格式): + ```json + { + "file_name": "example.md", + "old_str": "旧文本", + "new_str": "新文本", + "session_id": "your_session_id" + } + ``` +tool_type: 'func' +input_keys: ['file_name', 'old_str', 'new_str', 'session_id'] +metadata: + type: 'TOOL' + module: 'agentuniverse.agent.action.tool.context_tool.context_str_replace' + class: 'ContextStrReplaceTool' diff --git a/examples/sample_apps/react_agent_app/intelligence/agentic/tool/google_search_tool.yaml b/examples/sample_apps/react_agent_app/intelligence/agentic/tool/google_search_tool.yaml index b4219fd60..a1d6b27b1 100644 --- a/examples/sample_apps/react_agent_app/intelligence/agentic/tool/google_search_tool.yaml +++ b/examples/sample_apps/react_agent_app/intelligence/agentic/tool/google_search_tool.yaml @@ -1,9 +1,15 @@ name: 'google_search_tool' description: | 该工具可以用来进行谷歌搜索,工具的输入是你想搜索的内容。 + 输入参数(JSON格式): + ```json + { + "input": "查询关键词(字符串,必需)" + } + ``` 工具输入示例: - 示例1: 你想要搜索上海的天气时,工具的输入应该是:上海今天的天气 - 示例2: 你想要搜索日本的天气时,工具的输入应该是:日本的天气 + 示例1: 你想要搜索上海的天气时,工具的输入应该是:{"input": "上海的天气"} + 示例2: 你想要搜索日本的天气时,工具的输入应该是:{"input": "日本的天气"} tool_type: 'api' input_keys: ['input'] metadata: diff --git a/examples/sample_apps/react_agent_app/intelligence/agentic/toolkit/__init__.py b/examples/sample_apps/react_agent_app/intelligence/agentic/toolkit/__init__.py new file mode 100644 index 000000000..5f73ff3e5 --- /dev/null +++ b/examples/sample_apps/react_agent_app/intelligence/agentic/toolkit/__init__.py @@ -0,0 +1,7 @@ +# !/usr/bin/env python3 +# -*- coding:utf-8 -*- + +# @Time : 2025/12/11 22:01 +# @Author : pengqingsong.pqs +# @Email : pengqingsong.pqs@antgroup.com +# @FileName: __init__.py.py diff --git a/examples/sample_apps/react_agent_app/intelligence/agentic/toolkit/context_toolkit.yaml b/examples/sample_apps/react_agent_app/intelligence/agentic/toolkit/context_toolkit.yaml new file mode 100644 index 000000000..8a2c9d75e --- /dev/null +++ b/examples/sample_apps/react_agent_app/intelligence/agentic/toolkit/context_toolkit.yaml @@ -0,0 +1,28 @@ +name: 'demo_react_context_toolkit' +description: | + 这是一个包含所有上下文管理工具的工具包,用于在会话环境中通过文件的形式管理上下文。 + 典型场景如:让不同的子Agent共享相同的环境上下文,或者将数据、代码、搜索内容等长上下文的内容外置到文件中。 + 当有需要的时候通过各种工具进行读取、修改等操作,最终返回给用户对应的内容或者文件地址。 + 包含的工具: + - context_insert: 在指定文件的指定行号后插入文本内容 + - context_read_files: 读取指定名称的文件内容,支持多文件和行范围读取 + - context_str_replace: 在指定文件中进行完全匹配的字符串替换 + - context_append_to_file: 向指定文件末尾追加内容,保留原有内容 + - context_download_files: 下载上下文文件,提供文件访问路径 + - context_list_files: 列出保存在上下文的所有文件名称 + - context_rename_file: 将一个文件重命名为新的名称 + - context_create_file: 创建或更新上下文文件 +include: + - 'context_insert' + - 'context_read_files' + - 'context_str_replace' + - 'context_append_to_file' + - 'context_download_files' + - 'context_list_files' + - 'context_rename_file' + - 'context_create_file' + +metadata: + type: 'TOOLKIT' + module: 'agentuniverse.agent.action.toolkit.toolkit' + class: 'Toolkit' \ No newline at end of file diff --git a/examples/sample_apps/react_agent_app/intelligence/test/test_react_agent.py b/examples/sample_apps/react_agent_app/intelligence/test/test_react_agent.py index 60ddc523e..15f843ce0 100644 --- a/examples/sample_apps/react_agent_app/intelligence/test/test_react_agent.py +++ b/examples/sample_apps/react_agent_app/intelligence/test/test_react_agent.py @@ -1,6 +1,6 @@ # !/usr/bin/env python3 # -*- coding:utf-8 -*- -import asyncio + # @Time : 2024/6/4 21:27 # @Author : wangchongshi # @Email : wangchongshi.wcs@antgroup.com @@ -9,7 +9,6 @@ from agentuniverse.agent.agent import Agent from agentuniverse.agent.agent_manager import AgentManager -from agentuniverse.agent.output_object import OutputObject from agentuniverse.base.agentuniverse import AgentUniverse @@ -24,6 +23,21 @@ def test_react_agent(self): query = '请给出一段python代码,可以判断数字是否为素数,给出之前必须验证代码是否可以运行,最少验证1次' instance.run(input=query) + def test_react_agent_with_long_term_memory_extract(self): + """Test demo reAct agent.""" + instance: Agent = AgentManager().get_instance_obj('demo_react_agent') + query = '请给出一段python代码,可以判断数字是否为素数,给出之前必须验证代码是否可以运行,最少验证1次。我喜欢简洁的代码' + instance.run(input=query, session_id='test_session_id') + query = '请给出一段python代码,可以判断数字是否为偶数' + instance.run(input=query, session_id='test_session_id') + query = '请给出一段python代码,可以判断数字是否为整数,我不喜欢简洁的代码' + instance.run(input=query, session_id='test_session_id') + + def test_react_agent_with_context_toolkit(self): + """Test demo reAct agent.""" + instance: Agent = AgentManager().get_instance_obj('demo_react_agent') + query = '帮我写一个 html 文件,展示各种经济数据。要美观、好看' + instance.run(input=query, session_id='test_session_id') if __name__ == '__main__': unittest.main() diff --git a/tests/test_agentuniverse/unit/agent/action/tool/__init__.py b/tests/test_agentuniverse/unit/agent/action/tool/__init__.py new file mode 100644 index 000000000..5f73ff3e5 --- /dev/null +++ b/tests/test_agentuniverse/unit/agent/action/tool/__init__.py @@ -0,0 +1,7 @@ +# !/usr/bin/env python3 +# -*- coding:utf-8 -*- + +# @Time : 2025/12/11 22:01 +# @Author : pengqingsong.pqs +# @Email : pengqingsong.pqs@antgroup.com +# @FileName: __init__.py.py diff --git a/tests/test_agentuniverse/unit/agent/action/tool/test_context_tools.py b/tests/test_agentuniverse/unit/agent/action/tool/test_context_tools.py new file mode 100644 index 000000000..b741ac189 --- /dev/null +++ b/tests/test_agentuniverse/unit/agent/action/tool/test_context_tools.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +# -*- coding:utf-8 -*- + +# @Time : 2025/12/7 16:20 +# @Author : pengqingsong.pqs +# @Email : pengqingsong.pqs@antgroup.com +# @FileName: test_context_tools.py + +import os +import tempfile +import unittest +from agentuniverse.agent.action.tool.context_tool.context_insert import ContextInsertTool +from agentuniverse.agent.action.tool.context_tool.context_read_files import ContextReadFilesTool +from agentuniverse.agent.action.tool.context_tool.context_str_replace import ContextStrReplaceTool +from agentuniverse.agent.action.tool.context_tool.context_append_to_file import ContextAppendToFileTool +from agentuniverse.agent.action.tool.context_tool.context_download_files import ContextDownloadFilesTool +from agentuniverse.agent.action.tool.context_tool.context_list_files import ContextListFilesTool +from agentuniverse.agent.action.tool.context_tool.context_rename_file import ContextRenameFileTool +from agentuniverse.agent.action.tool.context_tool.context_create_file import ContextCreateFileTool + + +class TestContextTools(unittest.TestCase): + """Context tools test class""" + + def setUp(self): + """Test setup""" + # Create temporary directory as context root path + self.temp_dir = tempfile.mkdtemp() + + # Set environment variable + os.environ['CONTEXT_FILE_ROOTPATH'] = self.temp_dir + + # Initialize tool instances + self.session_id = "test_session" + + self.insert_tool = ContextInsertTool() + self.read_tool = ContextReadFilesTool() + self.replace_tool = ContextStrReplaceTool() + self.append_tool = ContextAppendToFileTool() + self.download_tool = ContextDownloadFilesTool() + self.list_tool = ContextListFilesTool() + self.rename_tool = ContextRenameFileTool() + self.create_tool = ContextCreateFileTool() + + def tearDown(self): + """Test cleanup""" + # Delete temporary directory + import shutil + shutil.rmtree(self.temp_dir) + + def test_context_create_file(self): + """Test create file tool""" + result = self.create_tool.execute( + file_name="test_file", + content="Hello, World!", + session_id=self.session_id + ) + + self.assertEqual(result["message"], "File created successfully") + self.assertIn("session_test_session/test_file.md", result["file_url"]) + + def test_context_read_files(self): + """Test read files tool""" + # Create file first + self.create_tool.execute( + file_name="test_file", + content="Line 1\nLine 2\nLine 3", + session_id=self.session_id + ) + + # Test reading file + result = self.read_tool.execute( + file_name="test_file", + session_id=self.session_id + ) + + self.assertEqual(result["message"], "File reading successful") + self.assertEqual(len(result["file_list"]), 1) + self.assertEqual(result["file_list"][0]["file"], "test_file") + self.assertIn("Line 1", result["file_list"][0]["content"]) + + def test_context_insert(self): + """Test insert content tool""" + # Create file first + self.create_tool.execute( + file_name="test_file", + content="Line 1\nLine 3", + session_id=self.session_id + ) + + # Insert content after line 1 + result = self.insert_tool.execute( + file_name="test_file", + insert_text="Line 2", + insert_line="1", + session_id=self.session_id + ) + + self.assertEqual(result["message"], "Content inserted successfully") + self.assertEqual(result["result"], "success") + + def test_context_str_replace(self): + """Test string replacement tool""" + # Create file first + self.create_tool.execute( + file_name="test_file", + content="Hello World!", + session_id=self.session_id + ) + + # Replace string + result = self.replace_tool.execute( + file_name="test_file", + old_str="Hello", + new_str="Hi", + session_id=self.session_id + ) + + self.assertEqual(result["message"], "String replacement successful") + self.assertIn("Hi World!", result["result"]) + + def test_context_append_to_file(self): + """Test append content tool""" + # Create file first + self.create_tool.execute( + file_name="test_file", + content="Original content", + session_id=self.session_id + ) + + # Append content + result = self.append_tool.execute( + file_name="test_file", + content="Appended content", + session_id=self.session_id + ) + + self.assertEqual(result["message"], "Content appended successfully") + + def test_context_list_files(self): + """Test list files tool""" + # Create some files + self.create_tool.execute( + file_name="file1", + content="Content 1", + session_id=self.session_id + ) + self.create_tool.execute( + file_name="file2", + content="Content 2", + session_id=self.session_id + ) + + # List files + result = self.list_tool.execute(session_id=self.session_id) + + self.assertEqual(result["message"], "File list obtained successfully") + self.assertIn("file1.md", result["all_files"]) + self.assertIn("file2.md", result["all_files"]) + + def test_context_rename_file(self): + """Test rename file tool""" + # Create file first + self.create_tool.execute( + file_name="old_file", + content="Content", + session_id=self.session_id + ) + + # Rename file + result = self.rename_tool.execute( + old_file_name="old_file", + new_file_name="new_file", + session_id=self.session_id + ) + + self.assertEqual(result["message"], "File renamed successfully") + + def test_context_download_files(self): + """Test download files tool""" + # Create file first + self.create_tool.execute( + file_name="test_file", + content="Download content", + session_id=self.session_id + ) + + # Get download path + result = self.download_tool.execute( + file_name="test_file", + session_id=self.session_id + ) + + self.assertEqual(result["message"], "File download paths obtained successfully") + self.assertEqual(len(result["file_list"]), 1) + self.assertEqual(result["file_list"][0]["file"], "test_file") + self.assertIn("session_test_session/test_file.md", result["file_list"][0]["fileUrl"]) + + def test_context_read_files_with_range(self): + """Test read files tool line range functionality""" + # Create file first + self.create_tool.execute( + file_name="test_file", + content="Line 1\nLine 2\nLine 3\nLine 4\nLine 5", + session_id=self.session_id + ) + + # Test reading specified line range + result = self.read_tool.execute( + file_name="test_file", + session_id=self.session_id, + line_range="[2, 4]" + ) + + self.assertEqual(result["message"], "File reading successful") + self.assertIn("Line 2", result["file_list"][0]["content"]) + self.assertIn("Line 3", result["file_list"][0]["content"]) + self.assertNotIn("Line 1", result["file_list"][0]["content"]) + self.assertNotIn("Line 5", result["file_list"][0]["content"]) + + def test_context_insert_at_beginning(self): + """Test insert content at beginning of file""" + # Create file first + self.create_tool.execute( + file_name="test_file", + content="Original content", + session_id=self.session_id + ) + + # Insert content at beginning of file + result = self.insert_tool.execute( + file_name="test_file", + insert_text="New content at beginning", + insert_line="0", + session_id=self.session_id + ) + + self.assertEqual(result["message"], "Content inserted successfully") + self.assertEqual(result["result"], "success") + + def test_context_insert_at_end(self): + """Test insert content at end of file""" + # Create file first + self.create_tool.execute( + file_name="test_file", + content="Original content", + session_id=self.session_id + ) + + # Insert content at end of file (no insert_line specified) + result = self.insert_tool.execute( + file_name="test_file", + insert_text="New content at end", + session_id=self.session_id + ) + + self.assertEqual(result["message"], "Content inserted successfully") + self.assertEqual(result["result"], "success") + + def test_context_read_files_multiple_files(self): + """Test reading multiple files""" + # Create multiple files + self.create_tool.execute( + file_name="file1", + content="Content 1", + session_id=self.session_id + ) + self.create_tool.execute( + file_name="file2", + content="Content 2", + session_id=self.session_id + ) + + # Test reading multiple files + result = self.read_tool.execute( + file_name="file1,file2", + session_id=self.session_id + ) + + self.assertEqual(result["message"], "File reading successful") + self.assertEqual(len(result["file_list"]), 2) + self.assertEqual(result["file_list"][0]["file"], "file1") + self.assertEqual(result["file_list"][1]["file"], "file2") + + def test_context_download_files_multiple_files(self): + """Test downloading multiple files""" + # Create multiple files + self.create_tool.execute( + file_name="file1", + content="Content 1", + session_id=self.session_id + ) + self.create_tool.execute( + file_name="file2", + content="Content 2", + session_id=self.session_id + ) + + # Test downloading multiple files + result = self.download_tool.execute( + file_name="file1,file2", + session_id=self.session_id + ) + + self.assertEqual(result["message"], "File download paths obtained successfully") + self.assertEqual(len(result["file_list"]), 2) + self.assertEqual(result["file_list"][0]["file"], "file1") + self.assertEqual(result["file_list"][1]["file"], "file2")