Current section

Files

Jump to
planck_agent lib planck agent hooks compactor.ex
Raw

lib/planck/agent/hooks/compactor.ex

defmodule Planck.Agent.Hooks.Compactor do
@moduledoc """
Behaviour and default implementation for context compaction in `Planck.Agent`.
## Behaviour
Use `use Planck.Agent.Hooks.Compactor` to implement a custom compaction strategy
in a sidecar. The only required callback is `compact/2`; `compact_timeout/0` has a
default of #{120_000} ms.
defmodule MySidecar.Compactors.Builder do
use Planck.Agent.Hooks.Compactor
@impl true
def compact(_model, messages) do
summary = Message.new({:custom, :summary}, [{:text, summarise(messages)}])
kept = Enum.take(messages, -5)
{:compact, summary, kept}
end
@impl true
def compact_timeout, do: 60_000
end
## Dispatch
`Planck.Agent` stores the resolved compactor module atom in state and calls
`compact/4` before every LLM turn:
Hooks.Compactor.compact(state.compactor, state.model, messages, state.sidecar_node)
- `module: nil` — uses the built-in local LLM-based compactor.
- `module` set, `sidecar_node: nil` — calls `module.compact/2` in-process.
- `module` set, `sidecar_node` set — calls the module on the remote node via RPC;
falls back to the local LLM-based compactor on `:badrpc`.
"""
require Logger
alias Planck.Agent.{AIBehaviour, Message}
alias Planck.AI.{Context, Model}
@default_compact_timeout_ms 120_000
@default_ratio 0.8
@keep_ratio 0.1
@doc """
Compact the message list.
Return `{:compact, summary_msg, kept}` to replace older messages with a summary,
or `:skip` to leave the list unchanged.
"""
@callback compact(model :: Model.t(), messages :: [Message.t()]) ::
{:compact, summary :: Message.t(), kept :: [Message.t()]} | :skip
@doc """
RPC call timeout in milliseconds when this compactor is invoked remotely.
Defaults to #{@default_compact_timeout_ms} ms.
"""
@callback compact_timeout() :: pos_integer()
@doc false
defmacro __using__(_opts) do
quote do
@behaviour unquote(__MODULE__)
@impl unquote(__MODULE__)
def compact_timeout, do: unquote(__MODULE__).default_compact_timeout()
defoverridable compact_timeout: 0
end
end
@doc "Default RPC timeout used when a compactor module omits `compact_timeout/0`."
@spec default_compact_timeout() :: pos_integer()
def default_compact_timeout, do: @default_compact_timeout_ms
@doc """
Dispatch compaction for the given module, model, and messages.
- `nil` — runs the built-in local LLM-based compactor.
- module set, `sidecar_node: nil` — calls `module.compact/2` in-process.
- module set, `sidecar_node` set — calls via RPC with local fallback on failure.
Returns `:skip` or `{:compact, summary_msg, kept}`.
"""
@spec compact(module() | nil, Model.t(), [Message.t()], atom() | nil) ::
:skip | {:compact, Message.t(), [Message.t()]}
def compact(module, model, messages, sidecar_node)
def compact(nil, model, messages, _sidecar_node) do
do_local(model, messages)
end
def compact(module, model, messages, nil) do
module.compact(model, messages)
end
def compact(module, model, messages, sidecar_node) do
:rpc.call(sidecar_node, :code, :ensure_loaded, [module], 5_000)
timeout = remote_timeout(module, sidecar_node)
case :rpc.call(sidecar_node, module, :compact, [model, messages], timeout) do
{:badrpc, reason} ->
Logger.warning(
"[Planck.Agent.Hooks.Compactor] RPC failed (#{module}): #{inspect(reason)}, falling back to local"
)
do_local(model, messages)
result ->
result
end
end
# ---------------------------------------------------------------------------
# Private
# ---------------------------------------------------------------------------
@summary_prompt """
Summarize the conversation below to reduce context length.
Your summary must:
- Describe completed work and resolved decisions briefly
- State clearly what is currently being worked on and the most recent requests
- Preserve any key facts, file paths, decisions, or constraints still relevant
- Be written as context for an AI agent continuing this conversation
Prioritize recency — the active task and latest requests take priority over earlier history.
"""
@spec do_local(Model.t(), [Message.t()]) :: :skip | {:compact, Message.t(), [Message.t()]}
defp do_local(model, messages) do
threshold = trunc(model.context_window * @default_ratio)
if Message.estimate_tokens(messages) >= threshold do
compact_local(messages, model)
else
:skip
end
end
@spec compact_local([Message.t()], Model.t()) ::
:skip | {:compact, Message.t(), [Message.t()]}
defp compact_local(messages, model) do
keep_budget = trunc(model.context_window * @keep_ratio)
{old, kept} = split_by_token_budget(messages, keep_budget)
to_summarize = Enum.reject(old, &match?(%Message{role: {:custom, :summary}}, &1))
case {to_summarize, summarize(to_summarize, model)} do
{[], _} ->
:skip
{_, {:ok, text}} ->
summary_msg = Message.new({:custom, :summary}, [{:text, text}])
{:compact, summary_msg, kept}
{_, {:error, _}} ->
:skip
end
end
# Walk from the tail, accumulating messages until the token budget is exceeded.
# Always keeps at least the last message even if it alone exceeds the budget.
@spec split_by_token_budget([Message.t()], non_neg_integer()) ::
{[Message.t()], [Message.t()]}
defp split_by_token_budget(messages, budget) do
{kept, _} =
messages
|> Enum.reverse()
|> Enum.reduce_while({[], 0}, fn msg, {kept, total} ->
cost = Message.estimate_tokens([msg])
new_total = total + cost
if new_total <= budget or kept == [] do
{:cont, {[msg | kept], new_total}}
else
{:halt, {kept, total}}
end
end)
Enum.split(messages, length(messages) - length(kept))
end
@spec summarize([Message.t()], Model.t()) :: {:ok, String.t()} | {:error, term()}
defp summarize(messages, model) do
history = format_history(messages)
context = %Context{
system: @summary_prompt,
messages: [%Planck.AI.Message{role: :user, content: [{:text, history}]}],
tools: []
}
result =
AIBehaviour.client().stream(model, context, [])
|> Enum.reduce({:ok, []}, fn
{:text_delta, text}, {:ok, acc} -> {:ok, [acc | text]}
{:error, reason}, _acc -> {:error, reason}
_other, acc -> acc
end)
case result do
{:ok, []} -> {:error, :empty_response}
{:ok, iodata} -> {:ok, IO.iodata_to_binary(iodata)}
{:error, _} = error -> error
end
end
@spec format_history([Message.t()]) :: String.t()
defp format_history(messages) do
messages
|> Enum.map_join("\n\n", fn %Message{role: role, content: content} ->
label = format_role(role)
text = extract_text(content)
"#{label}: #{text}"
end)
end
@spec format_role(Message.role()) :: String.t()
defp format_role(:user), do: "User"
defp format_role(:assistant), do: "Assistant"
defp format_role(:tool_result), do: "Tool result"
defp format_role({:custom, kind}), do: kind |> Atom.to_string() |> String.capitalize()
@spec extract_text([Planck.AI.Message.content_part()]) :: String.t()
defp extract_text(content) do
content
|> Enum.flat_map(fn
{:text, text} -> [text]
{:tool_result, _id, value} -> [value]
_ -> []
end)
|> IO.iodata_to_binary()
end
@spec remote_timeout(module(), atom()) :: pos_integer()
defp remote_timeout(module, sidecar_node) do
case :rpc.call(sidecar_node, module, :compact_timeout, [], 5_000) do
timeout when is_integer(timeout) and timeout > 0 -> timeout
_ -> @default_compact_timeout_ms
end
end
end