Creating Plugins¶
Step 1: Define your argument schema¶
Subclass ToolArgs (a Pydantic BaseModel) to describe your tool's inputs:
from pydantic import Field
from aifred_tk.core.interfaces import ToolArgs
class MyToolArgs(ToolArgs):
query: str = Field(..., description="The search query")
limit: int = Field(10, ge=1, description="Maximum number of results")
Step 2: Implement your tool¶
Subclass Tool and implement all abstract properties and execute():
from typing import Any
from aifred_tk.core.interfaces import Tool, ToolArgs, ToolResult
class MyTool(Tool):
@property
def tool_id(self) -> str:
return "my_tool"
@property
def name(self) -> str:
return "My Tool"
@property
def description(self) -> str:
return "Does something useful."
@property
def args_schema(self) -> type[MyToolArgs]:
return MyToolArgs
def execute(self, args: ToolArgs) -> ToolResult:
assert isinstance(args, MyToolArgs)
# ... your logic here ...
return ToolResult(output={"result": args.query})
Step 3: Implement your plugin¶
Subclass Plugin and return your tools from get_tools():
from dynaconf import Dynaconf
from aifred_tk.core.interfaces import Plugin, Tool
class MyPlugin(Plugin):
@property
def plugin_id(self) -> str:
return "my_plugin"
@property
def name(self) -> str:
return "My Plugin"
def get_tools(self) -> list[Tool]:
return [MyTool()]
def create_plugin(settings: Dynaconf) -> MyPlugin:
return MyPlugin()
Step 4: Register the entry point¶
In your package's pyproject.toml:
After installing your package (e.g. pip install -e .), aifred-tk discovers
your tool automatically:
Accessing settings¶
The settings argument in create_plugin provides access to the full configuration
chain. Use it to read plugin-specific configuration:
def create_plugin(settings: Dynaconf) -> MyPlugin:
api_key = settings.get("TOOLS__MY_TOOL__API_KEY", None)
return MyPlugin(api_key=api_key)
Using LLM Agents¶
If your tool needs to interact with an LLM, use the build_agent_from_settings facility. This helper automatically resolves the LLM configuration from the tool's settings, handles provider-specific models, and populates model settings like max_tokens.
lazy instantiation¶
It is a best practice to instantiate the agent lazily (on first use) within your tool's execute method or a private property. This ensures that the plugin can be registered even if required environment variables (like API keys) are missing at startup.
Note that build_agent_from_settings returns an AgentRunner rather than a plain pydantic_ai.Agent. This interface is shared by both the standard agent and the FallbackAgent, allowing your tool to transparently support fallback chains.
from typing import Any
from aifred_tk.core.interfaces import Tool, ToolArgs
from aifred_tk.core.llm import build_agent_from_settings, AgentRunner
class MyLlmTool(Tool):
def __init__(self, settings: Dynaconf) -> None:
self._settings = settings
self._agent: AgentRunner[str] | None = None
@property
def tool_id(self) -> str:
return "my_llm_tool"
def _get_agent(self) -> AgentRunner[str]:
if self._agent is None:
# Build the agent using the tool's unique ID and settings.
# Returns an AgentRunner (either a pydantic-ai Agent or a FallbackAgent).
self._agent = build_agent_from_settings(
self.tool_id,
self._settings,
output_type=str,
system_prompt="You are a helpful assistant.",
)
return self._agent
def execute(self, args: ToolArgs) -> ToolResult:
agent = self._get_agent()
result = agent.run_sync("Hello world")
return ToolResult(
output={"response": result.output},
llm_name=agent.model_name
)
Customizing Agent Behavior (Capabilities and Retries)¶
You can pass optional parameters capabilities and retries to build_agent_from_settings to customize the underlying agent's execution behavior:
capabilities(Sequence[Any] | None): A sequence of Pydantic-AI capabilities, such asHooks, to register on the agent. These will be appended after any system-provided debug hooks if debug mode is active.retries(int): The number of attempts for both model failures and tool execution failures (defaults to3).
[!NOTE] When a fallback LLM configuration is resolved, these capabilities and retry settings are automatically and recursively propagated to all leaf agents in the fallback chain.
from pydantic_ai import Hooks
from aifred_tk.core.llm import build_agent_from_settings
# Define a custom hook to intercept agent events
class MyCustomHook(Hooks):
def on_llm_request(self, request):
print(f"LLM Prompt: {request}")
self._agent = build_agent_from_settings(
self.tool_id,
self._settings,
output_type=str,
system_prompt="Analyze code style.",
capabilities=[MyCustomHook()],
retries=5, # Allow up to 5 validation / tool retries
)
Configuration¶
The user must map an LLM to your tool in their settings file using the llm key under your tool's namespace:
# ~/.config/aifred-tk/settings.yml
llms:
gpt4:
provider: openai
model: gpt-4o
tools:
my_llm_tool:
enabled: true
llm:
type: ref
ref: gpt4
See LLMs for more details on LLM configuration.
Tool Results and State Mutation¶
Every tool must return a ToolResult object. This container holds the tool's output and metadata used for governance and reporting.
from aifred_tk.core.interfaces import ToolResult
# Simple return
return ToolResult(output={"status": "ok"})
# Return with change tracking
return ToolResult(
output={"status": "committed"},
made_changes=True,
llm_name="openai/gpt-4o"
)
| Field | Description |
|---|---|
output |
The primary result (JSON-serializable dict or a raw str). |
made_changes |
Set to True if the tool performed a persistent side effect (file write, commit, etc.). |
llm_name |
The "provider/model" string of the agent that drove the change. |
The readonly property¶
By default, all tools are assumed to be read-only (readonly = True). This is reflected in the MCP readOnlyHint annotation. If your tool modifies external state, you must override this property:
class MyMutationTool(Tool):
@property
def readonly(self) -> bool:
return False
def execute(self, args: ToolArgs) -> ToolResult:
# ... performing changes ...
return ToolResult(output={"ok": True}, made_changes=True)
When made_changes=True, the presentation layer automatically logs the llm_name to the AI-assisted guard file.
Safe File Access¶
If your tool reads files from disk, you MUST ensure that access is both secure (preventing path traversal attacks) and ethical (respecting the user's .aiignore rules).
The aifred_tk.core.paths module provides two utilities to handle this automatically.
Validating a single file¶
Use sanitize_file_path() when your tool takes a specific file path as input. It resolves the path, ensures it is a regular file, and checks it against .aiignore. If the file is inaccessible or ignored, it raises a FileAccessError.
from aifred_tk.core.paths import sanitize_file_path, FileAccessError
def execute(self, args: ToolArgs) -> ToolResult:
try:
# Resolves traversal, checks existence, and enforces .aiignore
safe_path = sanitize_file_path(args.file_path)
except FileAccessError as e:
return ToolResult(output={"status": "error", "message": str(e)})
content = safe_path.read_text()
# ...
return ToolResult(output={"content": content})
Filtering multiple paths¶
Use filter_accessible_paths() when handling a collection of paths (for example, from a glob search). It resolves each path and silently drops any that are missing, are not regular files, or match an .aiignore pattern.
from aifred_tk.core.paths import filter_accessible_paths
def execute(self, args: ToolArgs) -> ToolResult:
raw_paths = ["src/main.py", "secrets.key", "missing.txt"]
# Returns only the resolved Paths that exist and are NOT ignored
accessible_paths = filter_accessible_paths(raw_paths)
# accessible_paths will likely only contain [Path(".../src/main.py")]
for path in accessible_paths:
# ... process file ...
return ToolResult(output={"processed": len(accessible_paths)})