ALLM.Session (allm v0.3.0)

Copy Markdown View Source

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_question is a non-nil binary carrying the question and :pending_tool_call_id binds the answer to the originating tool call.
  • :awaiting_tools — the loop halted with pending tool calls the caller must execute; :pending_tool_calls is the non-empty list.
  • :completed — the loop terminated normally.
  • :error — an unrecoverable adapter or tool error occurred. By convention metadata[: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 \ Opstart/3reply/4continue/3step/3submit_tool_result/3
:idlen/a (fresh-session entry)legallegallegalArgumentError
:awaiting_usern/alegal — clears pending fieldsArgumentError (*)ArgumentErrorArgumentError
:awaiting_toolsn/aArgumentErrorlegal IFF nil message AND pending_tool_calls == []ArgumentErrorlegal
:completedn/alegal (treated as :idle)legal (treated as :idle)legal (treated as :idle)ArgumentError
:errorn/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.

t()

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

session_input()

@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}}.

session_opts()

@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_turnspos_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 to session.id / session.context.

status()

@type status() :: :idle | :awaiting_user | :awaiting_tools | :completed | :error

t()

@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

append(s, m)

@spec append(t(), ALLM.Message.t()) :: t()

append_tool_result(s, tool_call_id, content)

@spec append_tool_result(t(), String.t(), String.t() | map()) :: t()

append_user(s, text)

@spec append_user(t(), String.t()) :: t()

continue(engine, session, message, opts \\ [])

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 any message.
  • :awaiting_user — raises ArgumentError (caller should use reply/4 to clear pending fields and supply user text). The reply/4 delegation passes a %Message{role: :user} through this function, so a user-role message on :awaiting_user is legal as the delegated path; any other shape raises.
  • :awaiting_tools — legal only with message == nil AND pending_tool_calls == [] (i.e., the caller has already submitted every pending tool result and the session has flipped back to :idle). Calling on :awaiting_tools while pending_tool_calls is non-empty raises ArgumentError.
  • :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

messages(session)

@spec messages(t()) :: [ALLM.Message.t()]

new(opts \\ [])

@spec new(keyword()) :: t()

pending_tool_calls(session)

@spec pending_tool_calls(t()) :: [ALLM.ToolCall.t()]

reply(engine, session, user_text, opts \\ [])

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

start(engine, session_input, opts \\ [])

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

step(engine, session, opts \\ [])

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

stream_reply(engine, session, user_text, opts \\ [])

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 at StreamReducer.finalize/1 time via apply_chat_result/2.
  • :awaiting_tools — raises ArgumentError.
  • :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

stream_start(engine, session_input, opts \\ [])

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

stream_step(engine, session, opts \\ [])

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

submit_tool_result(session, tool_call_id, content)

@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
[]

submit_tool_results(session, results)

@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
[]