v0.1.3

Generic JSON schema validation

  • Tool.validate_args/2 now validates every tool invocation against the tool's JSON Schema parameters using ex_json_schema before execute_fn is called. Covers required fields, types, enums, and any other constraint declared in the schema — no per-tool validation boilerplate needed.
  • Validation is wired in resolve_tool_fn in agent.ex, so all tools — built-in, inter-agent, and sidecar — get it automatically.
  • Enum errors include the list of valid values; required-field and type errors name the offending field.
  • ex_json_schema ~> 0.11 added as a dependency.

spawn_agent provider enum

  • The provider parameter now declares "enum": ["anthropic", "openai", "google", "ollama", "llama_cpp"]. Invalid provider names are caught by schema validation with a clear error before execute_fn runs — the ArgumentError rescue that previously handled this case has been removed.

v0.1.2

Duplicate agent types allowed

  • spawn_agent no longer enforces type uniqueness. Multiple agents of the same type are now permitted (e.g. two "developer" agents working on different features in parallel). All agents are uniquely identified by their id; type is display metadata only. list_team + ID-based targeting makes type uniqueness unnecessary.

Agent resilience

  • Tool task crash recoveryexecute_fn is now wrapped in try/rescue inside the task. Any exception sends {:tool_done, id, name, {:error, message}} instead of leaving the agent stuck in :executing_tools. Queued user messages sent during tool execution are therefore always flushed and persisted, preventing them from disappearing on page refresh.
  • Stream task crash recovery — the LLM stream task is also wrapped in try/rescue. Exceptions send {:stream_event, ref, {:error, message}}, hitting the existing reset_streaming path and returning the agent to :idle.
  • Orphaned tool-call stripping — when an agent starts with an existing session, handle_continue(:load_session_history) loads the DB history and strips any trailing assistant turn whose tool calls were never answered (left by a previous crash). The turn is removed from both in-memory state and the session DB.
  • bash nil-command guard — the bash builtin returns {:error, "missing required argument: command"} instead of crashing when the LLM omits the command argument.

Identity line moved to runtime

  • AgentSpec.to_start_opts/2 no longer prepends the identity line to system_prompt. The agent's build_system_prompt/1 now injects it at runtime before each LLM call, generating "You are a <type>." or "You are <name>, a <type>." depending on whether name and type differ.

Inter-agent tool overhaul

  • Renames — tools renamed to make the sync/async distinction explicit:
    • ask_agentcall_agent (sync, blocks until the target responds)
    • delegate_tasksend_agent (async, fire-and-forget)
    • send_responserespond_agent
  • checkpoint_agent removed — replaced by reset_previous_context: true parameter on call_agent and send_agent. When true, archives the target's prior history before sending the message, giving it a clean slate. Workers have automatic compaction for in-task context growth; reset_previous_context is for deliberate redirection across tasks.
  • ID-only targetingidentifier + identifier_type parameters removed from all targeting tools. All tools now accept a single agent_id (from list_team). Name/type-based resolution is gone; agents must call list_team to discover IDs before targeting. list_team description updated to highlight the id field. Error messages guide recovery: "Agent not found. Call list_team to get current agent IDs."
  • spawn_agent guidance — returns the new agent's ID; the caller must save it for subsequent call_agent/send_agent/interrupt_agent/destroy_agent calls.

Planck.Agent.SystemPrompt module

  • All system prompt assembly extracted from agent.ex into a dedicated Planck.Agent.SystemPrompt module with build/1 as the public entry point.
  • Per-tool guidance sections injected only when the relevant tool is present. Grouped as: discovery → spawn → interaction → management.
  • Role-aware intro: shows the ask/delegate decision rule when the agent has both call_agent and send_agent; simplified variant for agents with only one.
  • Each section uses "Use when…" framing consistent with the skill description convention.
  • Planck.Agent.Tools error messages updated with recovery hints ("Call list_team", "Call list_models") that fire at exactly the moment the agent needs them.

v0.1.1

checkpoint_agent tool + Planck.Agent.checkpoint/2

  • checkpoint_agent — orchestrator-only tool that inserts a {:custom, :summary} message into a target worker's conversation. The worker's next LLM call only sees the checkpoint and later messages; prior history is preserved in the session DB. Added to orchestrator_tools/6 alongside spawn_agent, destroy_agent, and interrupt_agent.
  • Planck.Agent.checkpoint/2 — new public API: checkpoint(agent, summary_text) issues a synchronous GenServer.call/2 that builds, persists, and appends the summary message. Works regardless of the agent's current status.

