# `ALLM.Session`
[🔗](https://github.com/cykod/ALLM/blob/v0.3.0/lib/allm/session.ex#L1)

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 \ 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` {: .info}
>
> `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`.

# `session_input`

```elixir
@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`

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

# `status`

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

# `t`

```elixir
@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()
}
```

# `append`

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

# `append_tool_result`

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

# `append_user`

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

# `continue`

```elixir
@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 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`

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

# `new`

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

# `pending_tool_calls`

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

# `reply`

```elixir
@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

# `start`

```elixir
@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

# `step`

```elixir
@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

# `stream_reply`

```elixir
@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 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`

```elixir
@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

# `stream_step`

```elixir
@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

# `submit_tool_result`

```elixir
@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 {: .info}
>
> 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`

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

---

*Consult [api-reference.md](api-reference.md) for complete listing*
