summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--agent.toml70
-rw-r--r--personal_agent/agent.py2
-rw-r--r--personal_agent/cli.py3
-rw-r--r--personal_agent/context.py28
-rw-r--r--personal_agent/database/migrations/002_add_memory_fts.sql6
-rw-r--r--personal_agent/database/migrations/003_populate_memory_fts.sql3
-rw-r--r--personal_agent/llm.py6
-rw-r--r--personal_agent/logging.py8
-rw-r--r--personal_agent/memory/store.py40
-rw-r--r--tests/test_context.py128
-rw-r--r--tests/test_database.py8
-rw-r--r--tests/test_memory_store.py59
12 files changed, 345 insertions, 16 deletions
diff --git a/agent.toml b/agent.toml
index d7873df..e45f385 100644
--- a/agent.toml
+++ b/agent.toml
@@ -88,6 +88,29 @@ Long-term memory is persistent information that should remain useful after
the current conversation ends. Use it selectively, but save information when
it clearly meets the criteria below.
+LONG-TERM MEMORY SAVE RULE:
+
+When the user gives you information that should be stored in long-term memory:
+
+1. ALWAYS call long_term_memory_search first.
+2. NEVER call long_term_memory_save before searching.
+3. If the search finds an existing memory that already represents the same
+ information, do not save anything.
+4. If the search finds no equivalent memory, call long_term_memory_save.
+5. If the search finds an older or superseded version, use the update procedure:
+ delete the stale memory with long_term_memory_delete, then save the new
+ memory with long_term_memory_save.
+
+This rule applies even if the information appears in the current conversation.
+Conversation history is not a substitute for long-term memory search.
+
+For a durable preference such as "C++ is my preferred language for systems
+programming", the required behavior is:
+
+long_term_memory_search -> inspect result -> decide whether to save
+
+Never simply acknowledge the preference without performing this procedure.
+
SEMANTIC MEMORY:
When the user states a durable personal fact, preference, decision, goal,
relationship, or other persistent information about themselves, save it with
@@ -115,6 +138,16 @@ to be useful in future conversations, save it with memory_save using category
Do not save ordinary daily events or trivial anecdotes merely because they
happened.
+MEMORY IMPORTANCE:
+
+When you save a long-term memory, assign an importance level:
+
+1 = useful but minor
+2 = normally useful for future conversations
+3 = especially important for future behavior
+
+Importance means future usefulness, not emotional intensity.
+
WORKING MEMORY VS LONG-TERM MEMORY:
Working memory contains information needed for the current task or ongoing
conversation. Long-term memory contains information that should remain useful
@@ -132,6 +165,29 @@ If information is saved as long-term memory, do not also call
memory_add_fact or memory_add_note for the same information unless that
information is independently necessary for the current task.
+Before calling long_term_memory_save for any new long-term memory, you MUST
+first call long_term_memory_search to check whether the information is already
+stored.
+
+The order is mandatory:
+1. Decide whether the information belongs in long-term memory.
+2. If it does, call long_term_memory_search before calling
+ long_term_memory_save.
+3. Examine the search results.
+4. If an existing memory already represents the same information, do not call
+ long_term_memory_save. The existing memory is sufficient.
+5. If no equivalent memory exists, call long_term_memory_save.
+6. If an existing memory represents an old or superseded value, follow the
+ updating-existing-memory procedure instead.
+7. Do not treat merely related memories as duplicates.
+
+This search-before-save procedure applies even when the user is repeating
+information from an earlier conversation. Conversation history does not
+guarantee that the information has already been stored in long-term memory.
+
+Never call long_term_memory_save for new long-term memory without first
+calling long_term_memory_search.
+
UPDATING EXISTING MEMORY:
When the user changes, corrects, replaces, or supersedes a durable fact or
preference, the new information replaces the old information.
@@ -164,9 +220,17 @@ Do not create a combined historical memory such as:
"Previously preferred C++, now prefers Rust"
unless the user explicitly asks to remember the history itself.
-When replacing a preference, save the new preference as a normal current-state
-statement, for example:
-"Rust is my preferred language for systems programming."
+When saving a replacement preference, save only the current state.
+
+For example, if the user changes their preference from C++ to Rust,
+save:
+
+"User prefers Rust as their language of choice for systems programming."
+
+Do not include the previous value in the saved memory, such as:
+"User now prefers Rust ... (changed from C++)."
+
+The deletion of the old memory already preserves the correct current state.
Do not delete unrelated memories merely because they mention the same general
subject. Delete only memories that represent the superseded information.
diff --git a/personal_agent/agent.py b/personal_agent/agent.py
index 9c456a8..8efe949 100644
--- a/personal_agent/agent.py
+++ b/personal_agent/agent.py
@@ -59,6 +59,8 @@ class Agent:
}
)
+ # print(f"[debug: {self.context.build_messages(self.messages, self.working_memory)}]")
+
for _ in range(5):
response = self.llm.chat(
self.context.build_messages(
diff --git a/personal_agent/cli.py b/personal_agent/cli.py
index 57df7b0..a2d6af5 100644
--- a/personal_agent/cli.py
+++ b/personal_agent/cli.py
@@ -4,6 +4,7 @@ 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.logging import configure_logging
from personal_agent.memory.store import LongTermMemoryStore
from personal_agent.tools import (
CalculatorTool,
@@ -13,6 +14,8 @@ from personal_agent.tools import (
WeatherTool,
)
+configure_logging()
+
llm = LLM(
"http://localhost:8080/v1/chat/completions",
)
diff --git a/personal_agent/context.py b/personal_agent/context.py
index 74cc254..defc835 100644
--- a/personal_agent/context.py
+++ b/personal_agent/context.py
@@ -1,6 +1,10 @@
+import logging
+
from personal_agent.identity import AgentIdentity, UserProfile
from personal_agent.memory.models import WorkingMemory
+logger = logging.getLogger(__name__)
+
class ContextBuilder:
def __init__(
@@ -18,21 +22,41 @@ class ContextBuilder:
agent = self.agent_identity
user = self.user_profile
- return (
+ prompt = (
f"You are {agent.name}, a personal AI assistant.\n"
f"Personality: {agent.personality['style']}.\n"
f"Honesty: {agent.values['honesty']}.\n"
f"Directness: {agent.values['directness']}.\n"
f"Favorite color: {agent.quirks['favorite_color']}.\n"
f"\n"
+ f"===BEHAVIOR==="
f"Task management:\n"
- f"{agent.behavior['task_management']}"
+ f"{agent.behavior['task_management']}\n"
+ f"\n"
+ f"Working memory:\n"
+ f"{agent.behavior['working_memory']}"
+ f"\n"
+ f"Long term memory:\n"
+ f"{agent.behavior['long_term_memory']}"
+ f"===BEHAVIOR===\n"
f"\n"
f"The user's name is {user.name}"
f"\n"
f"{self.build_working_memory(memory)}"
)
+ logger.debug("Built system prompt: %d characters", len(prompt))
+
+ logger.debug(
+ "Behavior section present: task_management=%s, working working_memory=%s, "
+ "long_term_memory=%s",
+ bool(agent.behavior.get("task_management")),
+ bool(agent.behavior.get("working_memory")),
+ bool(agent.behavior.get("long_term_memory")),
+ )
+
+ return prompt
+
def build_working_memory(self, memory: WorkingMemory) -> str:
lines = ["Current working memory:"]
diff --git a/personal_agent/database/migrations/002_add_memory_fts.sql b/personal_agent/database/migrations/002_add_memory_fts.sql
new file mode 100644
index 0000000..26ccada
--- /dev/null
+++ b/personal_agent/database/migrations/002_add_memory_fts.sql
@@ -0,0 +1,6 @@
+CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts
+USING fts5(
+ content,
+ content='memories',
+ content_rowid='rowid'
+);
diff --git a/personal_agent/database/migrations/003_populate_memory_fts.sql b/personal_agent/database/migrations/003_populate_memory_fts.sql
new file mode 100644
index 0000000..35c2a4e
--- /dev/null
+++ b/personal_agent/database/migrations/003_populate_memory_fts.sql
@@ -0,0 +1,3 @@
+INSERT INTO memories_fts(rowid, content)
+SELECT rowid, content
+FROM memories;
diff --git a/personal_agent/llm.py b/personal_agent/llm.py
index 418c478..cd35676 100644
--- a/personal_agent/llm.py
+++ b/personal_agent/llm.py
@@ -1,8 +1,11 @@
import json
+import logging
from dataclasses import dataclass
import requests
+logger = logging.getLogger(__name__)
+
@dataclass
class ToolCall:
@@ -43,7 +46,8 @@ class LLM:
response.raise_for_status()
data = response.json()
- print(f"\n[debug: {data}]\n")
+ logger.debug("LLM response: %s", data)
+ # print(f"\n[debug: {data}]\n")
message = data["choices"][0]["message"]
content = message.get("content")
diff --git a/personal_agent/logging.py b/personal_agent/logging.py
new file mode 100644
index 0000000..8f86003
--- /dev/null
+++ b/personal_agent/logging.py
@@ -0,0 +1,8 @@
+import logging
+
+
+def configure_logging(level: int = logging.INFO) -> None:
+ logging.basicConfig(
+ level=level,
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
+ )
diff --git a/personal_agent/memory/store.py b/personal_agent/memory/store.py
index e5fca96..164d11f 100644
--- a/personal_agent/memory/store.py
+++ b/personal_agent/memory/store.py
@@ -21,6 +21,16 @@ class LongTermMemoryStore:
(str(memory.id), memory.content, memory.category.value, created_at),
)
+ connection.execute(
+ """
+ INSERT INTO memories_fts(rowid, content)
+ SELECT rowid, content
+ FROM memories
+ WHERE id = ?
+ """,
+ (str(memory.id),),
+ )
+
def get(self, memory_id: UUID) -> LongTermMemory | None:
with self.database.connect() as connection:
row = connection.execute(
@@ -52,6 +62,18 @@ class LongTermMemoryStore:
def delete(self, memory_id: UUID) -> None:
with self.database.connect() as connection:
connection.execute(
+ """
+ DELETE FROM memories_fts
+ WHERE rowid = (
+ SELECT rowid
+ FROM memories
+ WHERE id = ?
+ )
+ """,
+ (str(memory_id),),
+ )
+
+ connection.execute(
"DELETE FROM memories WHERE id = ?",
(str(memory_id),),
)
@@ -68,18 +90,24 @@ class LongTermMemoryStore:
if not query.strip():
raise ValueError("Search query must not be empty")
+ terms = query.split()
+ fts_query = " OR ".join(
+ f'"{term.replace(chr(34), chr(34) * 2)}"' for term in terms
+ )
+
sql = """
- SELECT id, content, category, created_at
- FROM memories
- WHERE content LIKE ?
+ SELECT m.id, m.content, m.category, m.created_at
+ FROM memories_fts as f
+ JOIN memories AS m ON m.rowid = f.rowid
+ WHERE memories_fts MATCH ?
"""
- parameters: list[str | int] = [f"%{query}%"]
+ parameters: list[str | int] = [fts_query]
if category is not None:
- sql += " AND category = ?"
+ sql += " AND m.category = ?"
parameters.append(category.value)
- sql += " ORDER BY id LIMIT ?"
+ sql += " ORDER BY bm25(memories_fts) LIMIT ?"
parameters.append(limit)
with self.database.connect() as connection:
diff --git a/tests/test_context.py b/tests/test_context.py
new file mode 100644
index 0000000..baf5dd6
--- /dev/null
+++ b/tests/test_context.py
@@ -0,0 +1,128 @@
+from personal_agent.context import ContextBuilder
+from personal_agent.identity import AgentIdentity, UserProfile
+from personal_agent.memory.models import TaskStatus, WorkingMemory
+
+
+def create_agent_identity() -> AgentIdentity:
+ return AgentIdentity(
+ name="Test Agent",
+ personality={"style": "direct"},
+ values={"honesty": True, "directness": True},
+ quirks={"favorite_color": "blue"},
+ behavior={
+ "task_management": "TASK MANAGEMENT POLICY",
+ "working_memory": "WORKING MEMORY POLICY",
+ "long_term_memory": "LONG TERM MEMORY POLICY",
+ },
+ )
+
+
+def create_user_profile() -> UserProfile:
+ return UserProfile(
+ name="Test User",
+ preferences={},
+ )
+
+
+def create_context_builder() -> ContextBuilder:
+ return ContextBuilder(
+ agent_identity=create_agent_identity(),
+ user_profile=create_user_profile(),
+ )
+
+
+def test_system_prompt_contains_agent_identity():
+ builder = create_context_builder()
+
+ prompt = builder.build_system_prompt(WorkingMemory())
+
+ assert "You are Test Agent, a personal AI assistant." in prompt
+ assert "Personality: direct." in prompt
+ assert "Honesty: True." in prompt
+ assert "Directness: True." in prompt
+ assert "Favorite color: blue." in prompt
+ assert "The user's name is Test User" in prompt
+
+
+def test_system_prompt_contains_all_behavior_policies():
+ builder = create_context_builder()
+
+ prompt = builder.build_system_prompt(WorkingMemory())
+
+ behavior = builder.agent_identity.behavior
+
+ assert behavior["task_management"] in prompt
+ assert behavior["working_memory"] in prompt
+ assert behavior["long_term_memory"] in prompt
+
+
+def test_system_prompt_contains_behavior_sections():
+ builder = create_context_builder()
+
+ prompt = builder.build_system_prompt(WorkingMemory())
+
+ assert "===BEHAVIOR===" in prompt
+ assert "Task management:" in prompt
+ assert "Working memory:" in prompt
+ assert "Long term memory:" in prompt
+
+
+def test_system_prompt_contains_working_memory():
+ builder = create_context_builder()
+
+ memory = WorkingMemory()
+ memory.set_objective("Implement authentication")
+ memory.set_status(TaskStatus.ACTIVE)
+ memory.add_fact("The project uses SQLite.")
+ memory.add_note("Need to add logout support.")
+
+ prompt = builder.build_system_prompt(memory)
+
+ assert "Current working memory:" in prompt
+ assert "Objective: Implement authentication" in prompt
+ assert "Status: active" in prompt
+ assert "The project uses SQLite." in prompt
+ assert "Need to add logout support." in prompt
+
+
+def test_empty_working_memory_is_explicitly_shown():
+ builder = create_context_builder()
+
+ prompt = builder.build_system_prompt(WorkingMemory())
+
+ assert "Current working memory:" in prompt
+ assert "(empty)" in prompt
+
+
+def test_build_messages_puts_system_prompt_first():
+ builder = create_context_builder()
+
+ history: list[dict[str, object]] = [
+ {
+ "role": "user",
+ "content": "Hello",
+ },
+ {
+ "role": "assistant",
+ "content": "Hi!",
+ },
+ ]
+
+ messages = builder.build_messages(history, WorkingMemory())
+
+ assert messages[0]["role"] == "system"
+ assert messages[1:] == history
+
+
+def test_build_messages_uses_current_working_memory():
+ builder = create_context_builder()
+
+ memory = WorkingMemory()
+ memory.set_objective("Build the memory system")
+
+ messages = builder.build_messages([], memory)
+
+ system_prompt = messages[0]["content"]
+
+ assert isinstance(system_prompt, str)
+ assert "Objective: Build the memory system" in system_prompt
diff --git a/tests/test_database.py b/tests/test_database.py
index 290b985..e387123 100644
--- a/tests/test_database.py
+++ b/tests/test_database.py
@@ -34,7 +34,7 @@ def test_migrate_records_version(tmp_path):
with database.connect() as connection:
rows = connection.execute("SELECT version FROM schema_migrations").fetchall()
- assert rows == [(1,)]
+ assert rows == [(1,), (2,), (3,)]
def test_migrate_is_idempotent(tmp_path):
@@ -46,13 +46,13 @@ def test_migrate_is_idempotent(tmp_path):
with database.connect() as connection:
rows = connection.execute("SELECT version FROM schema_migrations").fetchall()
- assert rows == [(1,)]
+ assert rows == [(1,), (2,), (3,)]
def test_failed_migration_rolls_back(tmp_path):
database = Database(tmp_path / "test.db")
- migration = database.migrations_dir / "002_failing_migration.sql"
+ migration = database.migrations_dir / "999_failing_migration.sql"
migration.write_text(
"""
THIS IS INVALID SQL;
@@ -68,7 +68,7 @@ def test_failed_migration_rolls_back(tmp_path):
"SELECT version FROM schema_migrations"
).fetchall()
- assert migrations == [(1,)]
+ assert migrations == [(1,), (2,), (3,)]
finally:
migration.unlink()
diff --git a/tests/test_memory_store.py b/tests/test_memory_store.py
index 8f3407d..b83d217 100644
--- a/tests/test_memory_store.py
+++ b/tests/test_memory_store.py
@@ -211,3 +211,62 @@ def test_search_respects_limit(tmp_path):
store.add(memory)
assert store.search("programming", limit=2) == memories[:2]
+
+
+def test_search_uses_fts(tmp_path):
+ database = Database(tmp_path / "agent.db")
+ database.migrate()
+ store = LongTermMemoryStore(database)
+
+ memory = LongTermMemory.create(
+ "User prefers Rust for systems programming.",
+ MemoryCategory.SEMANTIC,
+ )
+ store.add(memory)
+
+ results = store.search("preferred systems programming language")
+
+ assert [result.id for result in results] == [memory.id]
+
+
+def test_delete_removes_memory_from_search(tmp_path):
+ database = Database(tmp_path / "agent.db")
+ database.migrate()
+ store = LongTermMemoryStore(database)
+
+ memory = LongTermMemory.create(
+ "User prefers Rust for systems programming.",
+ MemoryCategory.SEMANTIC,
+ )
+ store.add(memory)
+
+ assert store.search("Rust")
+
+ store.delete(memory.id)
+
+ assert store.search("Rust") == []
+
+
+def test_search_filters_by_category(tmp_path):
+ database = Database(tmp_path / "agent.db")
+ database.migrate()
+ store = LongTermMemoryStore(database)
+
+ semantic = LongTermMemory.create(
+ "User prefers Rust.",
+ MemoryCategory.SEMANTIC,
+ )
+ episodic = LongTermMemory.create(
+ "User used Rust on a filesystem project.",
+ MemoryCategory.EPISODIC,
+ )
+
+ store.add(semantic)
+ store.add(episodic)
+
+ results = store.search(
+ "Rust",
+ category=MemoryCategory.EPISODIC,
+ )
+
+ assert [result.id for result in results] == [episodic.id]