ADR-0021: Hybrid Multi-Backend Sessions

View Source

Status

Proposed

Context

After establishing the unified Session API (ADR-0019) and Backend behaviour (ADR-0020), we identified a usability gap: multi-backend workflows require creating separate sessions for each backend, manually orchestrating data flow, and managing multiple conversation histories.

Common patterns like "fetch with Native → analyze with Local → report with Anthropic" require significant boilerplate:

# Current approach - separate sessions, manual orchestration
{:ok, logs} = fetch_logs_native(url)           # Session 1
{:ok, analysis} = analyze_logs_local(logs)      # Session 2
{:ok, report} = generate_report_anthropic(analysis)  # Session 3

This creates friction for developers building multi-capability agents.

Decision

We will introduce a Hybrid Session mode that manages multiple backend sub-sessions internally, providing:

  1. Single session creation with multiple backends
  2. Automatic tool routing based on skill registration
  3. Unified conversation history
  4. Aggregated file tracking across backends
  5. Lazy cross-backend file resolution

Public API

# Create hybrid session with multiple backends
session = Conjure.Session.new_hybrid([
  {:native, [MyApp.Skills.LogFetcher]},
  {:local, local_skills},
  {:docker, docker_skills, executor: Conjure.Executor.Docker},
  {:anthropic, [{:anthropic, "xlsx", "latest"}]}
])

# Single chat call - tools route automatically
{:ok, response, session} = Conjure.Session.chat(
  session,
  "Fetch logs from the API, analyze them, and create an Excel report",
  &api_callback/1
)

# Access unified file list
files = Conjure.Session.get_created_files(session)
# => [%{id: "...", source: :native}, %{id: "file_abc", source: :anthropic}]

Session Structure

Extended Session struct with hybrid-specific fields:

defstruct [
  :execution_mode,    # :hybrid for this mode
  :skills,            # Merged list of all skills (for reference)
  :messages,          # Unified conversation history
  :container_id,      # nil (Anthropic container tracked in sub-session)
  :created_files,     # Aggregated from all backends
  :context,           # nil (each sub-session has own context)
  :opts,
  # New fields for hybrid:
  :sub_sessions,      # %{backend_type => Session.t()}
  :routing_table      # %{prefixed_tool_name => {backend_type, skill_ref}}
]

Tool Naming (Skill Prefix Strategy)

To prevent collisions when multiple skills expose same tool types:

# Original tool definitions:
# Native skill "log-fetcher" → "execute" tool
# Local skill "analyzer" → "bash_tool" tool

# After prefixing:
# "log-fetcher_execute"
# "analyzer_bash_tool"

# Routing table:
%{
  "log-fetcher_execute" => {:native, MyApp.Skills.LogFetcher},
  "analyzer_bash_tool" => {:local, "analyzer"},
  "xlsx_code_execution" => {:anthropic, {:anthropic, "xlsx", "latest"}}
}

Implementation

1. Session Creation (lib/conjure/session.ex)

@spec new_hybrid([backend_spec()], keyword()) :: t()
def new_hybrid(backend_specs, opts \\ []) do
  # Create sub-session for each backend
  sub_sessions = create_sub_sessions(backend_specs, opts)

  # Build routing table from all tool definitions
  routing_table = build_routing_table(sub_sessions)

  # Merge all skills for reference
  all_skills = merge_skills(sub_sessions)

  %__MODULE__{
    execution_mode: :hybrid,
    skills: all_skills,
    messages: [],
    container_id: nil,
    created_files: [],
    context: nil,
    opts: opts,
    sub_sessions: sub_sessions,
    routing_table: routing_table
  }
end

defp create_sub_sessions(backend_specs, opts) do
  Enum.reduce(backend_specs, %{}, fn
    {:native, modules}, acc ->
      Map.put(acc, :native, new_native(modules, opts))

    {:local, skills}, acc ->
      Map.put(acc, :local, new_local(skills, opts))

    {:docker, skills, backend_opts}, acc ->
      merged_opts = Keyword.merge(opts, backend_opts)
      Map.put(acc, :docker, new_local(skills, merged_opts))

    {:anthropic, skill_specs}, acc ->
      Map.put(acc, :anthropic, new_anthropic(skill_specs, opts))
  end)
end

2. Hybrid Conversation Loop (lib/conjure/session/hybrid.ex)

