summaryrefslogtreecommitdiff
path: root/personal_agent
diff options
context:
space:
mode:
Diffstat (limited to 'personal_agent')
-rw-r--r--personal_agent/__init__.py0
-rw-r--r--personal_agent/agent.py128
-rw-r--r--personal_agent/cli.py65
-rw-r--r--personal_agent/context.py69
-rw-r--r--personal_agent/database/__init__.py0
-rw-r--r--personal_agent/database/database.py73
-rw-r--r--personal_agent/database/migrations/001_create_memories.sql6
-rw-r--r--personal_agent/identity.py40
-rw-r--r--personal_agent/llm.py87
-rw-r--r--personal_agent/memory/__init__.py0
-rw-r--r--personal_agent/memory/models.py101
-rw-r--r--personal_agent/memory/store.py99
-rw-r--r--personal_agent/tools/__init__.py29
-rw-r--r--personal_agent/tools/base.py55
-rw-r--r--personal_agent/tools/calculator.py62
-rw-r--r--personal_agent/tools/filesystem.py112
-rw-r--r--personal_agent/tools/hello.py40
-rw-r--r--personal_agent/tools/memory.py197
-rw-r--r--personal_agent/tools/weather.py122
19 files changed, 1285 insertions, 0 deletions
diff --git a/personal_agent/__init__.py b/personal_agent/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/personal_agent/__init__.py
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
--- /dev/null
+++ b/personal_agent/database/__init__.py
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
--- /dev/null
+++ b/personal_agent/memory/__init__.py
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))}"
+ )
+ )