summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorbatsumaru <>2026-09-15 15:13:34 +0900
committerbatsumaru <>2026-09-15 15:13:34 +0900
commit1a67c5edb6fa6165e99cc25ed380303ed817af09 (patch)
treed2eab77e7275a559bfd639fd80b15321b5e514f3
parent5629cabbb38c7d91e66a419ba7cb3a93a9a988a1 (diff)
Cleaned up project tree
-rw-r--r--personal_agent/__init__.py (renamed from database/__init__.py)0
-rw-r--r--personal_agent/agent.py (renamed from agent.py)10
-rw-r--r--personal_agent/cli.py (renamed from client.py)8
-rw-r--r--personal_agent/context.py (renamed from context.py)4
-rw-r--r--personal_agent/database/__init__.py0
-rw-r--r--personal_agent/database/database.py (renamed from database/database.py)0
-rw-r--r--personal_agent/database/migrations/001_create_memories.sql (renamed from database/migrations/001_create_memories.sql)0
-rw-r--r--personal_agent/identity.py (renamed from identity.py)0
-rw-r--r--personal_agent/llm.py (renamed from llm.py)2
-rw-r--r--personal_agent/memory/__init__.py0
-rw-r--r--personal_agent/memory/models.py (renamed from memory.py)0
-rw-r--r--personal_agent/memory/store.py (renamed from memory_store.py)4
-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
-rw-r--r--tests/test_database.py2
-rw-r--r--tests/test_memory_store.py6
-rw-r--r--tools.py582
22 files changed, 635 insertions, 600 deletions
diff --git a/database/__init__.py b/personal_agent/__init__.py
index e69de29..e69de29 100644
--- a/database/__init__.py
+++ b/personal_agent/__init__.py
diff --git a/agent.py b/personal_agent/agent.py
index a79fd74..73cc422 100644
--- a/agent.py
+++ b/personal_agent/agent.py
@@ -1,10 +1,10 @@
import json
-from context import ContextBuilder
-from identity import AgentIdentity, UserProfile
-from llm import LLM
-from memory import WorkingMemory
-from tools import (
+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,
diff --git a/client.py b/personal_agent/cli.py
index d91d79d..7bd747a 100644
--- a/client.py
+++ b/personal_agent/cli.py
@@ -1,7 +1,7 @@
-from agent import Agent
-from identity import load_agent_identity, load_user_profile
-from llm import LLM
-from tools import (
+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,
diff --git a/context.py b/personal_agent/context.py
index 669e237..74cc254 100644
--- a/context.py
+++ b/personal_agent/context.py
@@ -1,5 +1,5 @@
-from identity import AgentIdentity, UserProfile
-from memory import WorkingMemory
+from personal_agent.identity import AgentIdentity, UserProfile
+from personal_agent.memory.models import WorkingMemory
class ContextBuilder:
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/database/database.py b/personal_agent/database/database.py
index 7119c7e..7119c7e 100644
--- a/database/database.py
+++ b/personal_agent/database/database.py
diff --git a/database/migrations/001_create_memories.sql b/personal_agent/database/migrations/001_create_memories.sql
index e79c907..e79c907 100644
--- a/database/migrations/001_create_memories.sql
+++ b/personal_agent/database/migrations/001_create_memories.sql
diff --git a/identity.py b/personal_agent/identity.py
index ec1cc19..ec1cc19 100644
--- a/identity.py
+++ b/personal_agent/identity.py
diff --git a/llm.py b/personal_agent/llm.py
index 16c8b82..960e798 100644
--- a/llm.py
+++ b/personal_agent/llm.py
@@ -68,7 +68,7 @@ class LLM:
if __name__ == "__main__":
- from tools import HelloTool, ToolRegistry
+ from personal_agent.tools import HelloTool, ToolRegistry
registry = ToolRegistry()
registry.register(HelloTool())
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/memory.py b/personal_agent/memory/models.py
index aab2064..aab2064 100644
--- a/memory.py
+++ b/personal_agent/memory/models.py
diff --git a/memory_store.py b/personal_agent/memory/store.py
index 2c69113..e5fca96 100644
--- a/memory_store.py
+++ b/personal_agent/memory/store.py
@@ -1,8 +1,8 @@
from datetime import datetime
from uuid import UUID
-from database.database import Database
-from memory import LongTermMemory, MemoryCategory
+from personal_agent.database.database import Database
+from personal_agent.memory.models import LongTermMemory, MemoryCategory
class LongTermMemoryStore:
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)