Current section

Files

Jump to
agentic lib agentic.ex
Raw

lib/agentic.ex

defmodule Agentic do
@moduledoc """
Agentic — A composable AI agent runtime for Elixir.
Provides a complete agent loop with skills, working memory, knowledge
persistence, and tool use. Drop it into any Elixir project to get a
fully functional AI agent.
## Quick Start
Agentic.run(
prompt: "Help me refactor this module",
workspace: "/path/to/workspace",
callbacks: %{
llm_chat: fn params -> MyLLM.chat(params) end
}
)
## Callbacks
The `callbacks` map connects Agentic to your LLM provider and external systems:
### Required
- `:llm_chat` - `(params) -> {:ok, response} | {:error, term}`
### Optional
- `:execute_tool` - custom tool handler (defaults to Agentic.Tools)
- `:on_event` - `(event, ctx) -> :ok` for UI streaming
- `:on_response_facts` - `(ctx, text) -> :ok` for custom fact processing
- `:on_tool_facts` - `(ws_id, name, result, turn) -> :ok`
- `:on_persist_turn` - `(ctx, text) -> :ok`
- `:get_tool_schema` - `(name) -> {:ok, schema} | {:error, reason}`
- `:get_secret` - `(service, key) -> {:ok, value} | {:error, reason}`
- `:knowledge_search` - `(query, opts) -> {:ok, entries} | {:error, term}`
- `:knowledge_create` - `(params) -> {:ok, entry} | {:error, term}`
- `:knowledge_recent` - `(scope_id) -> {:ok, entries} | {:error, term}`
- `:search_tools` - `(query, opts) -> [result]`
- `:execute_external_tool` - `(name, args, ctx) -> {:ok, result} | {:error, reason}`
"""
alias Agentic.Loop.Context
alias Agentic.Loop.Engine
alias Agentic.Loop.Profile
alias Agentic.ModelRouter
alias Agentic.Tools
alias Agentic.Tools.Activation
alias Agentic.Telemetry
require Logger
@doc """
Run the agent loop.
## Options
- `:prompt` — user prompt (required)
- `:workspace` — workspace directory path (required)
- `:callbacks` — map of callback functions (required, at minimum `:llm_chat`)
- `:system_prompt` — custom system prompt (optional, auto-assembled if omitted)
- `:history` — list of prior conversation messages (optional)
- `:profile` — loop profile (optional, default `:agentic`)
- `:mode` — execution mode `:agentic | :agentic_planned | :turn_by_turn | :conversational` (optional, overrides `:profile`)
- `:plan` — pre-built plan map for `:agentic_planned` mode, skips planning phase (optional)
- `:model_tier` — model tier for LLM calls (optional, default `:primary`)
- `:model_selection_mode``:manual` or `:auto` (optional, default `:manual`)
- `:model_preference``:optimize_price` or `:optimize_speed` (optional, default `:optimize_price`, only used in `:auto` mode)
- `:model_filter` — constrain model candidates: `:free_only` or `nil` (optional, only used in `:auto` mode)
- `:session_id` — for telemetry and event tracking (optional)
- `:user_id` — for API key resolution (optional)
- `:caller` — pid to receive events (optional, defaults to self())
- `:workspace_id` — workspace identifier for ContextKeeper (optional)
- `:cost_limit` — per-session cost limit in USD (optional, default 5.0)
- `:model_routes` — fallback model routes for routing (optional, e.g. `[primary: [...]]`)
- `:strategy` — orchestration strategy id or module (optional, default `:default`)
- `:strategy_opts` — extra opts passed to strategy `init/1` (optional)
Returns `{:ok, %{text: string, cost: float, tokens: integer, steps: integer}}` or `{:error, reason}`.
"""
def run(opts) do
strategy_mod = resolve_strategy(opts)
strategy_opts = Keyword.get(opts, :strategy_opts, [])
case strategy_mod.init(strategy_opts) do
{:ok, strategy_state} ->
run_with_strategy(strategy_mod, strategy_state, opts)
{:error, reason} ->
{:error, {:strategy_init, reason}}
end
end
defp run_with_strategy(mod, state, opts) do
case mod.prepare_run(opts, state) do
{:ok, prepared_opts, new_state} ->
prepared_opts = Keyword.put(prepared_opts, :strategy, mod.id())
case run_single(prepared_opts) do
{:ok, result} ->
handle_strategy_result(mod, {:ok, result}, prepared_opts, new_state)
{:error, reason} ->
mod.handle_result({:error, reason}, prepared_opts, new_state)
end
{:error, reason} ->
{:error, {:strategy_prepare, reason}}
end
end
defp handle_strategy_result(mod, result, opts, state) do
case mod.handle_result(result, opts, state) do
{:done, final_result, _final_state} ->
{:ok, final_result}
{:rerun, new_opts, new_state} ->
run_with_strategy(mod, new_state, new_opts)
{:ok, _new_state} ->
{:ok, elem(result, 1)}
{:error, reason} ->
{:error, reason}
end
end
defp resolve_strategy(opts) do
case Keyword.get(opts, :strategy) do
nil ->
Agentic.Strategy.Default
id when is_atom(id) ->
case Agentic.Strategy.Registry.fetch(id) do
nil -> id
mod -> mod
end
end
end
defp run_single(opts) do
prompt = Keyword.fetch!(opts, :prompt)
workspace = Keyword.fetch!(opts, :workspace)
callbacks = Keyword.fetch!(opts, :callbacks)
history = Keyword.get(opts, :history, [])
mode = Keyword.get(opts, :mode, :agentic)
profile_name = Keyword.get_lazy(opts, :profile, fn -> mode end)
model_tier = Keyword.get(opts, :model_tier, :primary)
model_selection_mode = Keyword.get(opts, :model_selection_mode, :manual)
model_preference = Keyword.get(opts, :model_preference, :optimize_price)
model_filter = Keyword.get(opts, :model_filter)
strategy = Keyword.get(opts, :strategy, :default)
session_id = Keyword.get(opts, :session_id, generate_session_id())
user_id = Keyword.get(opts, :user_id)
caller = Keyword.get(opts, :caller, self())
workspace_id = Keyword.get(opts, :workspace_id)
cost_limit = Keyword.get(opts, :cost_limit, 5.0)
prebuilt_plan = Keyword.get(opts, :plan)
tool_permissions = Keyword.get(opts, :tool_permissions, %{})
if model_routes = opts[:model_routes] do
Enum.each(model_routes, fn {tier, routes} ->
ModelRouter.set_routes(tier, routes)
end)
else
if tier_overrides = opts[:tier_overrides] do
ModelRouter.set_tier_overrides(tier_overrides)
end
end
system_prompt =
Keyword.get_lazy(opts, :system_prompt, fn ->
"You are a helpful AI assistant working in #{workspace}."
end)
messages =
[%{"role" => "system", "content" => system_prompt}] ++
history ++
[%{"role" => "user", "content" => prompt}]
callbacks =
Map.put_new(callbacks, :execute_tool, fn name, input, ctx ->
Tools.execute(name, input, ctx)
end)
callbacks =
if backend = opts[:transcript_backend] do
Map.put_new(callbacks, :transcript_backend, backend)
else
callbacks
end
core_tools = Tools.definitions()
config = Profile.config(profile_name)
config = Map.put(config, :session_cost_limit_usd, cost_limit)
initial_phase = Agentic.Loop.Phase.initial_phase(mode)
effective_phase =
if prebuilt_plan != nil and mode == :agentic_planned do
:execute
else
initial_phase
end
allowed_roots = Keyword.get(opts, :allowed_roots, [workspace])
backend_config = Keyword.get(opts, :backend_config, %{}) |> Map.put_new(:workspace, workspace)
ctx =
Context.new(
session_id: session_id,
user_id: user_id,
caller: caller,
profile: profile_name,
metadata: %{
workspace: workspace,
workspace_id: workspace_id,
allowed_roots: allowed_roots
},
messages: messages,
core_tools: core_tools,
tools: core_tools,
model_tier: model_tier,
model_selection_mode: model_selection_mode,
model_preference: model_preference,
model_filter: model_filter,
strategy: strategy,
config: config,
callbacks: callbacks,
backend_config: backend_config
)
ctx = %{ctx | mode: mode, phase: effective_phase, tool_permissions: tool_permissions}
ctx =
if prebuilt_plan != nil do
%{ctx | plan: prebuilt_plan}
else
ctx
end
ctx = Activation.init(ctx)
stages = Profile.stages(profile_name)
pipeline = Engine.build_pipeline(stages)
ctx = %{ctx | reentry_pipeline: pipeline}
Telemetry.event([:session, :start], %{}, %{
session_id: session_id,
mode: mode,
profile: profile_name,
strategy: strategy
})
session_start = System.monotonic_time()
result = Engine.run(ctx, stages)
session_duration = System.monotonic_time() - session_start
case result do
{:ok, res} ->
Telemetry.event([:session, :stop], Map.put(res, :duration, session_duration), %{
session_id: session_id,
mode: mode,
strategy: strategy
})
{:error, reason} ->
Telemetry.event([:session, :error], %{duration: session_duration}, %{
session_id: session_id,
mode: mode,
strategy: strategy,
error: inspect(reason)
})
end
result
end
@doc "Scaffold a new workspace directory with default identity files."
def new_workspace(path, opts \\ []) do
Agentic.Workspace.Service.create_workspace(path, opts)
end
@doc """
Resume a previous session from its transcript.
Loads the transcript for the given session, reconstructs the conversation
messages, and starts the pipeline from where it left off.
## Options
- `:session_id` — session to resume (required)
- `:workspace` — workspace directory path (required)
- `:callbacks` — map of callback functions (required, at minimum `:llm_chat`)
- `:transcript_backend` — module implementing `Agentic.Persistence.Transcript` (optional, defaults to `Transcript.Local`)
- All other options from `run/1` are supported and override transcript values.
"""
def resume(opts) do
session_id = Keyword.fetch!(opts, :session_id)
workspace = Keyword.fetch!(opts, :workspace)
callbacks = Keyword.fetch!(opts, :callbacks)
backend = Keyword.get(opts, :transcript_backend, Agentic.Persistence.Transcript.Local)
case backend.load(session_id, workspace: workspace) do
{:ok, events} when events != [] ->
{messages, turns_used, cost, tokens, plan} = reconstruct_from_events(events)
Telemetry.event([:session, :resume], %{}, %{
session_id: session_id,
turns_restored: turns_used
})
mode = Keyword.get(opts, :mode, :agentic)
profile_name = Keyword.get_lazy(opts, :profile, fn -> mode end)
model_tier = Keyword.get(opts, :model_tier, :primary)
model_selection_mode = Keyword.get(opts, :model_selection_mode, :manual)
model_preference = Keyword.get(opts, :model_preference, :optimize_price)
model_filter = Keyword.get(opts, :model_filter)
user_id = Keyword.get(opts, :user_id)
caller = Keyword.get(opts, :caller, self())
workspace_id = Keyword.get(opts, :workspace_id)
cost_limit = Keyword.get(opts, :cost_limit, 5.0)
_system_prompt =
Keyword.get_lazy(opts, :system_prompt, fn ->
"You are a helpful AI assistant working in #{workspace}."
end)
resume_msg = %{
"role" => "user",
"content" =>
"[System: This session was resumed from a previous conversation. " <>
"Continue from where you left off.]"
}
messages = messages ++ [resume_msg]
callbacks =
Map.put_new(callbacks, :execute_tool, fn name, input, ctx ->
Tools.execute(name, input, ctx)
end)
callbacks = Map.put_new(callbacks, :transcript_backend, backend)
core_tools = Tools.definitions()
config = Profile.config(profile_name)
config = Map.put(config, :session_cost_limit_usd, cost_limit)
initial_phase = Agentic.Loop.Phase.initial_phase(mode)
ctx =
Context.new(
session_id: session_id,
user_id: user_id,
caller: caller,
profile: profile_name,
metadata: %{workspace: workspace, workspace_id: workspace_id},
messages: messages,
core_tools: core_tools,
tools: core_tools,
model_tier: model_tier,
model_selection_mode: model_selection_mode,
model_preference: model_preference,
model_filter: model_filter,
config: config,
callbacks: callbacks
)
ctx = %{
ctx
| mode: mode,
phase: initial_phase,
turns_used: turns_used,
total_cost: cost,
total_tokens: tokens,
plan: plan
}
ctx = Activation.init(ctx)
stages = Profile.stages(profile_name)
pipeline = Engine.build_pipeline(stages)
ctx = %{ctx | reentry_pipeline: pipeline}
Engine.run(ctx, stages)
{:ok, []} ->
{:error, :empty_transcript}
{:error, :not_found} ->
{:error, :session_not_found}
{:error, reason} ->
{:error, reason}
end
end
defp reconstruct_from_events(events) do
{messages, turns, cost, tokens, plan} =
Enum.reduce(events, {[], 0, 0.0, 0, nil}, fn event, {msgs, turns, cost, tokens, plan} ->
case event["type"] do
"llm_response" ->
data = event["data"] || %{}
usage = data["usage"] || %{}
input_t = usage["input_tokens"] || 0
output_t = usage["output_tokens"] || 0
content =
if preview = data["content_preview"] do
[%{"type" => "text", "text" => preview}]
else
[]
end
assistant_msg = %{"role" => "assistant", "content" => content}
{msgs ++ [assistant_msg], max(turns, event["turn"] || 0),
cost + (data["cost"] || 0.0), tokens + input_t + output_t, plan}
"tool_call" ->
data = event["data"] || %{}
tool_call = %{
"type" => "tool_use",
"id" => data["id"],
"name" => data["name"],
"input" => data["input"] || %{}
}
last = List.last(msgs)
msgs =
if last && last["role"] == "assistant" && is_list(last["content"]) do
updated_content = last["content"] ++ [tool_call]
List.replace_at(msgs, length(msgs) - 1, %{last | "content" => updated_content})
else
msgs ++
[%{"role" => "assistant", "content" => [tool_call]}]
end
tool_result = %{
"type" => "tool_result",
"tool_use_id" => data["id"],
"content" => "[result from previous session]"
}
result_msg = %{"role" => "user", "content" => [tool_result]}
{msgs ++ [result_msg], turns, cost, tokens, plan}
"plan_snapshot" ->
plan = event["data"]["plan"]
{msgs, turns, cost, tokens, plan}
"phase_transition" ->
{msgs, turns, cost, tokens, plan}
_ ->
{msgs, turns, cost, tokens, plan}
end
end)
{messages, turns, cost, tokens, plan}
end
defp generate_session_id do
"agx-" <> Base.encode16(:crypto.strong_rand_bytes(8), case: :lower)
end
end