Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
- Add `Legion.Store` for persisting conversations across process and application restarts; stores exchange partial `Legion.Store.Payload` values containing conversation state and metadata through `get/1` and `save/1`
- Persist the user message with `status: :running` before execution and the final conversation with `status: :idle` before replying, so a reply is a commit receipt for the completed turn
- Add optional `persistence_frequency/0`; stores default to `:turn`, while `:step` also checkpoints intermediate eval results, recoverable errors, bindings, and executor progress
- Add configurable, generated agent ids, `Legion.get_agent_id/1`, `Legion.lookup/1`, and `Legion.resume/2` for identifying, finding, and restarting persisted conversations
- Add configurable, generated UTF-8 string agent ids, `Legion.get_agent_id/1`, `Legion.lookup/1`, and `Legion.resume/2` for identifying, finding, and restarting persisted conversations
- Register live agents cluster-wide by agent id through `:global`, preventing duplicate ownership across connected nodes
- Remove the `:name` option from `Legion.start_link/2`; use `:agent_id` for identity and `Legion.lookup/1` to resolve a live process
- Add `Legion.recover/2` and opt-in `:recovery` startup configuration to automatically drive interrupted root-agent runs (`status: :running`) to completion after an application restart. Recovery skips sub-agents to avoid replaying delegated work.
Expand Down
23 changes: 21 additions & 2 deletions lib/legion.ex
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ defmodule Legion do

## Options
- `:store`, `:agent_id` - persist the conversation across restarts; see `Legion.Store`.
When supplied, `:agent_id` must be a valid UTF-8 string.
A store set globally with `config :legion, :store, MyApp.AgentStore` applies to
every agent, so you need only pass `:agent_id`. If a store is in effect but no
`:agent_id` is given, Legion generates one - read it back with `get_agent_id/1`.
Expand Down Expand Up @@ -133,8 +134,8 @@ defmodule Legion do
end

@doc """
Returns the id of a running agent. Always set - Legion generates one when
none is passed.
Returns the valid UTF-8 string id of a running agent. Always set - Legion
generates one when none is passed.

When a store is configured but you let Legion generate the id, capture it
with this to resume the same conversation on a later start.
Expand Down Expand Up @@ -180,6 +181,8 @@ defmodule Legion do
@doc """
Looks up the live process for an agent id.

`agent_id` must be a valid UTF-8 string. Raises `ArgumentError` otherwise.

Uses Legion's cluster-wide runtime index as the source of truth for the
`agent_id -> pid` mapping. Returns `{:ok, pid}` when the agent is currently
registered on any connected node, or `:error` when no live process owns
Expand All @@ -191,6 +194,10 @@ defmodule Legion do
:error = Legion.lookup("missing_agent_id")
"""
def lookup(agent_id) do
if not is_binary(agent_id) or not String.valid?(agent_id) do
raise ArgumentError, ":agent_id must be a valid UTF-8 string, got: #{inspect(agent_id)}"
end

case AgentIndex.lookup(agent_id) do
{:ok, pid} -> {:ok, pid}
_ -> :error
Expand All @@ -200,6 +207,8 @@ defmodule Legion do
@doc """
Resumes a persisted conversation.

`agent_id` must be a valid UTF-8 string. Raises `ArgumentError` otherwise.

Loads the persisted conversation with `get/1` to determine its agent module,
then atomically starts it under the same `agent_id`. If a live process already
owns that ID, returns the existing pid instead. A new process restores the
Expand Down Expand Up @@ -227,6 +236,10 @@ defmodule Legion do
Legion.resume("missing_chat", store: MyApp.AgentStore)
"""
def resume(agent_id, opts \\ []) do
if not is_binary(agent_id) or not String.valid?(agent_id) do
raise ArgumentError, ":agent_id must be a valid UTF-8 string, got: #{inspect(agent_id)}"
end

store = store!(opts, :resume)

case store.get(agent_id) do
Expand All @@ -248,6 +261,8 @@ defmodule Legion do
@doc """
Recovers an interrupted persisted run and waits for it to finish.

`agent_id` must be a valid UTF-8 string. Raises `ArgumentError` otherwise.

