defmodule Condukt.Tool do @moduledoc """ Behaviour for defining tools that agents can use. Tools are functions that agents can call to interact with the world: reading files, running commands, making HTTP requests, etc. ## Defining a Tool defmodule MyApp.Tools.Weather do use Condukt.Tool @impl true def name, do: "get_weather" @impl true def description do "Gets the current weather for a location" end @impl true def parameters do %{ type: "object", properties: %{ location: %{ type: "string", description: "City name, e.g. 'San Francisco, CA'" } }, required: ["location"] } end @impl true def call(%{"location" => location}, _context) do case WeatherAPI.get(location) do {:ok, data} -> {:ok, format_weather(data)} {:error, reason} -> {:error, reason} end end end ## Tool Context The `call/2` function receives a context map with: - `:agent` - The agent PID - `:agent_module` - The agent module for the session - `:sandbox` - The active `Condukt.Sandbox` struct (use this for any filesystem or process-execution work) - `:cwd` - Project working directory (kept for tools that need to refer to the host project root for non-sandbox concerns; tools that read/write files or run commands should go through `:sandbox`) - `:secrets` - Resolved session secrets. Built-in command tools expose these as environment variables. Custom trusted tools can use `Condukt.Secrets.env/1` when they need the same values. - `:opts` - Options passed when adding the tool to the agent ## Sandbox-aware tools If your tool reads or writes files, or runs subprocesses, route through `Condukt.Sandbox.read/2`, `Condukt.Sandbox.write/3`, `Condukt.Sandbox.exec/3`, etc. Direct `File.*` or `MuonTrap.cmd/3` calls bypass the sandbox and break the consumer's ability to swap one in (e.g. an in-memory virtual sandbox). Tools that touch unrelated systems (HTTP APIs, databases, in-process state) have nothing to sandbox and are unaffected. ## Parameterized Tools Tools can be parameterized when added to an agent: defmodule MyApp.Tools.Database do use Condukt.Tool @impl true def name(opts), do: "query_\#{opts[:table]}" @impl true def description(opts) do "Query the \#{opts[:table]} table" end @impl true def call(args, context) do table = context.opts[:table] Repo.all(from r in table, where: ^build_where(args)) end end # In agent: def tools do [ {MyApp.Tools.Database, table: "users"}, {MyApp.Tools.Database, table: "orders"} ] end """ @doc """ Returns the tool name as it will appear to the LLM. """ @callback name() :: String.t() @callback name(opts :: keyword()) :: String.t() @doc """ Returns a description of what the tool does. """ @callback description() :: String.t() @callback description(opts :: keyword()) :: String.t() @doc """ Returns the JSON Schema for the tool's parameters. """ @callback parameters() :: map() @callback parameters(opts :: keyword()) :: map() @doc """ Executes the tool with the given arguments. """ @callback call(args :: map(), context :: map()) :: {:ok, term()} | {:error, term()} @optional_callbacks [name: 1, description: 1, parameters: 1] defmacro __using__(_opts) do quote do @behaviour Condukt.Tool # Default: no-opts versions delegate to opts versions with empty list def name, do: name([]) def description, do: description([]) def parameters, do: parameters([]) # Default opts implementations call the no-opts versions def name([]), do: raise("#{inspect(__MODULE__)} must implement name/0 or name/1") def description([]), do: raise("#{inspect(__MODULE__)} must implement description/0 or description/1") def parameters([]), do: raise("#{inspect(__MODULE__)} must implement parameters/0 or parameters/1") defoverridable name: 0, name: 1, description: 0, description: 1, parameters: 0, parameters: 1 end end @doc """ Gets the tool name for a tool spec. """ def name(%Condukt.Tool.Inline{name: name}), do: name def name({module, opts}), do: module.name(opts) def name(module) when is_atom(module), do: module.name() @doc """ Builds a tool specification for the LLM provider. """ def to_spec(%Condukt.Tool.Inline{} = inline) do %{ name: inline.name, description: inline.description, parameters: inline.parameters } end def to_spec({module, opts}) do %{ name: module.name(opts), description: module.description(opts), parameters: module.parameters(opts) } end def to_spec(module) when is_atom(module) do %{ name: module.name(), description: module.description(), parameters: module.parameters() } end @doc """ Executes a tool by name with arguments. """ def execute(%Condukt.Tool.Inline{call: call}, args, context) do context = Map.put(context, :opts, []) invoke_callable(call, args, context) end def execute({module, opts}, args, context) do context |> Map.put(:opts, opts) |> execute_call(module, args) end def execute(module, args, context) when is_atom(module) do context |> Map.put(:opts, []) |> execute_call(module, args) end defp execute_call(context, module, args) do module.call(args, context) catch :error, error -> {:error, format_error(error)} end defp invoke_callable(call, args, context) do call.(args, context) catch :error, error -> {:error, format_error(error)} end defp format_error(error) do if is_exception(error) do Exception.message(error) else inspect(error) end end end