Dynamic skill injection

  • Agent state gains skill_names: [String.t()] and skill_refresh_fn: (-> [Skill.t()]) | nil fields.
  • do_run_llm now calls build_system_prompt/1 (private) before each LLM turn: invokes skill_refresh_fn.() to get the current skill pool from ResourceStore and appends a fresh skill section. Skills are no longer baked into state.system_prompt at agent start time.
  • AgentSpec.assemble_system_prompt/1 returns the base prompt only (identity line + user prompt). to_start_opts/2 stores skill names in the skill_names: start opt and accepts a skill_refresh_fn: override.

YAML frontmatter via yamerl

  • Planck.Agent.Skill now parses SKILL.md frontmatter using yamerl instead of a hand-rolled regex. Handles multi-line values, special characters, and the YAML > folded-scalar syntax without fragile string splitting.
  • yamerl ~> 0.10 added as a dependency.
  • Note: YAML description values containing : must be quoted: description: "Generate images: text-to-image and img2img."

Binary tool output guard

  • truncate_tool_output/1 now checks String.valid?/1 before truncating. Non-UTF-8 binary output (e.g. raw image bytes returned by a tool) is replaced with [binary file, N bytes — cannot display] instead of crashing.

Dynamic skill injection — load_skill_tool/2

  • Skill.load_skill_tool/2 accepts an optional skill_refresh_fn second argument. When provided, load_skill resolves the skill at call time rather than at agent start, enabling hot reload of edited SKILL.md files without restarting the agent.

Dependency update

  • ex_doc bumped to ~> 0.40.2.

v0.1.0

execute_fn receives agent_id

  • Tool.execute_fn type updated to (agent_id, tool_call_id, args) — every tool now receives the calling agent's id as the first argument.
  • ask_agent drops the own_id closure capture — reads from agent_id.
  • spawn_agent drops the orchestrator_id closure capture — reads from agent_id.
  • worker_tools/3 (was /4) and orchestrator_tools/6 (was /7) — each lost one parameter as a result.
  • list_models marks the caller's current model with current: true via a dynamic Agent.get_state lookup — works correctly when granted to workers.
  • AIBehaviour — added get_model/3 callback for base-url-aware lookups.

Explicit agent targeting

  • ask_agent, delegate_task, destroy_agent, interrupt_agent — replaced the three optional type/name/id fields with a required identifier string and a required identifier_type enum ("type", "name", "id"). The LLM can no longer omit all three and silently fail to target an agent.

spawn_agent hardening

  • base_url is now always required in spawn_agent (cloud providers may pass a placeholder; only ollama/llama_cpp use it).
  • spawn_agent execute_fn refactored into focused helpers: validate_base_url, resolve_spawn_model, build_spawn_start_opts, filter_granted.

Tool output truncation

  • Tool results are now capped at 2 000 lines or 50 KB (whichever is reached first) before being stored in the session. Outputs that exceed either limit are truncated and suffixed with \n[output truncated]. Both limits are always enforced — line truncation is applied first, then byte truncation on the result.

Compactor fixes

  • estimate_tokens now counts {:tool_call, id, name, args} content parts (previously ignored, causing systematic underestimates).
  • compact_local filters all {:custom, :summary} messages from old before calling summarize/2 — only messages since the last checkpoint are summarised, preventing the previous checkpoint from bloating the request.
  • format_history strips thinking blocks and truncates tool results to 2 000 chars — keeps the summarisation input small without losing signal.

Queued message follow-up fix

  • A user message sent while the orchestrator is executing tools now correctly triggers a dedicated follow-up turn after all tools complete. Previously, do_run_llm called during tool continuation advanced stream_start past the queued message, so maybe_turn_start found no pending input.

Runtime model switching

  • Planck.Agent.change_model/2 — replaces the model in the agent's GenServer state for subsequent LLM turns without affecting the current conversation history or status.

AGENTS.md prepending for all agents

  • Tools.prepend_agents_md/2 is now public — walks up from cwd to the nearest .git root, reads AGENTS.md if found, and prepends its content to the given system prompt. Returns the prompt unchanged when no file is found or cwd is empty.
  • orchestrator_tools/7 — added cwd parameter (default ""); passed into the spawn_agent closure so dynamically spawned workers inherit the same project context.
  • spawn_agent tool — prepends AGENTS.md to the worker's system prompt before starting the agent process; cwd is stored in the new agent's state.
  • Agent.t — added cwd: String.t() field (default ""); set from start opts.

