Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
f079714
Add Legion.Store conversation persistence with a built-in Postgres ad…
dimamik Jul 6, 2026
be6b873
Review feedback and minor flow improvements
dimamik Jul 9, 2026
328ef14
WiP
dimamik Jul 15, 2026
07e837d
Move handling AgentServer PID from Store to Registry
tom-ehh Jul 16, 2026
a6d2026
Resolve Credo style warnings
tom-ehh Jul 16, 2026
d361d1e
Modify Store contract - merge saves and loads
tom-ehh Jul 17, 2026
31dabdf
Finalize Store behavior: Snapshot -> Conversation; add Conversation s…
tom-ehh Jul 17, 2026
be36ac8
Adjust Postgres adapter to follow new Store contract
tom-ehh Jul 17, 2026
d109205
Wire new store contract into AgentServer
tom-ehh Jul 17, 2026
9103db6
Update store contract\nStore no longer uses specific column groups th…
tom-ehh Jul 21, 2026
9aa4605
Wire new store contract into AgentServer
tom-ehh Jul 21, 2026
4f3353f
Fix race condition in resume/2
tom-ehh Jul 21, 2026
f71080b
Add ability to store intra-turn updates
tom-ehh Jul 21, 2026
ed2f72a
Add parent_agent_id and started_at time to AgentRegistry
tom-ehh Jul 21, 2026
afcff80
Apply review suggestions and update docs
tom-ehh Jul 23, 2026
cee0be8
Move migrations closer to Oban style
tom-ehh Jul 23, 2026
1a2a24a
Add 'Migrating Without Ecto' section
tom-ehh Jul 23, 2026
4b08fab
Revise resume/2 wiring
tom-ehh Jul 29, 2026
84b4865
Add recovery mechanics
tom-ehh Jul 30, 2026
30f2203
Resolve merge conflicts
tom-ehh Jul 30, 2026
5fb7d14
Update docs for newly added recovery flow and consistency
tom-ehh Jul 30, 2026
0bbd68f
Resolve merge conflicts
tom-ehh Jul 31, 2026
8197af5
Resolve merge conflicts from other feature branch
tom-ehh Jul 31, 2026
a3f4847
Add token tracking to Executor and AgentServer
tom-ehh Jul 31, 2026
161da2d
Update tracking contract: token -> usage
tom-ehh Aug 2, 2026
e57cc8d
Address revive concerns from diff branch
tom-ehh Aug 3, 2026
8930fbe
Address review comments
tom-ehh Aug 4, 2026
582b857
Move from Registry to :global, refactor resume/2 and recover/2
tom-ehh Aug 4, 2026
4e218e2
Fix docs and test descriptions
tom-ehh Aug 5, 2026
109472b
Revise Legion startup (#14)
tom-ehh Aug 6, 2026
6d1c9d9
Merge branch 'feat/add-revive' into feat/add-usage-tracking
tom-ehh Aug 6, 2026
328d028
Resolve merge artefacts
tom-ehh Aug 6, 2026
9f57184
Switch usage tracking to string keyed map
tom-ehh Aug 10, 2026
9549186
Merge main
tom-ehh Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
- Propagate stores to sub-agents and persist `parent_agent_id`, `agent_module`, and `started_at` metadata for reconstructing conversation trees
- Add `Legion.Store.Postgres`, backed by an existing PostgreSQL Ecto repo, with partial upserts, `get/1`, `list/1`, configurable table names, configurable persistence frequency, and an optional `ecto_sql` dependency
- Add versioned, idempotent `Legion.Store.Migration.Postgres` migrations with configurable table names and `pg_notify` notifications for inserts and updates; migration versions are tracked in the agents table comment; generated stores expose `__repo__/0` and `__table__/0` for database-backed consumers such as LegionWeb
- Add configurable per-request LLM usage persistence, enabled by default and disabled globally with `config :legion, :track_usage, false`.; each usage map is recursively string-keyed and `Legion.Store.Postgres` stores them as jsonb[].
- Bump the default model from `openai:gpt-4o-mini` to `openai:gpt-5.4`
- `Legion.Tools.HumanTool.ask/1` now raises when called under `eval_and_complete` - the turn would end as soon as the code returns, silently discarding the human's answer; the error feeds back to the model, which retries under `eval_and_continue`

Expand Down
46 changes: 45 additions & 1 deletion lib/legion.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,53 @@ defmodule Legion do
|> String.split("<!-- MDOC -->")
|> Enum.fetch!(1)

use Supervisor

alias Legion.{AgentIndex, AgentServer}
alias Legion.Store.Payload

@doc """
Starts Legion's supervisor.

Add it to your application's supervision tree after dependencies required by
its configured recovery stores. For a Repo-backed store, place it after your
Repo.

## Examples

defmodule MyApp.Application do
use Application

def start(_type, _args) do
children = [
MyApp.Repo,
{Legion, []}
]

Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end
end
"""
def start_link(opts) when is_list(opts) do
Supervisor.start_link(__MODULE__, nil, name: __MODULE__)
end

@doc false
def child_spec(opts) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, [opts]},
type: :supervisor
}
end

@impl Supervisor
def init(_opts) do
children = [{Legion.Recovery, Application.fetch_env(:legion, :recovery)}]

