Agent Session Manager

ASM (Agent Session Manager)

HexHexDocsGitHub

ASM is an OTP-native Elixir runtime for running multi-turn AI sessions across multiple CLI providers with one API.

Supported providers:

Documentation Menu

Why ASM

Install

def deps do
  [
    {:agent_session_manager, "~> 0.9.0"}
  ]
end

For local workspace development, replace that published requirement with the repo-local path: override.

That dependency gives you ASM's normalized kernel plus the discovery modules for the current built-in Claude/Codex extension namespaces. Those namespace modules are always present in ASM, but they activate only when the matching optional provider SDK dependency is installed.

Optional provider SDK dependencies stay additive. Add one only when you want that provider's SDK lane/runtime kit or, where it exists today, its ASM provider-native namespace:

Declaring the optional dependency is the only client-side activation step. No extra ASM wiring is required. ASM always keeps the common surface available through cli_subprocess_core, auto-detects optional provider runtime availability, and activates only the provider-native extension namespaces that genuinely exist today.

The package publication order for this stack remains: cli_subprocess_core first, then the provider SDK packages, then agent_session_manager.

CLI Setup

Install provider CLIs you plan to use:

npm install -g @anthropic-ai/claude-code
npm install -g @google/gemini-cli
npm install -g @openai/codex
npm install -g @sourcegraph/amp

Authenticate each CLI with its native flow before using ASM.

For Codex local OSS via Ollama, ASM forwards backend intent into the shared core model registry instead of selecting a local model itself. Example:

{:ok, session} =
  ASM.start_link(
    provider: :codex,
    provider_backend: :oss,
    oss_provider: "ollama",
    model: "llama3.2"
  )

gpt-oss:20b remains the default validated Codex/Ollama example model in the shared stack, but ASM also accepts other installed local models such as llama3.2 and forwards them through the same route. In the example suite, those broader local models should be treated as accepted but potentially degraded upstream paths rather than guaranteed exact-output smoke targets.

ASM does not keep a second model-resolution layer above the shared core. Run paths validate ASM/common-surface options first, then finalize provider opts through CliSubprocessCore.ModelInput.normalize/3 so backends consume an attached model_payload instead of re-resolving model/backend intent locally.

Optional explicit CLI paths:

Quick Start

# OTP-friendly session startup
{:ok, session} = ASM.start_link(provider: :claude)

# Stream text chunks
session
|> ASM.stream("Reply with exactly: OK")
|> ASM.Stream.text_content()
|> Enum.each(&IO.write/1)

# Query convenience API
case ASM.query(session, "Say hello") do
  {:ok, result} -> IO.puts(result.text)
  {:error, error} -> IO.puts("failed: #{Exception.message(error)}")
end

:ok = ASM.stop_session(session)

Provider atom form for one-off queries:

{:ok, result} = ASM.query(:gemini, "Say hello")

CLI Inference Endpoint Publication

ASM.InferenceEndpoint publishes CLI-backed providers as endpoint-shaped targets for northbound inference consumers.

The stable northbound API is:

The built-in CLI provider set is published honestly from the landed provider profiles:

ASM derives cli_completion_v1, cli_streaming_v1, and cli_agent_v2 from the landed provider profiles and runtime tiers, but the endpoint path only exposes completion and streaming. Tool-bearing or agent-loop-shaped requests are rejected on that endpoint seam.

Gemini and Amp remain common-surface-only providers. Their capability publication can make them valid endpoint targets without introducing a second ASM-native extension namespace.

See guides/inference-endpoints.md and examples/inference_endpoint_http.exs for the published descriptor contract and an offline endpoint proof.

Session Model

ASM has three option layers:

Zoi is now the canonical boundary-schema layer for new dynamic ASM boundary work. NimbleOptions remains at the public keyword ingress during the coexistence window, but schema-backed normalization now owns:

Per-run options override session defaults. Session defaults are inherited automatically.

Generic Execution-Surface Carriage

ASM keeps the bridge-to-core contract transport-neutral.

Session defaults and per-run overrides carry transport placement separately from runtime environment and approval context:

execution_surface = [
  surface_kind: :ssh_exec,
  transport_options: [destination: "buildbox-a", port: 2222],
  target_id: "buildbox-a"
]

