ADR-0019: Unified Execution Model

View Source

Status

Proposed

Context

Conjure supports two fundamentally different execution modes:

  1. Local/Docker execution - Conjure executes tools in the user's environment
  2. Anthropic execution - Anthropic executes skills in their managed containers (see ADR-0011)

Initially, these were designed as separate APIs with different modules:

This creates a fragmented developer experience where switching execution modes requires significant code changes.

Key Differences Between Modes

AspectLocal/DockerAnthropic Hosted
Who executesYour applicationAnthropic's container
Conversation looptool_use → execute → tool_resultpause_turn continuation
Skills sourceLocal filesystemUploaded to Anthropic
Multi-turn stateMessage historyContainer ID + messages
File outputLocal filesystemFiles API download

Despite these differences, users want the same interaction patterns regardless of execution backend.

Reference

Decision

We will provide a unified execution model where the same API works for both local/Docker and Anthropic execution. Users can switch execution modes with minimal code changes while getting identical interaction patterns.

Unified Session API

defmodule Conjure.Session do
  @moduledoc """
  Manage multi-turn conversation sessions.

  Works with both local/Docker and Anthropic execution modes.
  """

  defstruct [
    :execution_mode,    # :local | :docker | :anthropic
    :skills,            # Local skills or Anthropic skill specs
    :messages,
    :container_id,      # For Anthropic container reuse
    :created_files,
    :context,           # ExecutionContext for local
    :opts
  ]

  @type t :: %__MODULE__{}

  @doc """
  Create session for local/Docker execution.
  """
  @spec new_local(skills :: [Skill.t()], keyword()) :: t()
  def new_local(skills, opts \\ [])

  @doc """
  Create session for Anthropic execution.
  """
  @spec new_anthropic(skill_specs :: [skill_spec()], keyword()) :: t()
  def new_anthropic(skill_specs, opts \\ [])

  @doc """
  Send a message and get response.

  Works identically for both execution modes.
  The api_callback is passed per-call, following API-agnostic design.
  """
  @spec chat(t(), String.t(), api_callback()) ::
    {:ok, response :: map(), updated_session :: t()} | {:error, term()}
  def chat(session, user_message, api_callback)

  @doc """
  Get files created during the session.

  Returns unified file info regardless of execution mode.
  """
  @spec get_created_files(t()) :: [file_info()]
  def get_created_files(session)
end

Unified Result Types

@type conversation_result :: %{
  messages: [message()],
  final_response: map(),
  created_files: [file_info()],
  iterations: pos_integer(),
  execution_mode: :local | :docker | :anthropic
}

@type file_info :: %{
  id: String.t(),           # file_id for Anthropic, path for local
  filename: String.t(),
  size: pos_integer(),
  source: :local | :anthropic
}

API Callback Pattern

All functions accept callbacks for HTTP operations, following the library's API-agnostic design (ADR-0004):

# Same callback works for both modes
api_callback = fn messages ->
  MyApp.Claude.call(messages)
end

# Local execution
session = Conjure.Session.new_local(skills, executor: Conjure.Executor.Docker)
{:ok, response, session} = Conjure.Session.chat(session, "Analyze this data", api_callback)

# Anthropic execution - same API!
session = Conjure.Session.new_anthropic([{:anthropic, "xlsx", "latest"}])
{:ok, response, session} = Conjure.Session.chat(session, "Analyze this data", api_callback)

Internal Loop Abstraction

The unified API handles the different conversation loop types internally:

# Local/Docker: tool_use → execute → tool_result loop
defp handle_local_conversation(session, messages, api_callback) do
  # Uses existing Conjure.Conversation.run_loop/4
  Conjure.Conversation.run_loop(
    messages,
    session.skills,
    api_callback,
    executor: get_executor(session)
  )
end

# Anthropic: pause_turn continuation loop
defp handle_anthropic_conversation(session, messages, api_callback) do
  # Uses Conjure.Conversation.Anthropic.run/4
  Conjure.Conversation.Anthropic.run(
    messages,
    build_container_config(session),
    api_callback,
    max_iterations: session.opts[:max_pause_iterations] || 10
  )
end

Module Organization

lib/conjure/
 session.ex                    # Unified session (NEW)
 api/
    anthropic.ex              # API request helpers (ADR-0011)
 conversation/
    conversation.ex           # Local loop (existing)
    anthropic.ex              # Anthropic pause_turn loop (ADR-0011)
 skills/
    anthropic.ex              # Skill upload/management (ADR-0011)
 files/
    anthropic.ex              # File downloads (ADR-0011)
 error.ex                      # Extended with new types

Usage Example

defmodule MyApp.Chat do
  @moduledoc """
  Unified chat interface - same code works for any backend.
  """

  alias Conjure.Session

  # Configuration determines execution mode
  def chat(user_message, opts \\ []) do
    session = create_session(opts)
    Session.chat(session, user_message, &call_claude/1)
  end

  defp create_session(opts) do
    case Keyword.get(opts, :execution, :local) do
      :local ->
        {:ok, skills} = Conjure.load("priv/skills")
        Session.new_local(skills, executor: Conjure.Executor.Local)

      :docker ->
        {:ok, skills} = Conjure.load("priv/skills")
        Session.new_local(skills, executor: Conjure.Executor.Docker)

      :anthropic ->
        Session.new_anthropic([
          {:anthropic, "xlsx", "latest"},
          {:anthropic, "pdf", "latest"}
        ])
    end
  end

  # Same callback for all modes
  defp call_claude(messages) do
    MyApp.Claude.post("/v1/messages", %{
      model: "claude-sonnet-4-5-20250929",
      max_tokens: 4096,
      messages: messages
    })
  end
end

File Handling

Created files are tracked uniformly with source information:

# After conversation
files = Session.get_created_files(session)

# Download based on source
Enum.each(files, fn file_info ->
  case file_info.source do
    :local ->
      # Already a local path
      File.read!(file_info.id)

    :anthropic ->
      # Download via Files API
      {:ok, content, _} = Conjure.Files.Anthropic.download(
        file_info.id,
        &api_callback/4
      )
      content
  end
end)

Consequences

Positive

  • Single API to learn - Users learn one interface for all execution modes
  • Easy mode switching - Change execution backend with configuration, not code rewrites
  • Consistent patterns - Same callback style, session management, file handling
  • Reduced cognitive load - No need to understand internal loop differences
  • Future-proof - New execution backends can be added behind the same API

Negative

  • Abstraction overhead - Some mode-specific features may be harder to access
  • Lowest common denominator - API limited to features available in all modes
  • Internal complexity - Unified module must handle different loop types

Neutral

  • Mode-specific modules still exist - Conjure.Conversation.Anthropic etc. are still available for advanced use
  • Configuration-driven - Execution mode determined at session creation
  • Source tracking - File results include source for mode-specific handling when needed

Alternatives Considered

Separate APIs per Execution Mode

Keep Conjure.Session.Anthropic and local execution completely separate. Rejected because:

  • Requires significant code changes to switch modes
  • Users must learn multiple APIs
  • Duplicated patterns and documentation

Execution Mode as Runtime Parameter

Pass execution mode to each chat/3 call instead of at session creation. Rejected because:

  • Inconsistent state if mode changes mid-session
  • More complex API surface
  • Session state depends on execution mode

Wrapper Module Only

Create a thin wrapper that delegates to mode-specific modules. Rejected because:

  • Still exposes mode differences in return types
  • File handling would remain inconsistent
  • Less robust abstraction

References