Supervisor.init(children, strategy: :one_for_one, name: Legion.Supervisor)
end

@doc """
Runs an agent on a single task and returns the result.

Expand Down Expand Up @@ -46,7 +90,7 @@ defmodule Legion do
{:ok, pid} = Legion.start_link(ChatAgent, store: MyApp.AgentStore, agent_id: "user_42:chat_7")
{:ok, pid} = Legion.start_link(ChatAgent, agent_id: "user_42:chat_7") # store from app config
"""
def start_link(agent_module, opts \\ []) do
def start_link(agent_module, opts \\ []) when is_atom(agent_module) do
AgentServer.start_link(agent_module, opts)
end

Expand Down
51 changes: 37 additions & 14 deletions lib/legion/agent_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ defmodule Legion.AgentServer do
:store,
:agent_id,
:persistence_frequency,
:track_usage,
usage: nil,
executor_state: :nonexistent,
bindings: []
]
Expand Down Expand Up @@ -58,11 +60,12 @@ defmodule Legion.AgentServer do

agent_id = agent_id || generate_id()
persistence_frequency = Store.persistence_frequency(store)
track_usage = Application.get_env(:legion, :track_usage, true)

gen_opts = [name: Legion.AgentIndex.name(agent_id)]
config = resolve_config(agent_module, opts)

{{agent_module, config, store, agent_id, persistence_frequency}, gen_opts}
{{agent_module, config, store, agent_id, persistence_frequency, track_usage}, gen_opts}
end

def call(agent, message, timeout \\ :infinity) do
Expand All @@ -84,7 +87,7 @@ defmodule Legion.AgentServer do
# Server callbacks

@impl true
def init({agent_module, config, store, agent_id, persistence_frequency}) do
def init({agent_module, config, store, agent_id, persistence_frequency, track_usage}) do
parent_agent_id = Vault.get(:agent_id)
mode = Map.get(config, :start_mode, :normal)

Expand All @@ -104,20 +107,21 @@ defmodule Legion.AgentServer do
%{agent: agent_module}
)

{saved_messages, saved_bindings, saved_executor_state} =
{saved_messages, saved_bindings, saved_executor_state, saved_usage} =
case store && store.get(agent_id) do
{:ok,
%Payload{
conversation_state: %{
messages: messages,
bindings: bindings,
executor_state: executor_state
}
},
usage: usage
}} ->
{messages, bindings, executor_state}
{messages, bindings, executor_state, if(track_usage, do: usage || [], else: nil)}

_no_state ->
{[], [], :nonexistent}
{[], [], :nonexistent, if(track_usage, do: [], else: nil)}
end

state = %__MODULE__{
Expand All @@ -128,14 +132,17 @@ defmodule Legion.AgentServer do
agent_id: agent_id,
persistence_frequency: persistence_frequency,
bindings: saved_bindings,
executor_state: saved_executor_state
executor_state: saved_executor_state,
track_usage: track_usage,
usage: saved_usage
}

{:ok,
persist(state,
agent_module: state.agent_module,
parent_agent_id: parent_agent_id,
started_at: NaiveDateTime.utc_now()
started_at: NaiveDateTime.utc_now(),
usage: state.usage
), {:continue, %{start_mode: mode, executor_state: saved_executor_state}}}
end

Expand Down Expand Up @@ -229,7 +236,7 @@ defmodule Legion.AgentServer do

executor_config = Map.put(state.config, :checkpoint, checkpoint)

{status, value, final_messages, final_bindings} =
{status, value, final_messages, final_bindings, turn_usage} =
Telemetry.span(
[:legion, :agent, :message],
%{agent: state.agent_module, message: state.messages |> List.last() |> Map.get(:content)},
Expand All @@ -239,7 +246,7 @@ defmodule Legion.AgentServer do

initial_bindings = if conversation_scope?, do: state.bindings, else: []

{status, value, messages, bindings} =
{status, value, messages, bindings, _turn_usage} =
result =
Executor.run(
state.agent_module,
Expand All @@ -250,15 +257,31 @@ defmodule Legion.AgentServer do
)

iterations = Enum.count(messages, &(&1[:role] == "assistant")) - prev_count
{result, %{iterations: iterations, status: status, result: value, bindings: bindings}}

{result,
%{
iterations: iterations,
status: status,
result: value,
bindings: bindings
}}
end
)

kept_bindings = if conversation_scope?, do: final_bindings, else: []

usage = if state.track_usage, do: state.usage ++ turn_usage

state =
%{state | messages: final_messages, bindings: kept_bindings}
|> persist([:conversation_state, status: :idle])
%{
state
| messages: final_messages,
bindings: kept_bindings,
usage: usage
}

fields = [:conversation_state, status: :idle, usage: usage]
state = persist(state, fields)

{{status, value}, state}
end
Expand All @@ -279,7 +302,7 @@ defmodule Legion.AgentServer do
%{payload | conversation_state: persisted_conversation_state(checkpoint)}

{field, value}, payload
when field in [:agent_module, :parent_agent_id, :status, :started_at] ->
when field in [:agent_module, :parent_agent_id, :status, :started_at, :usage] ->
Map.put(payload, field, value)

unknown, _payload ->
Expand Down
Loading
Loading