LemonGateway.Engine behaviour (lemon_gateway v0.1.0)

View Source

Behaviour for AI engine plugins.

An engine wraps an AI backend (CLI tool, API, or native integration) and provides a uniform interface for starting runs, streaming output, cancellation, session resumption, and mid-run steering.

Implementing an Engine

defmodule MyEngine do
  @behaviour LemonGateway.Engine

  @impl true
  def id, do: "myengine"

  @impl true
  def start_run(job, opts, sink_pid) do
    # Start the AI run, send events to sink_pid
    {:ok, make_ref(), cancel_context}
  end

  # ... implement remaining callbacks
end

Event Protocol

Engines send events to sink_pid as {:engine_event, run_ref, event} messages where event is a plain tagged map built via Event.started/1, Event.action_event/1, or Event.completed/1. Streaming text is sent as {:engine_delta, run_ref, text}.

Summary

Callbacks

Stop a run, given the cancel_ctx that start_run/3 returned.

Inject text into a run that is already in flight.

Whether this engine accepts mid-run steering.

Types

run_opts()

@type run_opts() :: %{
  optional(:cwd) => String.t(),
  optional(:env) => %{required(String.t()) => String.t()},
  optional(:timeout_ms) => non_neg_integer(),
  optional(:capabilities) => map()
}

Callbacks

cancel(cancel_ctx)

@callback cancel(cancel_ctx :: term()) :: :ok

Stop a run, given the cancel_ctx that start_run/3 returned.

Must return :ok for any term, including a context this engine never produced, and must be idempotent — cancelling twice, or cancelling a run that already completed, is still :ok.

That is stricter than it looks like it needs to be, and it is deliberate: the gateway calls cancel/1 from supervisors and timeout paths, after crashes and across restarts, where the context it holds may be stale or partial. An engine that pattern-matches only its own happy-path context turns a routine cancellation into a FunctionClauseError in the caller. End your clauses with a catch-all:

def cancel(%{task_pid: pid}) when is_pid(pid) do
  Process.exit(pid, :kill)
  :ok
end

def cancel(_ctx), do: :ok

extract_resume(t)

@callback extract_resume(String.t()) :: LemonCore.ResumeToken.t() | nil

format_resume(t)

@callback format_resume(LemonCore.ResumeToken.t()) :: String.t()

id()

@callback id() :: String.t()

is_resume_line(t)

@callback is_resume_line(String.t()) :: boolean()

start_run(job, opts, sink_pid)

@callback start_run(
  job :: LemonGateway.Types.Job.t(),
  opts :: run_opts(),
  sink_pid :: pid()
) ::
  {:ok, run_ref :: reference(), cancel_ctx :: term()} | {:error, term()}

steer(cancel_ctx, text)

(optional)
@callback steer(cancel_ctx :: term(), text :: String.t()) :: :ok | {:error, term()}

Inject text into a run that is already in flight.

Optional, and only called when supports_steer?/0 answers true. Receives the same cancel_ctx as cancel/1 and, like it, should report a context it cannot use as {:error, reason} rather than raising.

supports_steer?()

@callback supports_steer?() :: boolean()

Whether this engine accepts mid-run steering.

Must be pure and stable. Answering true obliges the engine to export steer/2; there is no way to declare that in the behaviour itself, since steer/2 has to stay optional for the engines that do not support it, so it is checked by LemonPlatformTest.EngineCase instead.