execution_environment = [
  workspace_root: "/repo",
  allowed_tools: ["git.status"],
  approval_posture: :manual,
  permission_mode: :default
]

{:ok, session} =
  ASM.start_session(
    provider: :codex,
    execution_surface: execution_surface,
    execution_environment: execution_environment
  )

Session startup normalizes stored defaults so ASM.session_info/1 reflects the same CliSubprocessCore.ExecutionSurface contract the downstream SDK repos consume. execution_environment is normalized separately and carries workspace_root, allowed_tools, approval_posture, and permission_mode. Run execution then merges per-run overrides, enforces non-empty allowed_tools in the ASM pipeline, and forwards placement only to the backend/runtime startup path. approval_posture: :none stays explicit and runtime backends reject unresolved starts instead of normalizing it away silently.

ASM keeps one public placement surface. surface_kind, transport_options, lease_ref, surface_ref, target_id, boundary_class, and observability belong inside execution_surface. Workspace and approval policy do not.

Transport expansion stays core-owned. ASM carries the opaque placement contract without branching on adapter modules or transport-family-specific path rules, so future built-in surfaces should not require another ASM contract rewrite. Boundary-backed external sessions can now arrive through that unchanged transport-neutral surface as attach-ready :guest_bridge placement authored above ASM. ASM does not inspect lower-boundary backend details; it only consumes the normalized execution_surface contract.

Phase D now proves that unchanged execution config path over SSH as well:

Runtime Architecture

Runtime execution path:

Lane selection is intentionally separate from execution mode:

This produces three distinct values in observability metadata:

When lane: :auto prefers :sdk but execution_mode: :remote_node, ASM records preferred_lane: :sdk and executes with lane: :core, backend: ASM.ProviderBackend.Core, and lane_fallback_reason: :sdk_remote_unsupported. An explicit lane: :sdk with execution_mode: :remote_node is a configuration error.

See Lane Selection for the full discovery and resolution flow.

Centralized Model Selection

ASM does not own provider model policy.

The authoritative model-selection contract is provided by cli_subprocess_core, and ASM consumes the resolved payload before dispatching into provider adapters.

Authoritative core surface:

ASM-side rules:

Provider-side alignment in the current stack is:

ASM-local schema ownership stops at orchestration boundaries. Provider-native runtime schemas still stay in their owning SDK repos.

Claude Ollama Backend Through ASM

Because ASM resolves Claude model payloads in core first, the Claude Ollama path is configured through ASM provider opts and still flows through CliSubprocessCore.ModelRegistry.

Relevant Claude provider opts:

Example:

{:ok, result} =
  ASM.query(:claude, "Reply with exactly: OK",
    provider_backend: :ollama,
    anthropic_base_url: "http://localhost:11434",
    external_model_overrides: %{"haiku" => "llama3.2"},
    model: "haiku"
  )

ASM does not build Ollama env itself. It forwards the Claude backend options to core, attaches the resolved payload, and the downstream Claude lane consumes that payload.

Lane Selection

Use ASM.ProviderRegistry to inspect lane availability and resolution:

{:ok, provider_info} = ASM.ProviderRegistry.provider_info(:codex)
{:ok, lane_info} = ASM.ProviderRegistry.lane_info(:codex, lane: :auto)

{:ok, resolution} =
  ASM.ProviderRegistry.resolve(:codex,
    lane: :auto,
    execution_mode: :remote_node
  )

provider_info/1 reports provider-level facts such as:

Those fields stay scoped to normalized lane/runtime discovery. Provider-native extension inventory is reported separately through ASM.Extensions.ProviderSDK.

lane_info/2 is discovery-only and returns:

resolve/2 adds execution-mode compatibility and returns the effective:

Typical projected metadata for a remote auto-lane run:

%{
  requested_lane: :auto,
  preferred_lane: :sdk,
  lane: :core,
  backend: ASM.ProviderBackend.Core,
  execution_mode: :remote_node,
  lane_fallback_reason: :sdk_remote_unsupported
}

Lane rules:

Provider Backend Model

ASM.ProviderBackend.Core is the baseline backend for every provider:

ASM.ProviderBackend.SDK is additive, not foundational:

