diff options
Diffstat (limited to 'personal_agent')
| -rw-r--r-- | personal_agent/__init__.py | 0 | ||||
| -rw-r--r-- | personal_agent/agent.py | 128 | ||||
| -rw-r--r-- | personal_agent/cli.py | 65 | ||||
| -rw-r--r-- | personal_agent/context.py | 69 | ||||
| -rw-r--r-- | personal_agent/database/__init__.py | 0 | ||||
| -rw-r--r-- | personal_agent/database/database.py | 73 | ||||
| -rw-r--r-- | personal_agent/database/migrations/001_create_memories.sql | 6 | ||||
| -rw-r--r-- | personal_agent/identity.py | 40 | ||||
| -rw-r--r-- | personal_agent/llm.py | 87 | ||||
| -rw-r--r-- | personal_agent/memory/__init__.py | 0 | ||||
| -rw-r--r-- | personal_agent/memory/models.py | 101 | ||||
| -rw-r--r-- | personal_agent/memory/store.py | 99 | ||||
| -rw-r--r-- | personal_agent/tools/__init__.py | 29 | ||||
| -rw-r--r-- | personal_agent/tools/base.py | 55 | ||||
| -rw-r--r-- | personal_agent/tools/calculator.py | 62 | ||||
| -rw-r--r-- | personal_agent/tools/filesystem.py | 112 | ||||
| -rw-r--r-- | personal_agent/tools/hello.py | 40 | ||||
| -rw-r--r-- | personal_agent/tools/memory.py | 197 | ||||
| -rw-r--r-- | personal_agent/tools/weather.py | 122 |
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))}" + ) + ) |
