A stateful, serializable chat session. See spec §5.7 and §11.
Layer A — pure serializable data; Layer D — stateful continuation
operations (start/3, reply/4, continue/3, step/3,
submit_tool_result/3, submit_tool_results/2) shipped in Phase 8 wrap
Phase 7's ALLM.Chat.run/3 and ALLM.Chat.step/3.
Status + pending_* fields
The :status atom is a closed union:
:idle— the session is ready for a new user turn.:awaiting_user— the loop halted mid-step requesting user input;:pending_questionis a non-nil binary carrying the question and:pending_tool_call_idbinds the answer to the originating tool call.:awaiting_tools— the loop halted with pending tool calls the caller must execute;:pending_tool_callsis the non-empty list.:completed— the loop terminated normally.:error— an unrecoverable adapter or tool error occurred. By conventionmetadata[:error]holds the underlying%ALLM.Error.*{}struct for post-mortem inspection.ALLM.Validate.session/1(sub-phase 1.4) enforces this.
Status transitions (Phase 8)
Phase-8 operations form the closed state-machine arrows over the status
union. The full matrix lives in steering/PHASE_8_DESIGN.md §Overview;
the abridged shape:
| From \ Op | start/3 | reply/4 | continue/3 | step/3 | submit_tool_result/3 |
|---|---|---|---|---|---|
:idle | n/a (fresh-session entry) | legal | legal | legal | ArgumentError |
:awaiting_user | n/a | legal — clears pending fields | ArgumentError (*) | ArgumentError | ArgumentError |
:awaiting_tools | n/a | ArgumentError | legal IFF nil message AND pending_tool_calls == [] | ArgumentError | legal |
:completed | n/a | legal (treated as :idle) | legal (treated as :idle) | legal (treated as :idle) | ArgumentError |
:error | n/a | {:error, %SessionError{}} | {:error, %SessionError{}} | {:error, %SessionError{}} | {:error, %SessionError{}} |
Status-precondition violations RAISE ArgumentError — they're
programmer errors (Decision #7). Data mismatches return {:error, %SessionError{}} — unknown_tool_call_id on submit_tool_result/3 is
the canonical case. :error-status sessions return
{:error, %SessionError{reason: :session_in_error_state}} on every
Phase-8 operation; construct a fresh session to recover.
(*) continue/3 on :awaiting_user
continue/3 raises ArgumentError on :awaiting_user for the
common case (caller should use reply/4). The one exception is the
delegation path: reply/4 is implemented as
continue(engine, session, ALLM.user(text), opts) (Decision #4), so
continue/3 accepts a %Message{role: :user} on :awaiting_user
as the legal reply/4-equivalent dispatch. Calling continue/3
directly with any other shape on :awaiting_user raises. Prefer
reply/4 for clarity at call sites.
Mid-stream errors
When ALLM.Chat.run/3 returns a %ChatResult{halted_reason: :error}
(mid-stream adapter error folded into the response per CLAUDE.md), the
resulting session has status: :error and metadata.error populated
with the underlying %ALLM.Error.AdapterError{} (or other Phase 1
error struct). The call-site tuple stays {:ok, _}; the failure is on
the session, not on the tuple.
Manual-mode tool cycle
mode: :manual halts at the first tool-call response with
status: :awaiting_tools and pending_tool_calls populated. The
caller submits results via submit_tool_result/3 (single) or
submit_tool_results/2 (batch); each call appends a :tool-role
message to session.thread, removes the matching tool call from
pending_tool_calls, and flips status back to :idle when the last
pending call is submitted. The caller then invokes continue/3 with a
nil message to drive the next adapter turn.
Per-tool manual cycle (Phase 18)
When mode: :auto and any called tool has manual: true,
:awaiting_tools is entered with pending_tool_calls containing
only the manual subset; auto tools have already executed and their
:tool messages are in session.thread. The same
submit_tool_result/3 flow resolves the manual subset; submitting a
result for an AUTO-bucket id returns
{:error, %SessionError{reason: :unknown_tool_call_id}} because that
id already ran and is not in pending_tool_calls. mode: :manual
whole-loop wins over per-tool flags (Phase 18 Decision #5): under
mode: :manual, pending_tool_calls is the full
response.tool_calls list regardless of any tool's manual flag.
context is caller-owned
The :context field is a free-form map() the library threads through
to arity-2 tool handlers (spec §5.2). The library does not walk or
validate its contents — a caller stuffing DateTime, Decimal, an
Ecto.Repo reference, or a callback module is legitimate.
The Layer A serializability invariant is preserved only for values the
caller knows are serializable. Stuffing a PID, ref, or anonymous
function into :context will cause :erlang.term_to_binary/1 to raise
ArgumentError at persist time; this is the caller's responsibility,
not the library's. See the Phase 1 design (non-obvious decision #8) for
the rationale. A typed serializable() walk may land in v0.3.
context propagation (Phase 8 — Decision #10)
Every Phase-8 operation sets :context on the opts forwarded to
ALLM.Chat.run/3 / ALLM.Chat.step/3 to session.context UNLESS the
caller has already passed context: in the call opts (caller-wins).
The resolution chain is caller_opts > session.context > engine.context; this is enforced by merge_session_opts/2, the single
resolution point.
session_id propagation (Phase 8 — Decision #9)
Symmetric to :context: every Phase-8 operation sets :session_id on
the opts forwarded to ALLM.Chat.run/3 / ALLM.Chat.step/3 to
session.id UNLESS the caller has already passed session_id: in the
call opts. When session.id is nil (no id assigned), no opt is
added — the tool handler sees nil.
Summary
Types
Input shape accepted where the spec calls for [Message.t()]. A
%Session{} passes through; a %Thread{} or [Message.t()] is wrapped
via Session.new/1. Anything else surfaces as
{:error, %ValidationError{reason: :invalid_session_input}}.
Options accepted by start/3, reply/4, continue/3, step/3. A
superset of ALLM.Chat.chat_opts/0; everything not listed below flows
verbatim to ALLM.Chat.run/3 / ALLM.Chat.step/3.
Functions
Drive the next adapter turn on a session.
Append a :user-role message with user_text and run ALLM.Chat.run/3.
Start a new session by running ALLM.Chat.run/3 against engine and
the supplied input.
Run a single adapter turn (ALLM.Chat.step/3) on the session.
Streaming counterpart to reply/4. Appends a :user-role message
with user_text to session.thread, then dispatches via
ALLM.Chat.stream/3. Returns {:ok, stream} whose terminal event is
:chat_completed.
Streaming counterpart to start/3. Returns {:ok, stream} where the
stream is the inner ALLM.Chat.stream/3 enumerable verbatim.
Streaming counterpart to step/3. Returns {:ok, stream} whose
terminal event is :step_completed (NOT :chat_completed — per
Decision #11). Composes ALLM.Chat.stream_step/3; the stream
represents one adapter turn.
Submit a tool-role result for one pending tool call. In-process state mutation only; this does NOT call the adapter (Decision #3).
Submit a batch of tool-role results in order.
Types
@type session_input() :: t() | ALLM.Thread.t() | [ALLM.Message.t()]
Input shape accepted where the spec calls for [Message.t()]. A
%Session{} passes through; a %Thread{} or [Message.t()] is wrapped
via Session.new/1. Anything else surfaces as
{:error, %ValidationError{reason: :invalid_session_input}}.
@type session_opts() :: keyword()
Options accepted by start/3, reply/4, continue/3, step/3. A
superset of ALLM.Chat.chat_opts/0; everything not listed below flows
verbatim to ALLM.Chat.run/3 / ALLM.Chat.step/3.
:mode—:auto(default) or:manual. Per-call; not sticky.:max_turns—pos_integer(); same precedence as Phase 7.:halt_when—(StepResult.t() -> boolean()); runtime fun, NEVER stored on%Session{}.:on_tool_error,:tool_timeout,:tool_executor,:tool_result_encoder— Phase 6/7 pass-through.:emit_text_deltas,:emit_tool_deltas,:include_raw_chunks,:on_event— Phase 5 stream-filter pass-through.:session_id,:context— caller-wins overrides; default tosession.id/session.context.
@type status() :: :idle | :awaiting_user | :awaiting_tools | :completed | :error
@type t() :: %ALLM.Session{ context: map(), id: String.t() | nil, metadata: map(), pending_question: String.t() | nil, pending_tool_call_id: String.t() | nil, pending_tool_calls: [ALLM.ToolCall.t()], status: status(), thread: ALLM.Thread.t() }
Functions
@spec append(t(), ALLM.Message.t()) :: t()
@spec continue(ALLM.Engine.t(), t(), ALLM.Message.t() | nil, session_opts()) :: {:ok, t(), ALLM.ChatResult.t()} | {:error, ALLM.Error.EngineError.t() | ALLM.Error.AdapterError.t() | ALLM.Error.ValidationError.t() | ALLM.Error.SessionError.t()}
Drive the next adapter turn on a session.
When message is a %Message{}, it is appended to session.thread
before the adapter call. When message is nil, no append happens —
this form is used for manual-tool-cycle resumption after the caller
has populated tool-role messages via submit_tool_result/3 (see
Decision #4).
Status preconditions:
:idle,:completed— legal with anymessage.:awaiting_user— raisesArgumentError(caller should usereply/4to clear pending fields and supply user text). Thereply/4delegation passes a%Message{role: :user}through this function, so a user-role message on:awaiting_useris legal as the delegated path; any other shape raises.:awaiting_tools— legal only withmessage == nilANDpending_tool_calls == [](i.e., the caller has already submitted every pending tool result and the session has flipped back to:idle). Calling on:awaiting_toolswhilepending_tool_callsis non-empty raisesArgumentError.:error— returns{:error, %SessionError{reason: :session_in_error_state}}.
Examples
iex> engine = ALLM.Engine.new(
...> adapter: ALLM.Providers.Fake,
...> adapter_opts: [
...> scripts: [
...> [{:text, "first"}, {:finish, :stop}],
...> [{:text, "second"}, {:finish, :stop}]
...> ]
...> ]
...> )
iex> {:ok, s, _} = ALLM.Session.start(engine, [ALLM.user("hi")])
iex> {:ok, s2, _} = ALLM.Session.continue(engine, s, ALLM.user("more"))
iex> s2.status
:completed
@spec messages(t()) :: [ALLM.Message.t()]
@spec pending_tool_calls(t()) :: [ALLM.ToolCall.t()]
@spec reply(ALLM.Engine.t(), t(), String.t(), session_opts()) :: {:ok, t(), ALLM.ChatResult.t()} | {:error, ALLM.Error.EngineError.t() | ALLM.Error.AdapterError.t() | ALLM.Error.ValidationError.t() | ALLM.Error.SessionError.t()}
Append a :user-role message with user_text and run ALLM.Chat.run/3.
Equivalent to continue(engine, session, ALLM.user(user_text), opts).
See continue/3.
Legal on :idle, :awaiting_user (clears the pending fields), and
:completed (treated as :idle per Decision #5). Raises
ArgumentError on :awaiting_tools. Returns
{:error, %SessionError{reason: :session_in_error_state}} on :error.
Examples
iex> engine = ALLM.Engine.new(
...> adapter: ALLM.Providers.Fake,
...> adapter_opts: [
...> scripts: [
...> [{:text, "ok"}, {:finish, :stop}],
...> [{:text, "again"}, {:finish, :stop}]
...> ]
...> ]
...> )
iex> {:ok, s, _} = ALLM.Session.start(engine, [ALLM.user("hi")])
iex> {:ok, s2, _} = ALLM.Session.reply(engine, s, "again")
iex> length(ALLM.Session.messages(s2)) > length(ALLM.Session.messages(s))
true
@spec start(ALLM.Engine.t(), session_input(), session_opts()) :: {:ok, t(), ALLM.ChatResult.t()} | {:error, ALLM.Error.EngineError.t() | ALLM.Error.AdapterError.t() | ALLM.Error.ValidationError.t() | ALLM.Error.SessionError.t()}
Start a new session by running ALLM.Chat.run/3 against engine and
the supplied input.
session_input may be a %Session{} (preserves :id, :context,
:metadata), a %Thread{}, or a list of %Message{} (per Decision
#2). Anything else returns
{:error, %ValidationError{reason: :invalid_session_input}}.
Returns {:ok, %Session{}, %ChatResult{}} on a successful adapter
round-trip. The session's :status reflects the chat result's
:halted_reason (see status-transition table in the moduledoc).
Examples
iex> engine = ALLM.Engine.new(
...> adapter: ALLM.Providers.Fake,
...> adapter_opts: [script: [{:text, "hi"}, {:finish, :stop}]]
...> )
iex> {:ok, session, result} = ALLM.Session.start(engine, [ALLM.user("hello")])
iex> session.status
:completed
iex> result.halted_reason
:completed
@spec step(ALLM.Engine.t(), t(), session_opts()) :: {:ok, t(), ALLM.StepResult.t()} | {:error, ALLM.Error.EngineError.t() | ALLM.Error.AdapterError.t() | ALLM.Error.ValidationError.t() | ALLM.Error.SessionError.t()}
Run a single adapter turn (ALLM.Chat.step/3) on the session.
Unlike continue/3, step/3 does not loop — it dispatches one
adapter call and returns the resulting %StepResult{} projected onto
the session via apply_step_result/2. Status follows Phase 6's
semantics; see steering/PHASE_8_DESIGN.md Decision #6 for the table.
Legal on :idle and :completed (treated as :idle). Raises
ArgumentError on :awaiting_user and :awaiting_tools. Returns
{:error, %SessionError{reason: :session_in_error_state}} on
:error.
Examples
step/3 is a single-turn entry point; this doctest seeds the thread
directly via Session.new/1 to keep the example focused on the
single-step return shape. The start/3 → step/3 flow is tested in
test/allm/session_test.exs.
iex> engine = ALLM.Engine.new(
...> adapter: ALLM.Providers.Fake,
...> adapter_opts: [script: [{:text, "hi"}, {:finish, :stop}]]
...> )
iex> {:ok, s, sr} = ALLM.Session.step(engine, ALLM.Session.new(thread: ALLM.Thread.from_messages([ALLM.user("hi")])))
iex> sr.done?
true
iex> s.status
:completed
@spec stream_reply(ALLM.Engine.t(), t(), String.t(), session_opts()) :: {:ok, Enumerable.t()} | {:error, ALLM.Error.EngineError.t() | ALLM.Error.AdapterError.t() | ALLM.Error.ValidationError.t() | ALLM.Error.SessionError.t()}
Streaming counterpart to reply/4. Appends a :user-role message
with user_text to session.thread, then dispatches via
ALLM.Chat.stream/3. Returns {:ok, stream} whose terminal event is
:chat_completed.
Status preconditions (synchronous, BEFORE the stream is constructed):
:idle,:completed— legal.:awaiting_user— legal; pending fields are cleared atStreamReducer.finalize/1time viaapply_chat_result/2.:awaiting_tools— raisesArgumentError.:error— returns{:error, %SessionError{reason: :session_in_error_state}}.
Pre-flight errors (missing adapter, validation, status precondition)
surface synchronously; mid-stream adapter errors fold into the
terminal :chat_completed event's result.halted_reason: :error.
Examples
iex> engine = ALLM.Engine.new(
...> adapter: ALLM.Providers.Fake,
...> adapter_opts: [
...> scripts: [
...> [{:text, "ok"}, {:finish, :stop}],
...> [{:text, "again"}, {:finish, :stop}]
...> ]
...> ]
...> )
iex> {:ok, s, _} = ALLM.Session.start(engine, [ALLM.user("hi")])
iex> {:ok, stream} = ALLM.Session.stream_reply(engine, s, "again")
iex> Enum.count(Enum.to_list(stream), &match?({:chat_completed, _}, &1))
1
@spec stream_start(ALLM.Engine.t(), session_input(), session_opts()) :: {:ok, Enumerable.t()} | {:error, ALLM.Error.EngineError.t() | ALLM.Error.AdapterError.t() | ALLM.Error.ValidationError.t() | ALLM.Error.SessionError.t()}
Streaming counterpart to start/3. Returns {:ok, stream} where the
stream is the inner ALLM.Chat.stream/3 enumerable verbatim.
Pre-flight errors (missing adapter, coerce_session_input/1 failure,
status precondition, :error-status session) are returned {:error, _} SYNCHRONOUSLY before any stream is constructed. Mid-stream adapter
errors fold into the terminal :chat_completed event's
result.halted_reason: :error; the call-site tuple stays {:ok, stream}. Mirrors Phase 7's Chat.stream/3 synchronous-vs-lazy split.
The consumer drives ALLM.Session.StreamReducer themselves to recover
the post-call session and %ChatResult{} (see Decision #15). No
session-side state changes happen until StreamReducer.finalize/1.
Examples
iex> engine = ALLM.Engine.new(
...> adapter: ALLM.Providers.Fake,
...> adapter_opts: [script: [{:text, "hi"}, {:finish, :stop}]]
...> )
iex> {:ok, stream} = ALLM.Session.stream_start(engine, [ALLM.user("hello")])
iex> events = Enum.to_list(stream)
iex> Enum.count(events, &match?({:chat_completed, _}, &1))
1
@spec stream_step(ALLM.Engine.t(), t(), session_opts()) :: {:ok, Enumerable.t()} | {:error, ALLM.Error.EngineError.t() | ALLM.Error.AdapterError.t() | ALLM.Error.ValidationError.t() | ALLM.Error.SessionError.t()}
Streaming counterpart to step/3. Returns {:ok, stream} whose
terminal event is :step_completed (NOT :chat_completed — per
Decision #11). Composes ALLM.Chat.stream_step/3; the stream
represents one adapter turn.
Pre-flight errors (missing adapter, status precondition, :error
state) surface synchronously. Consumers fold the stream through
ALLM.Session.StreamReducer.new(session, mode: :step) to recover
{updated_session, %StepResult{}}.
Legal on :idle and :completed. Raises ArgumentError on
:awaiting_user and :awaiting_tools. Returns
{:error, %SessionError{reason: :session_in_error_state}} on
:error.
Examples
iex> engine = ALLM.Engine.new(
...> adapter: ALLM.Providers.Fake,
...> adapter_opts: [script: [{:text, "hi"}, {:finish, :stop}]]
...> )
iex> session = ALLM.Session.new(thread: ALLM.Thread.from_messages([ALLM.user("hi")]))
iex> {:ok, stream} = ALLM.Session.stream_step(engine, session)
iex> events = Enum.to_list(stream)
iex> Enum.count(events, &match?({:step_completed, _}, &1))
1
@spec submit_tool_result(t(), String.t(), String.t() | map()) :: t() | {:error, ALLM.Error.SessionError.t()}
Submit a tool-role result for one pending tool call. In-process state mutation only; this does NOT call the adapter (Decision #3).
Appends a :tool-role message to session.thread with the supplied
tool_call_id and content, removes the matching %ToolCall{} from
pending_tool_calls, and flips status back to :idle when the last
pending call is submitted.
Returns the updated %Session{} on success or {:error, %SessionError{reason: :unknown_tool_call_id, metadata: %{tool_call_id: id}}} when the id doesn't match any pending call (Decision #14 —
data-validation, not a programmer-flow error).
Raises ArgumentError if session.status != :awaiting_tools
(Decision #7).
Doctest setup
Per steering/PHASE_8_DESIGN.md §8.2.2, this doctest constructs an
:awaiting_tools session by hand instead of driving through
start/3 to keep the example focused.
Examples
iex> tc = %ALLM.ToolCall{id: "c0", name: "echo", arguments: %{}}
iex> session = ALLM.Session.new(
...> status: :awaiting_tools,
...> pending_tool_calls: [tc],
...> thread: ALLM.Thread.from_messages([ALLM.user("hi")])
...> )
iex> updated = ALLM.Session.submit_tool_result(session, "c0", %{ok: true})
iex> updated.status
:idle
iex> updated.pending_tool_calls
[]
@spec submit_tool_results(t(), [{String.t(), String.t() | map()}]) :: t() | {:error, ALLM.Error.SessionError.t()}
Submit a batch of tool-role results in order.
Equivalent to folding submit_tool_result/3 over results; on the
first {:error, _} the fold short-circuits and returns that error
unchanged — no partial submissions land (matches ALLM.Validate's
hard-reject semantics).
An empty list is identity (returns the session unchanged).
Errors mirror submit_tool_result/3: an unknown id yields
{:error, %SessionError{reason: :unknown_tool_call_id}}. A status
mismatch raises ArgumentError.
Examples
iex> tc0 = %ALLM.ToolCall{id: "c0", name: "echo", arguments: %{}}
iex> tc1 = %ALLM.ToolCall{id: "c1", name: "echo", arguments: %{}}
iex> session = ALLM.Session.new(
...> status: :awaiting_tools,
...> pending_tool_calls: [tc0, tc1],
...> thread: ALLM.Thread.from_messages([ALLM.user("hi")])
...> )
iex> updated = ALLM.Session.submit_tool_results(session, [{"c0", "r0"}, {"c1", "r1"}])
iex> updated.status
:idle
iex> updated.pending_tool_calls
[]