Approval routing, interrupt control, and result projection are lane-agnostic. The lane changes how the provider backend is started, not how the session aggregate behaves.

ASM intentionally stops at this normalized backend boundary. Rich provider-native control families such as Claude hooks/permission callbacks and Codex app-server remain in the provider SDK repos and stay out of ASM's core execution model.

See Provider Backends for the backend contract and lane responsibilities.

Provider SDK Extensions

Phase 4 keeps an explicit provider-native extension foundation above the normalized kernel.

Use ASM.Extensions.ProviderSDK when you need to discover optional richer provider-native seams without widening ASM, ASM.Stream, or ASM.ProviderRegistry:

alias ASM.Extensions.ProviderSDK

catalog = ProviderSDK.extensions()
active_extensions = ProviderSDK.available_extensions()
{:ok, active_claude_extensions} = ProviderSDK.available_provider_extensions(:claude)
{:ok, claude_extension} = ProviderSDK.extension(:claude)
{:ok, codex_native_caps} = ProviderSDK.provider_capabilities(:codex)
{:ok, gemini_report} = ProviderSDK.provider_report(:gemini)

report = ProviderSDK.capability_report()

claude_extension.namespace
# ASM.Extensions.ProviderSDK.Claude

Enum.map(catalog, & &1.provider)
# [:claude, :codex]

Enum.map(active_extensions, & &1.provider)
# subset of [:claude, :codex], depending on installed optional deps

Enum.map(active_claude_extensions, & &1.namespace)
# [] or [ASM.Extensions.ProviderSDK.Claude]

codex_native_caps
# [:app_server, :mcp, :realtime, :voice]

report.claude.sdk_available?
# true | false

gemini_report.namespaces
# []

Current built-in namespaces:

Optional-loading rules:

The Claude namespace now exposes an explicit bridge into the SDK-local control family:

alias ASM.Extensions.ProviderSDK.Claude

asm_opts = [
  provider: :claude,
  cwd: File.cwd!(),
  execution_environment: [permission_mode: :plan],
  model: "sonnet"
]

native_overrides = [
  enable_file_checkpointing: true,
  thinking: %{type: :adaptive}
]

{:ok, sdk_options} = Claude.sdk_options(asm_opts, native_overrides)

{:ok, client} =
  Claude.start_client(
    asm_opts,
    native_overrides,
    transport: MyApp.MockTransport
  )

:ok = ClaudeAgentSDK.Client.set_permission_mode(client, :plan)

That bridge is intentionally separate from the normalized kernel:

The Codex namespace now exposes a narrow bridge into the SDK-local app-server entry path:

alias ASM.Extensions.ProviderSDK.Codex
alias Codex, as: CodexSDK

{:ok, conn} =
  Codex.connect_app_server(
    [
      provider: :codex,
      cli_path: "/usr/local/bin/codex",
      model: "gpt-5.4",
      reasoning_effort: :high
    ],
    [model_personality: :pragmatic],
    experimental_api: true
  )

{:ok, thread_opts} =
  Codex.thread_options(
    [
      provider: :codex,
      cwd: "/workspaces/repo",
      execution_environment: [permission_mode: :auto],
      approval_timeout_ms: 45_000,
      output_schema: %{"type" => "object"}
    ],
    transport: {:app_server, conn},
    personality: :pragmatic
  )

{:ok, codex_opts} =
  Codex.codex_options(
    [provider: :codex, model: "gpt-5.4"],
    model_personality: :pragmatic
  )

{:ok, thread} = CodexSDK.start_thread(codex_opts, thread_opts)

That bridge is intentionally narrow:

See Provider SDK Extensions for the kernel-versus-extension split and the discovery API.

Common And Partial Provider Features

ASM keeps the public approval knob normalized as :permission_mode, but the provider-native terminology still matters for observability, examples, and host application UX. ASM.ProviderFeatures is the public discovery surface for that mapping and for ASM common features that are only supported by some providers.

ASM.ProviderFeatures.permission_mode!(:codex, :yolo).cli_excerpt
# => "--dangerously-bypass-approvals-and-sandbox"

ASM.ProviderFeatures.common_feature!(:claude, :ollama)
# => %{supported?: true, activation: %{provider_backend: :ollama}, ...}

