Changelog

Copy Markdown

v0.10.0

Headlined by a pending-message queue: a user can now type while the agent is working without their message being lost, and a tool can hand the model instructions as a real user turn rather than as tool-result data.

The rest is a correctness pass on sub-agents. Interrupts raised inside a sub-agent now behave the way their type says they should: a :halt reaches the parent as a halt, a tool-raised interrupt fails cleanly instead of crashing the caller, and sub-agents no longer receive an ask_user tool no one can answer. Plus two new hooks for host applications: a middleware callback for shaping display messages, and a way for a mode to report why it paused.

No breaking API changes. No function changed arity or return type, and no migration is required. There are four intentional behavior changes worth knowing about before you upgrade, described below.

Upgrading from v0.9.0 to v0.10.0

  • AgentServer.add_message/2 no longer returns an error while a run is in flight. It used to reply {:error, "Cannot execute, server is in state: running"} and then discard the message anyway; it now queues the message and returns :ok. The arity and the :ok | {:error, term()} type are unchanged, so the compiler will not flag this. Host code that matched that error tuple to render an "agent is busy" notice should drop the branch and instead subscribe to {:agent, {:message_queued, %Message{}}} if it wants to show queued state. #152
  • Sub-agents no longer inherit Sagents.Middleware.AskUserQuestion. If a sub-agent genuinely needs it, name it in that sub-agent's own :middleware list on its SubAgent.Config. Explicit configuration still receives it. Inherited ask_user calls previously produced an empty approval prompt rather than a visible question, so there is little working behavior to preserve. #151
  • A sub-agent :halt now reaches the parent as :halt, not as %{type: :subagent_hitl}. Parent code matching on :subagent_hitl to catch halts needs to match :halt instead. #150
  • A failed until_tool termination now returns the tool's own error content as {:error, content} instead of a generic extraction failure. #143

Added

  • Pending-message queue on AgentServer. A single-slot queue on ServerState holds a message that arrives mid-run and delivers it as an ordinary :user message at the head of a follow-up run, so nothing downstream has to know where it came from. Two doors into it: #152
    • add_message/3 (the human door) queues instead of erroring when the server is :running. Non-:user roles are still rejected. Two messages queued during one run merge their content parts into one turn.
    • queue_message_from_tool/3 (the tool door) lets a tool hand the model a playbook or slash-command body as instruction rather than as tool-result data. It is a cast so a tool cannot deadlock against a concurrent :cancel, and returns {:error, :no_server} under a bare Agent.execute/3 or inside a sub-agent so callers can take a fallback path.
    • A :display option on both doors splits the transcript half from the model-visible half. :none is model-visible and transcript-invisible; an explicit %LangChain.Message{} supplies both halves, which may differ in role. Resolution happens at queue time, so a user sees their own words immediately rather than a turn later.
    • Drain policy is explicit per terminal clause: {:ok, _} drains and starts a follow-up run, while {:interrupt, _, _}, {:pause, _} and {:error, _} hold. The drained branch deliberately does not broadcast {:status_changed, :idle, nil}, so a UI never flickers "done" between the two runs.
    • A circuit breaker caps the framework at 10 consecutive self-started runs. The counter resets on the human door and never on the tool door. This is distinct from :max_runs, which counts LLM calls within one execution and resets on every fresh chain. A tripped breaker still appends the message; it only declines to start another run.
    • pending_message is serialized alongside state and restored at boot, so a node dying mid-run does not lose the user's words. Payloads written before this release simply lack the key and read as "nothing queued".
    • New events: {:agent, {:message_queued, %Message{}}}, plus {:messages_drained, count}, {:pending_message_held, :error} and {:auto_execution_limit_reached, limit} on the debug channel.
    • Known scope limit: delivery is at the run boundary, not the turn boundary. A message typed twenty tool calls into a long job is late, not lost. Turn-boundary delivery is deliberately out of scope.
  • New optional middleware callback transform_display_message/2, the outbound mirror of MessagePreprocessor. Each middleware gets a chance to annotate metadata (or rewrite content) on a message before it is persisted as a display message and broadcast to subscribers. The message in agent state is left untouched, so the LLM never sees the annotation. Passthrough default; composes across the stack. #139
  • Pause cause on the :paused status event. A mode step may now return {:pause, chain, reason}; Sagents.Mode.Steps.normalize_pause/1 folds the reason into custom_context.pause_reason, the agent reads it onto the new virtual State.pause_reason field, and AgentServer broadcasts it as the payload of {:agent, {:status_changed, :paused, pause_reason}}, which was previously always nil. Agent.execute/3 still returns {:pause, state}, and a pause without a reason broadcasts nil as before. #149

