xueyiming 2 هفته پیش
کامیت
5e9c9d27e2

+ 17 - 0
.env.example

@@ -0,0 +1,17 @@
+# OpenRouter API Key (https://openrouter.ai/keys)
+OPENROUTER_API_KEY=sk-or-v1-...
+
+# Default model (any OpenRouter-supported model)
+# Examples: openai/gpt-4o, anthropic/claude-sonnet-4, google/gemini-2.5-pro-preview
+OPENROUTER_MODEL=anthropic/claude-sonnet-4
+
+# Agent defaults
+AGENT_MAX_ITERATIONS=20
+AGENT_TEMPERATURE=0.7
+
+# Skills directory (relative to project root or absolute path)
+SKILLS_DIR=skills
+
+# Logging
+LOG_ENABLED=true
+LOGS_DIR=logs

+ 14 - 0
.gitignore

@@ -0,0 +1,14 @@
+__pycache__/
+*.py[cod]
+*$py.class
+*.egg-info/
+dist/
+build/
+.venv/
+.env
+.idea/
+.pytest_cache/
+.ruff_cache/
+*.log
+logs/
+tests/

+ 160 - 0
README.md

@@ -0,0 +1,160 @@
+# SupplyAgent
+
+一个现代、可扩展的 Python AI Agent 框架,支持 OpenRouter 多模型、Tools 工具调用和 Skills 技能系统。
+
+## 架构
+
+```
+supply_agent/
+├── config.py          # 配置管理(环境变量 / .env)
+├── types.py           # 核心类型定义
+├── llm/
+│   └── client.py      # OpenRouter LLM 客户端(OpenAI 兼容 API)
+├── tools/
+│   ├── base.py        # @tool 装饰器 & Tool 类
+│   └── registry.py    # 工具注册表 & 执行器
+├── skills/
+│   ├── loader.py      # SKILL.md 加载器(兼容 Cursor 格式)
+│   └── registry.py    # 技能注册表
+└── agent/
+    ├── core.py        # Agent 主类
+    └── loop.py        # ReAct 循环(Reason → Act → Observe)
+```
+
+### 核心设计
+
+| 模块 | 职责 |
+|------|------|
+| **LLM Client** | 通过 OpenRouter 调用任意模型,支持同步/异步/流式 |
+| **Tool Registry** | 注册工具、自动生成 JSON Schema、执行工具调用 |
+| **Skill Registry** | 从 `SKILL.md` 加载专业技能指令,按需注入上下文 |
+| **Agent Loop** | ReAct 循环:模型推理 → 工具调用 → 观察结果 → 重复 |
+
+## 快速开始
+
+### 1. 安装依赖
+
+```bash
+pip install -e ".[dev]"
+```
+
+### 2. 配置环境变量
+
+```bash
+cp .env.example .env
+# 编辑 .env,填入你的 OpenRouter API Key
+```
+
+### 3. 运行示例
+
+```bash
+# 基础对话
+python examples/basic_agent.py
+
+# 带自定义工具
+python examples/with_tools.py
+
+# 带 Skills 技能
+python examples/with_skills.py
+
+# 流式事件
+python examples/streaming.py
+```
+
+## 使用指南
+
+### 创建 Agent
+
+```python
+from supply_agent import Agent
+
+# 使用默认配置(从 .env 读取)
+agent = Agent()
+
+# 指定模型
+agent = Agent(model="openai/gpt-4o")
+
+# 运行时切换模型
+agent.model = "google/gemini-2.5-pro-preview"
+```
+
+### 注册 Tools
+
+```python
+from supply_agent.tools import tool
+
+@tool
+def search(query: str, limit: int = 10) -> str:
+    """Search the web for information."""
+    return f"Results for: {query}"
+
+agent = Agent()
+agent.tools.from_decorated(search)
+
+result = agent.run("Search for Python tutorials")
+print(result.content)
+```
+
+### 使用 Skills
+
+在 `skills/` 目录下创建 `SKILL.md` 文件(兼容 Cursor Skills 格式):
+
+```
+skills/
+└── my-skill/
+    └── SKILL.md
+```
+
+Agent 会自动发现技能。模型可通过内置的 `load_skill` 工具按需加载专业技能指令。
+
+### 流式事件
+
+```python
+from supply_agent.types import AgentEventType
+
+for event in agent.stream("Your question"):
+    if event.type == AgentEventType.TOOL_CALL:
+        print(f"Calling: {event.data['name']}")
+    elif event.type == AgentEventType.MESSAGE:
+        print(event.data["content"])
+```
+
+### 异步 API
+
+```python
+result = await agent.arun("Your question")
+
+async for event in agent.astream("Your question"):
+  ...
+```
+
+## 支持的模型
+
+通过 OpenRouter 可使用任意支持的模型,例如:
+
+- `anthropic/claude-sonnet-4`
+- `openai/gpt-4o`
+- `google/gemini-2.5-pro-preview`
+- `meta-llama/llama-4-maverick`
+
+完整列表见 [OpenRouter Models](https://openrouter.ai/models)。
+
+## 环境变量
+
+| 变量 | 说明 | 默认值 |
+|------|------|--------|
+| `OPENROUTER_API_KEY` | OpenRouter API 密钥 | (必填) |
+| `OPENROUTER_MODEL` | 默认模型 | `anthropic/claude-sonnet-4` |
+| `AGENT_MAX_ITERATIONS` | 最大循环次数 | `20` |
+| `AGENT_TEMPERATURE` | 生成温度 | `0.7` |
+| `SKILLS_DIR` | Skills 目录 | `skills` |
+
+## 运行测试
+
+```bash
+pytest
+```
+
+## License
+
+MIT

+ 35 - 0
pyproject.toml

@@ -0,0 +1,35 @@
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "supply-agent"
+version = "0.1.0"
+description = "A modern, extensible AI Agent framework with OpenRouter, Tools, and Skills support"
+readme = "README.md"
+requires-python = ">=3.11"
+dependencies = [
+    "openai>=1.50.0",
+    "pydantic>=2.0",
+    "pydantic-settings>=2.0",
+    "httpx>=0.27.0",
+    "rich>=13.0",
+]
+
+[project.optional-dependencies]
+dev = [
+    "pytest>=8.0",
+    "pytest-asyncio>=0.24",
+    "ruff>=0.8",
+]
+
+[tool.hatch.build.targets.wheel]
+packages = ["supply_agent"]
+
+[tool.ruff]
+line-length = 100
+target-version = "py311"
+
+[tool.pytest.ini_options]
+asyncio_mode = "auto"
+testpaths = ["tests"]

+ 9 - 0
supply_agent/__init__.py

@@ -0,0 +1,9 @@
+"""SupplyAgent - A modern AI Agent framework."""
+
+from supply_agent.agent.core import Agent
+from supply_agent.config import Settings
+from supply_agent.skills.registry import SkillRegistry
+from supply_agent.tools.registry import ToolRegistry
+
+__version__ = "0.1.0"
+__all__ = ["Agent", "Settings", "ToolRegistry", "SkillRegistry"]

+ 5 - 0
supply_agent/agent/__init__.py

@@ -0,0 +1,5 @@
+"""Agent module."""
+
+from supply_agent.agent.core import Agent
+
+__all__ = ["Agent"]

+ 190 - 0
supply_agent/agent/core.py

@@ -0,0 +1,190 @@
+from __future__ import annotations
+
+from collections.abc import AsyncIterator, Iterator
+
+from supply_agent.agent.loop import AgentLoop
+from supply_agent.config import Settings, get_settings
+from supply_agent.llm.client import LLMClient
+from supply_agent.logging.logger import AgentLogger
+from supply_agent.skills.registry import SkillRegistry
+from supply_agent.tools.registry import ToolRegistry
+from supply_agent.types import AgentEvent, AgentEventType, AgentResult, Message, Role
+
+
+DEFAULT_SYSTEM_PROMPT = """\
+You are a helpful AI assistant powered by SupplyAgent.
+
+You have access to tools to help accomplish tasks. Use them when needed.
+Think step by step, call tools to gather information or take actions, \
+and provide clear final answers.
+
+## Skills (IMPORTANT)
+When the system prompt lists Available Skills, you MUST call `load_skill` \
+with the matching skill name BEFORE starting the task. \
+Skills contain specialized instructions that you must follow.
+Do NOT guess the output format — load the skill first.
+"""
+
+
+class Agent:
+    """
+    Main Agent class — orchestrates LLM, tools, and skills.
+
+    Usage:
+        agent = Agent(model="anthropic/claude-sonnet-4")
+        agent.tools.register(my_tool)
+        result = agent.run("What is 2+2?")
+    """
+
+    def __init__(
+        self,
+        settings: Settings | None = None,
+        *,
+        model: str | None = None,
+        system_prompt: str | None = None,
+        tools: ToolRegistry | None = None,
+        skills: SkillRegistry | None = None,
+        max_iterations: int | None = None,
+        temperature: float | None = None,
+        logger: AgentLogger | None = None,
+    ) -> None:
+        self.settings = settings or get_settings()
+        self.logger = logger or AgentLogger(
+            self.settings.logs_dir,
+            enabled=self.settings.log_enabled,
+        )
+        self.llm = LLMClient(self.settings, logger=self.logger)
+        self.tools = tools or ToolRegistry()
+        self.skills = skills or SkillRegistry(self.settings.skills_dir)
+        self.system_prompt = system_prompt or DEFAULT_SYSTEM_PROMPT
+        self.max_iterations = max_iterations or self.settings.agent_max_iterations
+        self.temperature = temperature
+
+        if model:
+            self.llm.set_model(model)
+
+        self._active_skills: list[str] = []
+        self._setup_builtin_tools()
+
+    @property
+    def model(self) -> str:
+        return self.llm.model
+
+    @model.setter
+    def model(self, value: str) -> None:
+        self.llm.set_model(value)
+
+    def _setup_builtin_tools(self) -> None:
+        """Register built-in tools for skill loading."""
+
+        def load_skill(name: str) -> str:
+            """Load a skill by name to get specialized instructions for a task."""
+            context = self.skills.get_skill_context(name)
+            if context is None:
+                available = ", ".join(self.skills.list_skills()) or "none"
+                return f'{{"error": "Skill \'{name}\' not found. Available: {available}"}}'
+            if name not in self._active_skills:
+                self._active_skills.append(name)
+            return context
+
+        load_skill.__doc__ = "Load a skill by name to get specialized instructions for a task."
+        from supply_agent.tools.base import tool as tool_decorator
+
+        decorated = tool_decorator(name="load_skill")(load_skill)
+        self.tools.register(decorated, name="load_skill")
+
+    def _build_system_message(self) -> Message:
+        parts = [self.system_prompt]
+        catalog = self.skills.get_catalog()
+        if catalog:
+            parts.append(catalog)
+        for skill_name in self._active_skills:
+            ctx = self.skills.get_skill_context(skill_name)
+            if ctx:
+                parts.append(ctx)
+        return Message(role=Role.SYSTEM, content="\n\n".join(parts))
+
+    def _create_loop(self, messages: list[Message]) -> AgentLoop:
+        return AgentLoop(
+            llm=self.llm,
+            tools=self.tools,
+            system_message=self._build_system_message(),
+            system_message_builder=self._build_system_message,
+            messages=messages,
+            max_iterations=self.max_iterations,
+            temperature=self.temperature,
+            logger=self.logger,
+            active_skills=self._active_skills,
+        )
+
+    def run(self, user_input: str, *, history: list[Message] | None = None) -> AgentResult:
+        """Run the agent synchronously with a user message."""
+        self.logger.start_run(user_input, model=self.model)
+        messages = list(history or [])
+        messages.append(Message(role=Role.USER, content=user_input))
+        loop = self._create_loop(messages)
+        result = loop.run()
+        self.logger.end_run(result)
+        return result
+
+    async def arun(
+        self, user_input: str, *, history: list[Message] | None = None
+    ) -> AgentResult:
+        """Run the agent asynchronously."""
+        self.logger.start_run(user_input, model=self.model)
+        messages = list(history or [])
+        messages.append(Message(role=Role.USER, content=user_input))
+        loop = self._create_loop(messages)
+        result = await loop.arun()
+        self.logger.end_run(result)
+        return result
+
+    def stream(
+        self, user_input: str, *, history: list[Message] | None = None
+    ) -> Iterator[AgentEvent]:
+        """Stream agent events during execution."""
+        self.logger.start_run(user_input, model=self.model)
+        messages = list(history or [])
+        messages.append(Message(role=Role.USER, content=user_input))
+        loop = self._create_loop(messages)
+        final_result: AgentResult | None = None
+        for event in loop.stream():
+            if event.type == AgentEventType.DONE:
+                data = event.data
+                final_result = AgentResult(
+                    content=data.get("content", ""),
+                    messages=loop.messages,
+                    iterations=data.get("iterations", 0),
+                    tool_calls_made=data.get("tool_calls_made", 0),
+                    skills_used=list(self._active_skills),
+                )
+            yield event
+        if final_result:
+            self.logger.end_run(final_result)
+
+    async def astream(
+        self, user_input: str, *, history: list[Message] | None = None
+    ) -> AsyncIterator[AgentEvent]:
+        """Async stream agent events."""
+        self.logger.start_run(user_input, model=self.model)
+        messages = list(history or [])
+        messages.append(Message(role=Role.USER, content=user_input))
+        loop = self._create_loop(messages)
+        final_result: AgentResult | None = None
+        async for event in loop.astream():
+            if event.type == AgentEventType.DONE:
+                data = event.data
+                final_result = AgentResult(
+                    content=data.get("content", ""),
+                    messages=loop.messages,
+                    iterations=data.get("iterations", 0),
+                    tool_calls_made=data.get("tool_calls_made", 0),
+                    skills_used=list(self._active_skills),
+                )
+            yield event
+        if final_result:
+            self.logger.end_run(final_result)
+
+    def reset(self) -> None:
+        """Reset active skills state."""
+        self._active_skills.clear()

+ 280 - 0
supply_agent/agent/loop.py

@@ -0,0 +1,280 @@
+from __future__ import annotations
+
+import json
+from collections.abc import AsyncIterator, Callable, Iterator
+from typing import TYPE_CHECKING
+
+from supply_agent.llm.client import LLMClient
+from supply_agent.tools.registry import ToolRegistry
+from supply_agent.types import (
+    AgentEvent,
+    AgentEventType,
+    AgentResult,
+    Message,
+    Role,
+)
+
+if TYPE_CHECKING:
+    from supply_agent.logging.logger import AgentLogger
+
+
+class AgentLoop:
+    """
+    ReAct-style agent loop: Reason → Act (tool call) → Observe → Repeat.
+
+    Implements the standard tool-calling pattern used by modern agent frameworks.
+    """
+
+    def __init__(
+        self,
+        llm: LLMClient,
+        tools: ToolRegistry,
+        system_message: Message,
+        messages: list[Message],
+        max_iterations: int = 20,
+        temperature: float | None = None,
+        logger: AgentLogger | None = None,
+        active_skills: list[str] | None = None,
+        system_message_builder: Callable[[], Message] | None = None,
+    ) -> None:
+        self.llm = llm
+        self.tools = tools
+        self.system_message = system_message
+        self.system_message_builder = system_message_builder
+        self.messages = messages
+        self.max_iterations = max_iterations
+        self.temperature = temperature
+        self.logger = logger
+        self.active_skills = active_skills or []
+        self.tool_calls_made = 0
+
+    def _all_messages(self) -> list[Message]:
+        return [self.system_message, *self.messages]
+
+    def _on_skill_loaded(self, arguments: str) -> None:
+        """Refresh system message after a skill is loaded."""
+        try:
+            args = json.loads(arguments)
+            skill_name = args.get("name", "")
+            if skill_name and skill_name not in self.active_skills:
+                self.active_skills.append(skill_name)
+        except json.JSONDecodeError:
+            pass
+
+        if self.system_message_builder:
+            self.system_message = self.system_message_builder()
+
+    def run(self) -> AgentResult:
+        iterations = 0
+        while iterations < self.max_iterations:
+            iterations += 1
+            response = self.llm.chat(
+                self._all_messages(),
+                tools=self.tools.definitions or None,
+                temperature=self.temperature,
+                iteration=iterations,
+            )
+            self.messages.append(response)
+
+            if not response.tool_calls:
+                return self._build_result(response.content or "", iterations)
+
+            for tc in response.tool_calls:
+                self.tool_calls_made += 1
+                result = self.tools.execute(tc.id, tc.name, tc.arguments)
+                if self.logger:
+                    self.logger.log_tool_call(
+                        iterations, tc.name, tc.arguments, result.content, result.is_error
+                    )
+                if tc.name == "load_skill" and not result.is_error:
+                    self._on_skill_loaded(tc.arguments)
+                    if self.logger:
+                        self.logger.log_skill_loaded(iterations, tc.arguments)
+                self.messages.append(
+                    Message(
+                        role=Role.TOOL,
+                        content=result.content,
+                        tool_call_id=result.tool_call_id,
+                        name=result.name,
+                    )
+                )
+
+        self.messages.append(
+            Message(
+                role=Role.USER,
+                content="Maximum iterations reached. Please provide your best answer now.",
+            )
+        )
+        final = self.llm.chat(
+            self._all_messages(),
+            temperature=self.temperature,
+            iteration=iterations + 1,
+        )
+        self.messages.append(final)
+        return self._build_result(final.content or "", iterations)
+
+    async def arun(self) -> AgentResult:
+        iterations = 0
+        while iterations < self.max_iterations:
+            iterations += 1
+            response = await self.llm.achat(
+                self._all_messages(),
+                tools=self.tools.definitions or None,
+                temperature=self.temperature,
+                iteration=iterations,
+            )
+            self.messages.append(response)
+
+            if not response.tool_calls:
+                return self._build_result(response.content or "", iterations)
+
+            for tc in response.tool_calls:
+                self.tool_calls_made += 1
+                result = await self.tools.aexecute(tc.id, tc.name, tc.arguments)
+                if self.logger:
+                    self.logger.log_tool_call(
+                        iterations, tc.name, tc.arguments, result.content, result.is_error
+                    )
+                if tc.name == "load_skill" and not result.is_error:
+                    self._on_skill_loaded(tc.arguments)
+                    if self.logger:
+                        self.logger.log_skill_loaded(iterations, tc.arguments)
+                self.messages.append(
+                    Message(
+                        role=Role.TOOL,
+                        content=result.content,
+                        tool_call_id=result.tool_call_id,
+                        name=result.name,
+                    )
+                )
+
+        self.messages.append(
+            Message(
+                role=Role.USER,
+                content="Maximum iterations reached. Please provide your best answer now.",
+            )
+        )
+        final = await self.llm.achat(
+            self._all_messages(),
+            temperature=self.temperature,
+            iteration=iterations + 1,
+        )
+        self.messages.append(final)
+        return self._build_result(final.content or "", iterations)
+
+    def stream(self) -> Iterator[AgentEvent]:
+        iterations = 0
+        while iterations < self.max_iterations:
+            iterations += 1
+            yield AgentEvent(
+                type=AgentEventType.THINKING,
+                data={"iteration": iterations},
+            )
+
+            response = self.llm.chat(
+                self._all_messages(),
+                tools=self.tools.definitions or None,
+                temperature=self.temperature,
+                iteration=iterations,
+            )
+            self.messages.append(response)
+
+            if not response.tool_calls:
+                yield AgentEvent(
+                    type=AgentEventType.MESSAGE,
+                    data={"content": response.content or ""},
+                )
+                yield AgentEvent(
+                    type=AgentEventType.DONE,
+                    data=self._build_result(response.content or "", iterations).model_dump(),
+                )
+                return
+
+            for tc in response.tool_calls:
+                self.tool_calls_made += 1
+                yield AgentEvent(
+                    type=AgentEventType.TOOL_CALL,
+                    data={"name": tc.name, "arguments": tc.arguments},
+                )
+                result = self.tools.execute(tc.id, tc.name, tc.arguments)
+                if self.logger:
+                    self.logger.log_tool_call(
+                        iterations, tc.name, tc.arguments, result.content, result.is_error
+                    )
+                yield AgentEvent(
+                    type=AgentEventType.TOOL_RESULT,
+                    data={"name": result.name, "content": result.content, "is_error": result.is_error},
+                )
+                self.messages.append(
+                    Message(
+                        role=Role.TOOL,
+                        content=result.content,
+                        tool_call_id=result.tool_call_id,
+                        name=result.name,
+                    )
+                )
+
+        yield AgentEvent(type=AgentEventType.DONE, data={"content": "Max iterations reached"})
+
+    async def astream(self) -> AsyncIterator[AgentEvent]:
+        iterations = 0
+        while iterations < self.max_iterations:
+            iterations += 1
+            yield AgentEvent(
+                type=AgentEventType.THINKING,
+                data={"iteration": iterations},
+            )
+
+            response = await self.llm.achat(
+                self._all_messages(),
+                tools=self.tools.definitions or None,
+                temperature=self.temperature,
+                iteration=iterations,
+            )
+            self.messages.append(response)
+
+            if not response.tool_calls:
+                yield AgentEvent(
+                    type=AgentEventType.MESSAGE,
+                    data={"content": response.content or ""},
+                )
+                yield AgentEvent(
+                    type=AgentEventType.DONE,
+                    data=self._build_result(response.content or "", iterations).model_dump(),
+                )
+                return
+
+            for tc in response.tool_calls:
+                self.tool_calls_made += 1
+                yield AgentEvent(
+                    type=AgentEventType.TOOL_CALL,
+                    data={"name": tc.name, "arguments": tc.arguments},
+                )
+                result = await self.tools.aexecute(tc.id, tc.name, tc.arguments)
+                if self.logger:
+                    self.logger.log_tool_call(
+                        iterations, tc.name, tc.arguments, result.content, result.is_error
+                    )
+                yield AgentEvent(
+                    type=AgentEventType.TOOL_RESULT,
+                    data={"name": result.name, "content": result.content, "is_error": result.is_error},
+                )
+                self.messages.append(
+                    Message(
+                        role=Role.TOOL,
+                        content=result.content,
+                        tool_call_id=result.tool_call_id,
+                        name=result.name,
+                    )
+                )
+
+        yield AgentEvent(type=AgentEventType.DONE, data={"content": "Max iterations reached"})
+
+    def _build_result(self, content: str, iterations: int) -> AgentResult:
+        return AgentResult(
+            content=content,
+            messages=self.messages,
+            iterations=iterations,
+            tool_calls_made=self.tool_calls_made,
+            skills_used=list(self.active_skills),
+        )

+ 61 - 0
supply_agent/config.py

@@ -0,0 +1,61 @@
+from __future__ import annotations
+
+from functools import lru_cache
+from pathlib import Path
+from typing import Self
+
+from pydantic import Field, model_validator
+from pydantic_settings import BaseSettings, SettingsConfigDict
+
+from supply_agent.paths import resolve_path
+
+
+class Settings(BaseSettings):
+    """Application settings loaded from environment variables or .env file."""
+
+    model_config = SettingsConfigDict(
+        env_file=".env",
+        env_file_encoding="utf-8",
+        extra="ignore",
+    )
+
+    # OpenRouter
+    openrouter_api_key: str = Field(..., alias="OPENROUTER_API_KEY")
+    openrouter_model: str = Field(
+        default="anthropic/claude-sonnet-4",
+        alias="OPENROUTER_MODEL",
+    )
+    openrouter_base_url: str = Field(
+        default="https://openrouter.ai/api/v1",
+        alias="OPENROUTER_BASE_URL",
+    )
+    openrouter_site_url: str = Field(default="", alias="OPENROUTER_SITE_URL")
+    openrouter_site_name: str = Field(default="SupplyAgent", alias="OPENROUTER_SITE_NAME")
+
+    # Agent
+    agent_max_iterations: int = Field(default=20, alias="AGENT_MAX_ITERATIONS")
+    agent_temperature: float = Field(default=0.7, alias="AGENT_TEMPERATURE")
+
+    # Skills
+    skills_dir: Path = Field(default=Path("skills"), alias="SKILLS_DIR")
+
+    # Logging
+    logs_dir: Path = Field(default=Path("logs"), alias="LOGS_DIR")
+    log_enabled: bool = Field(default=True, alias="LOG_ENABLED")
+
+    @model_validator(mode="after")
+    def resolve_relative_paths(self) -> Self:
+        """Resolve skills_dir and logs_dir against project root when needed."""
+        self.skills_dir = resolve_path(self.skills_dir)
+        self.logs_dir = resolve_path(self.logs_dir)
+        return self
+
+    @classmethod
+    def from_env(cls, **overrides: object) -> Self:
+        """Create settings with optional overrides (useful for testing)."""
+        return cls(**overrides)  # type: ignore[arg-type]
+
+
+@lru_cache
+def get_settings() -> Settings:
+    return Settings()  # type: ignore[call-arg]

+ 5 - 0
supply_agent/llm/__init__.py

@@ -0,0 +1,5 @@
+"""LLM client module."""
+
+from supply_agent.llm.client import LLMClient
+
+__all__ = ["LLMClient"]

+ 189 - 0
supply_agent/llm/client.py

@@ -0,0 +1,189 @@
+from __future__ import annotations
+
+from collections.abc import AsyncIterator, Iterator
+from typing import TYPE_CHECKING, Any
+
+from openai import AsyncOpenAI, OpenAI
+
+from supply_agent.config import Settings
+from supply_agent.types import Message, Role, ToolCall, ToolDefinition
+
+if TYPE_CHECKING:
+    from supply_agent.logging.logger import AgentLogger
+
+
+class LLMClient:
+    """OpenRouter LLM client using the OpenAI-compatible API."""
+
+    def __init__(
+        self,
+        settings: Settings,
+        logger: AgentLogger | None = None,
+    ) -> None:
+        self.settings = settings
+        self.model = settings.openrouter_model
+        self.logger = logger
+
+        extra_headers: dict[str, str] = {}
+        if settings.openrouter_site_url:
+            extra_headers["HTTP-Referer"] = settings.openrouter_site_url
+        if settings.openrouter_site_name:
+            extra_headers["X-Title"] = settings.openrouter_site_name
+
+        self._client = OpenAI(
+            api_key=settings.openrouter_api_key,
+            base_url=settings.openrouter_base_url,
+            default_headers=extra_headers or None,
+        )
+        self._async_client = AsyncOpenAI(
+            api_key=settings.openrouter_api_key,
+            base_url=settings.openrouter_base_url,
+            default_headers=extra_headers or None,
+        )
+
+    def chat(
+        self,
+        messages: list[Message],
+        tools: list[ToolDefinition] | None = None,
+        temperature: float | None = None,
+        *,
+        iteration: int = 0,
+    ) -> Message:
+        """Send a chat completion request and return the assistant message."""
+        temp = temperature if temperature is not None else self.settings.agent_temperature
+
+        if self.logger:
+            self.logger.log_llm_input(iteration, self.model, messages, tools, temp)
+
+        response = self._client.chat.completions.create(
+            model=self.model,
+            messages=[m.to_api_dict() for m in messages],
+            tools=[t.to_api_dict() for t in tools] if tools else None,
+            temperature=temp,
+        )
+        raw_message = response.choices[0].message
+        result = self._parse_response(raw_message)
+
+        if self.logger:
+            self.logger.log_llm_output(iteration, result, raw_response=response)
+
+        return result
+
+    async def achat(
+        self,
+        messages: list[Message],
+        tools: list[ToolDefinition] | None = None,
+        temperature: float | None = None,
+        *,
+        iteration: int = 0,
+    ) -> Message:
+        """Async chat completion."""
+        temp = temperature if temperature is not None else self.settings.agent_temperature
+
+        if self.logger:
+            self.logger.log_llm_input(iteration, self.model, messages, tools, temp)
+
+        response = await self._async_client.chat.completions.create(
+            model=self.model,
+            messages=[m.to_api_dict() for m in messages],
+            tools=[t.to_api_dict() for t in tools] if tools else None,
+            temperature=temp,
+        )
+        raw_message = response.choices[0].message
+        result = self._parse_response(raw_message)
+
+        if self.logger:
+            self.logger.log_llm_output(iteration, result, raw_response=response)
+
+        return result
+
+    def stream(
+        self,
+        messages: list[Message],
+        tools: list[ToolDefinition] | None = None,
+        temperature: float | None = None,
+        *,
+        iteration: int = 0,
+    ) -> Iterator[str]:
+        """Stream text content chunks from the model."""
+        temp = temperature if temperature is not None else self.settings.agent_temperature
+
+        if self.logger:
+            self.logger.log_llm_input(iteration, self.model, messages, tools, temp)
+
+        stream = self._client.chat.completions.create(
+            model=self.model,
+            messages=[m.to_api_dict() for m in messages],
+            tools=[t.to_api_dict() for t in tools] if tools else None,
+            temperature=temp,
+            stream=True,
+        )
+        chunks: list[str] = []
+        for chunk in stream:
+            delta = chunk.choices[0].delta
+            if delta.content:
+                chunks.append(delta.content)
+                yield delta.content
+
+        if self.logger:
+            full_content = "".join(chunks)
+            self.logger.log_llm_output(
+                iteration,
+                Message(role=Role.ASSISTANT, content=full_content),
+            )
+
+    async def astream(
+        self,
+        messages: list[Message],
+        tools: list[ToolDefinition] | None = None,
+        temperature: float | None = None,
+        *,
+        iteration: int = 0,
+    ) -> AsyncIterator[str]:
+        """Async stream text content chunks."""
+        temp = temperature if temperature is not None else self.settings.agent_temperature
+
+        if self.logger:
+            self.logger.log_llm_input(iteration, self.model, messages, tools, temp)
+
+        stream = await self._async_client.chat.completions.create(
+            model=self.model,
+            messages=[m.to_api_dict() for m in messages],
+            tools=[t.to_api_dict() for t in tools] if tools else None,
+            temperature=temp,
+            stream=True,
+        )
+        chunks: list[str] = []
+        async for chunk in stream:
+            delta = chunk.choices[0].delta
+            if delta.content:
+                chunks.append(delta.content)
+                yield delta.content
+
+        if self.logger:
+            full_content = "".join(chunks)
+            self.logger.log_llm_output(
+                iteration,
+                Message(role=Role.ASSISTANT, content=full_content),
+            )
+
+    def _parse_response(self, choice_message: Any) -> Message:
+        tool_calls = None
+        if choice_message.tool_calls:
+            tool_calls = [
+                ToolCall(
+                    id=tc.id,
+                    name=tc.function.name,
+                    arguments=tc.function.arguments,
+                )
+                for tc in choice_message.tool_calls
+            ]
+        return Message(
+            role=Role.ASSISTANT,
+            content=choice_message.content,
+            tool_calls=tool_calls,
+        )
+
+    def set_model(self, model: str) -> None:
+        """Switch to a different model at runtime."""
+        self.model = model

+ 5 - 0
supply_agent/logging/__init__.py

@@ -0,0 +1,5 @@
+"""Logging module for SupplyAgent."""
+
+from supply_agent.logging.logger import AgentLogger, get_agent_logger
+
+__all__ = ["AgentLogger", "get_agent_logger"]

+ 203 - 0
supply_agent/logging/logger.py

@@ -0,0 +1,203 @@
+from __future__ import annotations
+
+import json
+import logging
+import uuid
+from datetime import datetime
+from pathlib import Path
+from typing import Any
+
+from supply_agent.types import AgentResult, Message, ToolDefinition
+
+
+def _serialize(obj: Any) -> str:
+    """Serialize any object to a complete, non-truncated JSON string."""
+
+    def default(o: Any) -> Any:
+        if hasattr(o, "model_dump"):
+            return o.model_dump()
+        if hasattr(o, "to_api_dict"):
+            return o.to_api_dict()
+        return str(o)
+
+    return json.dumps(obj, ensure_ascii=False, indent=2, default=default)
+
+
+class _FullContentFormatter(logging.Formatter):
+    """Formatter that never truncates message content."""
+
+    def format(self, record: logging.LogRecord) -> str:
+        record.message = record.getMessage()
+        return f"[{self.formatTime(record, '%Y-%m-%d %H:%M:%S')}] {record.message}"
+
+
+class AgentLogger:
+    """
+    Encapsulated logger for agent runs.
+
+    Each run writes to a separate file under ``logs/`` with complete
+    LLM input/output and tool execution records — nothing is truncated.
+    """
+
+    def __init__(self, logs_dir: Path | str = "logs", *, enabled: bool = True) -> None:
+        self.logs_dir = Path(logs_dir)
+        self.enabled = enabled
+        self._run_id: str | None = None
+        self._log_file: Path | None = None
+        self._logger: logging.Logger | None = None
+
+    @property
+    def run_id(self) -> str | None:
+        return self._run_id
+
+    @property
+    def log_file(self) -> Path | None:
+        return self._log_file
+
+    def start_run(self, user_input: str, *, model: str) -> str:
+        """Start a new run log file. Returns the run id."""
+        if not self.enabled:
+            return ""
+
+        self.logs_dir.mkdir(parents=True, exist_ok=True)
+        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
+        self._run_id = f"{timestamp}_{uuid.uuid4().hex[:8]}"
+        self._log_file = self.logs_dir / f"run_{self._run_id}.log"
+
+        self._logger = logging.getLogger(f"supply_agent.run.{self._run_id}")
+        self._logger.setLevel(logging.DEBUG)
+        self._logger.handlers.clear()
+        self._logger.propagate = False
+
+        handler = logging.FileHandler(self._log_file, encoding="utf-8")
+        handler.setFormatter(_FullContentFormatter())
+        self._logger.addHandler(handler)
+
+        self._write_block(
+            "RUN START",
+            {
+                "run_id": self._run_id,
+                "model": model,
+                "user_input": user_input,
+                "log_file": str(self._log_file),
+            },
+        )
+        return self._run_id
+
+    def log_llm_input(
+        self,
+        iteration: int,
+        model: str,
+        messages: list[Message],
+        tools: list[ToolDefinition] | None,
+        temperature: float,
+    ) -> None:
+        """Log the complete LLM request payload."""
+        if not self.enabled or not self._logger:
+            return
+
+        payload = {
+            "iteration": iteration,
+            "model": model,
+            "temperature": temperature,
+            "messages": [m.to_api_dict() for m in messages],
+            "tools": [t.to_api_dict() for t in tools] if tools else None,
+        }
+        self._write_block(f"LLM INPUT | iteration={iteration}", payload)
+
+    def log_llm_output(
+        self,
+        iteration: int,
+        response: Message,
+        raw_response: Any | None = None,
+    ) -> None:
+        """Log the complete LLM response."""
+        if not self.enabled or not self._logger:
+            return
+
+        payload: dict[str, Any] = {
+            "iteration": iteration,
+            "parsed": response.to_api_dict(),
+        }
+        if raw_response is not None:
+            if hasattr(raw_response, "model_dump"):
+                payload["raw"] = raw_response.model_dump()
+            else:
+                payload["raw"] = raw_response
+
+        self._write_block(f"LLM OUTPUT | iteration={iteration}", payload)
+
+    def log_tool_call(
+        self,
+        iteration: int,
+        name: str,
+        arguments: str,
+        result: str,
+        is_error: bool = False,
+    ) -> None:
+        """Log a tool execution with full arguments and result."""
+        if not self.enabled or not self._logger:
+            return
+
+        self._write_block(
+            f"TOOL CALL | iteration={iteration} | tool={name}",
+            {
+                "iteration": iteration,
+                "tool": name,
+                "arguments": arguments,
+                "result": result,
+                "is_error": is_error,
+            },
+        )
+
+    def log_skill_loaded(self, iteration: int, skill_name: str) -> None:
+        """Log when a skill is loaded."""
+        if not self.enabled or not self._logger:
+            return
+
+        self._write_block(
+            f"SKILL LOADED | iteration={iteration}",
+            {"skill": skill_name},
+        )
+
+    def end_run(self, result: AgentResult) -> None:
+        """Log run summary and close the run log."""
+        if not self.enabled or not self._logger:
+            return
+
+        self._write_block(
+            "RUN END",
+            {
+                "run_id": self._run_id,
+                "iterations": result.iterations,
+                "tool_calls_made": result.tool_calls_made,
+                "skills_used": result.skills_used,
+                "final_content": result.content,
+            },
+        )
+
+        for handler in self._logger.handlers:
+            handler.close()
+        self._logger.handlers.clear()
+
+    def _write_block(self, title: str, data: Any) -> None:
+        assert self._logger is not None
+        separator = "=" * 80
+        body = _serialize(data)
+        self._logger.info("%s\n%s\n%s\n%s", separator, title, separator, body)
+
+
+# Module-level default logger instance
+_default_logger: AgentLogger | None = None
+
+
+def get_agent_logger(
+    logs_dir: Path | str = "logs",
+    *,
+    enabled: bool = True,
+) -> AgentLogger:
+    """Get or create the default AgentLogger instance."""
+    global _default_logger
+    if _default_logger is None:
+        _default_logger = AgentLogger(logs_dir, enabled=enabled)
+    return _default_logger

+ 46 - 0
supply_agent/paths.py

@@ -0,0 +1,46 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+# Package location: supply_agent/paths.py → project root is parent of supply_agent/
+_PACKAGE_DIR = Path(__file__).resolve().parent
+_DEFAULT_PROJECT_ROOT = _PACKAGE_DIR.parent
+
+
+def find_project_root(start: Path | None = None) -> Path:
+    """
+    Find the project root directory.
+
+    Walks upward looking for pyproject.toml or a skills/ directory.
+    Falls back to the package's parent directory.
+    """
+    current = (start or Path.cwd()).resolve()
+
+    for directory in [current, *current.parents]:
+        if (directory / "pyproject.toml").exists():
+            return directory
+        if (directory / "skills").is_dir():
+            return directory
+
+    return _DEFAULT_PROJECT_ROOT
+
+
+def resolve_path(path: Path | str, *, project_root: Path | None = None) -> Path:
+    """
+    Resolve a path relative to CWD or project root.
+
+    Priority:
+      1. Absolute paths — used as-is
+      2. Relative path exists under CWD — use CWD-relative
+      3. Otherwise — resolve against project root
+    """
+    resolved = Path(path)
+    if resolved.is_absolute():
+        return resolved
+
+    root = project_root or find_project_root()
+    cwd_candidate = (Path.cwd() / resolved).resolve()
+    if cwd_candidate.exists():
+        return cwd_candidate
+
+    return (root / resolved).resolve()

+ 6 - 0
supply_agent/skills/__init__.py

@@ -0,0 +1,6 @@
+"""Skills module."""
+
+from supply_agent.skills.loader import SkillLoader
+from supply_agent.skills.registry import SkillRegistry
+
+__all__ = ["SkillLoader", "SkillRegistry"]

+ 58 - 0
supply_agent/skills/loader.py

@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+import re
+from pathlib import Path
+
+from supply_agent.types import SkillInfo
+
+
+class SkillLoader:
+    """Load skills from SKILL.md files (Cursor-compatible format)."""
+
+    SKILL_FILENAME = "SKILL.md"
+
+    def __init__(self, skills_dir: Path | str) -> None:
+        self.skills_dir = Path(skills_dir)
+
+    def discover(self) -> list[Path]:
+        """Find all SKILL.md files in the skills directory."""
+        if not self.skills_dir.exists():
+            return []
+        return sorted(self.skills_dir.rglob(self.SKILL_FILENAME))
+
+    def load(self, skill_path: Path) -> SkillInfo:
+        """Load a single skill from a SKILL.md file."""
+        content = skill_path.read_text(encoding="utf-8")
+        name, description = self._parse_frontmatter(content, skill_path)
+        body = self._strip_frontmatter(content)
+        return SkillInfo(
+            name=name,
+            description=description,
+            content=body.strip(),
+            path=str(skill_path),
+        )
+
+    def load_all(self) -> list[SkillInfo]:
+        """Load all discovered skills."""
+        return [self.load(p) for p in self.discover()]
+
+    def _parse_frontmatter(self, content: str, path: Path) -> tuple[str, str]:
+        """Extract name and description from YAML frontmatter or heading."""
+        name = path.parent.name
+        description = ""
+
+        fm_match = re.match(r"^---\s*\n(.*?)\n---", content, re.DOTALL)
+        if fm_match:
+            fm = fm_match.group(1)
+            for line in fm.splitlines():
+                if line.startswith("name:"):
+                    name = line.split(":", 1)[1].strip().strip('"').strip("'")
+                elif line.startswith("description:"):
+                    description = line.split(":", 1)[1].strip().strip('"').strip("'")
+            return name, description
+
+        # Fallback: use directory name; extract description from first paragraph if present
+        return name, description
+
+    def _strip_frontmatter(self, content: str) -> str:
+        return re.sub(r"^---\s*\n.*?\n---\s*\n?", "", content, count=1, flags=re.DOTALL)

+ 69 - 0
supply_agent/skills/registry.py

@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from supply_agent.skills.loader import SkillLoader
+from supply_agent.types import SkillInfo
+
+
+class SkillRegistry:
+    """Registry for managing agent skills (specialized instruction sets)."""
+
+    def __init__(self, skills_dir: Path | str | None = None) -> None:
+        self._skills: dict[str, SkillInfo] = {}
+        if skills_dir:
+            self.load_from_directory(skills_dir)
+
+    def register(self, skill: SkillInfo) -> None:
+        self._skills[skill.name] = skill
+
+    def unregister(self, name: str) -> None:
+        self._skills.pop(name, None)
+
+    def get(self, name: str) -> SkillInfo | None:
+        return self._skills.get(name)
+
+    def list_skills(self) -> list[str]:
+        return list(self._skills.keys())
+
+    def load_from_directory(self, skills_dir: Path | str) -> int:
+        """Discover and load all skills from a directory. Returns count loaded."""
+        loader = SkillLoader(skills_dir)
+        for skill in loader.load_all():
+            self.register(skill)
+        return len(self._skills)
+
+    def get_catalog(self) -> str:
+        """Return a catalog of available skills for the system prompt."""
+        if not self._skills:
+            return ""
+        lines = ["## Available Skills", ""]
+        for skill in self._skills.values():
+            lines.append(f"- **{skill.name}**: {skill.description or 'No description'}")
+        lines.append("")
+        lines.append(
+            "To use a skill, call the `load_skill` tool with the skill name. "
+            "The skill instructions will be injected into your context."
+        )
+        return "\n".join(lines)
+
+    def get_skill_context(self, name: str) -> str | None:
+        """Get the full skill content for injection into context."""
+        skill = self._skills.get(name)
+        if not skill:
+            return None
+        return (
+            f"<skill name=\"{skill.name}\">\n"
+            f"{skill.content}\n"
+            f"</skill>"
+        )
+
+    @property
+    def skills(self) -> list[SkillInfo]:
+        return list(self._skills.values())
+
+    def __len__(self) -> int:
+        return len(self._skills)
+
+    def __contains__(self, name: str) -> bool:
+        return name in self._skills

+ 6 - 0
supply_agent/tools/__init__.py

@@ -0,0 +1,6 @@
+"""Tools module."""
+
+from supply_agent.tools.base import tool
+from supply_agent.tools.registry import ToolRegistry
+
+__all__ = ["tool", "ToolRegistry"]

+ 144 - 0
supply_agent/tools/base.py

@@ -0,0 +1,144 @@
+from __future__ import annotations
+
+import inspect
+import json
+from collections.abc import Callable
+from typing import Any, get_type_hints
+
+from pydantic import BaseModel
+
+from supply_agent.types import ToolDefinition
+
+
+def _python_type_to_json_schema(py_type: type) -> dict[str, Any]:
+    """Map Python types to JSON Schema types."""
+    origin = getattr(py_type, "__origin__", None)
+
+    if py_type is str or py_type is inspect.Parameter.empty:
+        return {"type": "string"}
+    if py_type is int:
+        return {"type": "integer"}
+    if py_type is float:
+        return {"type": "number"}
+    if py_type is bool:
+        return {"type": "boolean"}
+    if origin is list:
+        args = getattr(py_type, "__args__", (Any,))
+        item_type = args[0] if args else Any
+        return {"type": "array", "items": _python_type_to_json_schema(item_type)}
+    if origin is dict:
+        return {"type": "object"}
+    return {"type": "string"}
+
+
+def _build_parameters(func: Callable[..., Any]) -> dict[str, Any]:
+    """Build JSON Schema parameters from function signature."""
+    sig = inspect.signature(func)
+    hints = get_type_hints(func)
+    properties: dict[str, Any] = {}
+    required: list[str] = []
+
+    for name, param in sig.parameters.items():
+        if name in ("self", "cls"):
+            continue
+        py_type = hints.get(name, str)
+        prop: dict[str, Any] = _python_type_to_json_schema(py_type)
+
+        if param.default is not inspect.Parameter.empty:
+            if not isinstance(param.default, (BaseModel,)):
+                prop["default"] = param.default
+        else:
+            required.append(name)
+
+        properties[name] = prop
+
+    return {"type": "object", "properties": properties, "required": required}
+
+
+def tool(
+    func: Callable[..., Any] | None = None,
+    *,
+    name: str | None = None,
+    description: str | None = None,
+) -> Callable[..., Any]:
+    """
+    Decorator to register a function as an agent tool.
+
+    Usage:
+        @tool
+        def search(query: str, limit: int = 10) -> str:
+            '''Search the web for information.'''
+            ...
+    """
+    def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
+        fn._is_tool = True  # type: ignore[attr-defined]
+        fn._tool_name = name or fn.__name__  # type: ignore[attr-defined]
+        fn._tool_description = description or (fn.__doc__ or "").strip()  # type: ignore[attr-defined]
+        fn._tool_parameters = _build_parameters(fn)  # type: ignore[attr-defined]
+        return fn
+
+    if func is not None:
+        return decorator(func)
+    return decorator
+
+
+class Tool:
+    """A callable tool with schema metadata."""
+
+    def __init__(
+        self,
+        func: Callable[..., Any],
+        name: str | None = None,
+        description: str | None = None,
+    ) -> None:
+        self.func = func
+        self.name = name or getattr(func, "_tool_name", func.__name__)
+        self.description = description or getattr(
+            func, "_tool_description", (func.__doc__ or "").strip()
+        )
+        self.parameters = getattr(func, "_tool_parameters", _build_parameters(func))
+
+    @property
+    def definition(self) -> ToolDefinition:
+        return ToolDefinition(
+            name=self.name,
+            description=self.description,
+            parameters=self.parameters,
+        )
+
+    def __call__(self, **kwargs: Any) -> Any:
+        return self.func(**kwargs)
+
+    async def acall(self, **kwargs: Any) -> Any:
+        result = self.func(**kwargs)
+        if inspect.isawaitable(result):
+            return await result
+        return result
+
+    def execute(self, arguments: str | dict[str, Any]) -> str:
+        """Execute the tool with JSON or dict arguments, returning a string result."""
+        if isinstance(arguments, str):
+            args = json.loads(arguments) if arguments.strip() else {}
+        else:
+            args = arguments
+        try:
+            result = self(**args)
+            if inspect.isawaitable(result):
+                raise RuntimeError(
+                    f"Tool '{self.name}' is async. Use ToolRegistry.aexecute() instead."
+                )
+            return str(result) if result is not None else ""
+        except Exception as e:
+            return json.dumps({"error": str(e)}, ensure_ascii=False)
+
+    async def aexecute(self, arguments: str | dict[str, Any]) -> str:
+        """Async execute the tool."""
+        if isinstance(arguments, str):
+            args = json.loads(arguments) if arguments.strip() else {}
+        else:
+            args = arguments
+        try:
+            result = await self.acall(**args)
+            return str(result) if result is not None else ""
+        except Exception as e:
+            return json.dumps({"error": str(e)}, ensure_ascii=False)

+ 93 - 0
supply_agent/tools/registry.py

@@ -0,0 +1,93 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+from typing import Any
+
+from supply_agent.tools.base import Tool
+from supply_agent.types import ToolDefinition, ToolResult
+
+
+class ToolRegistry:
+    """Registry for managing and executing agent tools."""
+
+    def __init__(self) -> None:
+        self._tools: dict[str, Tool] = {}
+
+    def register(
+        self,
+        func: Callable[..., Any],
+        name: str | None = None,
+        description: str | None = None,
+    ) -> Tool:
+        """Register a function as a tool."""
+        tool = Tool(func, name=name, description=description)
+        self._tools[tool.name] = tool
+        return tool
+
+    def register_tool(self, tool: Tool) -> None:
+        """Register an existing Tool instance."""
+        self._tools[tool.name] = tool
+
+    def unregister(self, name: str) -> None:
+        self._tools.pop(name, None)
+
+    def get(self, name: str) -> Tool | None:
+        return self._tools.get(name)
+
+    def list_tools(self) -> list[str]:
+        return list(self._tools.keys())
+
+    @property
+    def definitions(self) -> list[ToolDefinition]:
+        return [t.definition for t in self._tools.values()]
+
+    def execute(self, tool_call_id: str, name: str, arguments: str) -> ToolResult:
+        """Execute a tool by name and return the result."""
+        tool = self._tools.get(name)
+        if not tool:
+            return ToolResult(
+                tool_call_id=tool_call_id,
+                name=name,
+                content=f'{{"error": "Tool \'{name}\' not found"}}',
+                is_error=True,
+            )
+        content = tool.execute(arguments)
+        is_error = content.startswith('{"error"')
+        return ToolResult(
+            tool_call_id=tool_call_id,
+            name=name,
+            content=content,
+            is_error=is_error,
+        )
+
+    async def aexecute(self, tool_call_id: str, name: str, arguments: str) -> ToolResult:
+        """Async execute a tool."""
+        tool = self._tools.get(name)
+        if not tool:
+            return ToolResult(
+                tool_call_id=tool_call_id,
+                name=name,
+                content=f'{{"error": "Tool \'{name}\' not found"}}',
+                is_error=True,
+            )
+        content = await tool.aexecute(arguments)
+        is_error = content.startswith('{"error"')
+        return ToolResult(
+            tool_call_id=tool_call_id,
+            name=name,
+            content=content,
+            is_error=is_error,
+        )
+
+    def from_decorated(self, *funcs: Callable[..., Any]) -> ToolRegistry:
+        """Register multiple @tool-decorated functions."""
+        for func in funcs:
+            if getattr(func, "_is_tool", False):
+                self.register(func)
+        return self
+
+    def __len__(self) -> int:
+        return len(self._tools)
+
+    def __contains__(self, name: str) -> bool:
+        return name in self._tools

+ 123 - 0
supply_agent/types.py

@@ -0,0 +1,123 @@
+from __future__ import annotations
+
+from enum import StrEnum
+from typing import Any, Literal
+
+from pydantic import BaseModel, Field
+
+
+class Role(StrEnum):
+    SYSTEM = "system"
+    USER = "user"
+    ASSISTANT = "assistant"
+    TOOL = "tool"
+
+
+class Message(BaseModel):
+    """A single message in the conversation."""
+
+    role: Role
+    content: str | None = None
+    tool_calls: list[ToolCall] | None = None
+    tool_call_id: str | None = None
+    name: str | None = None
+
+    def to_api_dict(self) -> dict[str, Any]:
+        """Convert to OpenAI-compatible API format."""
+        data: dict[str, Any] = {"role": self.role.value}
+        if self.content is not None:
+            data["content"] = self.content
+        if self.tool_calls:
+            data["tool_calls"] = [tc.to_api_dict() for tc in self.tool_calls]
+        if self.tool_call_id:
+            data["tool_call_id"] = self.tool_call_id
+        if self.name:
+            data["name"] = self.name
+        return data
+
+
+class ToolCall(BaseModel):
+    """A tool invocation requested by the model."""
+
+    id: str
+    name: str
+    arguments: str  # JSON string
+
+    def to_api_dict(self) -> dict[str, Any]:
+        return {
+            "id": self.id,
+            "type": "function",
+            "function": {
+                "name": self.name,
+                "arguments": self.arguments,
+            },
+        }
+
+
+class ToolDefinition(BaseModel):
+    """OpenAI-compatible tool schema."""
+
+    name: str
+    description: str
+    parameters: dict[str, Any] = Field(default_factory=lambda: {
+        "type": "object",
+        "properties": {},
+        "required": [],
+    })
+
+    def to_api_dict(self) -> dict[str, Any]:
+        return {
+            "type": "function",
+            "function": {
+                "name": self.name,
+                "description": self.description,
+                "parameters": self.parameters,
+            },
+        }
+
+
+class ToolResult(BaseModel):
+    """Result of executing a tool."""
+
+    tool_call_id: str
+    name: str
+    content: str
+    is_error: bool = False
+
+
+class SkillInfo(BaseModel):
+    """Metadata and content for a loaded skill."""
+
+    name: str
+    description: str
+    content: str
+    path: str
+
+
+class AgentEventType(StrEnum):
+    """Events emitted during agent execution."""
+
+    THINKING = "thinking"
+    TOOL_CALL = "tool_call"
+    TOOL_RESULT = "tool_result"
+    MESSAGE = "message"
+    SKILL_LOADED = "skill_loaded"
+    DONE = "done"
+    ERROR = "error"
+
+
+class AgentEvent(BaseModel):
+    """A streaming event from the agent loop."""
+
+    type: AgentEventType
+    data: dict[str, Any] = Field(default_factory=dict)
+
+
+class AgentResult(BaseModel):
+    """Final result of an agent run."""
+
+    content: str
+    messages: list[Message]
+    iterations: int
+    tool_calls_made: int
+    skills_used: list[str] = Field(default_factory=list)