The current partial common feature is the ASM Ollama surface:

See Common And Partial Provider Features for the discovery API and the Claude-versus-Codex Ollama semantics.

Important boundary:

Examples:

So if a host is reasoning at the ASM layer:

Event Model And Result Projection

Backends emit core runtime events. ASM.Run.Server wraps them into %ASM.Event{} values that carry run/session scope plus stable observability metadata. Stream consumers therefore see the same lane and execution metadata that final results expose.

%ASM.Event{} remains the ergonomic runtime envelope, while ASM.Schema.Event owns parsing and projection for persisted or rebuilt event maps. Forward- compatible event maps preserve unknown keys on the struct's :extra field instead of pushing ad hoc map traversal into callers.

Common metadata keys include:

ASM.Stream.final_result/1 reduces the streamed %ASM.Event{} sequence through ASM.Run.EventReducer and projects a final %ASM.Result{}. %ASM.Result.metadata is therefore derived from the event stream rather than from a side channel, which keeps streaming and query-style consumption aligned.

See Event Model And Result Projection for the reducer and metadata projection details.

Approval Routing And Interrupts

Approvals are session-scoped even though they originate from individual runs:

If an approval is not resolved before approval_timeout_ms, ASM emits :approval_resolved with decision: :deny and reason: "timeout".

Interrupts are run-scoped:

These control semantics stay the same across :core and :sdk, and across local versus remote execution.

See Approvals And Interrupts for the session/run control flow in more detail.

Remote Node Execution

Remote execution is opt-in per session or per run. Local mode remains the default.

Session-level remote default:

{:ok, session} =
  ASM.start_session(
    provider: :codex,
    execution_mode: :remote_node,
    # Remote backend options stay under :driver_opts
    driver_opts: [
      remote_node: :"asm@sandbox-a",
      remote_cookie: :cluster_cookie,
      remote_cwd: "/workspaces/t-123"
    ]
  )

Per-run remote override:

ASM.query(session, "analyze this",
  execution_mode: :remote_node,
  driver_opts: [remote_node: :"asm@sandbox-b"]
)

Per-run local override (when session default is remote):

ASM.query(session, "quick local check", execution_mode: :local)

Remote execution options:

Operational requirements for remote worker nodes:

Public API

Core lifecycle:

Run execution:

Runtime control:

Lane and provider introspection:

Streaming helpers:

Error Semantics

ASM.query/3 returns:

Result projections also include structured cost and terminal error:

Execution Control Options

Session defaults and per-run overrides can also control execution behavior:

Provider Options

Common options:

Provider-specific examples:

Live Examples

The repo examples are provider-agnostic and stay on the common ASM surface. They only run when you explicitly choose a provider with --provider.

mix run --no-start examples/live_query.exs -- --provider claude
mix run --no-start examples/live_stream.exs -- --provider gemini
mix run --no-start examples/live_session_lifecycle.exs -- --provider codex
./examples/run_all.sh --provider amp

Environment knobs used by examples:

If you omit --provider, the example prints a usage note and exits without running a live provider. See examples/README.md for the full example set.

Guides

Architecture Notes

Per-session subtree strategy uses :rest_for_one:

Run workers are restart: :temporary to avoid restart loops after normal completion.

Remote backend sessions are supervised on the remote node, and startup is performed through the remote backend starter.

Quality Gates

mix format --check-formatted
mix compile --warnings-as-errors
mix test
mix credo --strict
mix dialyzer
mix docs
mix hex.build

Model Selection Contract

/home/home/p/g/n/agent_session_manager centralizes provider model resolution through /home/home/p/g/n/cli_subprocess_core before delegating to provider backends or SDK adapters. The authoritative policy APIs are CliSubprocessCore.ModelRegistry.resolve/3, CliSubprocessCore.ModelRegistry.validate/2, and CliSubprocessCore.ModelRegistry.default_model/2.

ASM option schemas are value carriers only. Backend lanes and provider extensions consume the resolved payload and do not own implicit provider/model fallback policy.

Session Control And Recovery Handles

agent_session_manager now owns a first-class session-control seam instead of relying on provider- specific escape hatches.

This is the orchestration boundary that lets prompt_runner_sdk resume the same provider conversation with Continue after a recoverable runtime failure.