From 1a67c5edb6fa6165e99cc25ed380303ed817af09 Mon Sep 17 00:00:00 2001 From: batsumaru <> Date: Tue, 15 Sep 2026 15:13:34 +0900 Subject: Cleaned up project tree --- agent.py | 128 ----- client.py | 65 --- context.py | 69 --- database/__init__.py | 0 database/database.py | 73 --- database/migrations/001_create_memories.sql | 6 - identity.py | 40 -- llm.py | 87 --- memory.py | 101 ---- memory_store.py | 99 ---- personal_agent/__init__.py | 0 personal_agent/agent.py | 128 +++++ personal_agent/cli.py | 65 +++ personal_agent/context.py | 69 +++ personal_agent/database/__init__.py | 0 personal_agent/database/database.py | 73 +++ .../database/migrations/001_create_memories.sql | 6 + personal_agent/identity.py | 40 ++ personal_agent/llm.py | 87 +++ personal_agent/memory/__init__.py | 0 personal_agent/memory/models.py | 101 ++++ personal_agent/memory/store.py | 99 ++++ personal_agent/tools/__init__.py | 29 + personal_agent/tools/base.py | 55 ++ personal_agent/tools/calculator.py | 62 +++ personal_agent/tools/filesystem.py | 112 ++++ personal_agent/tools/hello.py | 40 ++ personal_agent/tools/memory.py | 197 +++++++ personal_agent/tools/weather.py | 122 +++++ tests/test_database.py | 2 +- tests/test_memory_store.py | 6 +- tools.py | 582 --------------------- 32 files changed, 1289 insertions(+), 1254 deletions(-) delete mode 100644 agent.py delete mode 100644 client.py delete mode 100644 context.py delete mode 100644 database/__init__.py delete mode 100644 database/database.py delete mode 100644 database/migrations/001_create_memories.sql delete mode 100644 identity.py delete mode 100644 llm.py delete mode 100644 memory.py delete mode 100644 memory_store.py create mode 100644 personal_agent/__init__.py create mode 100644 personal_agent/agent.py create mode 100644 personal_agent/cli.py create mode 100644 personal_agent/context.py create mode 100644 personal_agent/database/__init__.py create mode 100644 personal_agent/database/database.py create mode 100644 personal_agent/database/migrations/001_create_memories.sql create mode 100644 personal_agent/identity.py create mode 100644 personal_agent/llm.py create mode 100644 personal_agent/memory/__init__.py create mode 100644 personal_agent/memory/models.py create mode 100644 personal_agent/memory/store.py create mode 100644 personal_agent/tools/__init__.py create mode 100644 personal_agent/tools/base.py create mode 100644 personal_agent/tools/calculator.py create mode 100644 personal_agent/tools/filesystem.py create mode 100644 personal_agent/tools/hello.py create mode 100644 personal_agent/tools/memory.py create mode 100644 personal_agent/tools/weather.py delete mode 100644 tools.py diff --git a/agent.py b/agent.py deleted file mode 100644 index a79fd74..0000000 --- a/agent.py +++ /dev/null @@ -1,128 +0,0 @@ -import json - -from context import ContextBuilder -from identity import AgentIdentity, UserProfile -from llm import LLM -from memory import WorkingMemory -from tools import ( - MemoryAddFactTool, - MemoryAddNoteTool, - MemoryRemoveFactTool, - MemoryRemoveNoteTool, - TaskStartTool, - ToolError, - ToolRegistry, -) - - -class Agent: - def __init__( - self, - llm: LLM, - agent_identity: AgentIdentity, - user_profile: UserProfile, - tools: ToolRegistry, - ) -> None: - self.llm = llm - self.tools = tools - - self.context = ContextBuilder( - agent_identity, - user_profile, - ) - - self.working_memory = WorkingMemory() - - 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.messages: list[dict[str, object]] = [] - - def chat(self, user_input: str) -> str: - print(f"\n[memory: {self.working_memory}]") - self.messages.append( - { - "role": "user", - "content": user_input, - } - ) - - for _ in range(5): - response = self.llm.chat( - self.context.build_messages( - self.messages, - self.working_memory, - ), - tools=self.tools.schemas(), - ) - - if response.tool_calls: - print(f"\n[tool call: {response.tool_calls}]\n") - - if not response.tool_calls: - answer = response.content or "" - - self.messages.append( - { - "role": "assistant", - "content": answer, - } - ) - - return answer - - self.messages.append( - { - "role": "assistant", - "content": response.content, - "tool_calls": [ - { - "id": call.id, - "type": "function", - "function": { - "name": call.name, - "arguments": json.dumps(call.arguments), - }, - } - for call in response.tool_calls - ], - } - ) - - for call in response.tool_calls: - tool = self.tools.get(call.name) - - if tool is None: - result = f"Unknown tool: {call.name}" - else: - try: - result = tool.execute(call.arguments).content - except ToolError as error: - result = f"Tool error: {error}" - - self.messages.append( - { - "role": "tool", - "tool_call_id": call.id, - "content": result, - } - ) - raise RuntimeError("Tool call limit exceeded") - - def end_task(self) -> None: - self.working_memory.end_task() - - def start_task(self, objective: str) -> None: - self.working_memory.start_task(objective) - - def pause_task(self) -> None: - self.working_memory.pause_task() - - def resume_task(self) -> None: - self.working_memory.resume_task() - - def complete_task(self) -> None: - self.working_memory.complete_task() diff --git a/client.py b/client.py deleted file mode 100644 index d91d79d..0000000 --- a/client.py +++ /dev/null @@ -1,65 +0,0 @@ -from agent import Agent -from identity import load_agent_identity, load_user_profile -from llm import LLM -from tools import ( - CalculatorTool, - FileSystemListTool, - FileSystemReadTool, - ToolRegistry, - WeatherTool, -) - -llm = LLM( - "http://localhost:8080/v1/chat/completions", -) - -agent_identity = load_agent_identity("agent.toml") -user_profile = load_user_profile("user.toml") - -tools = ToolRegistry() -# tools.register(HelloTool()) -tools.register(CalculatorTool()) -tools.register(WeatherTool()) -tools.register(FileSystemListTool()) -tools.register(FileSystemReadTool()) - -agent = Agent( - llm, - agent_identity, - user_profile, - tools, -) - -while True: - user_input = input("You: ") - - if user_input.lower() in {"exit", "quit"}: - break - - match user_input: - case "/end": - agent.end_task() - print("[task ended]") - continue - - case "/task": - print(f"\n[memory: {agent.working_memory}]\n") - continue - - case "/pause": - agent.pause_task() - print("[task paused]") - continue - - case "/resume": - agent.resume_task() - print("[task resumed]") - continue - - case "/complete": - agent.complete_task() - print("[task completed]") - continue - - answer = agent.chat(user_input) - print(f"Agent: {answer}") diff --git a/context.py b/context.py deleted file mode 100644 index 669e237..0000000 --- a/context.py +++ /dev/null @@ -1,69 +0,0 @@ -from identity import AgentIdentity, UserProfile -from memory import WorkingMemory - - -class ContextBuilder: - def __init__( - self, - agent_identity: AgentIdentity, - user_profile: UserProfile, - ) -> None: - self.agent_identity = agent_identity - self.user_profile = user_profile - - def build_system_prompt( - self, - memory: WorkingMemory, - ) -> str: - agent = self.agent_identity - user = self.user_profile - - return ( - 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"Task management:\n" - f"{agent.behavior['task_management']}" - f"\n" - f"The user's name is {user.name}" - f"\n" - f"{self.build_working_memory(memory)}" - ) - - def build_working_memory(self, memory: WorkingMemory) -> str: - lines = ["Current working memory:"] - - if memory.objective: - lines.append(f"Objective: {memory.objective}") - - if memory.status: - lines.append(f"Status: {memory.status.value}") - - if memory.facts: - lines.append("Facts:") - lines.extend(f"- {fact}" for fact in memory.facts) - - if memory.notes: - lines.append("Notes:") - lines.extend(f"- {note}" for note in memory.notes) - - if len(lines) == 1: - lines.append("(empty)") - - return "\n".join(lines) - - def build_messages( - self, - history: list[dict[str, object]], - memory: WorkingMemory, - ) -> list[dict[str, object]]: - return [ - { - "role": "system", - "content": self.build_system_prompt(memory), - }, - *history, - ] diff --git a/database/__init__.py b/database/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/database/database.py b/database/database.py deleted file mode 100644 index 7119c7e..0000000 --- a/database/database.py +++ /dev/null @@ -1,73 +0,0 @@ -import sqlite3 -from datetime import UTC, datetime -from pathlib import Path - - -class Database: - def __init__(self, path: Path) -> None: - self.path = path - self.migrations_dir = Path(__file__).parent / "migrations" - - def connect(self) -> sqlite3.Connection: - return sqlite3.connect(self.path) - - def migrate(self) -> None: - with self.connect() as connection: - connection.execute( - """ - CREATE TABLE IF NOT EXISTS schema_migrations ( - version INTEGER PRIMARY KEY, - applied_at TEXT NOT NULL - ) - """ - ) - - applied_versions = { - row[0] - for row in connection.execute("SELECT version FROM schema_migrations") - } - - for migration_version, path in self._get_migrations(): - if migration_version in applied_versions: - continue - - sql = path.read_text(encoding="utf-8") - - connection.execute("BEGIN") - - try: - connection.execute(sql) - - applied_at = datetime.now(UTC).isoformat().replace("+00:00", "Z") - - connection.execute( - """ - INSERT INTO schema_migrations (version, applied_at) - VALUES (?, ?) - """, - (migration_version, applied_at), - ) - - connection.commit() - except Exception: - connection.rollback() - raise - - def _get_migrations(self) -> list[tuple[int, Path]]: - migrations = [] - - for path in self.migrations_dir.glob("*.sql"): - version_text, _, _ = path.stem.partition("_") - - if not version_text.isdigit(): - continue - - migrations.append((int(version_text), path)) - - migrations.sort() - - versions = [version for version, _ in migrations] - if len(versions) != len(set(versions)): - raise ValueError("Duplicate migration version") - - return migrations diff --git a/database/migrations/001_create_memories.sql b/database/migrations/001_create_memories.sql deleted file mode 100644 index e79c907..0000000 --- a/database/migrations/001_create_memories.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE IF NOT EXISTS memories ( - id TEXT PRIMARY KEY, - content TEXT NOT NULL, - category TEXT NOT NULL, - created_at TEXT NOT NULL -); diff --git a/identity.py b/identity.py deleted file mode 100644 index ec1cc19..0000000 --- a/identity.py +++ /dev/null @@ -1,40 +0,0 @@ -import tomllib -from dataclasses import dataclass - - -@dataclass -class AgentIdentity: - name: str - personality: dict[str, str] - values: dict[str, bool] - quirks: dict[str, str] - behavior: dict[str, str] - - -@dataclass -class UserProfile: - name: str - preferences: dict[str, str] - - -def load_agent_identity(path: str) -> AgentIdentity: - with open(path, "rb") as file: - data = tomllib.load(file) - - return AgentIdentity( - name=data["name"], - personality=data["personality"], - values=data["values"], - quirks=data["quirks"], - behavior=data["behavior"], - ) - - -def load_user_profile(path: str) -> UserProfile: - with open(path, "rb") as file: - data = tomllib.load(file) - - return UserProfile( - name=data["name"], - preferences=data["preferences"], - ) diff --git a/llm.py b/llm.py deleted file mode 100644 index 16c8b82..0000000 --- a/llm.py +++ /dev/null @@ -1,87 +0,0 @@ -import json -from dataclasses import dataclass - -import requests - - -@dataclass -class ToolCall: - id: str - name: str - arguments: dict[str, object] - - -@dataclass -class LLMResponse: - content: str | None - tool_calls: list[ToolCall] - - -class LLM: - def __init__(self, url: str) -> None: - self.url = url - - def chat( - self, - messages: list[dict[str, object]], - tools: list[dict[str, object]] | None = None, - max_tokens: int = 256, - ) -> LLMResponse: - payload: dict[str, object] = { - "messages": messages, - "max_tokens": max_tokens, - "temperature": 0.9, - } - - if tools is not None: - payload["tools"] = tools - - response = requests.post( - self.url, - json=payload, # type: ignore[arg-type] - ) - response.raise_for_status() - - data = response.json() - print(f"\n[debug: {data}]\n") - message = data["choices"][0]["message"] - - content = message.get("content") - - tool_calls: list[ToolCall] = [] - - for call in message.get("tool_calls", []): - function = call["function"] - - tool_calls.append( - ToolCall( - id=call["id"], - name=function["name"], - arguments=json.loads(function["arguments"]), - ) - ) - - return LLMResponse( - content=content, - tool_calls=tool_calls, - ) - - -if __name__ == "__main__": - from tools import HelloTool, ToolRegistry - - registry = ToolRegistry() - registry.register(HelloTool()) - llm = LLM("http://localhost:8080/v1/chat/completions") - - response = llm.chat( - messages=[ - { - "role": "user", - "content": "Say hello to Joseph using the hello tool.", - }, - ], - tools=registry.schemas(), - ) - - print(response) diff --git a/memory.py b/memory.py deleted file mode 100644 index aab2064..0000000 --- a/memory.py +++ /dev/null @@ -1,101 +0,0 @@ -from dataclasses import dataclass, field -from datetime import UTC, datetime -from enum import Enum -from uuid import UUID, uuid7 - - -class MemoryCategory(Enum): - SEMANTIC = "semantic" - EPISODIC = "episodic" - - -@dataclass -class LongTermMemory: - id: UUID - content: str - category: MemoryCategory - created_at: datetime - - @classmethod - def create(cls, content: str, category: MemoryCategory) -> LongTermMemory: - return cls( - id=uuid7(), content=content, category=category, created_at=datetime.now(UTC) - ) - - -class TaskStatus(Enum): - ACTIVE = "active" - PAUSED = "paused" - COMPLETED = "completed" - - -@dataclass -class WorkingMemory: - objective: str | None = None - facts: list[str] = field(default_factory=list) - notes: list[str] = field(default_factory=list) - status: TaskStatus | None = None - - def __str__(self) -> str: - parts: list[str] = [] - - if self.objective: - parts.append(f"Objective: {self.objective}") - - if self.status: - parts.append(f"Status: {self.status.value}") - - if self.facts: - parts.append("Facts:") - parts.extend(f"- {fact}" for fact in self.facts) - - if self.notes: - parts.append("Notes:") - parts.extend(f"- {note}" for note in self.notes) - - return "\n".join(parts) if parts else "(empty)" - - def set_objective(self, objective: str) -> None: - self.objective = objective - - def add_fact(self, fact: str) -> None: - self.facts.append(fact) - - def remove_fact(self, fact: str) -> None: - if fact in self.facts: - self.facts.remove(fact) - - def add_note(self, note: str) -> None: - self.notes.append(note) - - def remove_note(self, note: str) -> None: - if note in self.notes: - self.notes.remove(note) - - def set_status(self, status: TaskStatus): - self.status = status - - def clear(self) -> None: - self.objective = None - self.status = None - self.facts.clear() - self.notes.clear() - - def start_task(self, objective: str) -> None: - self.clear() - self.objective = objective - self.set_status(TaskStatus.ACTIVE) - - def pause_task(self) -> None: - if self.status == TaskStatus.ACTIVE: - self.set_status(TaskStatus.PAUSED) - - def resume_task(self) -> None: - if self.status == TaskStatus.PAUSED: - self.set_status(TaskStatus.ACTIVE) - - def complete_task(self) -> None: - self.set_status(TaskStatus.COMPLETED) - - def end_task(self) -> None: - self.clear() diff --git a/memory_store.py b/memory_store.py deleted file mode 100644 index 2c69113..0000000 --- a/memory_store.py +++ /dev/null @@ -1,99 +0,0 @@ -from datetime import datetime -from uuid import UUID - -from database.database import Database -from memory import LongTermMemory, MemoryCategory - - -class LongTermMemoryStore: - def __init__(self, database: Database) -> None: - self.database = database - - def add(self, memory: LongTermMemory) -> None: - created_at = memory.created_at.isoformat().replace("+00:00", "Z") - - with self.database.connect() as connection: - connection.execute( - """ - INSERT INTO memories (id, content, category, created_at) - VALUES (?, ?, ?, ?) - """, - (str(memory.id), memory.content, memory.category.value, created_at), - ) - - def get(self, memory_id: UUID) -> LongTermMemory | None: - with self.database.connect() as connection: - row = connection.execute( - """ - SELECT id, content, category, created_at - FROM memories - WHERE id = ? - """, - (str(memory_id),), - ).fetchone() - - if row is None: - return None - - return self._from_row(row) - - def list(self) -> list[LongTermMemory]: - with self.database.connect() as connection: - rows = connection.execute( - """ - SELECT id, content, category, created_at - FROM memories - ORDER BY id - """ - ).fetchall() - - return [self._from_row(row) for row in rows] - - def delete(self, memory_id: UUID) -> None: - with self.database.connect() as connection: - connection.execute( - "DELETE FROM memories WHERE id = ?", - (str(memory_id),), - ) - - def search( - self, - query: str, - category: MemoryCategory | None = None, - limit: int = 10, - ) -> list[LongTermMemory]: - if limit <= 0: - raise ValueError("Search limit must be positive") - - if not query.strip(): - raise ValueError("Search query must not be empty") - - sql = """ - SELECT id, content, category, created_at - FROM memories - WHERE content LIKE ? - """ - parameters: list[str | int] = [f"%{query}%"] - - if category is not None: - sql += " AND category = ?" - parameters.append(category.value) - - sql += " ORDER BY id LIMIT ?" - parameters.append(limit) - - with self.database.connect() as connection: - rows = connection.execute(sql, parameters).fetchall() - - return [self._from_row(row) for row in rows] - - @staticmethod - def _from_row(row: tuple[str, str, str, str]) -> LongTermMemory: - memory_id, content, category, created_at = row - - return LongTermMemory( - id=UUID(memory_id), - content=content, - category=MemoryCategory(category), - created_at=datetime.fromisoformat(created_at), - ) diff --git a/personal_agent/__init__.py b/personal_agent/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/personal_agent/agent.py b/personal_agent/agent.py new file mode 100644 index 0000000..73cc422 --- /dev/null +++ b/personal_agent/agent.py @@ -0,0 +1,128 @@ +import json + +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.tools import ( + MemoryAddFactTool, + MemoryAddNoteTool, + MemoryRemoveFactTool, + MemoryRemoveNoteTool, + TaskStartTool, + ToolError, + ToolRegistry, +) + + +class Agent: + def __init__( + self, + llm: LLM, + agent_identity: AgentIdentity, + user_profile: UserProfile, + tools: ToolRegistry, + ) -> None: + self.llm = llm + self.tools = tools + + self.context = ContextBuilder( + agent_identity, + user_profile, + ) + + self.working_memory = WorkingMemory() + + 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.messages: list[dict[str, object]] = [] + + def chat(self, user_input: str) -> str: + print(f"\n[memory: {self.working_memory}]") + self.messages.append( + { + "role": "user", + "content": user_input, + } + ) + + for _ in range(5): + response = self.llm.chat( + self.context.build_messages( + self.messages, + self.working_memory, + ), + tools=self.tools.schemas(), + ) + + if response.tool_calls: + print(f"\n[tool call: {response.tool_calls}]\n") + + if not response.tool_calls: + answer = response.content or "" + + self.messages.append( + { + "role": "assistant", + "content": answer, + } + ) + + return answer + + self.messages.append( + { + "role": "assistant", + "content": response.content, + "tool_calls": [ + { + "id": call.id, + "type": "function", + "function": { + "name": call.name, + "arguments": json.dumps(call.arguments), + }, + } + for call in response.tool_calls + ], + } + ) + + for call in response.tool_calls: + tool = self.tools.get(call.name) + + if tool is None: + result = f"Unknown tool: {call.name}" + else: + try: + result = tool.execute(call.arguments).content + except ToolError as error: + result = f"Tool error: {error}" + + self.messages.append( + { + "role": "tool", + "tool_call_id": call.id, + "content": result, + } + ) + raise RuntimeError("Tool call limit exceeded") + + def end_task(self) -> None: + self.working_memory.end_task() + + def start_task(self, objective: str) -> None: + self.working_memory.start_task(objective) + + def pause_task(self) -> None: + self.working_memory.pause_task() + + def resume_task(self) -> None: + self.working_memory.resume_task() + + def complete_task(self) -> None: + self.working_memory.complete_task() diff --git a/personal_agent/cli.py b/personal_agent/cli.py new file mode 100644 index 0000000..7bd747a --- /dev/null +++ b/personal_agent/cli.py @@ -0,0 +1,65 @@ +from personal_agent.agent import Agent +from personal_agent.identity import load_agent_identity, load_user_profile +from personal_agent.llm import LLM +from personal_agent.tools import ( + CalculatorTool, + FileSystemListTool, + FileSystemReadTool, + ToolRegistry, + WeatherTool, +) + +llm = LLM( + "http://localhost:8080/v1/chat/completions", +) + +agent_identity = load_agent_identity("agent.toml") +user_profile = load_user_profile("user.toml") + +tools = ToolRegistry() +# tools.register(HelloTool()) +tools.register(CalculatorTool()) +tools.register(WeatherTool()) +tools.register(FileSystemListTool()) +tools.register(FileSystemReadTool()) + +agent = Agent( + llm, + agent_identity, + user_profile, + tools, +) + +while True: + user_input = input("You: ") + + if user_input.lower() in {"exit", "quit"}: + break + + match user_input: + case "/end": + agent.end_task() + print("[task ended]") + continue + + case "/task": + print(f"\n[memory: {agent.working_memory}]\n") + continue + + case "/pause": + agent.pause_task() + print("[task paused]") + continue + + case "/resume": + agent.resume_task() + print("[task resumed]") + continue + + case "/complete": + agent.complete_task() + print("[task completed]") + continue + + answer = agent.chat(user_input) + print(f"Agent: {answer}") diff --git a/personal_agent/context.py b/personal_agent/context.py new file mode 100644 index 0000000..74cc254 --- /dev/null +++ b/personal_agent/context.py @@ -0,0 +1,69 @@ +from personal_agent.identity import AgentIdentity, UserProfile +from personal_agent.memory.models import WorkingMemory + + +class ContextBuilder: + def __init__( + self, + agent_identity: AgentIdentity, + user_profile: UserProfile, + ) -> None: + self.agent_identity = agent_identity + self.user_profile = user_profile + + def build_system_prompt( + self, + memory: WorkingMemory, + ) -> str: + agent = self.agent_identity + user = self.user_profile + + return ( + 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"Task management:\n" + f"{agent.behavior['task_management']}" + f"\n" + f"The user's name is {user.name}" + f"\n" + f"{self.build_working_memory(memory)}" + ) + + def build_working_memory(self, memory: WorkingMemory) -> str: + lines = ["Current working memory:"] + + if memory.objective: + lines.append(f"Objective: {memory.objective}") + + if memory.status: + lines.append(f"Status: {memory.status.value}") + + if memory.facts: + lines.append("Facts:") + lines.extend(f"- {fact}" for fact in memory.facts) + + if memory.notes: + lines.append("Notes:") + lines.extend(f"- {note}" for note in memory.notes) + + if len(lines) == 1: + lines.append("(empty)") + + return "\n".join(lines) + + def build_messages( + self, + history: list[dict[str, object]], + memory: WorkingMemory, + ) -> list[dict[str, object]]: + return [ + { + "role": "system", + "content": self.build_system_prompt(memory), + }, + *history, + ] diff --git a/personal_agent/database/__init__.py b/personal_agent/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/personal_agent/database/database.py b/personal_agent/database/database.py new file mode 100644 index 0000000..7119c7e --- /dev/null +++ b/personal_agent/database/database.py @@ -0,0 +1,73 @@ +import sqlite3 +from datetime import UTC, datetime +from pathlib import Path + + +class Database: + def __init__(self, path: Path) -> None: + self.path = path + self.migrations_dir = Path(__file__).parent / "migrations" + + def connect(self) -> sqlite3.Connection: + return sqlite3.connect(self.path) + + def migrate(self) -> None: + with self.connect() as connection: + connection.execute( + """ + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + applied_at TEXT NOT NULL + ) + """ + ) + + applied_versions = { + row[0] + for row in connection.execute("SELECT version FROM schema_migrations") + } + + for migration_version, path in self._get_migrations(): + if migration_version in applied_versions: + continue + + sql = path.read_text(encoding="utf-8") + + connection.execute("BEGIN") + + try: + connection.execute(sql) + + applied_at = datetime.now(UTC).isoformat().replace("+00:00", "Z") + + connection.execute( + """ + INSERT INTO schema_migrations (version, applied_at) + VALUES (?, ?) + """, + (migration_version, applied_at), + ) + + connection.commit() + except Exception: + connection.rollback() + raise + + def _get_migrations(self) -> list[tuple[int, Path]]: + migrations = [] + + for path in self.migrations_dir.glob("*.sql"): + version_text, _, _ = path.stem.partition("_") + + if not version_text.isdigit(): + continue + + migrations.append((int(version_text), path)) + + migrations.sort() + + versions = [version for version, _ in migrations] + if len(versions) != len(set(versions)): + raise ValueError("Duplicate migration version") + + return migrations diff --git a/personal_agent/database/migrations/001_create_memories.sql b/personal_agent/database/migrations/001_create_memories.sql new file mode 100644 index 0000000..e79c907 --- /dev/null +++ b/personal_agent/database/migrations/001_create_memories.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS memories ( + id TEXT PRIMARY KEY, + content TEXT NOT NULL, + category TEXT NOT NULL, + created_at TEXT NOT NULL +); diff --git a/personal_agent/identity.py b/personal_agent/identity.py new file mode 100644 index 0000000..ec1cc19 --- /dev/null +++ b/personal_agent/identity.py @@ -0,0 +1,40 @@ +import tomllib +from dataclasses import dataclass + + +@dataclass +class AgentIdentity: + name: str + personality: dict[str, str] + values: dict[str, bool] + quirks: dict[str, str] + behavior: dict[str, str] + + +@dataclass +class UserProfile: + name: str + preferences: dict[str, str] + + +def load_agent_identity(path: str) -> AgentIdentity: + with open(path, "rb") as file: + data = tomllib.load(file) + + return AgentIdentity( + name=data["name"], + personality=data["personality"], + values=data["values"], + quirks=data["quirks"], + behavior=data["behavior"], + ) + + +def load_user_profile(path: str) -> UserProfile: + with open(path, "rb") as file: + data = tomllib.load(file) + + return UserProfile( + name=data["name"], + preferences=data["preferences"], + ) diff --git a/personal_agent/llm.py b/personal_agent/llm.py new file mode 100644 index 0000000..960e798 --- /dev/null +++ b/personal_agent/llm.py @@ -0,0 +1,87 @@ +import json +from dataclasses import dataclass + +import requests + + +@dataclass +class ToolCall: + id: str + name: str + arguments: dict[str, object] + + +@dataclass +class LLMResponse: + content: str | None + tool_calls: list[ToolCall] + + +class LLM: + def __init__(self, url: str) -> None: + self.url = url + + def chat( + self, + messages: list[dict[str, object]], + tools: list[dict[str, object]] | None = None, + max_tokens: int = 256, + ) -> LLMResponse: + payload: dict[str, object] = { + "messages": messages, + "max_tokens": max_tokens, + "temperature": 0.9, + } + + if tools is not None: + payload["tools"] = tools + + response = requests.post( + self.url, + json=payload, # type: ignore[arg-type] + ) + response.raise_for_status() + + data = response.json() + print(f"\n[debug: {data}]\n") + message = data["choices"][0]["message"] + + content = message.get("content") + + tool_calls: list[ToolCall] = [] + + for call in message.get("tool_calls", []): + function = call["function"] + + tool_calls.append( + ToolCall( + id=call["id"], + name=function["name"], + arguments=json.loads(function["arguments"]), + ) + ) + + return LLMResponse( + content=content, + tool_calls=tool_calls, + ) + + +if __name__ == "__main__": + from personal_agent.tools import HelloTool, ToolRegistry + + registry = ToolRegistry() + registry.register(HelloTool()) + llm = LLM("http://localhost:8080/v1/chat/completions") + + response = llm.chat( + messages=[ + { + "role": "user", + "content": "Say hello to Joseph using the hello tool.", + }, + ], + tools=registry.schemas(), + ) + + print(response) diff --git a/personal_agent/memory/__init__.py b/personal_agent/memory/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/personal_agent/memory/models.py b/personal_agent/memory/models.py new file mode 100644 index 0000000..aab2064 --- /dev/null +++ b/personal_agent/memory/models.py @@ -0,0 +1,101 @@ +from dataclasses import dataclass, field +from datetime import UTC, datetime +from enum import Enum +from uuid import UUID, uuid7 + + +class MemoryCategory(Enum): + SEMANTIC = "semantic" + EPISODIC = "episodic" + + +@dataclass +class LongTermMemory: + id: UUID + content: str + category: MemoryCategory + created_at: datetime + + @classmethod + def create(cls, content: str, category: MemoryCategory) -> LongTermMemory: + return cls( + id=uuid7(), content=content, category=category, created_at=datetime.now(UTC) + ) + + +class TaskStatus(Enum): + ACTIVE = "active" + PAUSED = "paused" + COMPLETED = "completed" + + +@dataclass +class WorkingMemory: + objective: str | None = None + facts: list[str] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + status: TaskStatus | None = None + + def __str__(self) -> str: + parts: list[str] = [] + + if self.objective: + parts.append(f"Objective: {self.objective}") + + if self.status: + parts.append(f"Status: {self.status.value}") + + if self.facts: + parts.append("Facts:") + parts.extend(f"- {fact}" for fact in self.facts) + + if self.notes: + parts.append("Notes:") + parts.extend(f"- {note}" for note in self.notes) + + return "\n".join(parts) if parts else "(empty)" + + def set_objective(self, objective: str) -> None: + self.objective = objective + + def add_fact(self, fact: str) -> None: + self.facts.append(fact) + + def remove_fact(self, fact: str) -> None: + if fact in self.facts: + self.facts.remove(fact) + + def add_note(self, note: str) -> None: + self.notes.append(note) + + def remove_note(self, note: str) -> None: + if note in self.notes: + self.notes.remove(note) + + def set_status(self, status: TaskStatus): + self.status = status + + def clear(self) -> None: + self.objective = None + self.status = None + self.facts.clear() + self.notes.clear() + + def start_task(self, objective: str) -> None: + self.clear() + self.objective = objective + self.set_status(TaskStatus.ACTIVE) + + def pause_task(self) -> None: + if self.status == TaskStatus.ACTIVE: + self.set_status(TaskStatus.PAUSED) + + def resume_task(self) -> None: + if self.status == TaskStatus.PAUSED: + self.set_status(TaskStatus.ACTIVE) + + def complete_task(self) -> None: + self.set_status(TaskStatus.COMPLETED) + + def end_task(self) -> None: + self.clear() diff --git a/personal_agent/memory/store.py b/personal_agent/memory/store.py new file mode 100644 index 0000000..e5fca96 --- /dev/null +++ b/personal_agent/memory/store.py @@ -0,0 +1,99 @@ +from datetime import datetime +from uuid import UUID + +from personal_agent.database.database import Database +from personal_agent.memory.models import LongTermMemory, MemoryCategory + + +class LongTermMemoryStore: + def __init__(self, database: Database) -> None: + self.database = database + + def add(self, memory: LongTermMemory) -> None: + created_at = memory.created_at.isoformat().replace("+00:00", "Z") + + with self.database.connect() as connection: + connection.execute( + """ + INSERT INTO memories (id, content, category, created_at) + VALUES (?, ?, ?, ?) + """, + (str(memory.id), memory.content, memory.category.value, created_at), + ) + + def get(self, memory_id: UUID) -> LongTermMemory | None: + with self.database.connect() as connection: + row = connection.execute( + """ + SELECT id, content, category, created_at + FROM memories + WHERE id = ? + """, + (str(memory_id),), + ).fetchone() + + if row is None: + return None + + return self._from_row(row) + + def list(self) -> list[LongTermMemory]: + with self.database.connect() as connection: + rows = connection.execute( + """ + SELECT id, content, category, created_at + FROM memories + ORDER BY id + """ + ).fetchall() + + return [self._from_row(row) for row in rows] + + def delete(self, memory_id: UUID) -> None: + with self.database.connect() as connection: + connection.execute( + "DELETE FROM memories WHERE id = ?", + (str(memory_id),), + ) + + def search( + self, + query: str, + category: MemoryCategory | None = None, + limit: int = 10, + ) -> list[LongTermMemory]: + if limit <= 0: + raise ValueError("Search limit must be positive") + + if not query.strip(): + raise ValueError("Search query must not be empty") + + sql = """ + SELECT id, content, category, created_at + FROM memories + WHERE content LIKE ? + """ + parameters: list[str | int] = [f"%{query}%"] + + if category is not None: + sql += " AND category = ?" + parameters.append(category.value) + + sql += " ORDER BY id LIMIT ?" + parameters.append(limit) + + with self.database.connect() as connection: + rows = connection.execute(sql, parameters).fetchall() + + return [self._from_row(row) for row in rows] + + @staticmethod + def _from_row(row: tuple[str, str, str, str]) -> LongTermMemory: + memory_id, content, category, created_at = row + + return LongTermMemory( + id=UUID(memory_id), + content=content, + category=MemoryCategory(category), + created_at=datetime.fromisoformat(created_at), + ) diff --git a/personal_agent/tools/__init__.py b/personal_agent/tools/__init__.py new file mode 100644 index 0000000..7c18f88 --- /dev/null +++ b/personal_agent/tools/__init__.py @@ -0,0 +1,29 @@ +from personal_agent.tools.base import Tool, ToolError, ToolRegistry, ToolResult +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 ( + MemoryAddFactTool, + MemoryAddNoteTool, + MemoryRemoveFactTool, + MemoryRemoveNoteTool, + TaskStartTool, +) +from personal_agent.tools.weather import WeatherTool + +__all__ = [ + "CalculatorTool", + "FileSystemListTool", + "FileSystemReadTool", + "HelloTool", + "MemoryAddFactTool", + "MemoryAddNoteTool", + "MemoryRemoveFactTool", + "MemoryRemoveNoteTool", + "TaskStartTool", + "Tool", + "ToolError", + "ToolRegistry", + "ToolResult", + "WeatherTool", +] diff --git a/personal_agent/tools/base.py b/personal_agent/tools/base.py new file mode 100644 index 0000000..8aad82a --- /dev/null +++ b/personal_agent/tools/base.py @@ -0,0 +1,55 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass + + +class ToolError(Exception): + pass + + +@dataclass +class ToolResult: + content: str + + +class Tool(ABC): + @property + @abstractmethod + def name(self) -> str: ... + + @property + @abstractmethod + def description(self) -> str: ... + + @property + @abstractmethod + def schema(self) -> dict[str, object]: ... + + @abstractmethod + def execute(self, arguments: dict[str, object]) -> ToolResult: ... + + +class ToolRegistry: + def __init__(self) -> None: + self.tools: dict[str, Tool] = {} + + def register(self, tool: Tool) -> None: + self.tools[tool.name] = tool + + def get(self, name: str) -> Tool | None: + return self.tools.get(name) + + def all(self) -> list[Tool]: + return list(self.tools.values()) + + def schemas(self) -> list[dict[str, object]]: + return [ + { + "type": "function", + "function": { + "name": tool.name, + "description": tool.description, + "parameters": tool.schema, + }, + } + for tool in self.tools.values() + ] diff --git a/personal_agent/tools/calculator.py b/personal_agent/tools/calculator.py new file mode 100644 index 0000000..dee8b77 --- /dev/null +++ b/personal_agent/tools/calculator.py @@ -0,0 +1,62 @@ +from personal_agent.tools.base import Tool, ToolError, ToolResult + + +class CalculatorTool(Tool): + @property + def name(self) -> str: + return "calculator" + + @property + def description(self) -> str: + return "Perform basic arithemtic" + + @property + def schema(self) -> dict[str, object]: + return { + "type": "object", + "properties": { + "a": { + "type": "number", + "description": "The first number.", + }, + "b": { + "type": "number", + "description": "The second number.", + }, + "operation": { + "type": "string", + "enum": ["add", "subtract", "multiply", "divide"], + "description": "The arithemtic operation to perform", + }, + }, + "required": ["a", "b", "operation"], + } + + def execute(self, arguments: dict[str, object]) -> ToolResult: + a = arguments["a"] + b = arguments["b"] + operation = arguments["operation"] + + if not isinstance(a, (int, float)): + raise ToolError("a must be a number") + + if not isinstance(b, (int, float)): + raise ToolError("b must be a number") + + if not isinstance(operation, str): + raise ToolError("operation must be a string") + + if operation == "add": + result = a + b + elif operation == "subtract": + result = a - b + elif operation == "multiply": + result = a * b + elif operation == "divide": + if b == 0: + raise ToolError("cannot divide by zero") + result = a / b + else: + raise ToolError(f"unknown operation: {operation}") + + return ToolResult(content=str(result)) diff --git a/personal_agent/tools/filesystem.py b/personal_agent/tools/filesystem.py new file mode 100644 index 0000000..72ad404 --- /dev/null +++ b/personal_agent/tools/filesystem.py @@ -0,0 +1,112 @@ +from pathlib import Path + +from personal_agent.tools.base import Tool, ToolError, ToolResult + + +class FileSystemListTool(Tool): + @property + def name(self) -> str: + return "filesystem_list" + + @property + def description(self) -> str: + return "List files and directories inside the agent's sandbox." + + @property + def schema(self) -> dict[str, object]: + return { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": ( + "Relative path inside the filesystem sandbox. " + "Use '.' for the sandbox root." + ), + }, + }, + "required": ["path"], + } + + def execute(self, arguments: dict[str, object]) -> ToolResult: + path = arguments.get("path") + + if not isinstance(path, str): + raise ToolError("path must be a string") + + sandbox = Path.home() / "Projects" / "personal-agent" / "data" + target = (sandbox / path).resolve() + + if not target.is_relative_to(sandbox.resolve()): + raise ToolError("path is outside the filesystem sandbox") + + if not target.exists(): + raise ToolError(f"path does not exist: {path}") + + if not target.is_dir(): + raise ToolError(f"path is not a directory: {path}") + + entries = [] + + for entry in sorted(target.iterdir()): + kind = "directory" if entry.is_dir() else "file" + entries.append(f"{kind}: {entry.name}") + + if not entries: + return ToolResult(content="Directory is empty.") + + return ToolResult(content="\n".join(entries)) + + +class FileSystemReadTool(Tool): + MAX_FILE_SIZE = 1024 * 1024 # 1 MiB + + @property + def name(self) -> str: + return "filesystem_read" + + @property + def description(self) -> str: + return "Read the contents of a file inside the agent's sandbox." + + @property + def schema(self) -> dict[str, object]: + return { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": ("Relative path inside the filesystem sandbox."), + }, + }, + "required": ["path"], + } + + def execute(self, arguments: dict[str, object]) -> ToolResult: + path = arguments.get("path") + + if not isinstance(path, str): + raise ToolError("path must be a string") + + sandbox = Path.home() / "Projects" / "personal-agent" / "data" + sandbox = sandbox.resolve() + target = (sandbox / path).resolve() + + if not target.is_relative_to(sandbox): + raise ToolError("path is outside the filesystem sandbox") + + if not target.exists(): + raise ToolError(f"path does not exist: {path}") + + if not target.is_file(): + raise ToolError(f"path is not a file: {path}") + + if target.stat().st_size > self.MAX_FILE_SIZE: + raise ToolError("file is too large to read") + + try: + content = target.read_text(encoding="utf-8") + except ToolError as error: + raise ToolError("file is not valid UTF-8 text") from error + + return ToolResult(content=content) diff --git a/personal_agent/tools/hello.py b/personal_agent/tools/hello.py new file mode 100644 index 0000000..8f1ce1f --- /dev/null +++ b/personal_agent/tools/hello.py @@ -0,0 +1,40 @@ +from personal_agent.tools.base import Tool, ToolRegistry, ToolResult + + +class HelloTool(Tool): + @property + def name(self) -> str: + return "hello" + + @property + def description(self) -> str: + return "Say hello to someone." + + @property + def schema(self) -> dict[str, object]: + return { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The person to greet.", + }, + }, + "required": ["name"], + } + + def execute(self, arguments: dict[str, object]) -> ToolResult: + name = arguments.get("name", "stranger") + return ToolResult(content=f"Hello, {name}!") + + +if __name__ == "__main__": + registry = ToolRegistry() + + registry.register(HelloTool()) + print(registry.schemas()) + tool = registry.get("hello") + + if tool is not None: + result = tool.execute({"name": "Joseph"}) + print(result.content) diff --git a/personal_agent/tools/memory.py b/personal_agent/tools/memory.py new file mode 100644 index 0000000..2287d4e --- /dev/null +++ b/personal_agent/tools/memory.py @@ -0,0 +1,197 @@ +from personal_agent.memory.models import WorkingMemory +from personal_agent.tools.base import Tool, ToolError, ToolResult + + +class TaskStartTool(Tool): + def __init__(self, memory: WorkingMemory) -> None: + self.memory = memory + + @property + def name(self) -> str: + return "task_start" + + @property + def description(self) -> str: + return ( + "Record the user's ongoing project or goal as the current task. " + "Use this when the user intends to work on something across multiple " + "turns or over a period of time. Do not use it for one-off requests " + "or temporary side conversations." + ) + + @property + def schema(self) -> dict[str, object]: + return { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": ( + "A concise description of the durable goal the user wants to pursue. " + "Describe what they are trying to accomplish, not mutable implementation " + "details, preferences, or constraints. For example: " + "'Build an image tagging API' or " + "'Design and implement a Rust CLI that converts directory trees into " + "static HTML.'" + ), + }, + }, + "required": ["objective"], + } + + def execute(self, arguments: dict[str, object]) -> ToolResult: + objective = arguments.get("objective") + + if not isinstance(objective, str): + raise ToolError("objective must be a string") + + if not objective.strip(): + raise ToolError("objective must not be empty") + + self.memory.start_task(objective) + + return ToolResult(content=f"Started task: {objective}") + + +class MemoryAddFactTool(Tool): + def __init__(self, memory: WorkingMemory) -> None: + self.memory = memory + + @property + def name(self) -> str: + return "memory_add_fact" + + @property + def description(self) -> str: + return "Add a useful fact to the current working memory." + + @property + def schema(self) -> dict[str, object]: + return { + "type": "object", + "properties": { + "fact": { + "type": "string", + "description": "A fact worth remembering during the current task.", + }, + }, + "required": ["fact"], + } + + def execute(self, arguments: dict[str, object]) -> ToolResult: + fact = arguments.get("fact") + + if not isinstance(fact, str): + raise ToolError("fact must be a string") + + self.memory.add_fact(fact) + + return ToolResult(content=f"Added working-memory fact: {fact}") + + +class MemoryAddNoteTool(Tool): + def __init__(self, memory: WorkingMemory) -> None: + self.memory = memory + + @property + def name(self) -> str: + return "memory_add_note" + + @property + def description(self) -> str: + return "Add a useful note to the current working memory." + + @property + def schema(self) -> dict[str, object]: + return { + "type": "object", + "properties": { + "note": { + "type": "string", + "description": "A useful observation, reminder, or note for the current task.", + }, + }, + "required": ["note"], + } + + def execute(self, arguments: dict[str, object]) -> ToolResult: + note = arguments.get("note") + + if not isinstance(note, str): + raise ToolError("note must be a string") + + self.memory.add_note(note) + + return ToolResult(content=f"Added working-memory note: {note}") + + +class MemoryRemoveFactTool(Tool): + def __init__(self, memory: WorkingMemory) -> None: + self.memory = memory + + @property + def name(self) -> str: + return "memory_remove_fact" + + @property + def description(self) -> str: + return "Remove a fact from the working memory when it is no longer true or relevant." + + @property + def schema(self) -> dict[str, object]: + return { + "type": "object", + "properties": { + "fact": { + "type": "string", + "description": "The exact fact to remove from working memory.", + }, + }, + "required": ["fact"], + } + + def execute(self, arguments: dict[str, object]) -> ToolResult: + fact = arguments.get("fact") + + if not isinstance(fact, str): + raise ToolError("fact must be a string") + + self.memory.remove_fact(fact) + + return ToolResult(content=f"Removed fact: {fact}") + + +class MemoryRemoveNoteTool(Tool): + def __init__(self, memory: WorkingMemory) -> None: + self.memory = memory + + @property + def name(self) -> str: + return "memory_remove_note" + + @property + def description(self) -> str: + return "Remove a note from the working memory when it is no longer true or relevant." + + @property + def schema(self) -> dict[str, object]: + return { + "type": "object", + "properties": { + "note": { + "type": "string", + "description": "The exact note to remove from working memory.", + }, + }, + "required": ["note"], + } + + def execute(self, arguments: dict[str, object]) -> ToolResult: + note = arguments.get("note") + + if not isinstance(note, str): + raise ToolError("note must be a string") + + self.memory.remove_note(note) + + return ToolResult(content=f"Removed note: {note}") diff --git a/personal_agent/tools/weather.py b/personal_agent/tools/weather.py new file mode 100644 index 0000000..7bb54ec --- /dev/null +++ b/personal_agent/tools/weather.py @@ -0,0 +1,122 @@ +from datetime import UTC, datetime, timedelta, timezone + +import requests + +from personal_agent.tools.base import Tool, ToolError, ToolResult + + +class WeatherTool(Tool): + @property + def name(self) -> str: + return "weather" + + @property + def description(self) -> str: + return "Get current weather conditions for a location" + + @property + def schema(self) -> dict[str, object]: + return { + "type": "object", + "properties": { + "location": { + "type": "string", + "description": ("The city or location to get weather for."), + }, + }, + "required": ["location"], + } + + def execute(self, arguments: dict[str, object]) -> ToolResult: + location = arguments.get("location") + + if not isinstance(location, str): + raise ToolError("location must be a string") + + geocoding_response = requests.get( + "https://geocoding-api.open-meteo.com/v1/search", + params={ + "name": location, + "count": 1, + "language": "en", + "format": "json", + }, + timeout=10, + ) + geocoding_response.raise_for_status() + + geocoding_data = geocoding_response.json() + results = geocoding_data.get("results") + + if not results: + raise ToolError(f"location not found: {location}") + + place = results[0] + + latitude = place["latitude"] + longitude = place["longitude"] + timezone_name = place["timezone"] + + forecast_response = requests.get( + "https://api.open-meteo.com/v1/forecast", + params={ + "latitude": latitude, + "longitude": longitude, + "hourly": ( + "temperature_2m," + "precipitation_probability," + "precipitation," + "weather_code," + "wind_speed_10m" + ), + "timezone": timezone_name, + "forecast_days": 2, + }, + timeout=10, + ) + forecast_response.raise_for_status() + + forecast = forecast_response.json() + hourly = forecast["hourly"] + + tomorrow = ( + (datetime.now(UTC) + timedelta(days=1)) + .astimezone(timezone(timedelta(seconds=forecast["utc_offset_seconds"]))) + .date() + ) + + temperatures = [] + precipitation_probabilities = [] + precipitation = [] + wind_speeds = [] + weather_codes = [] + + for index, timestamp in enumerate(hourly["time"]): + forecast_date = datetime.fromisoformat(timestamp).date() + + if forecast_date != tomorrow: + continue + + temperatures.append(hourly["temperature_2m"][index]) + precipitation_probabilities.append( + hourly["precipitation_probability"][index] + ) + precipitation.append(hourly["precipitation"][index]) + wind_speeds.append(hourly["wind_speed_10m"][index]) + weather_codes.append(hourly["weather_code"][index]) + + if not temperatures: + raise ToolError("no forecast data available for tomorrow") + return ToolResult( + content=( + f"Location: {place['name']}, {place['country']}\n" + f"Forecast date: {tomorrow}\n" + f"Temperature: {min(temperatures):.1f}–" + f"{max(temperatures):.1f} °C\n" + f"Maximum precipitation probability: " + f"{max(precipitation_probabilities)}%\n" + f"Total precipitation: {sum(precipitation):.1f} mm\n" + f"Maximum wind speed: {max(wind_speeds):.1f} km/h\n" + f"Weather codes: {sorted(set(weather_codes))}" + ) + ) diff --git a/tests/test_database.py b/tests/test_database.py index e33fdae..290b985 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -2,7 +2,7 @@ import sqlite3 import pytest -from database.database import Database +from personal_agent.database.database import Database def test_migrate_creates_schema(tmp_path): diff --git a/tests/test_memory_store.py b/tests/test_memory_store.py index f4862cb..8f3407d 100644 --- a/tests/test_memory_store.py +++ b/tests/test_memory_store.py @@ -1,9 +1,9 @@ from datetime import UTC, datetime from uuid import UUID, uuid7 -from database.database import Database -from memory import LongTermMemory, MemoryCategory -from memory_store import LongTermMemoryStore +from personal_agent.database.database import Database +from personal_agent.memory.models import LongTermMemory, MemoryCategory +from personal_agent.memory.store import LongTermMemoryStore def create_store(tmp_path): diff --git a/tools.py b/tools.py deleted file mode 100644 index ee5e63c..0000000 --- a/tools.py +++ /dev/null @@ -1,582 +0,0 @@ -from abc import ABC, abstractmethod -from dataclasses import dataclass -from datetime import UTC, datetime, timedelta, timezone -from pathlib import Path - -import requests - -from memory import WorkingMemory - - -class ToolError(Exception): - pass - - -@dataclass -class ToolResult: - content: str - - -class Tool(ABC): - @property - @abstractmethod - def name(self) -> str: ... - - @property - @abstractmethod - def description(self) -> str: ... - - @property - @abstractmethod - def schema(self) -> dict[str, object]: ... - - @abstractmethod - def execute(self, arguments: dict[str, object]) -> ToolResult: ... - - -class ToolRegistry: - def __init__(self) -> None: - self.tools: dict[str, Tool] = {} - - def register(self, tool: Tool) -> None: - self.tools[tool.name] = tool - - def get(self, name: str) -> Tool | None: - return self.tools.get(name) - - def all(self) -> list[Tool]: - return list(self.tools.values()) - - def schemas(self) -> list[dict[str, object]]: - return [ - { - "type": "function", - "function": { - "name": tool.name, - "description": tool.description, - "parameters": tool.schema, - }, - } - for tool in self.tools.values() - ] - - -class TaskStartTool(Tool): - def __init__(self, memory: WorkingMemory) -> None: - self.memory = memory - - @property - def name(self) -> str: - return "task_start" - - @property - def description(self) -> str: - return ( - "Record the user's ongoing project or goal as the current task. " - "Use this when the user intends to work on something across multiple " - "turns or over a period of time. Do not use it for one-off requests " - "or temporary side conversations." - ) - - @property - def schema(self) -> dict[str, object]: - return { - "type": "object", - "properties": { - "objective": { - "type": "string", - "description": ( - "A concise description of the durable goal the user wants to pursue. " - "Describe what they are trying to accomplish, not mutable implementation " - "details, preferences, or constraints. For example: " - "'Build an image tagging API' or " - "'Design and implement a Rust CLI that converts directory trees into " - "static HTML.'" - ), - }, - }, - "required": ["objective"], - } - - def execute(self, arguments: dict[str, object]) -> ToolResult: - objective = arguments.get("objective") - - if not isinstance(objective, str): - raise ToolError("objective must be a string") - - if not objective.strip(): - raise ToolError("objective must not be empty") - - self.memory.start_task(objective) - - return ToolResult(content=f"Started task: {objective}") - - -class MemoryAddFactTool(Tool): - def __init__(self, memory: WorkingMemory) -> None: - self.memory = memory - - @property - def name(self) -> str: - return "memory_add_fact" - - @property - def description(self) -> str: - return "Add a useful fact to the current working memory." - - @property - def schema(self) -> dict[str, object]: - return { - "type": "object", - "properties": { - "fact": { - "type": "string", - "description": "A fact worth remembering during the current task.", - }, - }, - "required": ["fact"], - } - - def execute(self, arguments: dict[str, object]) -> ToolResult: - fact = arguments.get("fact") - - if not isinstance(fact, str): - raise ToolError("fact must be a string") - - self.memory.add_fact(fact) - - return ToolResult(content=f"Added working-memory fact: {fact}") - - -class MemoryAddNoteTool(Tool): - def __init__(self, memory: WorkingMemory) -> None: - self.memory = memory - - @property - def name(self) -> str: - return "memory_add_note" - - @property - def description(self) -> str: - return "Add a useful note to the current working memory." - - @property - def schema(self) -> dict[str, object]: - return { - "type": "object", - "properties": { - "note": { - "type": "string", - "description": "A useful observation, reminder, or note for the current task.", - }, - }, - "required": ["note"], - } - - def execute(self, arguments: dict[str, object]) -> ToolResult: - note = arguments.get("note") - - if not isinstance(note, str): - raise ToolError("note must be a string") - - self.memory.add_note(note) - - return ToolResult(content=f"Added working-memory note: {note}") - - -class MemoryRemoveFactTool(Tool): - def __init__(self, memory: WorkingMemory) -> None: - self.memory = memory - - @property - def name(self) -> str: - return "memory_remove_fact" - - @property - def description(self) -> str: - return "Remove a fact from the working memory when it is no longer true or relevant." - - @property - def schema(self) -> dict[str, object]: - return { - "type": "object", - "properties": { - "fact": { - "type": "string", - "description": "The exact fact to remove from working memory.", - }, - }, - "required": ["fact"], - } - - def execute(self, arguments: dict[str, object]) -> ToolResult: - fact = arguments.get("fact") - - if not isinstance(fact, str): - raise ToolError("fact must be a string") - - self.memory.remove_fact(fact) - - return ToolResult(content=f"Removed fact: {fact}") - - -class MemoryRemoveNoteTool(Tool): - def __init__(self, memory: WorkingMemory) -> None: - self.memory = memory - - @property - def name(self) -> str: - return "memory_remove_note" - - @property - def description(self) -> str: - return "Remove a note from the working memory when it is no longer true or relevant." - - @property - def schema(self) -> dict[str, object]: - return { - "type": "object", - "properties": { - "note": { - "type": "string", - "description": "The exact note to remove from working memory.", - }, - }, - "required": ["note"], - } - - def execute(self, arguments: dict[str, object]) -> ToolResult: - note = arguments.get("note") - - if not isinstance(note, str): - raise ToolError("note must be a string") - - self.memory.remove_note(note) - - return ToolResult(content=f"Removed note: {note}") - - -class HelloTool(Tool): - @property - def name(self) -> str: - return "hello" - - @property - def description(self) -> str: - return "Say hello to someone." - - @property - def schema(self) -> dict[str, object]: - return { - "type": "object", - "properties": { - "name": { - "type": "string", - "description": "The person to greet.", - }, - }, - "required": ["name"], - } - - def execute(self, arguments: dict[str, object]) -> ToolResult: - name = arguments.get("name", "stranger") - return ToolResult(content=f"Hello, {name}!") - - -class CalculatorTool(Tool): - @property - def name(self) -> str: - return "calculator" - - @property - def description(self) -> str: - return "Perform basic arithemtic" - - @property - def schema(self) -> dict[str, object]: - return { - "type": "object", - "properties": { - "a": { - "type": "number", - "description": "The first number.", - }, - "b": { - "type": "number", - "description": "The second number.", - }, - "operation": { - "type": "string", - "enum": ["add", "subtract", "multiply", "divide"], - "description": "The arithemtic operation to perform", - }, - }, - "required": ["a", "b", "operation"], - } - - def execute(self, arguments: dict[str, object]) -> ToolResult: - a = arguments["a"] - b = arguments["b"] - operation = arguments["operation"] - - if not isinstance(a, (int, float)): - raise ToolError("a must be a number") - - if not isinstance(b, (int, float)): - raise ToolError("b must be a number") - - if not isinstance(operation, str): - raise ToolError("operation must be a string") - - if operation == "add": - result = a + b - elif operation == "subtract": - result = a - b - elif operation == "multiply": - result = a * b - elif operation == "divide": - if b == 0: - raise ToolError("cannot divide by zero") - result = a / b - else: - raise ToolError(f"unknown operation: {operation}") - - return ToolResult(content=str(result)) - - -class WeatherTool(Tool): - @property - def name(self) -> str: - return "weather" - - @property - def description(self) -> str: - return "Get current weather conditions for a location" - - @property - def schema(self) -> dict[str, object]: - return { - "type": "object", - "properties": { - "location": { - "type": "string", - "description": ("The city or location to get weather for."), - }, - }, - "required": ["location"], - } - - def execute(self, arguments: dict[str, object]) -> ToolResult: - location = arguments.get("location") - - if not isinstance(location, str): - raise ToolError("location must be a string") - - geocoding_response = requests.get( - "https://geocoding-api.open-meteo.com/v1/search", - params={ - "name": location, - "count": 1, - "language": "en", - "format": "json", - }, - timeout=10, - ) - geocoding_response.raise_for_status() - - geocoding_data = geocoding_response.json() - results = geocoding_data.get("results") - - if not results: - raise ToolError(f"location not found: {location}") - - place = results[0] - - latitude = place["latitude"] - longitude = place["longitude"] - timezone_name = place["timezone"] - - forecast_response = requests.get( - "https://api.open-meteo.com/v1/forecast", - params={ - "latitude": latitude, - "longitude": longitude, - "hourly": ( - "temperature_2m," - "precipitation_probability," - "precipitation," - "weather_code," - "wind_speed_10m" - ), - "timezone": timezone_name, - "forecast_days": 2, - }, - timeout=10, - ) - forecast_response.raise_for_status() - - forecast = forecast_response.json() - hourly = forecast["hourly"] - - tomorrow = ( - (datetime.now(UTC) + timedelta(days=1)) - .astimezone(timezone(timedelta(seconds=forecast["utc_offset_seconds"]))) - .date() - ) - - temperatures = [] - precipitation_probabilities = [] - precipitation = [] - wind_speeds = [] - weather_codes = [] - - for index, timestamp in enumerate(hourly["time"]): - forecast_date = datetime.fromisoformat(timestamp).date() - - if forecast_date != tomorrow: - continue - - temperatures.append(hourly["temperature_2m"][index]) - precipitation_probabilities.append( - hourly["precipitation_probability"][index] - ) - precipitation.append(hourly["precipitation"][index]) - wind_speeds.append(hourly["wind_speed_10m"][index]) - weather_codes.append(hourly["weather_code"][index]) - - if not temperatures: - raise ToolError("no forecast data available for tomorrow") - return ToolResult( - content=( - f"Location: {place['name']}, {place['country']}\n" - f"Forecast date: {tomorrow}\n" - f"Temperature: {min(temperatures):.1f}–" - f"{max(temperatures):.1f} °C\n" - f"Maximum precipitation probability: " - f"{max(precipitation_probabilities)}%\n" - f"Total precipitation: {sum(precipitation):.1f} mm\n" - f"Maximum wind speed: {max(wind_speeds):.1f} km/h\n" - f"Weather codes: {sorted(set(weather_codes))}" - ) - ) - - -class FileSystemListTool(Tool): - @property - def name(self) -> str: - return "filesystem_list" - - @property - def description(self) -> str: - return "List files and directories inside the agent's sandbox." - - @property - def schema(self) -> dict[str, object]: - return { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": ( - "Relative path inside the filesystem sandbox. " - "Use '.' for the sandbox root." - ), - }, - }, - "required": ["path"], - } - - def execute(self, arguments: dict[str, object]) -> ToolResult: - path = arguments.get("path") - - if not isinstance(path, str): - raise ToolError("path must be a string") - - sandbox = Path.home() / "Projects" / "personal-agent" / "data" - target = (sandbox / path).resolve() - - if not target.is_relative_to(sandbox.resolve()): - raise ToolError("path is outside the filesystem sandbox") - - if not target.exists(): - raise ToolError(f"path does not exist: {path}") - - if not target.is_dir(): - raise ToolError(f"path is not a directory: {path}") - - entries = [] - - for entry in sorted(target.iterdir()): - kind = "directory" if entry.is_dir() else "file" - entries.append(f"{kind}: {entry.name}") - - if not entries: - return ToolResult(content="Directory is empty.") - - return ToolResult(content="\n".join(entries)) - - -class FileSystemReadTool(Tool): - MAX_FILE_SIZE = 1024 * 1024 # 1 MiB - - @property - def name(self) -> str: - return "filesystem_read" - - @property - def description(self) -> str: - return "Read the contents of a file inside the agent's sandbox." - - @property - def schema(self) -> dict[str, object]: - return { - "type": "object", - "properties": { - "path": { - "type": "string", - "description": ("Relative path inside the filesystem sandbox."), - }, - }, - "required": ["path"], - } - - def execute(self, arguments: dict[str, object]) -> ToolResult: - path = arguments.get("path") - - if not isinstance(path, str): - raise ToolError("path must be a string") - - sandbox = Path.home() / "Projects" / "personal-agent" / "data" - sandbox = sandbox.resolve() - target = (sandbox / path).resolve() - - if not target.is_relative_to(sandbox): - raise ToolError("path is outside the filesystem sandbox") - - if not target.exists(): - raise ToolError(f"path does not exist: {path}") - - if not target.is_file(): - raise ToolError(f"path is not a file: {path}") - - if target.stat().st_size > self.MAX_FILE_SIZE: - raise ToolError("file is too large to read") - - try: - content = target.read_text(encoding="utf-8") - except ToolError as error: - raise ToolError("file is not valid UTF-8 text") from error - - return ToolResult(content=content) - - -if __name__ == "__main__": - registry = ToolRegistry() - - registry.register(HelloTool()) - print(registry.schemas()) - tool = registry.get("hello") - - if tool is not None: - result = tool.execute({"name": "Joseph"}) - print(result.content) -- cgit v1.3