An interrupted run is signaled by a stored payload with `status: :running`. In addition
only runs without `parent_agent_id` are recoverable, since sub-agents are not restarted. Unlike
`resume/2`, a live agent is not returned.
Expand Down Expand Up @@ -277,6 +292,10 @@ defmodule Legion do
Legion.recover("completed_chat", store: MyApp.AgentStore)
"""
def recover(agent_id, opts \\ []) do
if not is_binary(agent_id) or not String.valid?(agent_id) do
raise ArgumentError, ":agent_id must be a valid UTF-8 string, got: #{inspect(agent_id)}"
end

store = store!(opts, :recover)

case store.get(agent_id) do
Expand Down
13 changes: 12 additions & 1 deletion lib/legion/agent_server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,18 @@ defmodule Legion.AgentServer do
":agent_id requires a :store - pass one or set `config :legion, :store, MyStore`"
end

agent_id = agent_id || generate_id()
agent_id =
cond do
is_nil(agent_id) ->
generate_id()

is_binary(agent_id) and String.valid?(agent_id) ->
agent_id

true ->
raise ArgumentError, ":agent_id must be a valid UTF-8 string, got: #{inspect(agent_id)}"
end

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

Expand Down
2 changes: 1 addition & 1 deletion lib/legion/eval_guard.ex
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ defmodule Legion.EvalGuard do

alias Legion.Telemetry

@type context :: %{agent: module(), agent_id: term(), tools: [module()]}
@type context :: %{agent: module(), agent_id: Legion.Store.agent_id(), tools: [module()]}

@callback check(code :: String.t(), context :: context()) :: :allow | {:deny, String.t()}

Expand Down
9 changes: 5 additions & 4 deletions lib/legion/store.ex
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,8 @@ defmodule Legion.Store do

`:agent_id` is the key a conversation is saved under - it names one
conversation, not one user. A chat app with many chats per user keys by the
chat; compose the id however you like, since Legion treats it as opaque:
chat. Agent ids must be valid UTF-8 strings, but Legion otherwise treats their
contents as opaque, so compose them however you like:

Legion.start_link(ChatAgent, agent_id: "user_42:chat_7")

Expand Down Expand Up @@ -114,16 +115,16 @@ defmodule Legion.Store do

## Reading conversations

`get/1` returns the persisted conversation for one `agent_id`, or `:error`
when the store has no row for that id.
`get/1` receives a valid UTF-8 `agent_id` and returns its persisted
conversation, or `:error` when the store has no row for that id.

`list/1` returns persisted conversations newest first for consumers that
rebuild a view of past conversations from the store alone.
"""

alias Legion.Store.Payload

@type agent_id :: term()
@type agent_id :: String.t()
@type status :: Payload.status()
@type payload :: Payload.t()
@type persistence_frequency :: :turn | :step
Expand Down
27 changes: 23 additions & 4 deletions lib/legion/store/postgres.ex
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ defmodule Legion.Store.Postgres do
persistence_frequency: :step
end

Agent ids must be strings. Snapshots are stored as compressed
Agent ids must be valid UTF-8 strings. Snapshots are stored as compressed
`:erlang.term_to_binary/2` blobs - readable only from Elixir, one row
per conversation, upserted on every save. Step snapshots therefore require
no additional migration.
Expand Down Expand Up @@ -102,7 +102,18 @@ defmodule Legion.Store.Postgres do
def persistence_frequency, do: unquote(persistence_frequency)

@impl Legion.Store
def get(agent_id) when is_binary(agent_id) do
def get(agent_id) when not is_binary(agent_id), do: :error
Comment thread
dimamik marked this conversation as resolved.

@impl Legion.Store
def get(agent_id) do
if String.valid?(agent_id) do
do_get(agent_id)
else
:error
end
end

defp do_get(agent_id) do
case unquote(repo).get(Record, agent_id) do
nil -> :error
record -> {:ok, Postgres.decode_record(record)}
Expand All @@ -119,10 +130,18 @@ defmodule Legion.Store.Postgres do
def save(map) when is_map(map) and not is_struct(map), do: :error

@impl Legion.Store
def save(%Payload{agent_id: nil}), do: :error
def save(%Payload{agent_id: agent_id}) when not is_binary(agent_id), do: :error
Comment thread
dimamik marked this conversation as resolved.

@impl Legion.Store
def save(%Payload{} = payload) do
def save(%Payload{agent_id: agent_id} = payload) do
if String.valid?(agent_id) do
do_save(payload)
else
:error
end
end

defp do_save(payload) do
attrs =
payload
|> Postgres.encode_data()
Expand Down
16 changes: 8 additions & 8 deletions lib/legion/telemetry.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,18 +8,18 @@ defmodule Legion.Telemetry do

