defmodule Planck.Agent.AgentSpec do
@moduledoc """
Static, serializable agent definition.
An `AgentSpec` is the shape used both by team members loaded from disk (via
`Planck.Agent.Team`) and by the orchestrator's `spawn_agent` tool when it
creates workers at runtime. It contains only serializable fields — no
`execute_fn`. Tool wiring is merged in programmatically before starting the
agent.
## Member-entry JSON schema
This is the format for a single entry in a team's `members` list:
{
"type": "builder",
"name": "Builder Joe",
"description": "Writes and edits code.",
"provider": "anthropic",
"model_id": "claude-sonnet-4-6",
"system_prompt": "You are an expert builder.",
"opts": { "temperature": 0.7 },
"tools": ["read", "write", "edit", "bash"],
"skills": ["code_review", "refactor"]
}
Required fields are `type`, `provider`, `model_id`. Valid providers are
derived from `Planck.AI.Model.providers/0`.
`system_prompt` is either inline text or a path to a `.md`/`.txt` file
resolved relative to a caller-provided `base_dir`. `tools` and `skills`
are lists of names resolved against caller-provided pools at start time
(see `to_start_opts/2`). When `skills` is non-empty, their descriptions
are appended to the system prompt via `Planck.Agent.Skill.system_prompt_section/1`.
## Construction
iex> AgentSpec.from_map(%{
...> "type" => "builder",
...> "provider" => "ollama",
...> "model_id" => "llama3.2",
...> "system_prompt" => "Build things."
...> })
{:ok, %AgentSpec{...}}
iex> AgentSpec.from_list(list_of_maps, base_dir: "/path/to/team")
[%AgentSpec{...}, ...]
"""
alias Planck.Agent.{AIBehaviour, Skill, Tool}
require Logger
@typedoc """
- `:type` — role identifier used for registry lookups and tool targeting (e.g. `"builder"`)
- `:name` — human-readable label shown to other agents via `list_team`; defaults
to `type` when not provided or empty
- `:description` — one-line purpose shown to other agents via `list_team`
- `:provider` — LLM provider atom (e.g. `:anthropic`, `:ollama`)
- `:model_id` — model identifier within the provider (e.g. `"claude-sonnet-4-6"`)
- `:system_prompt` — system prompt text sent to the model at the start of every turn
- `:opts` — provider-specific options forwarded to the LLM call (e.g. `temperature:`)
- `:tools` — tool names to resolve from a `tool_pool:` at start time (e.g. `["read", "bash"]`)
- `:skills` — skill names to resolve from a `skill_pool:` at start time; when
non-empty, their descriptions are appended to `system_prompt` in `to_start_opts/2`
- `:base_url` — base URL of the model server for local providers that run multiple
instances (e.g. `"http://localhost:11434"` for a specific Ollama server). When
`nil`, the provider's default URL is used.
- `:compactor` — fully-qualified module name of a sidecar compactor for this agent,
e.g. `"MySidecar.Compactors.Builder"`. The module must implement `compact/2`.
planck_headless resolves this via `Planck.Agent.Sidecar.compactor_for/1` when
materialising the agent. `nil` means the default compactor is used.
"""
@type t :: %__MODULE__{
type: String.t(),
name: String.t(),
description: String.t() | nil,
provider: atom(),
model_id: String.t(),
base_url: String.t() | nil,
system_prompt: String.t(),
opts: keyword(),
tools: [String.t()],
skills: [String.t()],
compactor: String.t() | nil
}
@enforce_keys [:type, :provider, :model_id, :system_prompt]
defstruct [
:type,
:name,
:description,
:provider,
:model_id,
:base_url,
:system_prompt,
opts: [],
tools: [],
skills: [],
compactor: nil
]
@provider_atoms Map.new(Planck.AI.Model.providers(), fn p -> {Atom.to_string(p), p} end)
@doc """
Build an `AgentSpec` from a keyword list of validated fields.
`name` defaults to `type` when not provided or empty — every agent has a
human-readable label, and teams with multiple members of the same type are
forced to assign explicit names (via `Team.load/1`'s name-uniqueness check).
"""
@spec new(keyword()) :: t()
def new(fields) do
type = Keyword.fetch!(fields, :type)
%__MODULE__{
type: type,
name: default_name(Keyword.get(fields, :name), type),
description: Keyword.get(fields, :description),
provider: Keyword.fetch!(fields, :provider),
model_id: Keyword.fetch!(fields, :model_id),
base_url: Keyword.get(fields, :base_url),
system_prompt: Keyword.fetch!(fields, :system_prompt),
opts: Keyword.get(fields, :opts, []),
tools: Keyword.get(fields, :tools, []),
skills: Keyword.get(fields, :skills, []),
compactor: Keyword.get(fields, :compactor)
}
end
@spec default_name(term(), String.t()) :: String.t()
defp default_name(name, _type) when is_binary(name) and name != "", do: name
defp default_name(_, type), do: type
@doc """
Convert a list of maps (as decoded from JSON) into a list of `AgentSpec` structs.
Invalid entries are skipped with a warning; the rest are returned. Accepts
`base_dir:` for resolving relative `system_prompt` file paths. Defaults to
`File.cwd!()`.
"""
@spec from_list([map()]) :: [t()]
@spec from_list([map()], keyword()) :: [t()]
def from_list(entries, opts \\ []) when is_list(entries) do
base_dir = Keyword.get(opts, :base_dir, File.cwd!())
Enum.flat_map(entries, fn entry ->
case from_map(entry, base_dir) do
{:ok, spec} ->
[spec]
{:error, reason} ->
Logger.warning("[Planck.Agent.AgentSpec] skipping entry: #{reason} — #{inspect(entry)}")
[]
end
end)
end
@doc """
Convert a single map into an `AgentSpec` struct.
Returns `{:ok, spec}` or `{:error, reason}`. `system_prompt` values ending in
`.md` or `.txt` are treated as file paths and read from disk relative to
`base_dir`.
"""
@spec from_map(map()) :: {:ok, t()} | {:error, String.t()}
@spec from_map(map(), Path.t()) :: {:ok, t()} | {:error, String.t()}
def from_map(entry, base_dir \\ ".")
def from_map(
%{"type" => type, "provider" => raw_provider, "model_id" => model_id} = entry,
base_dir
)
when is_binary(type) and type != "" and is_binary(model_id) and model_id != "" do
with {:ok, provider} <- parse_provider(raw_provider),
{:ok, system_prompt} <- resolve_system_prompt(entry["system_prompt"], base_dir) do
{:ok,
new(
type: type,
name: entry["name"],
description: entry["description"],
provider: provider,
model_id: model_id,
base_url: entry["base_url"],
system_prompt: system_prompt,
opts: parse_opts(entry["opts"]),
tools: parse_string_list(entry["tools"]),
skills: parse_string_list(entry["skills"]),
compactor: entry["compactor"]
)}
end
end
def from_map(%{"type" => ""}, _base_dir), do: {:error, "type must not be empty"}
def from_map(%{"type" => _}, _base_dir),
do: {:error, "missing required field: provider or model_id"}
def from_map(_, _base_dir), do: {:error, "missing required field: type"}
@doc """
Convert an `AgentSpec` to keyword options suitable for `Planck.Agent.start_link/1`.
Accepts optional overrides: `tools:`, `tool_pool:`, `skill_pool:`, `team_id:`,
`session_id:`, `available_models:`, `on_compact:`.
## Tool resolution
When `spec.tools` is non-empty, tool names are resolved against `tool_pool:` (a list
of `Tool.t()` structs). Unknown names are silently ignored. Any tools passed via
`tools:` are appended after the resolved ones. When `spec.tools` is empty, `tools:`
is used directly.
## Skill resolution
When `spec.skills` is non-empty, skill names are resolved against `skill_pool:` (a
list of `Skill.t()` structs). The resolved skills' descriptions are appended to
`spec.system_prompt` via `Planck.Agent.Skill.system_prompt_section/1`. Unknown
names are silently ignored. When `spec.skills` is empty, `system_prompt` passes
through unchanged.
## Examples
iex> AgentSpec.to_start_opts(spec, tool_pool: [read_tool, bash_tool], team_id: "team-1")
[id: "...", type: "builder", tools: [read_tool], ...]
"""
@spec to_start_opts(t(), keyword()) :: keyword()
def to_start_opts(%__MODULE__{} = spec, overrides \\ []) do
available_models = Keyword.get(overrides, :available_models, [])
model = resolve_model!(spec.provider, spec.model_id, spec.base_url, available_models)
tools = resolve_tools(spec, overrides)
system_prompt = assemble_system_prompt(spec, overrides)
[
id: generate_id(),
type: spec.type,
name: spec.name,
description: spec.description,
model: model,
system_prompt: system_prompt,
opts: spec.opts,
tools: tools,
team_id: Keyword.get(overrides, :team_id),
session_id: Keyword.get(overrides, :session_id),
available_models: Keyword.get(overrides, :available_models, []),
on_compact: Keyword.get(overrides, :on_compact)
]
end
@spec resolve_tools(t(), keyword()) :: [Tool.t()]
defp resolve_tools(spec, overrides) do
skill_pool = Keyword.get(overrides, :skill_pool, [])
declared =
case spec.tools do
[] ->
Keyword.get(overrides, :tools, [])
names ->
pool = Keyword.get(overrides, :tool_pool, [])
pool_map = Map.new(pool, &{&1.name, &1})
resolved = Enum.flat_map(names, &List.wrap(Map.get(pool_map, &1)))
resolved ++ Keyword.get(overrides, :tools, [])
end
# load_skill is automatically available to every agent when skills exist —
# agents do not need to declare it in their TEAM.json.
case skill_pool do
[] -> declared
pool -> declared ++ [Skill.load_skill_tool(pool)]
end
end
@spec assemble_system_prompt(t(), keyword()) :: String.t()
defp assemble_system_prompt(spec, overrides) do
base =
with [_ | _] = names <- spec.skills,
pool = Keyword.get(overrides, :skill_pool, []),
pool_map = Map.new(pool, &{&1.name, &1}),
resolved = Enum.flat_map(names, &List.wrap(Map.get(pool_map, &1))),
section when is_binary(section) <- Skill.system_prompt_section(resolved) do
spec.system_prompt <> "\n\n" <> section
else
_ ->
spec.system_prompt
end
identity_line(spec) <> base
end
@spec identity_line(t()) :: String.t()
defp identity_line(spec)
defp identity_line(%__MODULE__{name: name, type: type}) do
label = if name != type, do: "#{name} (#{type})", else: type
"You are #{label}.\n\n"
end
# ---------------------------------------------------------------------------
# Private
# ---------------------------------------------------------------------------
@spec parse_provider(String.t()) :: {:ok, atom()} | {:error, String.t()}
defp parse_provider(p) do
case Map.fetch(@provider_atoms, p) do
{:ok, atom} ->
{:ok, atom}
:error ->
{:error,
"unknown provider #{inspect(p)}; valid: #{Enum.join(Map.keys(@provider_atoms), ", ")}"}
end
end
@spec resolve_system_prompt(String.t() | nil, Path.t()) ::
{:ok, String.t()} | {:error, String.t()}
defp resolve_system_prompt(nil, _base_dir), do: {:ok, ""}
defp resolve_system_prompt("", _base_dir), do: {:ok, ""}
defp resolve_system_prompt(value, base_dir) when is_binary(value) do
if file_path?(value) do
full_path = Path.join(base_dir, value)
case File.read(full_path) do
{:ok, content} -> {:ok, String.trim(content)}
{:error, reason} -> {:error, "could not read system_prompt file #{full_path}: #{reason}"}
end
else
{:ok, value}
end
end
@spec file_path?(String.t()) :: boolean()
defp file_path?(value) do
String.ends_with?(value, ".md") or String.ends_with?(value, ".txt")
end
@spec parse_opts(map() | nil) :: keyword()
defp parse_opts(nil), do: []
defp parse_opts(map) when is_map(map) do
Enum.flat_map(map, fn {k, v} ->
try do
[{String.to_existing_atom(k), v}]
rescue
ArgumentError ->
Logger.warning("[Planck.Agent.AgentSpec] unknown opt key #{inspect(k)}, skipping")
[]
end
end)
end
defp parse_opts(_), do: []
@spec parse_string_list(term()) :: [String.t()]
defp parse_string_list(nil), do: []
defp parse_string_list(list) when is_list(list), do: Enum.filter(list, &is_binary/1)
defp parse_string_list(_), do: []
@spec resolve_model!(atom(), String.t(), String.t() | nil, [Planck.AI.Model.t()]) ::
Planck.AI.Model.t()
defp resolve_model!(provider, model_id, base_url, available_models) do
declared =
Enum.find(available_models, fn m ->
m.provider == provider && m.id == model_id
end)
if declared do
declared
else
resolve_model_dynamic!(provider, model_id, base_url)
end
end
@spec resolve_model_dynamic!(atom(), String.t(), String.t() | nil) :: Planck.AI.Model.t()
defp resolve_model_dynamic!(provider, model_id, nil) do
case AIBehaviour.client().get_model(provider, model_id) do
{:ok, model} -> model
{:error, :not_found} -> raise ArgumentError, "model not found: #{provider}:#{model_id}"
end
end
defp resolve_model_dynamic!(provider, model_id, base_url) do
case Planck.AI.get_model(provider, model_id, base_url: base_url) do
{:ok, model} ->
model
{:error, :not_found} ->
raise ArgumentError, "model not found: #{provider}:#{model_id} at #{base_url}"
end
end
@spec generate_id() :: String.t()
defp generate_id do
:crypto.strong_rand_bytes(8) |> Base.encode16(case: :lower)
end
end