docs/adr/0019-unified-execution-model.md

# ADR-0019: Unified Execution Model

## 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:
- Local: `Conjure.Conversation.run_loop/4` with `Conjure.Executor` behaviours
- Anthropic: `Conjure.Session.Anthropic` and `Conjure.Conversation.Anthropic`

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

### Key Differences Between Modes

| Aspect | Local/Docker | Anthropic Hosted |
|--------|--------------|------------------|
| Who executes | Your application | Anthropic's container |
| Conversation loop | tool_use → execute → tool_result | pause_turn continuation |
| Skills source | Local filesystem | Uploaded to Anthropic |
| Multi-turn state | Message history | Container ID + messages |
| File output | Local filesystem | Files API download |

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

### Reference

- [Anthropic Skills API Guide](https://platform.claude.com/docs/en/build-with-claude/skills-guide)

## 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

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

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

```elixir
# 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:

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

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

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

- [ADR-0002: Pluggable Executor Architecture](0002-pluggable-executor-architecture.md)
- [ADR-0004: API-Client Agnostic Design](0004-api-client-agnostic-design.md)
- [ADR-0011: Anthropic Skills API Integration](0011-anthropic-executor.md)
- [Anthropic Skills API Guide](https://platform.claude.com/docs/en/build-with-claude/skills-guide)