summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorbatsumaru <>2026-09-15 16:23:26 +0900
committerbatsumaru <>2026-09-15 16:23:26 +0900
commit796c8978eaaf080146ba41ec49c75f3c7f3cf404 (patch)
treee23495733dc9e9e5534c35731e1030fb2d3f5da9
parent1a67c5edb6fa6165e99cc25ed380303ed817af09 (diff)
Wired long-term memory into agent
-rw-r--r--personal_agent/agent.py9
-rw-r--r--personal_agent/cli.py10
-rw-r--r--personal_agent/tools/__init__.py6
-rw-r--r--personal_agent/tools/memory.py194
-rw-r--r--tests/test_memory_tools.py169
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")