defmodule Pi.Plugin.Worker do
@moduledoc "Isolated GenServer wrapper for one project-local pi_bridge plugin."
use GenServer
alias Pi.Plugin.API
alias Pi.Plugin.Command
@enforce_keys [:module]
defstruct [:module, state: %{}]
@type t :: %__MODULE__{module: module(), state: term()}
def child_spec(module) when is_atom(module) do
%{
id: {__MODULE__, module},
start: {__MODULE__, :start_link, [module]},
restart: :temporary,
type: :worker
}
end
def start_link(module) when is_atom(module) do
GenServer.start_link(__MODULE__, module)
end
def dispatch_event(pid, event) when is_pid(pid) and is_map(event) do
GenServer.cast(pid, {:event, event})
end
def apis(pid) when is_pid(pid), do: GenServer.call(pid, :apis)
def commands(pid) when is_pid(pid), do: GenServer.call(pid, :commands)
def run_command(pid, name, args) when is_pid(pid) and is_atom(name) and is_binary(args) do
GenServer.call(pid, {:command, name, args}, :infinity)
end
def tool_call(pid, call, context) when is_pid(pid) and is_map(call) and is_map(context) do
GenServer.call(pid, {:tool_call, call, context})
end
def tool_result(pid, result, context) when is_pid(pid) and is_map(result) and is_map(context) do
GenServer.call(pid, {:tool_result, result, context})
end
def info(pid) when is_pid(pid), do: GenServer.call(pid, :info)
def shutdown(pid) when is_pid(pid), do: GenServer.call(pid, :shutdown)
@impl true
def init(module), do: {:ok, %__MODULE__{module: module, state: init_plugin(module)}}
@impl true
def handle_cast({:event, event}, %__MODULE__{module: module, state: state} = plugin) do
{:noreply, %{plugin | state: handle_plugin_event(module, event, state)}}
end
@impl true
def handle_call(:apis, _from, %__MODULE__{module: module} = plugin) do
{:reply, plugin_apis(module), plugin}
end
def handle_call(:commands, _from, %__MODULE__{module: module} = plugin) do
{:reply, plugin_commands(module), plugin}
end
def handle_call(
{:command, name, args},
_from,
%__MODULE__{module: module, state: state} = plugin
) do
{reply, next_state} = handle_plugin_command(module, name, args, state)
{:reply, reply, %{plugin | state: next_state}}
end
def handle_call(
{:tool_call, call, context},
_from,
%__MODULE__{module: module, state: state} = plugin
) do
{reply, next_state} = handle_plugin_tool_call(module, call, context, state)
{:reply, reply, %{plugin | state: next_state}}
end
def handle_call(
{:tool_result, result, context},
_from,
%__MODULE__{module: module, state: state} = plugin
) do
{reply, next_state} = handle_plugin_tool_result(module, result, context, state)
{:reply, reply, %{plugin | state: next_state}}
end
def handle_call(:info, _from, %__MODULE__{module: module} = plugin) do
{:reply, {module, module_name(module)}, plugin}
end
def handle_call(:shutdown, _from, %__MODULE__{module: module, state: state} = plugin) do
{:reply, shutdown_plugin(module, state), plugin}
end
defp init_plugin(module) do
if function_exported?(module, :init, 1) do
normalize_init(module.init([]))
else
%{}
end
end
defp normalize_init({:ok, state}), do: state
defp normalize_init({:error, _reason}), do: %{}
defp normalize_init(state), do: state
defp handle_plugin_event(module, event, state) do
if function_exported?(module, :handle_event, 2) do
case module.handle_event(event, state) do
{:noreply, next_state} -> next_state
next_state -> next_state
end
else
state
end
rescue
_exception in [ArgumentError, FunctionClauseError, KeyError, MatchError, RuntimeError] ->
state
end
defp shutdown_plugin(module, state) do
if function_exported?(module, :shutdown, 1) do
module.shutdown(state)
else
:ok
end
rescue
_exception in [ArgumentError, FunctionClauseError, KeyError, MatchError, RuntimeError] -> :ok
end
defp handle_plugin_command(module, name, args, state) do
if function_exported?(module, :handle_command, 3) do
normalize_stateful_result(module.handle_command(name, args, state), state)
else
{{:error, "Unknown plugin command"}, state}
end
rescue
exception in [ArgumentError, FunctionClauseError, KeyError, MatchError, RuntimeError] ->
{{:error, Exception.message(exception)}, state}
end
defp handle_plugin_tool_call(module, call, context, state) do
if function_exported?(module, :tool_call, 3) do
normalize_stateful_result(module.tool_call(call, context, state), state)
else
{:ok, state}
end
rescue
exception in [ArgumentError, FunctionClauseError, KeyError, MatchError, RuntimeError] ->
{{:block, Exception.message(exception)}, state}
end
defp handle_plugin_tool_result(module, result, context, state) do
if function_exported?(module, :tool_result, 3) do
normalize_stateful_result(module.tool_result(result, context, state), state)
else
{:ok, state}
end
rescue
_exception in [ArgumentError, FunctionClauseError, KeyError, MatchError, RuntimeError] ->
{:ok, state}
end
defp normalize_stateful_result({reply, next_state}, _state), do: {reply, next_state}
defp normalize_stateful_result(reply, state), do: {reply, state}
defp plugin_apis(module) do
if function_exported?(module, :apis, 0) do
module.apis()
|> List.wrap()
|> normalize_apis()
else
[]
end
end
defp plugin_commands(module) do
if function_exported?(module, :commands, 0) do
module.commands()
|> List.wrap()
|> normalize_commands(module)
else
[]
end
end
defp normalize_apis([{key, _value} | _rest] = api) when is_atom(key), do: [API.new(api)]
defp normalize_apis(apis), do: Enum.map(apis, &API.new/1)
defp normalize_commands([{key, _value} | _rest] = command, module) when is_atom(key) do
[command |> Map.new() |> Map.put_new(:plugin, module) |> Command.new()]
end
defp normalize_commands(commands, module) do
Enum.map(commands, fn command ->
command
|> Command.new()
|> Map.update!(:plugin, &(&1 || module))
end)
end
defp module_name(module), do: module |> Module.split() |> List.last()
end