Skills — explicit load_skill / list_skills tools

  • Skill.load_skill_tool/1 — builds a load_skill tool as a closure over the skill pool; automatically injected by AgentSpec.to_start_opts/2 for every agent when skill_pool: is non-empty. No TEAM.json declaration needed.
  • Skill.list_skills_tool/1 — builds a list_skills tool returning all available skill names and descriptions. Opt-in: add "list_skills" to an agent's TEAM.json "tools" array to enable autonomous skill discovery.
  • Skill.system_prompt_section/1 updated: no longer includes file paths or resources dir; instructs agents to use load_skill instead of read.
  • AgentSpec.resolve_tools/2 updated: automatically appends load_skill_tool when skill_pool: is non-empty, regardless of spec.skills.

Inter-agent tools — deadlock detection + improvements

  • ask_agent/2 — now accepts own_id for deadlock detection; before blocking, registers {:waiting, own_id} → target_id in Planck.Agent.Registry (auto- cleared on task exit) and checks for a circular wait chain; returns a clear error instead of deadlocking if a cycle is detected.
  • worker_tools/4 — added own_id parameter (passed to ask_agent for cycle detection); callers must now supply the agent's own id.
  • orchestrator_tools/6 — added grantable_skills parameter so skills can be granted to dynamically spawned workers via spawn_agent.
  • spawn_agent — spawned workers now receive a sender identity so the orchestrator knows which worker replied via send_response.
  • list_team/1 — added verbose: boolean parameter; verbose mode includes tool names and model for each team member.
  • list_models/1 — output now includes base_url for each model so the LLM can pass the correct base_url when calling spawn_agent.
  • Agent init broadcasts :worker_spawned on the session PubSub topic when a worker with a delegator_id starts, enabling UIs to refresh the agent list.
  • Non-blocking tool execution: handle_continue({:execute_tools}) now spawns each tool as a supervised fire-and-forget task; results collected via handle_info({:tool_done}); the GenServer loop stays free for abort/prompt during tool execution.
  • abort/1 changed from cast to call; blocks until the agent is idle, closing the race condition between abort and subsequent prompt/rewind calls.
  • cost: float() added to agent state; accumulated from model rates on each :done event; persisted to session metadata; broadcast in :usage_delta.
  • Message.estimate_tokens/1 — public character-based token estimator.
  • Planck.Agent.estimate_tokens/1 — public API that computes current context size.
  • running_tools / tool_results_acc added to agent state for non-blocking tool tracking.

Prior entries

First release.

  • Planck.Agent.Sidecar — behaviour for distributed sidecar extensions; single tools/0 callback; module-level RPC entry points: discover/0 (auto-detects the entry module via :persistent_term-cached scan, only caches on success), list_tools/0, list_tools/1, execute_tool/3, execute_tool/4
  • Planck.Agent.Compactor — redesigned: compact/2 and compact_timeout/0 callbacks; unified build/2 accepting sidecar_node: and compactor: opts for remote sidecar compactors with local fallback; compactor: string is converted to :"Elixir.<name>" atom before RPC; load/1 removed
  • AgentSpec.compactor — per-agent compactor module name string; resolved via Compactor.build/2 at session start
  • OTP-based agent runtime with GenServer per agent
  • Team lifecycle: orchestrator owns team, team dies with orchestrator
  • Inter-agent tools: ask_agent, delegate_task, send_response, list_team
  • Orchestrator-only tools: spawn_agent, destroy_agent, interrupt_agent, list_models
  • spawn_agent accepts a "tools" JSON array; the orchestrator may grant any subset of its own grantable_tools to the spawned worker (no privilege escalation)
  • Planck.Agent.ExternalTool — declarative external tool spec loaded from <name>/TOOL.json; {{key}} interpolation in commands; erlexec-backed execution; load_all/1, from_file/1
  • Planck.Agent.Compactor — defines @callback compact/1; custom compactors implement this behaviour in a module inside a .exs file, allowing helper functions alongside the main callback; load/1 compiles the file and wraps the module's compact/1 as an on_compact function
  • Registry-based agent discovery by type, name, or id
  • Parallel tool execution via Task.async_stream
  • Phoenix.PubSub broadcasting on "agent:#{id}" and "session:#{session_id}" topics
  • Token usage tracking: :usage_delta events in real-time and usage in :turn_end
  • stop/1 — graceful shutdown; cancels in-flight stream via terminate/2
  • get_info/1 — lightweight metadata snapshot
  • Planck.Agent.BuiltinToolsread/0, write/0, edit/0, bash/0 tool factories
    • read streams line-by-line with optional offset and limit
    • bash is backed by erlexec; accepts cwd and timeout as runtime JSON args; stdout and stderr both captured
  • Planck.Agent.Skill — filesystem-based skill loader; load_all/1, from_file/1, system_prompt_section/1; skills are <name>/SKILL.md directories with YAML-style frontmatter
  • Planck.Agent.Session — SQLite-backed session store with checkpoint-based pagination; caller-supplied :dir (no default)
  • Planck.Agent.Compactor — default LLM-based context compaction anchored on model.context_window
  • Planck.Agent.Team — directory-based team loader (TEAM.json + members/<name>.md); %Team{source: :filesystem | :dynamic}; Team.load/1 and Team.dynamic/1

  • Planck.Agent.AgentSpec — explicit constructor new/1; JSON parsers from_map/2 and from_list/2 for member entries; description, tools: [String.t()], and skills: [String.t()] fields; to_start_opts/2 accepts tool_pool: and skill_pool: overrides — tool names resolve from tool_pool: (falling back to the tools: override when spec.tools is empty); skill names resolve from skill_pool: and their descriptions are appended to system_prompt via Skill.system_prompt_section/1 when spec.skills is non-empty
  • Member name defaults to type when not provided; Team.load/1 rejects duplicate names so multiple same-type members must be explicitly named
  • spawn_agent tool accepts a "skills" parameter and a grantable_skills closure arg, symmetric with grantable_tools
  • Planck.AI.Model.providers/0 — valid provider atoms
  • Pluggable on_compact hook — Compactor.build/2 returns a ready-to-use function
  • @type agent and @type t now have full @typedoc documentation with all fields typed

