Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ classifiers = [
]
dependencies = [
"jinja2==3.1.6",
"jsonschema==4.26.0",
"litellm==1.82.0",
"pydantic==2.12.5",
"pyyaml==6.0.3",
Expand Down Expand Up @@ -150,6 +151,10 @@ plugins = ["pydantic.mypy"]
module = "litellm.*"
ignore_missing_imports = true

[[tool.mypy.overrides]]
module = "jsonschema.*"
ignore_missing_imports = true

[tool.pydantic-mypy]
init_forbid_extra = true
init_typed = true
Expand Down
17 changes: 17 additions & 0 deletions src/ai_company/observability/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,23 @@
BUDGET_TIME_RANGE_INVALID: Final[str] = "budget.time_range.invalid"
BUDGET_DEPARTMENT_RESOLVE_FAILED: Final[str] = "budget.department.resolve_failed"

# ── Tool lifecycle ────────────────────────────────────────────────

TOOL_REGISTRY_BUILT: Final[str] = "tool.registry.built"
TOOL_REGISTRY_DUPLICATE: Final[str] = "tool.registry.duplicate"
TOOL_NOT_FOUND: Final[str] = "tool.not_found"
TOOL_INVOKE_START: Final[str] = "tool.invoke.start"
TOOL_INVOKE_SUCCESS: Final[str] = "tool.invoke.success"
TOOL_INVOKE_TOOL_ERROR: Final[str] = "tool.invoke.tool_error"
TOOL_INVOKE_NOT_FOUND: Final[str] = "tool.invoke.not_found"
TOOL_INVOKE_PARAMETER_ERROR: Final[str] = "tool.invoke.parameter_error"
TOOL_INVOKE_SCHEMA_ERROR: Final[str] = "tool.invoke.schema_error"
TOOL_INVOKE_EXECUTION_ERROR: Final[str] = "tool.invoke.execution_error"
TOOL_INVOKE_NON_RECOVERABLE: Final[str] = "tool.invoke.non_recoverable"
TOOL_INVOKE_VALIDATION_UNEXPECTED: Final[str] = "tool.invoke.validation_unexpected"
TOOL_BASE_INVALID_NAME: Final[str] = "tool.base.invalid_name"
TOOL_REGISTRY_CONTAINS_TYPE_ERROR: Final[str] = "tool.registry.contains_type_error"

# ── Role catalog ──────────────────────────────────────────────────

ROLE_LOOKUP_MISS: Final[str] = "role.lookup.miss"
19 changes: 19 additions & 0 deletions src/ai_company/tools/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Tool system — base abstraction, registry, invoker, and errors."""

from .base import BaseTool, ToolExecutionResult
from .errors import ToolError, ToolExecutionError, ToolNotFoundError, ToolParameterError
from .examples.echo import EchoTool
from .invoker import ToolInvoker
from .registry import ToolRegistry

__all__ = [
"BaseTool",
"EchoTool",
"ToolError",
"ToolExecutionError",
"ToolExecutionResult",
"ToolInvoker",
"ToolNotFoundError",
"ToolParameterError",
"ToolRegistry",
]
139 changes: 139 additions & 0 deletions src/ai_company/tools/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""Base tool abstraction and execution result model.

Defines the ``BaseTool`` ABC that all concrete tools extend, and the
``ToolExecutionResult`` value object returned by tool execution.
"""

import copy
from abc import ABC, abstractmethod
from typing import Any

from pydantic import BaseModel, ConfigDict, Field

from ai_company.observability import get_logger
from ai_company.observability.events import TOOL_BASE_INVALID_NAME
from ai_company.providers.models import ToolDefinition

logger = get_logger(__name__)


class ToolExecutionResult(BaseModel):
"""Result of executing a tool's business logic.

This is the internal result type returned by ``BaseTool.execute``.
The invoker converts it into a ``ToolResult`` for the LLM, carrying
only ``content`` and ``is_error`` — ``metadata`` is not forwarded
to the LLM and is available only for programmatic consumers.

Note:
The ``metadata`` dict is shallowly immutable under the frozen
model — reassignment is prevented but contents can still be
mutated. Callers should treat it as read-only.

Attributes:
content: Tool output as a string.
is_error: Whether the execution failed.
metadata: Optional structured data for programmatic consumers.
"""

model_config = ConfigDict(frozen=True)

content: str = Field(description="Tool output")
is_error: bool = Field(default=False, description="Whether tool errored")
metadata: dict[str, Any] = Field(
default_factory=dict,
description="Optional structured metadata",
)


class BaseTool(ABC):
"""Abstract base class for all tools in the system.

Subclasses must implement ``execute`` to define tool behavior.
The ``to_definition`` method converts the tool into a
``ToolDefinition`` suitable for sending to an LLM provider.

Attributes:
name: Non-blank tool name.
description: Human-readable description of the tool.
parameters_schema: JSON Schema dict describing expected arguments,
or ``None`` if the tool accepts any arguments.
"""

def __init__(
self,
*,
name: str,
description: str = "",
parameters_schema: dict[str, Any] | None = None,
) -> None:
"""Initialize a tool with name, description, and schema.

Args:
name: Non-blank tool name.
description: Human-readable description.
parameters_schema: JSON Schema for tool parameters.

Raises:
ValueError: If name is empty or whitespace-only.
"""
if not name or not name.strip():
logger.warning(TOOL_BASE_INVALID_NAME, name=repr(name))
msg = "Tool name must not be empty or whitespace-only"
raise ValueError(msg)
self._name = name
self._description = description
self._parameters_schema: dict[str, Any] | None = (
copy.deepcopy(parameters_schema) if parameters_schema is not None else None
)

@property
def name(self) -> str:
"""Tool name."""
return self._name

@property
def description(self) -> str:
"""Tool description."""
return self._description

@property
def parameters_schema(self) -> dict[str, Any] | None:
"""JSON Schema for tool parameters, or None if unspecified.

Returns a deep copy to prevent mutation of the internal schema.
"""
return copy.deepcopy(self._parameters_schema)

def to_definition(self) -> ToolDefinition:
"""Convert this tool to a ``ToolDefinition`` for LLM providers.

Returns:
A ``ToolDefinition`` with name, description, and schema.
"""
return ToolDefinition(
name=self._name,
description=self._description,
parameters_schema=self.parameters_schema or {},
)
Comment thread
greptile-apps[bot] marked this conversation as resolved.

@abstractmethod
async def execute(
self,
*,
arguments: dict[str, Any],
) -> ToolExecutionResult:
"""Execute the tool with the given arguments.

Arguments are pre-validated against the tool's JSON Schema (if
one is defined) by the ``ToolInvoker`` before reaching this
method. Implementations with a schema can assume compliance
when invoked through the invoker; tools without a schema
receive unvalidated arguments.

Args:
arguments: Parsed arguments matching the parameters schema.

Returns:
A ``ToolExecutionResult`` with the tool output.
"""
56 changes: 56 additions & 0 deletions src/ai_company/tools/errors.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"""Tool error hierarchy.

All tool errors carry an immutable context mapping for structured
metadata. Unlike provider errors, tool errors have no ``is_retryable``
flag — retry decisions are made at higher layers.
"""

from types import MappingProxyType
from typing import Any


class ToolError(Exception):
"""Base exception for all tool-layer errors.

Attributes:
message: Human-readable error description.
context: Immutable metadata about the error (tool name, etc.).
"""

def __init__(
self,
message: str,
*,
context: dict[str, Any] | None = None,
) -> None:
Comment on lines +20 to +25

Copilot AI Mar 5, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The context parameter is typed as dict[str, Any], but the implementation immediately copies it and stores an immutable mapping. Accepting a broader Mapping[str, Any] | None (and documenting that it’s copied) would make the API more flexible for callers (e.g., when they already have an immutable mapping) without changing runtime behavior.

Copilot uses AI. Check for mistakes.
"""Initialize a tool error.

Args:
message: Human-readable error description.
context: Arbitrary metadata about the error. Stored as an
immutable mapping; defaults to empty if not provided.
"""
self.message = message
self.context: MappingProxyType[str, Any] = MappingProxyType(
dict(context) if context else {},
)
super().__init__(message)

def __str__(self) -> str:
"""Format error with optional context metadata."""
if self.context:
ctx = ", ".join(f"{k}={v!r}" for k, v in self.context.items())
return f"{self.message} ({ctx})"
return self.message


class ToolNotFoundError(ToolError):
"""Requested tool is not registered in the registry."""


class ToolParameterError(ToolError):
"""Tool parameters failed schema validation."""


class ToolExecutionError(ToolError):
"""Tool execution raised an unexpected error."""
Empty file.
49 changes: 49 additions & 0 deletions src/ai_company/tools/examples/echo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Echo tool — returns the input message unchanged.

A minimal reference implementation of ``BaseTool`` useful for testing
and as a starting point for new tool implementations.
"""

from typing import Any

from ai_company.tools.base import BaseTool, ToolExecutionResult


class EchoTool(BaseTool):
"""Echoes the input message back as the tool result.

Examples:
Basic usage::

tool = EchoTool()
result = await tool.execute(arguments={"message": "hello"})
assert result.content == "hello"
"""

def __init__(self) -> None:
"""Initialize the echo tool with a fixed schema."""
super().__init__(
name="echo",
description="Echoes the input message back",
parameters_schema={
"type": "object",
"properties": {"message": {"type": "string"}},
"required": ["message"],
"additionalProperties": False,
},
)

async def execute(
self,
*,
arguments: dict[str, Any],
) -> ToolExecutionResult:
"""Return the ``message`` argument as content.

Args:
arguments: Must contain a ``message`` key with a string value.

Returns:
A ``ToolExecutionResult`` with the message as content.
"""
return ToolExecutionResult(content=arguments["message"])
Loading
Loading