Packages

AI agent framework for Elixir built on OTP. TEA-based agents with crash isolation, inter-agent messaging, team supervision, and real SSE streaming to Anthropic, OpenAI, Ollama, and more.

Current section

Files

Jump to
raxol_agent lib raxol agent native_harness.ex
Raw

lib/raxol/agent/native_harness.ex

defmodule Raxol.Agent.NativeHarness do
@moduledoc """
Behaviour for a native CLI harness: an external coding-agent CLI (Claude Code,
Cursor, ...) that owns its own agent loop and tool dispatch.
This is the "vendor owns the loop" half of the meta-harness. A backend built on
a native harness reports `handles_tools_internally?() == true`, so the
framework's ReAct loop does NOT drive tool calls; instead the CLI runs its own
loop and Raxol's tools are injected into it over MCP (see
`Raxol.Agent.Harness.McpToolConfig`).
A harness driver is a small, mostly-pure module that knows three things about
its CLI:
- `executable/0` -- the binary name to look up on `PATH`.
- `args/1` -- the argv (after the executable) to spawn one non-interactive run.
- `parse_line/1` -- turn one line of the CLI's stdout into normalized events.
The generic Port runtime (`Raxol.Agent.Backend.Native`) handles spawning,
line framing, streaming, and exit/timeout. Drivers stay protocol-only and are
trivially unit-testable.
## Normalized events
`parse_line/1` returns a (possibly empty) list of these tuples, in order:
- `{:text, binary}` -- assistant text to surface to the user.
- `{:reasoning, binary}` -- chain-of-thought / thinking text.
- `{:tool_call, map}` -- the CLI invoked a tool (observability only; the tool
itself is served by the injected MCP server, not by the framework).
- `{:done, %{content: binary, usage: map}}` -- the run finished; `content` is
the final answer, `usage` is token accounting (may be empty).
- `{:error, term}` -- the CLI reported an error.
"""
@type event ::
{:text, binary()}
| {:reasoning, binary()}
| {:tool_call, map()}
| {:done, %{content: binary(), usage: map()}}
| {:error, term()}
@typedoc """
Run configuration passed to `args/1`. Keys:
- `:prompt` -- the user prompt for this run.
- `:model` -- model id, or `nil` for the CLI default.
- `:system_prompt` -- optional system/append prompt.
- `:mcp_config_path` -- path to the MCP config file injecting Raxol tools, or
`nil` when no tools are exposed.
- `:cwd` -- working directory.
- `:extra_args` -- raw argv appended verbatim.
"""
@type run_config :: %{
optional(:prompt) => binary(),
optional(:model) => binary() | nil,
optional(:system_prompt) => binary() | nil,
optional(:mcp_config_path) => Path.t() | nil,
optional(:cwd) => Path.t() | nil,
optional(:extra_args) => [binary()]
}
@doc "The CLI binary name, looked up on `PATH`."
@callback executable() :: String.t()
@doc "The argv (after the executable) for one non-interactive run."
@callback args(run_config()) :: [String.t()]
@doc "Parse one stdout line into zero or more normalized events."
@callback parse_line(line :: String.t()) :: [event()]
@doc "Human-readable harness name."
@callback name() :: String.t()
@doc """
Whether this harness exposes Raxol tools to the CLI over MCP.
When `true` (the default for tool-using harnesses), the runtime builds an MCP
config from the agent's Actions and passes its path in `run_config.mcp_config_path`.
"""
@callback injects_mcp_tools?() :: boolean()
@optional_callbacks injects_mcp_tools?: 0
@doc "Whether the CLI for `driver` is available on this machine."
@spec available?(module()) :: boolean()
def available?(driver) when is_atom(driver) do
not is_nil(System.find_executable(driver.executable()))
end
@doc "Query `injects_mcp_tools?/0`, defaulting to `true`."
@spec injects_mcp_tools?(module()) :: boolean()
def injects_mcp_tools?(driver) when is_atom(driver) do
if function_exported?(driver, :injects_mcp_tools?, 0) do
driver.injects_mcp_tools?()
else
true
end
end
end