Session API additions

  • Session.append/3 changed from fire-and-forget cast to synchronous call — returns pos_integer() | nil (the SQLite autoincrement row id, or nil when the session is not found); enables the agent to set Message.id = db_id immediately after each persist
  • Session.truncate_after/2 — deletes all messages with id >= db_id across all agents in a session; used by the edit-message feature
  • Session.messages/1 rows now include db_id: pos_integer() — the SQLite row id
  • Message.id is now the SQLite row id after persistence (previously a random UUID); this unifies the two identifiers so callers never need to track both
  • Message.id is not stored in the serialised blob — the field is stripped before writing and set from the DB id column on every read; the row id is therefore authoritative for all rows, including legacy ones that stored a UUID
  • Planck.Agent.rewind_to_message/2 — truncates both the session and in-memory history to strictly before the given db_id, then reloads from the DB to restore canonical order and rebuild turn_checkpoints; replaces the old rewind/2 (removed)
  • rewind/2 removed — replaced by Planck.Agent.rewind_to_message/2

Message persistence ordering

  • Queued messages (received while the agent is streaming) are no longer persisted immediately; they retain a UUID id in memory and are flushed to the session at the start of the next LLM turn via flush_unpersisted_messages. This guarantees that the queued message's db_id is always greater than the current turn's assistant response, preserving correct insertion order in the DB
  • flush_unpersisted_messages and reload_messages_from_session are internal helpers that keep in-memory message order consistent with DB order after queuing or rewind; turn_checkpoints is rebuilt from the reloaded list

Agent API

  • Planck.Agent.prompt/3 is now a synchronous call (was a cast) — returns :ok once the agent has set its status to :streaming; if the agent is already busy the message is queued (appended to history) and re-triggered automatically after the current turn ends via maybe_turn_start/1
  • send_response tool now carries sender attribution: orchestrator receives {:agent_response, response, %{id, name}} and stores sender_id/sender_name in the message metadata
  • to_ai_messages/1 converts {:custom, :agent_response} messages to :user role, prefixed with "Response from <name>: " when sender_name metadata is present
  • ask_agent no longer accepts a timeout_ms parameter — blocks indefinitely; monitors the target process and returns {:error, "Agent terminated: ..."} if it crashes; subscribes before prompting to close the race condition
  • delegate_task tool result now includes guidance to end the turn

Notes

  • planck_agent is a pure library with no runtime config module; filesystem-path configuration (sessions, skills, tools, compactor) lives in Planck.Headless.Config. Callers using planck_agent directly pass paths as explicit arguments.
  • Planck.Agent.TeamTemplate iterated out during development — superseded by Planck.Agent.Team and AgentSpec.from_map/2/from_list/2.