Planck.Agent.Sidecar behaviour (Planck.Agent v0.1.0)

Copy Markdown View Source

Behaviour and utilities for sidecar applications that extend planck_headless over distributed Erlang.

Behaviour

A sidecar entry-point module implements one required callback:

  • tools/0 — returns [Planck.Agent.Tool.t()] with full execute_fn closures. These run locally on the sidecar node.

Module-level utilities

Planck.Agent.Sidecar itself provides module-level functions that planck_headless calls on the sidecar node via :rpc.call/5. Because planck_agent is a dependency of both planck_headless and the sidecar, these are available on both nodes:

  • discover/0 — finds the module implementing this behaviour (cached in :persistent_term after the first call).
  • list_tools/0 — discovers the entry module and returns its tools as [Planck.AI.Tool.t()] (no closures, serialisable across nodes).
  • list_tools/1 — same but takes an explicit module; intended for tests.
  • execute_tool/3 — discovers the entry module and executes a named tool.
  • execute_tool/4 — same but takes an explicit module; intended for tests.

planck_headless calls:

:rpc.call(sidecar_node, Planck.Agent.Sidecar, :list_tools, [])
:rpc.call(sidecar_node, Planck.Agent.Sidecar, :execute_tool,
          [tool_name, agent_id, args], timeout)

Minimal example

defmodule MySidecar.Planck do
  use Planck.Agent.Sidecar

  @impl true
  def tools do
    [
      Planck.Agent.Tool.new(
        name: "run_tests",
        description: "Run the test suite. Pass timeout_ms to override the default.",
        parameters: %{
          "type" => "object",
          "properties" => %{
            "timeout_ms" => %{
              "type" => "integer",
              "description" => "Max milliseconds to wait (default 120000)"
            }
          }
        },
        execute_fn: fn _agent_id, _id, args ->
          timeout = Map.get(args, "timeout_ms", 120_000)
          case System.cmd("mix", ["test"], timeout: timeout) do
            {output, 0} -> {:ok, output}
            {output, _} -> {:error, output}
          end
        end
      )
    ]
  end
end

See specs/sidecar.md for the full design.

Summary

Callbacks

Return the sidecar's tools as Planck.Agent.Tool structs (with execute_fn).

Functions

Convenience macro for implementing the Planck.Agent.Sidecar behaviour.

Discover the module in the current node that implements Planck.Agent.Sidecar.

Discover the entry module and execute a named tool.

Execute a named tool via an explicit sidecar module's tools/0 list.

Discover the sidecar entry module and return its tools as [Planck.AI.Tool.t()].

Convert module.tools() to [Planck.AI.Tool.t()] — serialisable, no closures.

Callbacks

tools()

@callback tools() :: [Planck.Agent.Tool.t()]

Return the sidecar's tools as Planck.Agent.Tool structs (with execute_fn).

This is the only required callback. execute_fn closures run locally on the sidecar node — they are never serialised or called on planck_headless.

Each tool should accept an optional "timeout_ms" argument in its parameter schema so the AI can hint at how long to wait for the tool call.

Functions

__using__(options)

(macro)

Convenience macro for implementing the Planck.Agent.Sidecar behaviour.

use Planck.Agent.Sidecar injects:

  • @behaviour Planck.Agent.Sidecar — marks the module as a sidecar entry point.
  • A default tools/0 returning [] — override this to provide tools.

Usage

defmodule MySidecar.Planck do
  use Planck.Agent.Sidecar

  @impl true
  def tools do
    [
      Planck.Agent.Tool.new(
        name: "run_tests",
        description: "Run the test suite.",
        parameters: %{"type" => "object", "properties" => %{}},
        execute_fn: fn _agent_id, _id, _args ->
          {out, 0} = System.cmd("mix", ["test"])
          {:ok, out}
        end
      )
    ]
  end
end

The tools/0 function is the only thing you normally need to override. list_tools/0, discover/0, execute_tool/3, and execute_tool/4 are not injected here — they are module-level functions on Planck.Agent.Sidecar itself that planck_headless calls on the sidecar node:

:rpc.call(node, Planck.Agent.Sidecar, :list_tools, [])
:rpc.call(node, Planck.Agent.Sidecar, :execute_tool,
          [tool_name, agent_id, args], timeout)

This design keeps the dispatch logic in planck_agent (available on both nodes) rather than requiring each sidecar module to implement it. No config is needed — list_tools/0 discovers the entry module automatically via discover/0.

discover()

@spec discover() :: module() | nil

Discover the module in the current node that implements Planck.Agent.Sidecar.

Scans modules across all loaded OTP applications and returns the first one whose @behaviour attribute includes Planck.Agent.Sidecar, or nil if none is found. Only Elixir modules (names starting with "Elixir.") are checked; Erlang modules are skipped.

Successful results are cached in :persistent_term. nil results are not cached — the next call will retry the scan, which is useful when the sidecar entry module is loaded after discover/0 is first called.

Called by planck_headless on the sidecar node via list_tools/0. You normally do not need to call this directly.

execute_tool(tool_name, agent_id, tool_call_id, args)

@spec execute_tool(String.t(), String.t(), String.t(), map()) ::
  {:ok, term()} | {:error, term()}

Discover the entry module and execute a named tool.

Called by planck_headless on the sidecar node:

:rpc.call(sidecar_node, Planck.Agent.Sidecar, :execute_tool,
          [tool_name, agent_id, tool_call_id, args], timeout)

The timeout is read from args["timeout_ms"] by the planck_headless RPC wrapper, not by this function.

execute_tool(module, tool_name, agent_id, tool_call_id, args)

@spec execute_tool(module(), String.t(), String.t(), String.t(), map()) ::
  {:ok, term()} | {:error, term()}

Execute a named tool via an explicit sidecar module's tools/0 list.

Intended for tests. Production code should use execute_tool/3.

list_tools()

@spec list_tools() :: [Planck.AI.Tool.t()]

Discover the sidecar entry module and return its tools as [Planck.AI.Tool.t()].

Combines discover/0 and list_tools/1. Returns [] if no entry module is found.

Called by planck_headless on the sidecar node:

:rpc.call(sidecar_node, Planck.Agent.Sidecar, :list_tools, [])

list_tools(module)

@spec list_tools(module()) :: [Planck.AI.Tool.t()]

Convert module.tools() to [Planck.AI.Tool.t()] — serialisable, no closures.