defmodule Planck.Agent.BuiltinTools do @moduledoc """ Factory functions for the four built-in tools: `read`, `write`, `edit`, `bash`. These tools cover file system access and shell execution. Together they are sufficient for most agent tasks — reading context, writing code, applying edits, and running scripts (including skill scripts). Shell execution uses `erlexec` (`:exec`) which manages process groups and cleans up child processes on timeout or termination. ## Usage tools = [ Planck.Agent.BuiltinTools.read(), Planck.Agent.BuiltinTools.write(), Planck.Agent.BuiltinTools.edit(), Planck.Agent.BuiltinTools.bash() ] Pass these alongside any inter-agent tools when starting an agent. """ alias Planck.Agent.Tool @default_bash_timeout 30_000 @doc """ Returns the `read` tool — reads the contents of a file from disk. Supports optional `offset` (lines to skip from the start) and `limit` (maximum number of lines to return) for reading large files in chunks. Uses line-by-line streaming so only the requested portion is loaded. """ @spec read() :: Tool.t() def read do Tool.new( name: "read", description: """ Read the contents of a file. Use offset and limit to read large files in chunks without loading the entire file into memory. """, parameters: %{ "type" => "object", "properties" => %{ "path" => %{"type" => "string", "description" => "Path to the file"}, "offset" => %{ "type" => "integer", "description" => "Number of lines to skip from the start (default: 0)" }, "limit" => %{ "type" => "integer", "description" => "Maximum number of lines to return (default: all)" } }, "required" => ["path"] }, execute_fn: fn _agent_id, _id, args -> path = args["path"] offset = Map.get(args, "offset", 0) limit = Map.get(args, "limit") path |> Path.expand() |> read_lines(path, offset, limit) end ) end @doc """ Returns the `write` tool — writes content to a file. Creates the file and any missing parent directories. Overwrites if the file already exists. """ @spec write() :: Tool.t() def write do Tool.new( name: "write", description: "Write content to a file, creating the file and any missing parent directories.", parameters: %{ "type" => "object", "properties" => %{ "path" => %{"type" => "string", "description" => "Path to the file"}, "content" => %{"type" => "string", "description" => "Content to write"} }, "required" => ["path", "content"] }, execute_fn: fn _agent_id, _id, %{"path" => path, "content" => content} -> expanded = Path.expand(path) dirname = Path.dirname(expanded) with :ok <- File.mkdir_p(dirname), :ok <- File.write(expanded, content) do {:ok, "Written #{path}."} else {:error, reason} -> {:error, "cannot write #{path}: #{:file.format_error(reason)}"} end end ) end @doc """ Returns the `edit` tool — replaces an exact string in a file. Fails if `old_string` is not found or appears more than once. Make `old_string` long enough to be unique in the file. """ @spec edit() :: Tool.t() def edit do Tool.new( name: "edit", description: """ Replace an exact string in a file. Fails if old_string is not found or appears more than once — include enough surrounding context to make it unique. """, parameters: %{ "type" => "object", "properties" => %{ "path" => %{"type" => "string", "description" => "Path to the file"}, "old_string" => %{ "type" => "string", "description" => "Exact string to replace (must be unique in the file)" }, "new_string" => %{"type" => "string", "description" => "Replacement string"} }, "required" => ["path", "old_string", "new_string"] }, execute_fn: fn _agent_id, _id, %{"path" => path, "old_string" => old, "new_string" => new} -> expanded = Path.expand(path) with {:ok, content} <- File.read(expanded), {:ok, before, rest} <- split_unique(content, old, path), :ok <- File.write(expanded, before <> new <> rest) do {:ok, "Edited #{path}."} else {:error, reason} when is_binary(reason) -> {:error, reason} {:error, posix} -> {:error, "cannot access #{path}: #{:file.format_error(posix)}"} end end ) end @doc """ Returns the `bash` tool — runs a shell command via `erlexec`. Process groups are cleaned up on timeout or termination. Both stdout and stderr are captured; stderr is appended to the output when non-empty. `cwd` and `timeout` are optional tool arguments supplied by the caller at runtime — they are not baked into the tool at construction time. """ @spec bash() :: Tool.t() def bash do Tool.new( name: "bash", description: "Run a shell command and return its output (stdout + stderr).", parameters: %{ "type" => "object", "properties" => %{ "command" => %{"type" => "string", "description" => "The shell command to run"}, "cwd" => %{ "type" => "string", "description" => "Working directory (default: current directory)" }, "timeout" => %{ "type" => "integer", "description" => "Timeout in milliseconds (default: #{@default_bash_timeout})" } }, "required" => ["command"] }, execute_fn: fn _agent_id, _id, args -> cmd = args["command"] cwd = Map.get(args, "cwd", File.cwd!()) timeout = Map.get(args, "timeout", @default_bash_timeout) run_bash(cmd, timeout, cwd) end ) end # --------------------------------------------------------------------------- # Private # --------------------------------------------------------------------------- @spec read_lines(Path.t(), Path.t(), non_neg_integer(), pos_integer() | nil) :: {:ok, String.t()} | {:error, String.t()} defp read_lines(expanded, display_path, offset, limit) defp read_lines(expanded, display_path, offset, limit) when is_binary(expanded) and is_binary(display_path) and offset >= 0 do stream = expanded |> File.stream!(:line) |> Stream.drop(offset) stream = if limit, do: Stream.take(stream, limit), else: stream lines = stream |> Enum.to_list() |> IO.iodata_to_binary() {:ok, lines} rescue e in File.Error -> reason = "cannot read #{display_path}: #{:file.format_error(e.reason)}" {:error, reason} end @spec split_unique(String.t(), String.t(), Path.t()) :: {:ok, String.t(), String.t()} | {:error, String.t()} defp split_unique(content, old, path) do case String.split(content, old, parts: 3) do [before, rest] -> {:ok, before, rest} [_] -> reason = "old_string not found in #{path}" {:error, reason} _ -> reason = "old_string appears more than once in #{path} — make it more specific" {:error, reason} end end @doc false @spec run_bash(String.t(), pos_integer(), Path.t()) :: {:ok, String.t()} | {:error, String.t()} def run_bash(command, timeout, cwd) do exec_opts = [:sync, :stdout, :stderr, {:cd, String.to_charlist(cwd)}] task = Task.async(fn -> :exec.run(command, exec_opts) end) case Task.yield(task, timeout) || Task.shutdown(task) do {:ok, result} -> decode_exec_result(result) {:exit, reason} -> {:error, "Process failed: #{inspect(reason)}"} nil -> {:error, "Process timed out after #{timeout}ms"} end end @spec decode_exec_result({:ok, term()} | {:error, term()}) :: {:ok, String.t()} | {:error, String.t()} defp decode_exec_result(result) defp decode_exec_result({:ok, result}) do stdout = result[:stdout] || [] stderr = result[:stderr] || [] {:ok, assemble(stdout, stderr)} end defp decode_exec_result({:error, result}) when is_list(result) do raw = result[:exit_status] || 256 status = decode_exit_status(raw) stdout = result[:stdout] || [] stderr = result[:stderr] || [] reason = "Process exited with status #{status}\n#{assemble(stdout, stderr)}" {:error, reason} end defp decode_exec_result({:error, reason}) do {:error, "failed to start process: #{inspect(reason)}"} end @spec decode_exit_status(integer()) :: integer() defp decode_exit_status(raw) defp decode_exit_status(raw) when rem(raw, 256) == 0, do: div(raw, 256) defp decode_exit_status(raw), do: raw @spec assemble([iodata()], [iodata()]) :: String.t() defp assemble(stdout, stderr) do s = IO.iodata_to_binary(stdout) e = IO.iodata_to_binary(stderr) if e == "", do: s, else: s <> "\nSTDERR:\n" <> e end end