Changed

  • AgentServer.add_message/2 returns :ok instead of an error when a run is in flight, because the message is now queued rather than rejected. See the Upgrading section. #152
  • langchain moves from 0.8.12 to 0.9.4 in mix.lock, along with transitive bumps to ecto, finch, mint, hpax, plug, plug_crypto and req. The mix.exs requirement is unchanged; >= 0.8.11 already allowed this. #152
  • Sagents.Middleware.AskUserQuestion is added to a new @never_inherited_middleware list alongside Sagents.Middleware.SubAgent, so neither is inherited by a sub-agent from its parent's stack. Explicitly configured additional_middleware is unaffected. #151
  • CI workflow dependency bumps: actions/checkout 6.0.2 → 7.0.0 (#132), actions/cache and its save/restore variants 5.0.5 → 6.1.0 (#136, #137, #138), and erlef/setup-beam 1.24.0 → 1.24.1 (#140).

Fixed

  • A user message added while the agent was :running is no longer silently destroyed. It was written into the rolling server state, then wiped moments later when handle_execution_result/2 replaced that state wholesale with the canonical state from Agent.execute/3, while the caller was told the server was busy. The message is now queued and delivered on the next run. #152
  • A :halt raised inside a sub-agent is propagated to the parent as a halt instead of being wrapped as :subagent_hitl. Previously the wrapper defeated every guarantee halt makes: the parent resumed and called the LLM again, the interrupt was not restorable across a cold start, and the author's message was never shown, so the user saw an empty approval dialog. The sub-agent process is now stopped, and the propagated halt preserves :source_tool and adds :source_task. Applies to both the initial run and a post-approval resume. #150
  • SubAgent.extract_result/1 now reads the matched terminating tool's result on an until_tool run. Every until_tool termination ends on a tool-result message, which the previous ChainResult.to_string/1 path could not handle, so extraction failed on success and failure alike and the parent received a generic error. A tool result flagged is_error becomes {:error, content}; runs ending in assistant prose are unchanged. Fixes #141. #143
  • SubAgent.resume/3 no longer crashes with a KeyError when the sub-agent's interrupt was raised from inside a tool body. Such an interrupt carries neither :action_requests nor :hitl_tool_call_ids, and is not resumable through this path; it now returns {:error, {:unsupported_interrupt, :tool_raised}}, which Middleware.SubAgent.handle_resume/5 already turns into a clean error tool result for the parent. Fixes #142. #144
  • Creating a file with empty or whitespace-only content no longer discards it. Ecto's default :empty_values treated "", "\n", " ", and "\t" as absent and replaced them with nil while still reporting a successful write. FileEntry.internal_changeset/2 now passes empty_values: [] so content is stored verbatim. Only the first write to a path was affected; overwrites bypass the changeset and always worked. #147

v0.9.0

Reworks the optional :horde distribution backend so cluster membership is correct, dynamic, and scopable. Full write-up in #134.

members: :participation — dynamic, role-scoped membership

config :sagents, :distribution, :horde
config :sagents, :horde, members: :participation

Membership becomes exactly the nodes that run Sagents.Supervisor, discovered via an OTP :pg group and kept current on :nodeup/:nodedown by the new Sagents.Horde.MembershipManager. Gate Sagents.Supervisor to your agent-hosting role(s) and membership follows automatically — no node-name predicate, and dead nodes are pruned for free. Prefer this over :auto whenever the Erlang cluster also contains nodes that should not host agents.

:partition — isolate participation into independent groups

config :sagents, :horde,
  members: :participation,
  partition: System.get_env("FLY_REGION")

An optional per-node :partition (any stable, opaque grouping key) scopes membership further so a node only clusters with same-partition nodes. The motivating case is geographic — set it to a Fly.io FLY_REGION so an agent for an Illinois user is never placed on, or routed through, a node in France — but it works for any per-node grouping. Cross-partition request routing remains an infra/app concern (e.g. Fly fly-replay). See docs/clustering.md.

Also in this release

See #134 for details on each:

  • members: :auto is now real — potential behaviour change. It previously froze to a one-time node snapshot; it now drives Horde's NodeListener for genuine dynamic membership with dead-node pruning. The undocumented static :members forms (list / function/0 / {m, f, a}) are removed; the only values are :auto (default) and :participation.
  • Registration-timeout resilienceAgentsDynamicSupervisor.start_agent_sync/1 retries on Horde's hardcoded-5s :via registration timeout, via new :registration_retries / :registration_retry_backoff options.
  • FileSystem distribution-safety — dropped Process.alive?/1 checks on potentially-remote pids that could raise.

v0.8.0

A large release that reworks the runtime foundations of the library: a new direct point-to-point event transport (replacing Phoenix.PubSub), session/factory lifecycle ownership moved into the library, interrupts that survive a process restart, a richer interrupt model (:halt, configurable ask_user), structured data extraction through the full middleware stack, cross-process caller-context propagation, and new tool-driven stop conditions.

This entry consolidates everything relevant to upgrading from the previous public release, v0.7.x. The v0.8.0 line went through 13 release candidates; several breaking changes were introduced and then superseded within the RC cycle and therefore do not affect anyone moving directly from v0.7.x to v0.8.0. For the complete, blow-by-blow history of every intermediate change, see the archived v0.8.0-rc.13 changelog.

Breaking changes — see the Upgrading section below.

Upgrading from v0.7.x to v0.8.0

The recommended path is to re-run the generators on a clean, committed workspace and merge your customizations back in, then apply a handful of host-code renames.

1. Regenerate scaffolding. Run mix sagents.setup (or the individual mix sagents.gen.* tasks) with the same options you used originally, accept the overwrites, and merge your customizations back with a diff tool. This absorbs the structural changes in one step: the new Session / Factory / FactoryRouter triad (replacing the old monolithic coordinator.ex + factory.ex), the new agent_subscriber_session.ex template, integer todo ids in valid_todo_entry?/1, the denormalized tool_call_id column in the persistence schema/context, and restorable-interrupt support. #97 #79 #116 #127 #96

2. Transport: Sagents.PubSub is removed. Replace any direct Sagents.PubSub.subscribe/1 / broadcast/2 calls with use Sagents.Subscriber plus the generated subscribe/2 helper, or pass :initial_subscribers when starting servers to enroll the caller inside init/1 and avoid the start/subscribe race. Existing handle_info/2 clauses keep matching — event payload shapes ({:agent, _}, {:file_system, _}, {:status_changed, _, _}, {:llm_deltas, _}, etc.) are unchanged. #79

3. SubAgent: subagent_typetask_name. The task and get_task_instructions tools now take task_name. Rename the key in any interrupt-data pattern match (%{type: :subagent_hitl, task_name: type, sub_agent_id: id}) and in any context.resume_info maps you build for sub-agent resume. Persisted v1 state is migrated to v2 automatically by StateSerializer. The available-tasks listing moved into an ## Available Tasks system-prompt section (suppressible via :include_task_list); update any custom prompts referencing the old wording. #78

4. Session API rename. Coordinator.ensure_session_running/1 is now ensure_agent_session_running/1 — update LiveViews, controllers, and tests. Factory helpers that were get_model/0 / get_middleware/0 become build_model/1 / build_middleware/1, branching on a %FactoryConfig{} struct. Per-request data (timezone, tool_context, project records) now flows through request_opts → FactoryRouter.resolve/3 → %FactoryConfig{} → Factory.create_agent/2 rather than being threaded as positional args. #97

5. Debug subscriptions. AgentServer.subscribe_debug/1 / unsubscribe_debug/1 are removed in favor of AgentServer.subscribe(agent_id, :debug) / unsubscribe(agent_id, :debug). The single-arg subscribe(agent_id) form is unchanged. #94

6. FileSystem: replace_file_lines removed. If your config, prompts, or evals reference it, either drop it (replace_file_text covers the same use cases for most agents) or re-add it as a project-local tool — the previous implementation lives in the #110 diff. Configs passing tools: / tool_descriptions: to Sagents.Middleware.FileSystem must remove the "replace_file_lines" entry. #110

7. Todo ids are integers. Host code calling Sagents.Todo.new/1, State.get_todo/2, or State.delete_todo/2 with string ids must switch to integers (Todo.new/1 now validates greater_than: 0). Code that builds todos from incoming maps should migrate to Sagents.Todo.list_from_maps/1, which assigns positional defaults for missing/non-numeric ids and coerces stringified integers. Persisted snapshots with legacy base64 string ids rehydrate to positional ids automatically on load. #116

8. Opt middleware into interrupt restoration (optional). Custom middleware that produce restorable, data-only interrupt_data should implement Sagents.Middleware.restorable_interrupt?/1 returning true for matching shapes. The default of false preserves the old safe demote-on-load behaviour with no code changes. Built-in AskUserQuestion and HumanInTheLoop already opt in; SubAgent deliberately does not. #96

Added

  • Direct-delivery transportSagents.Publisher / Sagents.Subscriber replace Phoenix.PubSub with monitored point-to-point delivery, an :initial_subscribers start option, and a Presence-based recovery loop for crash-restart and Horde migration. #79
  • Session/Factory lifecycle in the librarySagents.Session owns the session-start lifecycle (router consult, factory invocation, state seeding, supervisor wiring, subscribers) and is idempotent on resume. Sagents.Factory / Sagents.FactoryRouter behaviours, Sagents.Routers.Single for one-factory apps, and a typed %FactoryConfig{} for per-request data. #97
  • Restorable interrupts — an agent that shut down (inactivity timeout, deploy, crash) with a pending ask_user question or HITL approval now boots back into :interrupted status with the original interrupt_data intact, rather than silently demoting to an error. New optional Sagents.Middleware.restorable_interrupt?/1 callback, set_interrupted/3 persistence callback, and cheap pre-deserialization interrupted?/1 read. #96
  • :halt terminal interrupt via the new Sagents.Middleware.Haltable — tools can hard-stop a workflow (e.g. a gating validation tool) without giving the LLM a chance to continue. Includes AgentServer.dismiss_interrupt/1 for UIs to acknowledge a halt and a [:sagents, :agent, :halt] telemetry event. #115
  • Sagents.Extract — structured data extraction that flows through the agent's full middleware stack. The submit tool is owned by the agent and selected via the :until_tool / :until_tool_success stop condition; run/3 returns the tool's processed_content when present. #108 #129 #128
  • Sagents.AgentResult — read helpers for pulling tool results, arguments, processed content, or final text out of Agent.execute/3 return values. #107
  • :until_tool and :until_tool_success stop conditions on Sagents.Agent.execute/3 and Sagents.SubAgent — complete a run when a target tool is called (or, for :until_tool_success, returns a non-error result), enabling the validate-and-retry pattern. #128
  • Sagents.Middleware.ProcessContext — propagates caller-process state (OpenTelemetry trace context, Sentry context, request-scoped Logger metadata, tenant scope) across the three process boundaries an agent invocation crosses, via :keys and :propagators configuration shapes. #82
  • Sagents.StreamingSession — host-agnostic streaming helpers (handle_tool_call_identified/2, handle_tool_execution_update/3) returning changes maps the host merges itself, with multi-tool-safe delta semantics. #104
  • TodoList :inline mode — each successful write_todos additionally persists a todo_snapshot synthetic display message into the transcript. #101 #102
  • AskUserQuestion config pinning — optional allow_other / allow_cancel init options force those values for every question instead of leaving them to the LLM. #124
  • SubAgent :initial_messages for seeding per-call messages, and :include_task_list to opt out of the auto-generated task menu. #100 #78
  • Sagents.AgentServer.save_synthetic_message_from/2 — lets middleware persist user-facing transcript entries through the same display-message pipeline LLM messages use. AskUserQuestion records the user's answer this way. #88 #89
  • Sagents.State.runtime virtual field for process-local values that must never be persisted, with merge_runtime/2. #84
  • agent_id on tool execution context (context.agent_id) so tools can publish events without reaching into state. #86
  • Tooling hardening: Credo, Dialyzer, sobelow, and mix_audit wired into mix precommit and CI. #93 #90 #106

Changed

  • BREAKING: Transport, SubAgent tool arguments, session/factory API, debug subscriptions, the FileSystem tool set, and Sagents.Todo ids all changed — see the Upgrading section above. #79 #78 #97 #94 #110 #116
  • The generated persistence templates denormalize the tool-call linking id into a dedicated indexed tool_call_id column, switching the hot tool-execution queries from a JSONB fragment(...) to indexed equality. New generations are clean; existing host apps absorb this by regenerating as described above. #127
  • Sagents.Middleware documents the full interrupt-data catalog (:ask_user_question, :halt, :subagent_hitl, HITL action-request map, :multiple_interrupts) and the "halt wins" policy. #115
  • Upgraded to Elixir 1.20 and bumped the langchain dependency floor to >= 0.8.11. #122 #106

For the per-RC Added / Changed / Fixed detail behind this summary — including bug fixes resolved within the RC cycle — see the archived v0.8.0-rc.13 changelog.


Changelog entries for v0.1.0 through v0.7.0 have been removed to give the v0.8.0 line a clean slate. The full detailed history remains available in git — see the v0.8.0-rc.13 changelog, which retains every entry back to the initial release.