- `[:legion, :agent, :started]` — agent process finished `init/1`
- Measurements: `%{system_time: NaiveDateTime.t()}`
- Metadata: `%{agent: module, agent_id: term, parent_agent_id: term}`
- Metadata: `%{agent: module, agent_id: String.t()}` (plus `parent_agent_id: String.t()`
when the agent was started inside another agent's run)
- `agent_id` names the conversation — stable across restarts, so a resumed
conversation emits under the same id.
- `parent_agent_id` is only present when the agent was started inside another
agent's run. Not emitted if `init/1` itself crashes (e.g. while building
the system prompt) — in that case `:stopped` is not emitted either,
since GenServer does not call `terminate/2` on init failure.
- Not emitted if `init/1` itself crashes (e.g. while building the system prompt) —
in that case `:stopped` is not emitted either, since GenServer does not call `terminate/2`
on init failure.

- `[:legion, :agent, :stopped]` — agent process terminated via `terminate/2`
- Measurements: `%{system_time: NaiveDateTime.t()}`
- Metadata: `%{agent: module, agent_id: term}` (plus `parent_agent_id`
when the parent's run is still on the process Vault)
- Metadata: `%{agent: module, agent_id: String.t()}` (plus `parent_agent_id: String.t()`
when the agent was started inside another agent's run)

## Agent Message Events (spans)

Expand Down Expand Up @@ -50,7 +50,7 @@ defmodule Legion.Telemetry do
## Eval Guard Events

- `[:legion, :eval_guard, :denied]` — a guard refused generated code
- Metadata: `%{agent: module, agent_id: term, guard: module, code: String.t(), reason: String.t()}`
- Metadata: `%{agent: module, agent_id: String.t(), guard: module, code: String.t(), reason: String.t()}`

## Default Logger

Expand Down
25 changes: 25 additions & 0 deletions test/legion/agent_server_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -965,6 +965,7 @@ defmodule Legion.AgentServerTest do
agent_id = Legion.get_agent_id(pid)

assert is_binary(agent_id)
assert String.valid?(agent_id)
{:ok, _} = Legion.call(pid, "What is the capital of France?")
assert {:ok, _snapshot} = MemoryStore.load(agent_id)
end
Expand Down Expand Up @@ -994,6 +995,30 @@ defmodule Legion.AgentServerTest do
end
end

test "rejects non-string explicit agent IDs" do
for agent_id <- [:agent, make_ref(), <<0xFF>>] do
assert_raise ArgumentError, ~r/:agent_id must be a valid UTF-8 string/, fn ->
Legion.start_link(MathAgent, store: MemoryStore, agent_id: agent_id)
end
end
end

test "public identity operations reject invalid non-string agent IDs" do
non_binary_agent_id = :agent
non_utf8_agent_id = <<0xFF>>

for operation <- [
fn -> Legion.lookup(non_binary_agent_id) end,
fn -> Legion.resume(non_binary_agent_id, store: MemoryStore) end,
fn -> Legion.recover(non_binary_agent_id, store: MemoryStore) end,
fn -> Legion.lookup(non_utf8_agent_id) end,
fn -> Legion.resume(non_utf8_agent_id, store: MemoryStore) end,
fn -> Legion.recover(non_utf8_agent_id, store: MemoryStore) end
] do
assert_raise ArgumentError, ~r/:agent_id must be a valid UTF-8 string/, operation
end
end

test "records run metadata on start" do
{:ok, _pid} = Legion.start_link(MathAgent, store: MemoryStore, agent_id: "meta")

Expand Down
12 changes: 12 additions & 0 deletions test/legion/store/postgres_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,16 @@ defmodule Legion.Store.PostgresTest do
assert :error = Store.save(%{agent_id: "user_42", unexpected: "value"})
assert FakeRepo.run("user_42") == nil
end

test "save/1 rejects a payload with non-string agent_id without inserting a row" do
assert :error = Store.save(%Payload{agent_id: 42})
assert FakeRepo.run(42) == nil
assert :error = Store.save(%Payload{agent_id: <<0xFF>>})
assert FakeRepo.run(<<0xFF>>) == nil
end

test "get/1 rejects a non-string agent_id" do
assert :error = Store.get(42)
assert :error = Store.get(<<0xFF>>)
end
end
Loading