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

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.
  • list_widgets/0 — discovers the entry module and returns the widget modules paired with its tools via each tool's :widget field. There is no separate widgets/0 callback; see Planck.Agent.Widget.
  • list_widgets/1 — same but takes an explicit module; intended for tests.
  • widget_render/2 — discovers the entry module and renders a widget by id.
  • widget_action/3 — discovers the entry module and dispatches an action to a widget by id.
  • widget_container/1 — discovers the entry module and returns a widget's declared container type by id (:modal if unset).
  • set_locale/1 — caches the current UI locale in :persistent_term, idempotently. Not tied to any i18n library — see below.
  • get_locale/0 — returns the cached locale, "en" if never set.

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)
:rpc.call(sidecar_node, Planck.Agent.Sidecar, :list_widgets, [])
:rpc.call(sidecar_node, Planck.Agent.Sidecar, :widget_render,
          [widget_id, myself])
:rpc.call(sidecar_node, Planck.Agent.Sidecar, :widget_action,
          [widget_id, action, args])
:rpc.call(sidecar_node, Planck.Agent.Sidecar, :widget_container,
          [widget_id])
:rpc.call(sidecar_node, Planck.Agent.Sidecar, :set_locale, [locale])

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 sidecar design, and specs/widgets.md for the widget mechanism specifically.

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.

Returns the cached current UI locale, "en" if set_locale/1 was never called (e.g. before planck_headless's sidecar connects, or in tests).

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.

Discover the sidecar entry module and return the widget modules paired with its tools via their :widget field.

Return the widget modules paired with an explicit module's tools/0.

Caches the sidecar-wide current UI locale (e.g. "en", "es").

Dispatch an action to a widget by id, via the discovered entry module.

Return a widget's declared container type by id — :modal, :drawer, or :fullscreen. See Planck.Agent.Widget.container/0. Defaults to :modal if the widget doesn't implement container/0.

Render a widget by id, via the discovered entry module.

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.

get_locale()

@spec get_locale() :: String.t()

Returns the cached current UI locale, "en" if set_locale/1 was never called (e.g. before planck_headless's sidecar connects, or in tests).

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.

list_widgets()

@spec list_widgets() :: [module()]

Discover the sidecar entry module and return the widget modules paired with its tools via their :widget field.

Combines discover/0 and list_widgets/1. Returns [] if no entry module is found. There is no separate widgets/0 callback — a widget is declared by setting a Planck.Agent.Tool.t()'s :widget field, and this just filters tools/0 for tools that have one.

Called by planck_headless on the sidecar node:

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

list_widgets(module)

@spec list_widgets(module()) :: [module()]

Return the widget modules paired with an explicit module's tools/0.

Intended for tests. Production code should use list_widgets/0.

set_locale(locale)

@spec set_locale(String.t()) :: :ok

Caches the sidecar-wide current UI locale (e.g. "en", "es").

Called by planck_headless (via Planck.Headless.Locale.set/1) on the sidecar node whenever Planck.Web.Locale.Plug resolves a locale — on every request, in practice, since the plug has no cheap way to know whether this call would be a no-op before making it. So this function is itself idempotent instead: absent, it's set; present and equal, no-op; present and different, updated. A widget module decides what to do with the value — this function knows nothing about Gettext or any other i18n library, only that it's tracking "the current locale" as a plain string.

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

widget_action(widget_id, action, args)

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

Dispatch an action to a widget by id, via the discovered entry module.

:rpc.call(sidecar_node, Planck.Agent.Sidecar, :widget_action,
          [widget_id, action, args])

widget_container(widget_id)

@spec widget_container(String.t()) ::
  {:ok, Planck.Agent.Widget.container()} | {:error, term()}

Return a widget's declared container type by id — :modal, :drawer, or :fullscreen. See Planck.Agent.Widget.container/0. Defaults to :modal if the widget doesn't implement container/0.

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

widget_render(widget_id, myself)

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

Render a widget by id, via the discovered entry module.

myself is passed through opaquely — see Planck.Agent.Widget.render/1.

:rpc.call(sidecar_node, Planck.Agent.Sidecar, :widget_render,
          [widget_id, myself])