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

ToolPurpose
read_fileRead file contents with offset/limit
write_fileWrite or overwrite a file
edit_fileExact-match single edit (pi safety model)
multi_editMultiple edits in one call with rollback
append_to_fileAppend content to a file
bashExecute shell commands
grepSearch file contents (ripgrep)
globFind files by name pattern
list_directoryList directory contents
treeVisual directory tree with sizes
file_infoFile metadata (size, type, mtime)
http_getFetch a URL

Installation

Add to your mix.exs:

def deps do
  [
    {:llm_toolkit, "~> 0.1"}
  ]
end

Usage

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

ModuleRole
LlmToolkit.ToolResolverBehaviour — resolve/1, available_tools/0, optional dispatch_recipe/1
LlmToolkit.ToolProvider-neutral tool definition (name, description, JSON Schema params)
LlmToolkit.Tool.CallAn LLM's request to invoke a tool
LlmToolkit.Tool.ResultThe outcome of executing a tool call
LlmToolkit.CodeToolsThe 12 base tools implementing ToolResolver
LlmToolkit.AgentResolveruse-based macro — list your tool modules, get a full resolver
LlmToolkit.CompositionMerge multiple resolvers into one (first match wins)
LlmToolkit.SessionToolsFilter tools by declaration, build context-bound closures
LlmToolkit.TraceEcto 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_get tool
  • Ecto (~> 3.12) — Schema/changesets for the Trace audit schema

Both are lightweight and runtime-only.