Changelog
Copy Markdownv0.11.0
An open ask_user question no longer disappears when the agent goes to sleep.
The interrupt was always durable — it is persisted with the state, and a fresh
boot rebuilds it and comes up :interrupted. The loss was in the host UI, which
treated "the process went away" as a status change and cleared the prompt. This
release separates the two facts hosts were conflating: agent_status is what the
conversation is waiting on, agent_alive? is whether a process is backing it
right now. Answering an interrupt now goes through Sagents.Session.resume/4,
which wakes a sleeping agent and hands it the answer at boot.
The same is true of any other restorable interrupt, including a :halt panel,
whose Dismiss button goes through the matching Sagents.Session.dismiss/3. If
your app renders a halt panel, read step 6 of the migration guide, since the
button has to be routed through the new path to work on a dormant conversation.
No compile-time breakage, and upgrading without touching host code is safe: everything behaves as v0.10.1 did, bug included. The fix is opt-in, because the affected modules were generated into your app. See MIGRATION_PROMPT_v0.10.x_TO_v0.11.0.md — it is written to be handed to a coding agent.
Full write-up in #159.
Added
Sagents.Session.resume/4— answer an interrupt, waking the agent if needed. #159Sagents.Session.dismiss/3— acknowledge a terminal:halt, waking the agent if needed. The mirror ofresume/4for the interrupt type that is dismissed rather than answered. A halt is restorable, so its panel now survives a nap, andAgentServer.dismiss_interrupt/1alone cannot clear one on a dormant conversation. An interrupt that needs a real response is passed through as an error rather than woken. The generated Coordinator gainsdismiss_agent_session/2and the generatedAgentLiveHelpersgainshandle_halt_dismissal/1. #159:pending_resume, a start option applied during boot before the initial status broadcast, so a woken agent announces:runningrather than an:interruptedsnapshot it is about to leave. #159Sagents.AgentUtils.shutdown_session_changes/2and theagent_alive?subscriber-state key. #159Sagents.State.interrupt_restorable?/2is now public — the authoritative predicate for whether an interrupt survives a cold boot. #159{:agent_shutdown, _}payloads carry:interrupt_restorable. #159Sagents.Session.start/3'ssession_infogained:started. #159
Changed
on_subscribed/3no longer fires for a pid already subscribed to that channel. It is a newly registered subscriber hook, and an already-registered pid has nothing to resync. If you relied on re-subscribing to force a refresh, useSagents.AgentServer.get_info/1. This is the only non-opt-in behaviour change in the release. #159- A
:haltpanel now survives a shutdown, for hosts that adoptAgentUtils.shutdown_session_changes/2.Haltable.restorable_interrupt?/1reports%{type: :halt}as restorable and the shutdown helper counts:pending_haltas a pending interrupt, so a halt is preserved on exactly the same terms as an open question. Route the panel's Dismiss action throughSession.dismiss/3:AgentServer.dismiss_interrupt/1talks to a live process and returns{:error, :agent_not_running}against a dormant one. See step 6 of the migration guide. Previously the generatedhandle_agent_shutdown/2cleared the halt on the way out, so this path was unreachable. #159 - All three
{:agent_shutdown, _}emit sites now send the same shape.terminate/2previously sent only%{reason:, status:}, omitting the agent id hosts need to correlate the event. Additive for subset map patterns. #159 - An empty
:multiple_interruptswrapper is no longer treated as restorable. #159
Fixed
- A process seeded via
:initial_subscribersthat then calledsubscribe/3received the boot status broadcast twice. This is the exact shapeSagents.Session.ensure_running/3produces. #159 - The generated
handle_agent_shutdown/2destroyed the agent subscription rather than letting presence-driven recovery restore it, and clearedagent_id, breakinghandle_conversation_title_generated/3. #159 - The generated interrupt handlers crashed on a duplicate question submission and could resume with a fabricated HITL decision on a duplicate approval. #159
- The generated
resume_or_flash/5built the log line and the user-facing flash from a singleerror_prefix, so an internal error term reached the end user verbatim (Failed to submit response: "Cannot resume, server is not interrupted"), in wording that says "agent". It now takes:log_labeland:user_messageseparately, and only the label is paired with the reason. #159 - The generated
AgentLiveHelpersexposes a single publicagent_request_opts/1instead of a private, resume-onlyresume_request_opts/1stub. Every path that can start an agent should read it, including the host's ownensure_agent_session_running/2call sites. Two copies of this decision drift silently, and the symptom is an agent configured differently only on the paths that had to wake it. #159
v0.10.2
Adds :suppress_debug_events on a sub-agent config. When set, that sub-agent
type publishes none of its events on the parent's :debug channel: not the
initial messages, not the inner LLM messages as they arrive, and not the full
inner chain that a failed or cancelled run would otherwise republish.
{Sagents.Middleware.SubAgent, [
model: model,
subagents: [
Sagents.SubAgent.Config.new!(%{
name: "pii-extractor",
description: "Extract structured fields from a sensitive document",
tools: [extract_tool],
suppress_debug_events: true
})
]
]}The parent still receives the run's outcome as the task tool result, so only
the observer fan-out is silenced. The setting is per sub-agent type and
all-or-nothing. It does not apply to the general-purpose sub-agent, which is
created dynamically and so has no config to carry the flag.
This covers the :debug channel only. If you are reaching for it to keep
sensitive content out of your observability stack, note that OpenTelemetry
content capture is configured separately and globally through
LangChain.OpenTelemetry.setup/1, and defaults to off.
Defaults to false. Additive only: nothing changed arity or return type, and no
migration is required.
Added
:suppress_debug_eventsonSagents.SubAgent.ConfigandSagents.SubAgent, silencing every event that sub-agent would publish on the parent's:debugchannel. #158
v0.10.1
Adds :otel_attributes, a flat map of your application's context (tenant, user,
feature) that lands on every OpenTelemetry span an agent produces: the
invoke_agent span, each chat span, and each execute_tool span, including
tools running in their own process.
{:ok, agent} =
Sagents.Agent.new(%{
model: model,
name: "support_agent",
otel_attributes: %{
"user.id" => current_user.id,
"organization.id" => org.id,
"myapp.plan" => org.plan
},
middleware: [...]
})No middleware, no callbacks, no OpenTelemetry knowledge. Additive only: nothing
changed arity or return type, and no migration is required. Requires langchain
0.9.5 or later for the attributes to reach spans.
Full details in the Observability guide.
Added
:otel_attributesonSagents.Agent, plusSagents.Agent.put_otel_attributes/2for values that are only known after the agent is built. #155- An
AgentServernow stamps itsconversation_idonto the state it executes, sogen_ai.conversation.idis set with no configuration. Combined with the agent's:name, traces group by conversation and by agent out of the box. Tools can read both fromcustom_context. #155 - Sub-agents inherit the parent's
:otel_attributesand conversation id, and add their own lineage:gen_ai.agent.idis the sub-agent's id andsagents.parent_agent_idis the parent's. #155 Sagents.State.conversation_id, a virtual field theAgentServersupplies on each execution. Not persisted; the server remains the source of truth. #155
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/2no 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:middlewarelist on itsSubAgent.Config. Explicit configuration still receives it. Inheritedask_usercalls previously produced an empty approval prompt rather than a visible question, so there is little working behavior to preserve. #151 - A sub-agent
:haltnow reaches the parent as:halt, not as%{type: :subagent_hitl}. Parent code matching on:subagent_hitlto catch halts needs to match:haltinstead. #150 - A failed
until_tooltermination 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 onServerStateholds a message that arrives mid-run and delivers it as an ordinary:usermessage at the head of a follow-up run, so nothing downstream has to know where it came from. Two doors into it: #152add_message/3(the human door) queues instead of erroring when the server is:running. Non-:userroles 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 acastso a tool cannot deadlock against a concurrent:cancel, and returns{:error, :no_server}under a bareAgent.execute/3or inside a sub-agent so callers can take a fallback path.- A
:displayoption on both doors splits the transcript half from the model-visible half.:noneis 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_messageis 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 ofMessagePreprocessor. Each middleware gets a chance to annotatemetadata(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
:pausedstatus event. A mode step may now return{:pause, chain, reason};Sagents.Mode.Steps.normalize_pause/1folds the reason intocustom_context.pause_reason, the agent reads it onto the new virtualState.pause_reasonfield, andAgentServerbroadcasts it as the payload of{:agent, {:status_changed, :paused, pause_reason}}, which was previously alwaysnil.Agent.execute/3still returns{:pause, state}, and a pause without a reason broadcastsnilas before. #149
Changed
AgentServer.add_message/2returns:okinstead of an error when a run is in flight, because the message is now queued rather than rejected. See the Upgrading section. #152langchainmoves from 0.8.12 to 0.9.4 inmix.lock, along with transitive bumps toecto,finch,mint,hpax,plug,plug_cryptoandreq. Themix.exsrequirement is unchanged;>= 0.8.11already allowed this. #152Sagents.Middleware.AskUserQuestionis added to a new@never_inherited_middlewarelist alongsideSagents.Middleware.SubAgent, so neither is inherited by a sub-agent from its parent's stack. Explicitly configuredadditional_middlewareis unaffected. #151- CI workflow dependency bumps:
actions/checkout6.0.2 → 7.0.0 (#132),actions/cacheand itssave/restorevariants 5.0.5 → 6.1.0 (#136, #137, #138), anderlef/setup-beam1.24.0 → 1.24.1 (#140).
Fixed
- A user message added while the agent was
:runningis no longer silently destroyed. It was written into the rolling server state, then wiped moments later whenhandle_execution_result/2replaced that state wholesale with the canonical state fromAgent.execute/3, while the caller was told the server was busy. The message is now queued and delivered on the next run. #152 - A
:haltraised 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_tooland adds:source_task. Applies to both the initial run and a post-approval resume. #150 SubAgent.extract_result/1now reads the matched terminating tool's result on anuntil_toolrun. Everyuntil_tooltermination ends on a tool-result message, which the previousChainResult.to_string/1path could not handle, so extraction failed on success and failure alike and the parent received a generic error. A tool result flaggedis_errorbecomes{:error, content}; runs ending in assistant prose are unchanged. Fixes #141. #143SubAgent.resume/3no longer crashes with aKeyErrorwhen the sub-agent's interrupt was raised from inside a tool body. Such an interrupt carries neither:action_requestsnor:hitl_tool_call_ids, and is not resumable through this path; it now returns{:error, {:unsupported_interrupt, :tool_raised}}, whichMiddleware.SubAgent.handle_resume/5already 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_valuestreated"","\n"," ", and"\t"as absent and replaced them withnilwhile still reporting a successful write.FileEntry.internal_changeset/2now passesempty_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: :participationMembership 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: :autois now real — potential behaviour change. It previously froze to a one-time node snapshot; it now drives Horde'sNodeListenerfor genuine dynamic membership with dead-node pruning. The undocumented static:membersforms (list /function/0/{m, f, a}) are removed; the only values are:auto(default) and:participation.- Registration-timeout resilience —
AgentsDynamicSupervisor.start_agent_sync/1retries on Horde's hardcoded-5s:viaregistration timeout, via new:registration_retries/:registration_retry_backoffoptions. - FileSystem distribution-safety — dropped
Process.alive?/1checks 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_type → task_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 transport —
Sagents.Publisher/Sagents.SubscriberreplacePhoenix.PubSubwith monitored point-to-point delivery, an:initial_subscribersstart option, and a Presence-based recovery loop for crash-restart and Horde migration. #79 - Session/Factory lifecycle in the library —
Sagents.Sessionowns the session-start lifecycle (router consult, factory invocation, state seeding, supervisor wiring, subscribers) and is idempotent on resume.Sagents.Factory/Sagents.FactoryRouterbehaviours,Sagents.Routers.Singlefor 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_userquestion or HITL approval now boots back into:interruptedstatus with the originalinterrupt_dataintact, rather than silently demoting to an error. New optionalSagents.Middleware.restorable_interrupt?/1callback,set_interrupted/3persistence callback, and cheap pre-deserializationinterrupted?/1read. #96 :haltterminal interrupt via the newSagents.Middleware.Haltable— tools can hard-stop a workflow (e.g. a gating validation tool) without giving the LLM a chance to continue. IncludesAgentServer.dismiss_interrupt/1for UIs to acknowledge a halt and a[:sagents, :agent, :halt]telemetry event. #115Sagents.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_successstop condition;run/3returns the tool'sprocessed_contentwhen present. #108 #129 #128Sagents.AgentResult— read helpers for pulling tool results, arguments, processed content, or final text out ofAgent.execute/3return values. #107:until_tooland:until_tool_successstop conditions onSagents.Agent.execute/3andSagents.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. #128Sagents.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:keysand:propagatorsconfiguration shapes. #82Sagents.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
:inlinemode — each successfulwrite_todosadditionally persists atodo_snapshotsynthetic display message into the transcript. #101 #102 AskUserQuestionconfig pinning — optionalallow_other/allow_cancelinit options force those values for every question instead of leaving them to the LLM. #124- SubAgent
:initial_messagesfor seeding per-call messages, and:include_task_listto 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.AskUserQuestionrecords the user's answer this way. #88 #89Sagents.State.runtimevirtual field for process-local values that must never be persisted, withmerge_runtime/2. #84agent_idon tool execution context (context.agent_id) so tools can publish events without reaching intostate. #86- Tooling hardening: Credo, Dialyzer,
sobelow, andmix_auditwired intomix precommitand CI. #93 #90 #106
Changed
- BREAKING: Transport, SubAgent tool arguments, session/factory API, debug subscriptions, the
FileSystemtool set, andSagents.Todoids 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_idcolumn, switching the hot tool-execution queries from a JSONBfragment(...)to indexed equality. New generations are clean; existing host apps absorb this by regenerating as described above. #127 Sagents.Middlewaredocuments 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
langchaindependency 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.