Current section
Files
Jump to
Current section
Files
lib/planck/agent/message_builder.ex
defmodule Planck.Agent.MessageBuilder do
@moduledoc """
Pure functions for constructing `Planck.Agent.Message` values.
No side effects — these functions take raw data and return messages.
Used by `Planck.Agent` to build assistant and tool-result messages
during the LLM loop.
"""
alias Planck.Agent.{Message, StreamBuffer}
@max_tool_output_lines 2_000
@max_tool_output_bytes 50_000
@doc """
Normalize a user prompt to a list of content parts.
A plain string is wrapped in a `{:text, text}` tuple. A list is
returned as-is.
"""
@spec normalize_content(String.t() | [Planck.AI.Message.content_part()]) ::
[Planck.AI.Message.content_part()]
def normalize_content(text)
def normalize_content(text) when is_binary(text), do: [{:text, text}]
def normalize_content(parts) when is_list(parts), do: parts
@doc """
Build an assistant message from a `StreamBuffer`.
Content order: thinking → text → tool calls. Fields that are empty strings
or empty lists are omitted from the message content.
"""
@spec build_assistant(StreamBuffer.t()) :: Message.t()
def build_assistant(stream_buffer)
def build_assistant(%StreamBuffer{text: text, thinking: thinking, calls: calls}) do
content =
Enum.map(calls, fn %{id: id, name: name, args: args} -> {:tool_call, id, name, args} end)
|> prepend_if(text != "", {:text, text})
|> prepend_if(thinking != "", {:thinking, thinking})
Message.new(:assistant, content)
end
@doc """
Build a tool-result message from a list of `{call_id, result}` pairs.
Results must be in `{:ok, string} | {:error, reason}` form. Each value is
truncated to #{@max_tool_output_lines} lines / #{@max_tool_output_bytes} bytes.
"""
@spec build_tool_result([{String.t(), {:ok, String.t()} | {:error, term()}}]) :: Message.t()
def build_tool_result(results)
def build_tool_result(results) when is_list(results) do
content =
Enum.map(results, fn {id, result} ->
value =
case result do
{:ok, v} when is_binary(v) -> v
{:ok, v} -> inspect(v)
{:error, reason} when is_binary(reason) -> "Error: #{reason}"
{:error, reason} -> "Error: #{inspect(reason)}"
end
{:tool_result, id, truncate_tool_output(value)}
end)
Message.new(:tool_result, content)
end
# ---------------------------------------------------------------------------
# Private
# ---------------------------------------------------------------------------
@spec truncate_tool_output(String.t()) :: String.t()
defp truncate_tool_output(value)
defp truncate_tool_output(value) when is_binary(value) do
if String.valid?(value) do
truncate_text_output(value)
else
"[binary file, #{byte_size(value)} bytes — cannot display]"
end
end
@spec truncate_text_output(String.t()) :: String.t()
defp truncate_text_output(value)
defp truncate_text_output(value) when is_binary(value) do
lines = String.split(value, "\n")
{value, line_truncated} =
if length(lines) > @max_tool_output_lines do
{Enum.take(lines, @max_tool_output_lines) |> Enum.join("\n"), true}
else
{value, false}
end
{value, truncated} =
if byte_size(value) > @max_tool_output_bytes do
{binary_part(value, 0, @max_tool_output_bytes), true}
else
{value, line_truncated}
end
if truncated, do: value <> "\n[output truncated]", else: value
end
@spec prepend_if(list(), boolean(), term()) :: list()
defp prepend_if(list, condition, item)
defp prepend_if(list, true, item), do: [item | list]
defp prepend_if(list, false, _item), do: list
end