defmodule Conjure.Session.Hybrid do
  @moduledoc "Hybrid session conversation loop with multi-backend routing."

  alias Conjure.Session

  def chat(session, user_message, api_callback) do
    user_msg = %{"role" => "user", "content" => user_message}
    messages = session.messages ++ [user_msg]

    # Merge tool definitions from all backends with skill prefixes
    tools = merged_tool_definitions(session)

    run_loop(messages, tools, session, api_callback)
  end

  defp run_loop(messages, tools, session, api_callback, iteration \\ 0) do
    max_iterations = Keyword.get(session.opts, :max_iterations, 25)

    if iteration >= max_iterations do
      {:error, %Conjure.Error{type: :max_iterations}}
    else
      case api_callback.(messages, tools) do
        {:ok, response} ->
          handle_response(response, messages, tools, session, api_callback, iteration)

        {:error, _} = error ->
          error
      end
    end
  end

  defp handle_response(response, messages, tools, session, api_callback, iteration) do
    tool_uses = extract_tool_uses(response)

    if Enum.empty?(tool_uses) do
      # End of conversation
      finalize(response, messages, session)
    else
      # Route and execute tools
      case execute_tools(tool_uses, session) do
        {:ok, results, updated_session} ->
          # Build next messages
          assistant_msg = %{"role" => "assistant", "content" => response["content"]}
          tool_results_msg = format_tool_results(results)
          new_messages = messages ++ [assistant_msg, tool_results_msg]

          run_loop(new_messages, tools, updated_session, api_callback, iteration + 1)

        {:error, _} = error ->
          error
      end
    end
  end

  defp execute_tools(tool_uses, session) do
    # Group tool calls by backend
    grouped = group_by_backend(tool_uses, session.routing_table)

    # Execute in parallel across backends
    results =
      grouped
      |> Task.async_stream(fn {backend_type, calls} ->
        execute_on_backend(backend_type, calls, session)
      end, timeout: 60_000)
      |> Enum.reduce({:ok, [], session}, &aggregate_result/2)

    results
  end

  defp execute_on_backend(backend_type, tool_calls, session) do
    sub_session = session.sub_sessions[backend_type]

    # Restore original tool names for backend execution
    restored_calls = restore_tool_names(tool_calls)

    # Execute via the appropriate backend
    case backend_type do
      :native -> execute_native(restored_calls, sub_session)
      :local -> execute_local(restored_calls, sub_session)
      :docker -> execute_local(restored_calls, sub_session)
      :anthropic -> execute_anthropic(restored_calls, sub_session)
    end
  end
end

3. Cross-Backend File Resolution (lib/conjure/session/hybrid/file_resolver.ex)

defmodule Conjure.Session.Hybrid.FileResolver do
  @moduledoc "Lazy file resolution across backends."

  def resolve(file_ref, session) do
    case find_file_source(file_ref, session.created_files) do
      {:local, path} -> {:ok, path}
      {:native, path} -> {:ok, path}
      {:docker, path} -> {:ok, path}
      {:anthropic, file_id} -> lazy_download(file_id, session)
      :not_found -> {:error, :file_not_found}
    end
  end

  defp lazy_download(file_id, session) do
    working_dir = Keyword.get(session.opts, :working_directory, System.tmp_dir!())
    cache_path = Path.join([working_dir, "anthropic_cache", file_id])

    if File.exists?(cache_path) do
      {:ok, cache_path}
    else
      File.mkdir_p!(Path.dirname(cache_path))

      case Conjure.API.Anthropic.download_file(file_id, cache_path) do
        :ok ->
          # Update file tracking with cached path
          {:ok, cache_path}

        {:error, reason} ->
          {:error, {:download_failed, file_id, reason}}
      end
    end
  end
end

File Structure

lib/conjure/
 session.ex                    # Add new_hybrid/2, :hybrid mode
 session/
    hybrid.ex                 # Hybrid conversation loop
    hybrid/
        file_resolver.ex      # Cross-backend file access
 backend.ex                    # Add Hybrid to available backends

docs/adr/
 0021-hybrid-multi-backend-sessions.md

test/conjure/
 session/
     hybrid_test.exs           # Hybrid session tests

Consequences

Positive

  1. Simplified Multi-Backend Workflows: Single session for complex agents
  2. Unified Conversation: One message history, one file list
  3. Automatic Routing: No manual backend selection per operation
  4. Backwards Compatible: New opt-in feature, existing code unchanged
  5. Parallel Execution: Tool calls to different backends run concurrently
  6. Lazy File Resolution: Anthropic files downloaded only when needed

Negative

  1. Increased Complexity: New session mode with sub-session management
  2. Memory Overhead: Sub-sessions and routing table add state
  3. Error Attribution: Need clear error context for multi-backend failures

Neutral

  1. No Backend Changes: Existing backends work unmodified
  2. Opt-In: Developers choose when to use hybrid mode
  3. Tool Name Prefixing: Changes visible tool names (may affect prompts)

Alternatives Considered

A. Skill-Level Backend Affinity

Skills declare their backend in metadata. Session routes based on skill.

Rejected: Couples skills to backends, reduces portability, requires Skill struct changes.

B. Tool-Level Configuration

External config maps tool names to backends.

Rejected: Too granular, configuration burden, less discoverable.

C. Workflow Composition Helpers

Better utilities for chaining single-backend sessions.

Rejected: Doesn't solve core UX issue of multiple sessions/histories.

References