LlmToolkit
Base code tools for agentic LLM execution — a self-contained, composable tool framework for building AI agents in Elixir.
Provides 12 file/shell/web tools, a ToolResolver behaviour, composable resolver architecture, session-scoped tool filtering, and an Ecto trace schema for audit logging.
Tools
| Tool | Purpose |
|---|---|
read_file | Read file contents with offset/limit |
write_file | Write or overwrite a file |
edit_file | Exact-match single edit (pi safety model) |
multi_edit | Multiple edits in one call with rollback |
append_to_file | Append content to a file |
bash | Execute shell commands |
grep | Search file contents (ripgrep) |
glob | Find files by name pattern |
list_directory | List directory contents |
tree | Visual directory tree with sizes |
file_info | File metadata (size, type, mtime) |
http_get | Fetch a URL |
Installation
Add to your mix.exs:
def deps do
[
{:llm_toolkit, "~> 0.1"}
]
endUsage
Standalone
alias LlmToolkit.CodeTools
alias LlmToolkit.Tool.Call
# Use with default cwd (".")
{:ok, content} = CodeTools.resolve(%Call{name: "read_file", arguments: %{"path" => "README.md"}})
# Use with specific cwd
{:ok, content} = CodeTools.resolve(
%Call{name: "read_file", arguments: %{"path" => "README.md"}},
"/path/to/project"
)With Your Agent Loop
# As the sole tool resolver
MyAgent.Loop.run(task, LlmToolkit.CodeTools, opts)
# Via resolver tuple (binds working directory)
MyAgent.Loop.run(task, {LlmToolkit.CodeTools, "/project"}, opts)Composed with Domain Tools
# Base tools + your own tools
resolver = LlmToolkit.Composition.new([
{LlmToolkit.CodeTools, "/project"},
MyApp.DomainTools
])
tools = LlmToolkit.Composition.available_tools(resolver)
{:ok, result} = LlmToolkit.Composition.resolve(resolver, call)
Configurable Resolver with use AgentResolver
defmodule MyApp.Tools.Resolver do
use LlmToolkit.AgentResolver, tools: [
MyApp.Tools.Search,
MyApp.Tools.Analyze
]
end
# Each tool module implements:
# definition/0 → %LlmToolkit.Tool{}
# execute/2 → (args, context) → {:ok, string} | {:error, string}
# sensitive_fields/0 → ["api_key"] (optional, for telemetry scrubbing)Session-Scoped Tool Filtering
# Prepare only the tools declared for a session turn
{tools, resolver_fn} = LlmToolkit.SessionTools.prepare(
MyApp.Tools.Resolver,
["read_file", "search"],
%{user_id: "abc", project: "/repo"}
)
# resolver_fn is a fresh closure — thread-safe, no process dictionary
{:ok, result} = resolver_fn.(%Call{name: "read_file", arguments: %{"path" => "README.md"}})Architecture
| Module | Role |
|---|---|
LlmToolkit.ToolResolver |
Behaviour — resolve/1, available_tools/0, optional dispatch_recipe/1 |
LlmToolkit.Tool | Provider-neutral tool definition (name, description, JSON Schema params) |
LlmToolkit.Tool.Call | An LLM's request to invoke a tool |
LlmToolkit.Tool.Result | The outcome of executing a tool call |
LlmToolkit.CodeTools |
The 12 base tools implementing ToolResolver |
LlmToolkit.AgentResolver | use-based macro — list your tool modules, get a full resolver |
LlmToolkit.Composition | Merge multiple resolvers into one (first match wins) |
LlmToolkit.SessionTools | Filter tools by declaration, build context-bound closures |
LlmToolkit.Trace | Ecto schema for audit logging tool invocations |
Safety Model
edit_file: Uses exact string matching with uniqueness validation. oldText must match exactly once. No silent corruption.
multi_edit: Transactional — applies edits sequentially in memory. If any edit fails, the file is never written. All-or-nothing.
Dependencies
- Req (~> 0.5) — HTTP client for the
http_gettool - Ecto (~> 3.12) — Schema/changesets for the
Traceaudit schema
Both are lightweight and runtime-only.