diff options
| author | batsumaru <> | 2026-09-15 16:23:26 +0900 |
|---|---|---|
| committer | batsumaru <> | 2026-09-15 16:23:26 +0900 |
| commit | 796c8978eaaf080146ba41ec49c75f3c7f3cf404 (patch) | |
| tree | e23495733dc9e9e5534c35731e1030fb2d3f5da9 | |
| parent | 1a67c5edb6fa6165e99cc25ed380303ed817af09 (diff) | |
Wired long-term memory into agent
| -rw-r--r-- | personal_agent/agent.py | 9 | ||||
| -rw-r--r-- | personal_agent/cli.py | 10 | ||||
| -rw-r--r-- | personal_agent/tools/__init__.py | 6 | ||||
| -rw-r--r-- | personal_agent/tools/memory.py | 194 | ||||
| -rw-r--r-- | tests/test_memory_tools.py | 169 |
5 files changed, 387 insertions, 1 deletions
diff --git a/personal_agent/agent.py b/personal_agent/agent.py index 73cc422..9c456a8 100644 --- a/personal_agent/agent.py +++ b/personal_agent/agent.py @@ -4,7 +4,11 @@ from personal_agent.context import ContextBuilder from personal_agent.identity import AgentIdentity, UserProfile from personal_agent.llm import LLM from personal_agent.memory.models import WorkingMemory +from personal_agent.memory.store import LongTermMemoryStore from personal_agent.tools import ( + LongTermMemoryDeleteTool, + LongTermMemorySaveTool, + LongTermMemorySearchTool, MemoryAddFactTool, MemoryAddNoteTool, MemoryRemoveFactTool, @@ -22,6 +26,7 @@ class Agent: agent_identity: AgentIdentity, user_profile: UserProfile, tools: ToolRegistry, + long_term_memory_store: LongTermMemoryStore, ) -> None: self.llm = llm self.tools = tools @@ -32,12 +37,16 @@ class Agent: ) self.working_memory = WorkingMemory() + self.long_term_memory_store = long_term_memory_store self.tools.register(MemoryAddFactTool(self.working_memory)) self.tools.register(MemoryRemoveFactTool(self.working_memory)) self.tools.register(MemoryRemoveNoteTool(self.working_memory)) self.tools.register(MemoryAddNoteTool(self.working_memory)) self.tools.register(TaskStartTool(self.working_memory)) + self.tools.register(LongTermMemorySaveTool(self.long_term_memory_store)) + self.tools.register(LongTermMemorySearchTool(self.long_term_memory_store)) + self.tools.register(LongTermMemoryDeleteTool(self.long_term_memory_store)) self.messages: list[dict[str, object]] = [] diff --git a/personal_agent/cli.py b/personal_agent/cli.py index 7bd747a..57df7b0 100644 --- a/personal_agent/cli.py +++ b/personal_agent/cli.py @@ -1,6 +1,10 @@ +from pathlib import Path + from personal_agent.agent import Agent +from personal_agent.database.database import Database from personal_agent.identity import load_agent_identity, load_user_profile from personal_agent.llm import LLM +from personal_agent.memory.store import LongTermMemoryStore from personal_agent.tools import ( CalculatorTool, FileSystemListTool, @@ -23,11 +27,17 @@ tools.register(WeatherTool()) tools.register(FileSystemListTool()) tools.register(FileSystemReadTool()) +database = Database(Path("data/agent.db")) +database.migrate() + +long_term_memory_store = LongTermMemoryStore(database) + agent = Agent( llm, agent_identity, user_profile, tools, + long_term_memory_store, ) while True: diff --git a/personal_agent/tools/__init__.py b/personal_agent/tools/__init__.py index 7c18f88..96ac65e 100644 --- a/personal_agent/tools/__init__.py +++ b/personal_agent/tools/__init__.py @@ -3,6 +3,9 @@ from personal_agent.tools.calculator import CalculatorTool from personal_agent.tools.filesystem import FileSystemListTool, FileSystemReadTool from personal_agent.tools.hello import HelloTool from personal_agent.tools.memory import ( + LongTermMemoryDeleteTool, + LongTermMemorySaveTool, + LongTermMemorySearchTool, MemoryAddFactTool, MemoryAddNoteTool, MemoryRemoveFactTool, @@ -16,6 +19,9 @@ __all__ = [ "FileSystemListTool", "FileSystemReadTool", "HelloTool", + "LongTermMemoryDeleteTool", + "LongTermMemorySaveTool", + "LongTermMemorySearchTool", "MemoryAddFactTool", "MemoryAddNoteTool", "MemoryRemoveFactTool", diff --git a/personal_agent/tools/memory.py b/personal_agent/tools/memory.py index 2287d4e..851a69d 100644 --- a/personal_agent/tools/memory.py +++ b/personal_agent/tools/memory.py @@ -1,4 +1,7 @@ -from personal_agent.memory.models import WorkingMemory +from uuid import UUID + +from personal_agent.memory.models import LongTermMemory, MemoryCategory, WorkingMemory +from personal_agent.memory.store import LongTermMemoryStore from personal_agent.tools.base import Tool, ToolError, ToolResult @@ -195,3 +198,192 @@ class MemoryRemoveNoteTool(Tool): self.memory.remove_note(note) return ToolResult(content=f"Removed note: {note}") + + +class LongTermMemorySaveTool(Tool): + def __init__(self, store: LongTermMemoryStore) -> None: + self.store = store + + @property + def name(self) -> str: + return "memory_save" + + @property + def description(self) -> str: + return ( + "Save durable information for use across future conversations. " + "Use semantic for stable facts or preferences, and episodic for " + "meaningful past events." + ) + + @property + def schema(self) -> dict[str, object]: + return { + "type": "object", + "properties": { + "content": { + "type": "string", + "description": "The durable information to remember.", + }, + "category": { + "type": "string", + "enum": ["semantic", "episodic"], + "description": ( + "semantic for stable facts/preferences; " + "episodic for meaningful past events." + ), + }, + }, + "required": ["content", "category"], + } + + def execute(self, arguments: dict[str, object]) -> ToolResult: + content = arguments.get("content") + category = arguments.get("category") + + if not isinstance(content, str): + raise ToolError("content must be a string") + + if not content.strip(): + raise ToolError("content must not be empty") + + if not isinstance(category, str): + raise ToolError("category must be a string") + + try: + memory_category = MemoryCategory(category) + except ValueError: + raise ToolError("category must be 'semantic' or 'episodic'") + + memory = LongTermMemory.create(content, memory_category) + self.store.add(memory) + + return ToolResult(content=f"Saved memory: {memory.id}") + + +class LongTermMemorySearchTool(Tool): + def __init__(self, store: LongTermMemoryStore) -> None: + self.store = store + + @property + def name(self) -> str: + return "memory_search" + + @property + def description(self) -> str: + return ( + "Search long-term memory for information relevant to the current " + "conversation. Use semantic for stable facts or preferences, and " + "episodic for meaningful past events." + ) + + @property + def schema(self) -> dict[str, object]: + return { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "The information to search for.", + }, + "category": { + "type": "string", + "enum": ["semantic", "episodic"], + "description": "Optionally restrict results to one category.", + }, + "limit": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of memories to return.", + }, + }, + "required": ["query"], + } + + def execute(self, arguments: dict[str, object]) -> ToolResult: + query = arguments.get("query") + category = arguments.get("category") + limit = arguments.get("limit", 10) + + if not isinstance(query, str): + raise ToolError("query must be a string") + + if not query.strip(): + raise ToolError("query must not be empty") + + if category is not None: + if not isinstance(category, str): + raise ToolError("category must be a string") + + try: + memory_category = MemoryCategory(category) + except ValueError: + raise ToolError("category must be 'semantic' or 'episodic'") + else: + memory_category = None + + if not isinstance(limit, int) or isinstance(limit, bool): + raise ToolError("limit must be an integer") + + if limit <= 0: + raise ToolError("limit must be positive") + + memories = self.store.search( + query=query, + category=memory_category, + limit=limit, + ) + + if not memories: + return ToolResult(content="No matching memories found.") + + lines = [ + f"- [{memory.category.value}] {memory.id}: {memory.content}" + for memory in memories + ] + + return ToolResult(content="\n".join(lines)) + + +class LongTermMemoryDeleteTool(Tool): + def __init__(self, store: LongTermMemoryStore) -> None: + self.store = store + + @property + def name(self) -> str: + return "memory_delete" + + @property + def description(self) -> str: + return ( + "Delete a long-term memory by its exact ID. Only delete a memory " + "when its ID is known from a previous memory search or save result." + ) + + @property + def schema(self) -> dict[str, object]: + return { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "The exact UUID of the memory to delete.", + }, + }, + "required": ["id"], + } + + def execute(self, arguments: dict[str, object]) -> ToolResult: + memory_id = arguments.get("id") + + if not isinstance(memory_id, str): + raise ToolError("id must be a string") + + try: + parsed_id = UUID(memory_id) + except ValueError: + raise ToolError("id must be a valid UUID") + + self.store.delete(parsed_id) + + return ToolResult(content=f"Deleted memory: {parsed_id}") diff --git a/tests/test_memory_tools.py b/tests/test_memory_tools.py new file mode 100644 index 0000000..9e0fcb0 --- /dev/null +++ b/tests/test_memory_tools.py @@ -0,0 +1,169 @@ +from personal_agent.database.database import Database +from personal_agent.memory.models import MemoryCategory +from personal_agent.memory.store import LongTermMemoryStore +from personal_agent.tools.memory import ( + LongTermMemoryDeleteTool, + LongTermMemorySaveTool, + LongTermMemorySearchTool, + ToolError, +) + + +def create_store(tmp_path): + database = Database(tmp_path / "test.db") + database.migrate() + return LongTermMemoryStore(database) + + +def test_save_tool_persists_memory(tmp_path): + store = create_store(tmp_path) + tool = LongTermMemorySaveTool(store) + + result = tool.execute( + { + "content": "User prefers Rust for systems programming.", + "category": "semantic", + } + ) + + memories = store.list() + + assert len(memories) == 1 + assert memories[0].content == "User prefers Rust for systems programming." + assert memories[0].category is MemoryCategory.SEMANTIC + assert str(memories[0].id) in result.content + + +def test_save_tool_rejects_empty_content(tmp_path): + store = create_store(tmp_path) + tool = LongTermMemorySaveTool(store) + + try: + tool.execute({"content": " ", "category": "semantic"}) + except ToolError as error: + assert str(error) == "content must not be empty" + else: + raise AssertionError("Expected ToolError") + + +def test_save_tool_rejects_invalid_category(tmp_path): + store = create_store(tmp_path) + tool = LongTermMemorySaveTool(store) + + try: + tool.execute( + { + "content": "Something", + "category": "invalid", + } + ) + except ToolError as error: + assert str(error) == "category must be 'semantic' or 'episodic'" + else: + raise AssertionError("Expected ToolError") + + +def test_search_tool_returns_matching_memories(tmp_path): + store = create_store(tmp_path) + save_tool = LongTermMemorySaveTool(store) + search_tool = LongTermMemorySearchTool(store) + + save_tool.execute( + { + "content": "User prefers Rust for systems programming.", + "category": "semantic", + } + ) + save_tool.execute( + { + "content": "User visited Tokyo last year.", + "category": "episodic", + } + ) + + result = search_tool.execute({"query": "Rust"}) + + assert "User prefers Rust for systems programming." in result.content + assert "User visited Tokyo last year." not in result.content + + +def test_search_tool_can_filter_category(tmp_path): + store = create_store(tmp_path) + save_tool = LongTermMemorySaveTool(store) + search_tool = LongTermMemorySearchTool(store) + + save_tool.execute( + { + "content": "User likes Rust.", + "category": "semantic", + } + ) + save_tool.execute( + { + "content": "User worked on a Rust project last summer.", + "category": "episodic", + } + ) + + result = search_tool.execute( + { + "query": "Rust", + "category": "episodic", + } + ) + + assert "worked on a Rust project" in result.content + assert "User likes Rust." not in result.content + + +def test_search_tool_returns_no_matches(tmp_path): + store = create_store(tmp_path) + search_tool = LongTermMemorySearchTool(store) + + result = search_tool.execute({"query": "nonexistent"}) + + assert result.content == "No matching memories found." + + +def test_search_tool_rejects_empty_query(tmp_path): + store = create_store(tmp_path) + search_tool = LongTermMemorySearchTool(store) + + try: + search_tool.execute({"query": " "}) + except ToolError as error: + assert str(error) == "query must not be empty" + else: + raise AssertionError("Expected ToolError") + + +def test_delete_tool_removes_memory(tmp_path): + store = create_store(tmp_path) + save_tool = LongTermMemorySaveTool(store) + delete_tool = LongTermMemoryDeleteTool(store) + + result = save_tool.execute( + { + "content": "User likes Rust.", + "category": "semantic", + } + ) + + memory_id = result.content.removeprefix("Saved memory: ") + + delete_result = delete_tool.execute({"id": memory_id}) + + assert delete_result.content == f"Deleted memory: {memory_id}" + assert store.list() == [] + + +def test_delete_tool_rejects_invalid_id(tmp_path): + store = create_store(tmp_path) + delete_tool = LongTermMemoryDeleteTool(store) + + try: + delete_tool.execute({"id": "not-a-uuid"}) + except ToolError as error: + assert str(error) == "id must be a valid UUID" + else: + raise AssertionError("Expected ToolError") |
