Skip to content

Instantly share code, notes, and snippets.

@mikehostetler
Created February 2, 2026 16:56
Show Gist options
  • Select an option

  • Save mikehostetler/771a8e380430006aef6cd4710a36ee8d to your computer and use it in GitHub Desktop.

Select an option

Save mikehostetler/771a8e380430006aef6cd4710a36ee8d to your computer and use it in GitHub Desktop.
Jido Thread & Context Projection Design Docs

Jido AI ReAct Thread Integration: LLM Context as Projection

Executive Summary

This document specifies the integration of Jido.Thread with the ReAct strategy, implementing the key insight from JIDO_THREAD.md: Thread is the canonical history; LLM context is a derived projection.

The Machine's conversation list becomes ephemeral—generated fresh each LLM call from Thread via a ContextPolicy projector. This enables token budgeting, windowing, summarization, and provider-agnostic storage while keeping the full interaction history intact.


Table of Contents

  1. Design Principles
  2. Architecture Overview
  3. ContextPolicy Specification
  4. Projector Implementation
  5. Entry Kind Mappings
  6. Strategy Integration
  7. Token Budgeting Algorithm
  8. Migration Path
  9. API Reference
  10. Future Enhancements

1. Design Principles

1.1 Separation of Concerns

Concern Owner Mutability
Canonical History Jido.Thread Append-only, never destructive
LLM Context Jido.AI.Thread.Projector Ephemeral, regenerated per-call
Provider Formatting ReqLLM.Context Transformation layer

1.2 Key Invariants

  1. Thread is source of truth — Machine's conversation field becomes derived, not authoritative
  2. Projection is pureproject(thread, policy) has no side effects
  3. Append-only history — Summaries are entries, not replacements
  4. Provider-agnostic storage — Thread payloads use generic role strings, not provider atoms

1.3 Consistency with Jido Patterns

Signal (data)    → Dispatch (runtime)
Agent (data)     → AgentServer (process)
Directive (desc) → Runtime (execution)
Thread (history) → Context (projection)  ← NEW

2. Architecture Overview

┌─────────────────────────────────────────────────────────────────────┐
│                      Jido.Thread (Data)                              │
│   Append-only: [Entry₁, Entry₂, ... Entryₙ]                         │
│   Stored in: agent.state.__thread__                                  │
└───────────────────────────┬─────────────────────────────────────────┘
                            │
                            ▼ project/2
┌─────────────────────────────────────────────────────────────────────┐
│                 Jido.AI.Thread.Projector                             │
│   Input: Thread + ContextPolicy                                      │
│   Output: %{messages: [...], meta: %{...}}                          │
│                                                                      │
│   Operations:                                                        │
│   1. Filter by entry kinds                                           │
│   2. Window (last N turns, last M tokens)                            │
│   3. Inject summaries for compressed history                         │
│   4. Map entries → ReqLLM message format                             │
│   5. Prepend system prompt                                           │
└───────────────────────────┬─────────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────────────────┐
│                 ReqLLM Messages (Ephemeral)                          │
│   [%{role: :system, ...}, %{role: :user, ...}, ...]                 │
│   Passed to Directive.ReqLLMStream                                   │
└─────────────────────────────────────────────────────────────────────┘

2.1 Data Flow

User Query
    │
    ▼
┌─────────────────────────────────┐
│  Strategy: react_start          │
│  1. Thread.append(:message)     │◄─── Write to Thread
│  2. Projector.project(policy)   │◄─── Read from Thread
│  3. Emit ReqLLMStream directive │
└─────────────────────────────────┘
    │
    ▼
LLM Response (tool_calls or final_answer)
    │
    ▼
┌─────────────────────────────────┐
│  Strategy: react_llm_result     │
│  1. Thread.append(:tool_call)   │◄─── Write tool calls
│     or Thread.append(:message)  │◄─── Write final answer
│  2. Emit ToolExec directives    │
└─────────────────────────────────┘
    │
    ▼
Tool Result
    │
    ▼
┌─────────────────────────────────┐
│  Strategy: react_tool_result    │
│  1. Thread.append(:tool_result) │◄─── Write result
│  2. Projector.project(policy)   │◄─── Fresh projection
│  3. Emit ReqLLMStream directive │
└─────────────────────────────────┘

3. ContextPolicy Specification

3.1 Module Definition

defmodule Jido.AI.Thread.ContextPolicy do
  @moduledoc """
  Configuration for projecting Thread → LLM context.
  
  Controls token budgeting, windowing, summarization strategy,
  and entry filtering.
  """
  
  @schema Zoi.struct(
    __MODULE__,
    %{
      # Token budgeting
      max_input_tokens: Zoi.integer(description: "Maximum tokens for input context")
                        |> Zoi.default(8000),
      reserve_output_tokens: Zoi.integer(description: "Tokens reserved for response")
                             |> Zoi.default(2000),
      
      # Windowing
      max_messages: Zoi.integer(description: "Max messages (0 = unlimited)")
                    |> Zoi.default(0),
      keep_last_turns: Zoi.integer(description: "Always keep last N user/assistant pairs")
                       |> Zoi.default(3),
      
      # Summarization
      summarization: Zoi.atom(description: ":none | :use_existing | :request_new")
                     |> Zoi.default(:use_existing),
      summary_role: Zoi.atom(description: ":system | :user - how to present summaries")
                    |> Zoi.default(:system),
      
      # Filtering
      include_kinds: Zoi.list(Zoi.atom())
                     |> Zoi.default([:message, :tool_call, :tool_result, :summary]),
      
      # System prompt handling
      system_prompt: Zoi.string() |> Zoi.optional(),
      
      # Token estimation strategy
      token_estimator: Zoi.atom(description: ":heuristic | :exact (future)")
                       |> Zoi.default(:heuristic)
    },
    coerce: true
  )
  
  @type t :: unquote(Zoi.type_spec(@schema))
  @enforce_keys Zoi.Struct.enforce_keys(@schema)
  defstruct Zoi.Struct.struct_fields(@schema)
  
  @doc "Create a default policy"
  @spec new(keyword()) :: t()
  def new(opts \\ []) do
    struct!(__MODULE__, opts)
  end
  
  @doc "Preset for short context windows (8K models)"
  @spec short_context() :: t()
  def short_context do
    new(max_input_tokens: 6000, keep_last_turns: 2)
  end
  
  @doc "Preset for long context windows (128K+ models)"
  @spec long_context() :: t()
  def long_context do
    new(max_input_tokens: 100_000, keep_last_turns: 10, max_messages: 0)
  end
  
  @doc "Preset for tool-heavy workflows"
  @spec tool_focused() :: t()
  def tool_focused do
    new(
      keep_last_turns: 5,
      include_kinds: [:message, :tool_call, :tool_result],
      summarization: :none
    )
  end
end

3.2 Policy Resolution

The strategy resolves policy from multiple sources (highest priority first):

  1. Per-request option: ask(pid, query, context_policy: policy)
  2. Agent state: agent.state.__context_policy__
  3. Strategy config: strategy: {ReAct, context_policy: policy, ...}
  4. Default: ContextPolicy.new()
defp resolve_context_policy(agent, params, config) do
  params[:context_policy]
  || agent.state[:__context_policy__]
  || config[:context_policy]
  || ContextPolicy.new()
end

4. Projector Implementation

4.1 Behaviour Definition

defmodule Jido.AI.Thread.Projector do
  @moduledoc """
  Behaviour for Thread → LLM context projection.
  
  Implementations transform a Thread into a list of LLM messages
  according to a ContextPolicy.
  """
  
  alias Jido.Thread
  alias Jido.AI.Thread.ContextPolicy
  
  @type projection_result :: %{
    messages: [map()],
    meta: %{
      estimated_tokens: non_neg_integer(),
      truncated?: boolean(),
      entries_included: non_neg_integer(),
      entries_total: non_neg_integer(),
      summary_used?: boolean()
    }
  }
  
  @callback project(Thread.t(), ContextPolicy.t(), keyword()) ::
    {:ok, projection_result()} | {:error, term()}
end

4.2 Default Projector

defmodule Jido.AI.Thread.Projectors.ReqLLM do
  @moduledoc """
  Default projector producing ReqLLM-compatible message lists.
  
  This projector:
  1. Filters entries by `include_kinds`
  2. Groups tool calls with their results
  3. Applies token budgeting from newest to oldest
  4. Injects summaries when available and enabled
  5. Prepends system prompt
  """
  
  @behaviour Jido.AI.Thread.Projector
  
  alias Jido.Thread
  alias Jido.Thread.Entry
  alias Jido.AI.Thread.ContextPolicy
  
  @impl true
  def project(%Thread{} = thread, %ContextPolicy{} = policy, opts \\ []) do
    entries = Thread.to_list(thread)
    
    # Step 1: Filter by kinds
    filtered = filter_by_kinds(entries, policy.include_kinds)
    
    # Step 2: Find latest summary (if summarization enabled)
    {summary_entry, entries_after_summary} = 
      if policy.summarization != :none do
        find_applicable_summary(filtered)
      else
        {nil, filtered}
      end
    
    # Step 3: Convert entries to message groups (preserving tool call atomicity)
    message_groups = entries_to_message_groups(entries_after_summary)
    
    # Step 4: Apply windowing (keep_last_turns)
    windowed = apply_turn_window(message_groups, policy.keep_last_turns)
    
    # Step 5: Apply token budget (from newest, drop oldest)
    budget = policy.max_input_tokens - policy.reserve_output_tokens
    {budgeted, truncated?, estimated_tokens} = 
      apply_token_budget(windowed, budget, policy.token_estimator)
    
    # Step 6: Build final message list
    messages = build_message_list(
      policy.system_prompt,
      summary_entry,
      budgeted,
      policy.summary_role
    )
    
    {:ok, %{
      messages: messages,
      meta: %{
        estimated_tokens: estimated_tokens,
        truncated?: truncated?,
        entries_included: count_entries(budgeted),
        entries_total: length(entries),
        summary_used?: summary_entry != nil
      }
    }}
  end
  
  # --- Private Implementation ---
  
  defp filter_by_kinds(entries, kinds) do
    kind_set = MapSet.new(kinds)
    Enum.filter(entries, &MapSet.member?(kind_set, &1.kind))
  end
  
  defp find_applicable_summary(entries) do
    case Enum.find(Enum.reverse(entries), &(&1.kind == :summary)) do
      nil -> 
        {nil, entries}
      
      %Entry{payload: %{to_seq: to_seq}} = summary ->
        # Only use entries after the summary's coverage
        after_summary = Enum.filter(entries, &(&1.seq > to_seq))
        {summary, after_summary}
    end
  end
  
  defp entries_to_message_groups(entries) do
    # Group tool_call + tool_result entries that belong together
    entries
    |> Enum.chunk_by(&group_key/1)
    |> Enum.map(&convert_group_to_messages/1)
  end
  
  defp group_key(%Entry{kind: :message}), do: {:message, :unique}
  defp group_key(%Entry{kind: :tool_call, refs: %{call_id: id}}), do: {:tool_group, id}
  defp group_key(%Entry{kind: :tool_result, refs: %{call_id: id}}), do: {:tool_group, id}
  defp group_key(%Entry{kind: kind}), do: {kind, :unique}
  
  defp convert_group_to_messages([%Entry{kind: :message} = e]) do
    %{
      type: :message,
      messages: [entry_to_message(e)],
      tokens: estimate_tokens(e)
    }
  end
  
  defp convert_group_to_messages(entries) when is_list(entries) do
    # Tool call group: assistant message + tool results
    tool_calls = Enum.filter(entries, &(&1.kind == :tool_call))
    tool_results = Enum.filter(entries, &(&1.kind == :tool_result))
    
    messages = 
      if tool_calls != [] do
        [build_assistant_tool_calls_message(tool_calls) | 
         Enum.map(tool_results, &entry_to_message/1)]
      else
        Enum.map(entries, &entry_to_message/1)
      end
    
    %{
      type: :tool_group,
      messages: messages,
      tokens: Enum.sum(Enum.map(entries, &estimate_tokens/1))
    }
  end
  
  defp entry_to_message(%Entry{kind: :message, payload: payload}) do
    role = String.to_existing_atom(payload["role"] || payload[:role] || "user")
    content = payload["content"] || payload[:content] || ""
    %{role: role, content: content}
  end
  
  defp entry_to_message(%Entry{kind: :tool_result, payload: payload, refs: refs}) do
    tool_call_id = refs[:tool_call_id] || payload["tool_call_id"] || payload[:tool_call_id]
    name = payload["name"] || payload[:name]
    result = payload["result"] || payload[:result]
    
    content = case result do
      {:ok, res} -> Jason.encode!(res)
      {:error, reason} -> Jason.encode!(%{error: inspect(reason)})
      other when is_binary(other) -> other
      other -> Jason.encode!(other)
    end
    
    %{role: :tool, tool_call_id: tool_call_id, name: name, content: content}
  end
  
  defp build_assistant_tool_calls_message(tool_call_entries) do
    tool_calls = Enum.map(tool_call_entries, fn %Entry{payload: p, refs: refs} ->
      %{
        id: refs[:tool_call_id] || p["id"] || p[:id],
        name: p["name"] || p[:name],
        arguments: p["arguments"] || p[:arguments] || %{}
      }
    end)
    
    %{role: :assistant, content: "", tool_calls: tool_calls}
  end
  
  defp apply_turn_window(groups, 0), do: groups
  defp apply_turn_window(groups, keep_last) do
    # Count "turns" (user + assistant pairs)
    # Keep at minimum the last N turns worth of groups
    # For simplicity, keep last (keep_last * 3) groups to account for tool exchanges
    min_groups = keep_last * 3
    if length(groups) <= min_groups do
      groups
    else
      Enum.take(groups, -min_groups)
    end
  end
  
  defp apply_token_budget(groups, budget, _estimator) do
    # Walk from end (newest) to start, accumulating until budget exceeded
    {kept, used, truncated?} = 
      groups
      |> Enum.reverse()
      |> Enum.reduce_while({[], 0, false}, fn group, {acc, tokens, _truncated?} ->
        new_tokens = tokens + group.tokens
        if new_tokens <= budget do
          {:cont, {[group | acc], new_tokens, false}}
        else
          {:halt, {acc, tokens, true}}
        end
      end)
    
    {kept, truncated?, used}
  end
  
  defp build_message_list(system_prompt, summary_entry, groups, summary_role) do
    # 1. System prompt (if provided)
    system_msgs = if system_prompt, do: [%{role: :system, content: system_prompt}], else: []
    
    # 2. Summary (if available)
    summary_msgs = case summary_entry do
      nil -> []
      %Entry{payload: %{content: content}} ->
        summary_content = "Summary of earlier conversation:\n#{content}"
        [%{role: summary_role, content: summary_content}]
    end
    
    # 3. Flatten message groups
    conversation_msgs = Enum.flat_map(groups, & &1.messages)
    
    system_msgs ++ summary_msgs ++ conversation_msgs
  end
  
  defp estimate_tokens(%Entry{payload: payload}) do
    # Simple heuristic: ~4 chars per token + overhead
    content = payload["content"] || payload[:content] || ""
    args = payload["arguments"] || payload[:arguments]
    
    text_size = byte_size(to_string(content))
    args_size = if args, do: byte_size(Jason.encode!(args)), else: 0
    
    div(text_size + args_size, 4) + 10
  end
  
  defp count_entries(groups) do
    Enum.sum(Enum.map(groups, fn g -> length(g.messages) end))
  end
end

5. Entry Kind Mappings

5.1 Thread Entry → LLM Message Mapping

Entry Kind Payload Schema LLM Message Format
:message %{role: "user"|"assistant"|"system", content: string} %{role: :user, content: "..."}
:tool_call %{id: string, name: string, arguments: map} Aggregated into %{role: :assistant, tool_calls: [...]}
:tool_result %{tool_call_id: string, name: string, result: term} %{role: :tool, tool_call_id: id, content: json}
:summary %{from_seq: int, to_seq: int, content: string} %{role: :system|:user, content: "Summary: ..."}

5.2 Payload Conventions

# User message
%Entry{
  kind: :message,
  payload: %{role: "user", content: "What's the weather in Tokyo?"},
  refs: %{request_id: "req_123"}
}

# Assistant message (final answer)
%Entry{
  kind: :message,
  payload: %{role: "assistant", content: "The weather in Tokyo is sunny."},
  refs: %{request_id: "req_123", iteration: 2}
}

# Tool call
%Entry{
  kind: :tool_call,
  payload: %{name: "get_weather", arguments: %{city: "Tokyo"}},
  refs: %{
    tool_call_id: "tc_abc123",
    call_id: "call_xyz",
    request_id: "req_123",
    iteration: 1
  }
}

# Tool result
%Entry{
  kind: :tool_result,
  payload: %{
    name: "get_weather",
    result: {:ok, %{temp: 22, condition: "sunny"}}
  },
  refs: %{
    tool_call_id: "tc_abc123",
    call_id: "call_xyz"
  }
}

# Summary (created by explicit summarization action)
%Entry{
  kind: :summary,
  payload: %{
    from_seq: 0,
    to_seq: 15,
    content: "User asked about weather in multiple cities. Got results for Tokyo and London.",
    format: :plain
  },
  refs: %{model: "anthropic:claude-haiku-4-5"}
}

6. Strategy Integration

6.1 Modified Strategy Flow

The ReAct strategy changes from:

# OLD: Machine owns conversation
{machine, directives} = Machine.update(machine, {:start, query, call_id}, env)
# machine.conversation is authoritative

To:

# NEW: Thread owns history, Machine is stateless per-call
agent = append_to_thread(agent, :message, %{role: "user", content: query})
{:ok, projection} = Projector.project(get_thread(agent), policy)
# projection.messages is ephemeral, derived from Thread

6.2 Strategy State Changes

# Current state structure
%{
  config: %{...},
  status: :idle | :awaiting_llm | ...,
  conversation: [...],  # <-- REMOVE: no longer authoritative
  pending_tool_calls: [...],
  ...
}

# New state structure  
%{
  config: %{..., context_policy: %ContextPolicy{}},
  status: :idle | :awaiting_llm | ...,
  pending_tool_calls: [...],
  # conversation removed - derived from __thread__
  ...
}

# Thread lives in agent.state.__thread__
agent.state.__thread__ :: %Jido.Thread{}

6.3 Integration Points in react.ex

defmodule Jido.AI.Strategies.ReAct do
  # ... existing code ...
  
  alias Jido.Thread
  alias Jido.AI.Thread.ContextPolicy
  alias Jido.AI.Thread.Projectors.ReqLLM, as: Projector
  
  # Helper to get/ensure thread
  defp ensure_thread(agent) do
    case agent.state[:__thread__] do
      nil -> %{agent | state: Map.put(agent.state, :__thread__, Thread.new())}
      _ -> agent
    end
  end
  
  defp get_thread(agent), do: agent.state[:__thread__]
  
  defp put_thread(agent, thread) do
    %{agent | state: Map.put(agent.state, :__thread__, thread)}
  end
  
  defp append_entry(agent, kind, payload, refs \\ %{}) do
    thread = get_thread(agent) || Thread.new()
    entry = %{kind: kind, payload: payload, refs: refs}
    put_thread(agent, Thread.append(thread, entry))
  end
  
  # Modified process_action for :react_start
  defp process_action(agent, @start, %{query: query} = params) do
    agent = ensure_thread(agent)
    state = StratState.get(agent, %{})
    config = state[:config]
    
    # 1. Append user message to Thread
    agent = append_entry(agent, :message, 
      %{role: "user", content: query},
      %{request_id: params[:request_id]}
    )
    
    # 2. Resolve context policy
    policy = resolve_context_policy(agent, params, config)
    |> Map.put(:system_prompt, config[:system_prompt])
    
    # 3. Project Thread → LLM messages
    {:ok, projection} = Projector.project(get_thread(agent), policy)
    
    # 4. Generate call ID and update state
    call_id = generate_call_id()
    
    new_state = state
    |> Map.put(:status, :awaiting_llm)
    |> Map.put(:current_llm_call_id, call_id)
    |> Map.put(:iteration, (state[:iteration] || 0) + 1)
    |> Map.put(:started_at, System.monotonic_time(:millisecond))
    
    agent = StratState.put(agent, new_state)
    
    # 5. Emit LLM directive with projected messages
    directives = [
      Directive.ReqLLMStream.new!(%{
        id: call_id,
        model: config[:model],
        context: projection.messages,
        tools: config[:reqllm_tools]
      })
    ]
    
    {agent, directives}
  end
  
  # Modified LLM result handling
  defp process_action(agent, @llm_result, %{call_id: call_id, result: {:ok, result}}) do
    state = StratState.get(agent, %{})
    
    case result.type do
      :tool_calls ->
        # Append tool call entries
        agent = Enum.reduce(result.tool_calls, agent, fn tc, acc ->
          append_entry(acc, :tool_call,
            %{name: tc.name, arguments: tc.arguments},
            %{tool_call_id: tc.id, call_id: call_id, iteration: state[:iteration]}
          )
        end)
        
        # Update state and emit tool exec directives
        # ... (existing logic)
        
      :final_answer ->
        # Append assistant message
        agent = append_entry(agent, :message,
          %{role: "assistant", content: result.text},
          %{call_id: call_id, iteration: state[:iteration]}
        )
        
        # Update state to completed
        # ... (existing logic)
    end
  end
  
  # Modified tool result handling
  defp process_action(agent, @tool_result, %{call_id: call_id, tool_name: name, result: result}) do
    # Append tool result entry
    agent = append_entry(agent, :tool_result,
      %{name: name, result: result},
      %{tool_call_id: call_id}
    )
    
    state = StratState.get(agent, %{})
    config = state[:config]
    
    # Check if all pending tool calls are complete
    # If so, project fresh context and continue
    if all_tools_complete?(state) do
      policy = resolve_context_policy(agent, %{}, config)
      |> Map.put(:system_prompt, config[:system_prompt])
      
      {:ok, projection} = Projector.project(get_thread(agent), policy)
      
      new_call_id = generate_call_id()
      # ... emit new LLM call with projection.messages
    end
  end
end

7. Token Budgeting Algorithm

7.1 Budget Calculation

available_budget = max_input_tokens - reserve_output_tokens

7.2 Allocation Strategy (Newest-First)

def allocate_budget(message_groups, budget) do
  # 1. Reserve space for system prompt (~100-500 tokens)
  system_reserve = 500
  conversation_budget = budget - system_reserve
  
  # 2. Walk from newest to oldest, keeping groups that fit
  {kept, _remaining} = 
    message_groups
    |> Enum.reverse()
    |> Enum.reduce_while({[], conversation_budget}, fn group, {acc, remaining} ->
      if group.tokens <= remaining do
        {:cont, {[group | acc], remaining - group.tokens}}
      else
        {:halt, {acc, remaining}}
      end
    end)
  
  kept
end

7.3 Atomic Group Preservation

Tool call groups are kept or dropped atomically:

# A tool call group = [assistant_tool_calls_msg, tool_result_msg, tool_result_msg, ...]
# Never split these - model expects paired tool calls and results

7.4 Token Estimation

Initial implementation uses a simple heuristic:

def estimate_tokens(text) when is_binary(text) do
  # ~4 characters per token (conservative for English)
  # Add overhead for message structure
  div(byte_size(text), 4) + 10
end

Future enhancement: provider-specific tokenizers via ReqLLM.Tokenizer behaviour.


8. Migration Path

8.1 Phase 1: Add Thread Support (Non-Breaking)

  1. Add Jido.AI.Thread.ContextPolicy module
  2. Add Jido.AI.Thread.Projector behaviour
  3. Add Jido.AI.Thread.Projectors.ReqLLM implementation
  4. Strategy optionally uses Thread if agent.state.__thread__ exists
  5. Machine's conversation remains authoritative if Thread missing

8.2 Phase 2: Thread as Primary (Breaking)

  1. Strategy always ensures Thread exists
  2. Machine's conversation field removed or made ephemeral
  3. All entry appends go through Thread
  4. Projector generates context for every LLM call

8.3 Backward Compatibility

During migration, the strategy detects which mode to use:

defp get_conversation_source(agent, state) do
  case agent.state[:__thread__] do
    %Thread{} = thread when thread.entries != [] ->
      {:thread, thread}
    _ ->
      # Fallback to machine conversation
      {:machine, state[:conversation] || []}
  end
end

9. API Reference

9.1 ReActAgent API (Extended)

# Existing
{:ok, request} = MyAgent.ask(pid, "query")
{:ok, result} = MyAgent.await(request)

# New: with context policy
{:ok, request} = MyAgent.ask(pid, "query", 
  context_policy: ContextPolicy.short_context()
)

# New: access thread
thread = MyAgent.get_thread(pid)  # Returns current Thread

# New: inject summary (for long sessions)
:ok = MyAgent.summarize(pid, from_seq: 0, to_seq: 50)

9.2 Thread Helpers in Agent

defmodule MyAgent do
  use Jido.AI.ReActAgent, ...
  
  @doc "Get the current thread"
  def get_thread(pid) do
    GenServer.call(pid, :get_thread)
  end
  
  @doc "Reset thread (start fresh)"
  def reset_thread(pid) do
    GenServer.cast(pid, :reset_thread)
  end
end

9.3 ContextPolicy Presets

# Model-aware presets
ContextPolicy.for_model("anthropic:claude-haiku-4-5")  # 8K context
ContextPolicy.for_model("anthropic:claude-sonnet-4")   # 200K context
ContextPolicy.for_model("openai:gpt-4o")                # 128K context

10. Future Enhancements

10.1 Automatic Summarization

When budget exceeded and no recent summary exists:

defmodule Jido.AI.Thread.Summarizer do
  @doc "Generate a summary of entries from_seq..to_seq"
  def summarize(thread, from_seq, to_seq, opts \\ []) do
    entries = Thread.slice(thread, from_seq, to_seq)
    
    # Use LLM to generate summary (via separate action)
    # Returns entry to append
    %{
      kind: :summary,
      payload: %{
        from_seq: from_seq,
        to_seq: to_seq,
        content: generated_content,
        format: :plain
      }
    }
  end
end

10.2 Provider-Specific Tokenizers

defmodule Jido.AI.Tokenizer do
  @callback count_tokens(String.t(), model :: String.t()) :: non_neg_integer()
end

# Implementations
defmodule Jido.AI.Tokenizers.Anthropic do
  # Uses Claude's tokenizer
end

defmodule Jido.AI.Tokenizers.OpenAI do
  # Uses tiktoken
end

10.3 Thread Branching (What-If)

# Fork thread at a specific point for exploration
{:ok, branch} = Thread.fork(thread, at_seq: 10, metadata: %{branch: "alt-1"})

# Each branch is an independent thread with parent reference
branch.metadata.parent_thread_id  # => original thread id
branch.metadata.fork_seq          # => 10

10.4 Cross-Request Thread Isolation

For agents handling concurrent requests:

# Each request_id gets its own thread namespace
agent.state.__threads__ = %{
  "req_123" => %Thread{...},
  "req_456" => %Thread{...}
}

# Or use separate agents per conversation
{:ok, agent} = AgentServer.start(agent: MyAgent, thread_id: "conv_123")

Appendix A: File Locations

projects/jido_ai/lib/jido_ai/
├── thread/
│   ├── context_policy.ex      # ContextPolicy struct
│   ├── projector.ex           # Projector behaviour
│   └── projectors/
│       └── req_llm.ex         # Default ReqLLM projector
├── strategies/
│   └── react.ex               # Modified to use Thread
└── agents/
    └── react_agent.ex         # Extended API for thread access

Appendix B: Example Session Flow

1. User sends "What's 2+2?"
   └─ Thread appends: Entry{kind: :message, payload: %{role: "user", content: "What's 2+2?"}}
   └─ Projector generates: [system_msg, user_msg]
   └─ LLM called with 2 messages

2. LLM returns final answer "4"
   └─ Thread appends: Entry{kind: :message, payload: %{role: "assistant", content: "4"}}
   └─ Status → :completed

3. User sends "Now multiply by 3"
   └─ Thread appends: Entry{kind: :message, payload: %{role: "user", content: "Now multiply by 3"}}
   └─ Projector generates: [system, user("2+2"), assistant("4"), user("multiply by 3")]
   └─ LLM called with 4 messages (full context preserved)

4. LLM returns tool call: calculator(12 * 3)
   └─ Thread appends: Entry{kind: :tool_call, payload: %{name: "calculator", ...}}
   └─ Tool executed

5. Tool returns 36
   └─ Thread appends: Entry{kind: :tool_result, payload: %{result: {:ok, 36}}}
   └─ Projector generates fresh context with all 6 entries
   └─ LLM called again

6. LLM returns "The result is 36"
   └─ Thread appends: Entry{kind: :message, payload: %{role: "assistant", content: "..."}}
   └─ Status → :completed

Thread now has 6 entries. LLM context was regenerated 3 times (calls 1, 3, 5), each time from the full Thread.

Jido Context Projection: A Functional Model for LLM Memory

Executive Summary

This document defines a functional programming model for projecting conversation history into LLM context. The core insight: treat LLM context as a pure projection of two distinct inputs—committed history (Thread) and pending inputs (current ask)—through a policy lens.

This model provides:

  • Separation of concerns: What happened vs what we're asking vs what we show
  • Explicit composition: Context segments combine as a monoid
  • Bounded computation: Policy enforces token limits deterministically
  • Auditability: Thread is the canonical record; projections are reproducible

Table of Contents

  1. The Three-Layer Model
  2. Committed vs Pending: The Core Distinction
  3. Projection as Pure Function
  4. Context Segments and Composition
  5. The Complete Lifecycle
  6. Correlation and Traceability
  7. Policy-Driven Windowing
  8. Summary Checkpoints
  9. Implementation Specification
  10. Appendix: Diagrams

1. The Three-Layer Model

LLM-powered agents operate across three distinct layers of "context":

┌─────────────────────────────────────────────────────────────────────┐
│  Layer 1: CANONICAL HISTORY (Thread)                                 │
│                                                                      │
│  "What happened" — immutable, append-only, complete                  │
│  • Every user message, assistant response, tool call, tool result    │
│  • Never modified, never truncated                                   │
│  • Provider-agnostic (no :user/:assistant atoms, just data)         │
│  • The source of truth for replay, audit, debugging                  │
└─────────────────────────────────────────────────────────────────────┘
                                    │
                                    │ project(thread, policy, pending)
                                    ▼
┌─────────────────────────────────────────────────────────────────────┐
│  Layer 2: PROJECTED CONTEXT (Ephemeral)                              │
│                                                                      │
│  "What we show the model" — bounded, formatted, regenerated          │
│  • Windowed by tokens/turns                                          │
│  • Includes summaries of older history                               │
│  • Formatted for specific provider (OpenAI, Anthropic, etc.)        │
│  • Discarded after each LLM call                                     │
└─────────────────────────────────────────────────────────────────────┘
                                    │
                                    │ LLM provider call
                                    ▼
┌─────────────────────────────────────────────────────────────────────┐
│  Layer 3: MODEL RESPONSE (Transient)                                 │
│                                                                      │
│  "What the model said" — parsed, then committed back to Thread       │
│  • Final answer text                                                 │
│  • Tool call requests                                                │
│  • Usage metadata                                                    │
└─────────────────────────────────────────────────────────────────────┘

Key invariant: Layer 1 (Thread) is never derived from Layer 2 (projection). The arrow only points down. Projection is a view, not a source.


2. Committed vs Pending: The Core Distinction

2.1 Definitions

Concept Definition Mutability Location
Committed Facts that have happened Append-only Thread.entries
Pending The current ask, not yet recorded Ephemeral Function parameter
Projected What we show the LLM Regenerated per-call Return value

2.2 Why This Matters

Consider a user asking "What's the weather in Tokyo?":

# WRONG: Implicitly mixing pending with committed
thread = Thread.append(thread, user_message)  # Committed immediately
projection = project(thread)                   # No way to "undo" if rejected

# RIGHT: Explicit pending → commit → project
pending = [%{kind: :message, payload: %{role: "user", content: query}}]

case accept_request?(agent, pending) do
  :ok ->
    # Commit point: pending becomes committed
    thread = Thread.append(thread, pending)
    projection = project(thread, policy, [])   # pending now empty
    
  {:reject, reason} ->
    # Never touched Thread
    {:error, reason}
end

2.3 The Commit Point

The commit point is when pending becomes committed. For most flows:

Signal arrives → Validate → COMMIT (append to Thread) → Project → Call LLM
                    │
                    └── Reject here = Thread unchanged

Rule: Commit as early as possible after acceptance for durability. If the LLM call fails, you still have a record of what the user asked.


3. Projection as Pure Function

3.1 The Signature

@type pending_entry :: %{
  kind: atom(),
  payload: map(),
  refs: map()
}

@type projection_input :: %{
  thread: Jido.Thread.t(),
  policy: Jido.AI.Thread.ContextPolicy.t(),
  pending: [pending_entry()],
  system: %{prompt: String.t()},
  tools: [tool_spec()],
  ids: %{request_id: String.t(), call_id: String.t()}
}

@type projection_result :: %{
  messages: [message()],
  tool_spec: [tool_spec()],
  meta: projection_meta()
}

@type projection_meta :: %{
  basis_rev: non_neg_integer(),
  basis_last_seq: non_neg_integer(),
  pending_count: non_neg_integer(),
  truncated?: boolean(),
  estimated_tokens: non_neg_integer(),
  summary_used?: boolean(),
  entries_included: non_neg_integer(),
  entries_total: non_neg_integer()
}

@spec project(projection_input()) :: {:ok, projection_result()} | {:error, term()}

3.2 Purity Guarantees

The projection function:

  • Has no side effects — doesn't modify Thread, doesn't call external services
  • Is deterministic — same inputs always produce same output
  • Is referentially transparent — can be memoized by (thread.rev, policy_hash, pending_hash)

3.3 Example

input = %{
  thread: thread,                    # 50 entries, rev: 50
  policy: ContextPolicy.new(max_input_tokens: 8000),
  pending: [],                       # Ask already committed
  system: %{prompt: "You are a helpful assistant."},
  tools: [calculator_tool, weather_tool],
  ids: %{request_id: "req_abc", call_id: "call_xyz"}
}

{:ok, result} = Projector.project(input)

result.messages
# => [
#   %{role: :system, content: "You are a helpful assistant."},
#   %{role: :system, content: "Summary of earlier conversation:\n..."},
#   %{role: :user, content: "What's 2+2?"},
#   %{role: :assistant, content: "4"},
#   %{role: :user, content: "Now multiply by 3"},
#   ...
# ]

result.meta
# => %{
#   basis_rev: 50,
#   truncated?: true,
#   estimated_tokens: 7832,
#   summary_used?: true,
#   entries_included: 12,
#   entries_total: 50
# }

4. Context Segments and Composition

4.1 Segments as a Monoid

Context is built by composing segments. Each segment is a list of messages, and composition is concatenation:

context = 
    system_segment(system_prompt)summary_segment(latest_summary)history_segment(windowed_entries)pending_segment(pending_entries)

Where is list concatenation with these properties:

  • Associative: (a ⊕ b) ⊕ c = a ⊕ (b ⊕ c)
  • Identity: [] ⊕ a = a ⊕ [] = a

4.2 Segment Types

Segment Source Cardinality Token Budget
System policy.system_prompt 0-1 messages Reserved (fixed)
Summary Latest :summary entry in Thread 0-1 messages Part of history budget
History Thread entries after summary 0-N messages Budgeted, newest-first
Pending Current ask 0-N messages Must fit

4.3 Segment Builders

defmodule Jido.AI.Thread.Segments do
  @doc "Build system prompt segment"
  def system(%{prompt: nil}), do: []
  def system(%{prompt: prompt}), do: [%{role: :system, content: prompt}]
  
  @doc "Build summary segment from Thread"
  def summary(thread, policy) do
    case find_latest_summary(thread) do
      nil -> []
      entry -> 
        content = "Summary of earlier conversation:\n#{entry.payload.content}"
        [%{role: policy.summary_role, content: content}]
    end
  end
  
  @doc "Build history segment with windowing"
  def history(thread, policy, budget) do
    thread
    |> filter_by_kinds(policy.include_kinds)
    |> exclude_summarized(thread)
    |> group_atomic_units()
    |> window_by_budget(budget)
    |> flatten_to_messages()
  end
  
  @doc "Build pending segment"
  def pending([]), do: []
  def pending(entries), do: Enum.map(entries, &entry_to_message/1)
end

4.4 Composition Example

def project(input) do
  %{thread: thread, policy: policy, pending: pending, system: system} = input
  
  # Calculate budgets
  system_tokens = estimate_tokens(system.prompt)
  available = policy.max_input_tokens - policy.reserve_output_tokens - system_tokens
  
  # Build segments
  system_seg = Segments.system(system)
  summary_seg = Segments.summary(thread, policy)
  summary_tokens = estimate_segment_tokens(summary_seg)
  
  history_budget = available - summary_tokens
  history_seg = Segments.history(thread, policy, history_budget)
  
  pending_seg = Segments.pending(pending)
  
  # Compose
  messages = system_seg ++ summary_seg ++ history_seg ++ pending_seg
  
  {:ok, %{messages: messages, meta: build_meta(...)}}
end

5. The Complete Lifecycle

5.1 Sequence Diagram

┌─────────┐     ┌──────────┐     ┌──────────┐     ┌─────────┐     ┌─────────┐
│  User   │     │  Agent   │     │ Strategy │     │ Runtime │     │   LLM   │
└────┬────┘     └────┬─────┘     └────┬─────┘     └────┬────┘     └────┬────┘
     │               │                │                │               │
     │ ask(query)    │                │                │               │
     │──────────────▶│                │                │               │
     │               │                │                │               │
     │               │ Signal         │                │               │
     │               │ "react.user_query"              │               │
     │               │───────────────▶│                │               │
     │               │                │                │               │
     │               │                │ 1. Validate    │               │
     │               │                │ 2. COMMIT      │               │
     │               │                │    (append to Thread)          │
     │               │                │ 3. Project     │               │
     │               │                │    (thread + policy)           │
     │               │                │                │               │
     │               │                │ Directive      │               │
     │               │                │ ReqLLMStream   │               │
     │               │                │───────────────▶│               │
     │               │                │                │               │
     │               │                │                │ POST /chat    │
     │               │                │                │──────────────▶│
     │               │                │                │               │
     │               │                │                │◀──────────────│
     │               │                │                │  response     │
     │               │                │                │               │
     │               │                │ Signal         │               │
     │               │                │ "reqllm.result"│               │
     │               │                │◀───────────────│               │
     │               │                │                │               │
     │               │                │ 4. COMMIT      │               │
     │               │                │    (append response)           │
     │               │                │                │               │
     │               │                │ 5. If tool_calls:              │
     │               │                │    - COMMIT tool_call entries  │
     │               │                │    - Emit ToolExec directive   │
     │               │                │    - Await tool results        │
     │               │                │    - COMMIT tool_result entries│
     │               │                │    - Re-PROJECT                │
     │               │                │    - Loop to step 3            │
     │               │                │                │               │
     │               │◀───────────────│ 6. Return result               │
     │◀──────────────│                │                │               │
     │  {:ok, answer}│                │                │               │

5.2 State Transitions

                          ┌─────────────────┐
                          │     IDLE        │
                          │  Thread: [...]  │
                          │  Pending: []    │
                          └────────┬────────┘
                                   │ ask(query)
                                   ▼
                          ┌─────────────────┐
                          │   ACCEPTING     │
                          │  Thread: [...]  │
                          │  Pending: [ask] │◀── Can reject here
                          └────────┬────────┘
                                   │ accept → commit
                                   ▼
                          ┌─────────────────┐
                          │   PROJECTING    │
                          │  Thread: [...,ask]
                          │  Pending: []    │
                          └────────┬────────┘
                                   │ project()
                                   ▼
                          ┌─────────────────┐
                          │  AWAITING_LLM   │
                          │  Projected: [...] (ephemeral)
                          └────────┬────────┘
                                   │
                    ┌──────────────┴──────────────┐
                    ▼                             ▼
           ┌────────────────┐            ┌────────────────┐
           │  FINAL_ANSWER  │            │  TOOL_CALLS    │
           │  commit answer │            │  commit calls  │
           └───────┬────────┘            └───────┬────────┘
                   │                             │
                   ▼                             ▼
           ┌────────────────┐            ┌────────────────┐
           │   COMPLETED    │            │ AWAITING_TOOLS │
           │  Thread: [...,ans]          │  exec tools    │
           └────────────────┘            └───────┬────────┘
                                                 │ all results
                                                 ▼
                                         ┌────────────────┐
                                         │ commit results │
                                         │ RE-PROJECT     │
                                         └───────┬────────┘
                                                 │
                                                 └──▶ AWAITING_LLM

5.3 Thread Evolution Example

# Initial state
thread.entries = []

# After user asks "What's the weather?"
thread.entries = [
  %Entry{seq: 0, kind: :message, payload: %{role: "user", content: "What's the weather?"}, 
         refs: %{request_id: "r1", call_id: "c1"}}
]

# After LLM requests tool call
thread.entries = [
  %Entry{seq: 0, kind: :message, ...},
  %Entry{seq: 1, kind: :tool_call, payload: %{name: "get_weather", arguments: %{city: "NYC"}},
         refs: %{request_id: "r1", call_id: "c1", tool_call_id: "tc1"}}
]

# After tool executes
thread.entries = [
  %Entry{seq: 0, kind: :message, ...},
  %Entry{seq: 1, kind: :tool_call, ...},
  %Entry{seq: 2, kind: :tool_result, payload: %{name: "get_weather", result: {:ok, %{temp: 72}}},
         refs: %{request_id: "r1", call_id: "c1", tool_call_id: "tc1"}}
]

# After LLM gives final answer
thread.entries = [
  %Entry{seq: 0, kind: :message, payload: %{role: "user", ...}},
  %Entry{seq: 1, kind: :tool_call, ...},
  %Entry{seq: 2, kind: :tool_result, ...},
  %Entry{seq: 3, kind: :message, payload: %{role: "assistant", content: "It's 72°F in NYC."},
         refs: %{request_id: "r1", call_id: "c2"}}
]

6. Correlation and Traceability

6.1 The refs Map

Every Thread entry carries a refs map for correlation:

%Entry{
  id: "entry_abc123",
  seq: 42,
  kind: :tool_call,
  payload: %{...},
  refs: %{
    request_id: "req_xyz",      # Outer ask correlation (from RequestTracking)
    call_id: "call_456",        # LLM call this belongs to
    tool_call_id: "tc_789",     # Tool call ID (from LLM response)
    iteration: 2,               # ReAct iteration number
    parent_call_id: "call_123"  # Optional: causal chain
  }
}

6.2 Correlation Hierarchy

request_id (outer)
    │
    ├── call_id (first LLM call)
    │       │
    │       ├── tool_call_id (tool request)
    │       └── tool_call_id (tool request)
    │
    └── call_id (second LLM call, after tool results)
            │
            └── (final answer)

6.3 Querying by Correlation

# Get all entries for a specific request
Thread.filter_by_ref(thread, :request_id, "req_xyz")

# Get the tool results for a specific LLM call
thread
|> Thread.filter_by_kind(:tool_result)
|> Enum.filter(& &1.refs.call_id == "call_456")

# Reconstruct the exact projection used for a call
entries_for_call = Thread.entries_before_call(thread, "call_456")

7. Policy-Driven Windowing

7.1 The Windowing Algorithm

def window(entries, policy, budget) do
  entries
  |> Enum.reverse()                           # Start from newest
  |> group_atomic_units()                     # Keep tool call/result pairs together
  |> Enum.reduce_while({[], 0}, fn group, {acc, tokens} ->
    group_tokens = estimate_group_tokens(group)
    if tokens + group_tokens <= budget do
      {:cont, {[group | acc], tokens + group_tokens}}
    else
      {:halt, {acc, tokens}}
    end
  end)
  |> elem(0)                                  # Return kept groups
end

7.2 Atomic Units

Tool calls and their results must stay together:

def group_atomic_units(entries) do
  entries
  |> Enum.chunk_by(&atomic_group_key/1)
  |> Enum.map(&%{entries: &1, tokens: estimate_entries(&1)})
end

defp atomic_group_key(%{kind: :tool_call, refs: %{call_id: id}}), do: {:tool_group, id}
defp atomic_group_key(%{kind: :tool_result, refs: %{call_id: id}}), do: {:tool_group, id}
defp atomic_group_key(%{kind: :message, seq: seq}), do: {:message, seq}
defp atomic_group_key(entry), do: {:other, entry.seq}

7.3 Budget Allocation

Total Budget: max_input_tokens - reserve_output_tokens
              │
              ├── System Prompt (fixed, ~100-500 tokens)
              │
              ├── Summary (if exists, ~200-1000 tokens)
              │
              ├── History Window (remaining budget)
              │   └── Filled newest-first until budget exhausted
              │
              └── Pending (must fit, typically small)

8. Summary Checkpoints

8.1 Summaries as Projection Boundaries

A summary entry defines a "checkpoint" in Thread:

%Entry{
  seq: 150,
  kind: :summary,
  payload: %{
    from_seq: 0,
    to_seq: 149,
    content: "User asked about weather in multiple cities. Got forecasts for NYC, LA, Chicago.",
    format: :plain,
    model: "claude-haiku"
  },
  refs: %{request_id: "r50"}
}

8.2 Projection with Summaries

def project_with_summary(thread, policy) do
  case find_latest_summary(thread) do
    nil ->
      # No summary: window all entries
      history_segment(thread.entries, policy)
      
    %Entry{payload: %{to_seq: covered_seq}} = summary ->
      # Summary exists: use it + entries after
      summary_msg = summary_to_message(summary, policy)
      recent = Enum.filter(thread.entries, & &1.seq > covered_seq)
      [summary_msg | history_segment(recent, policy)]
  end
end

8.3 Auto-Summarization Triggers

def should_summarize?(thread, policy, projection_meta) do
  cond do
    # Trigger 1: Projection had to truncate
    projection_meta.truncated? -> true
    
    # Trigger 2: Too many entries since last summary
    entries_since_summary(thread) > policy.summarize_threshold -> true
    
    # Trigger 3: Estimated tokens exceed threshold
    estimate_thread_tokens(thread) > policy.max_thread_tokens -> true
    
    true -> false
  end
end

8.4 Summarization is Explicit

Summarization is not automatic within projection. Instead:

  1. Projection detects need for summary (returns meta.needs_summary?: true)
  2. Strategy decides whether to summarize now
  3. If yes, emits a Directive.Summarize or calls a summarization action
  4. Summarization appends a :summary entry to Thread
  5. Next projection uses the new summary as checkpoint
# In strategy, after projection
if projection.meta.needs_summary? and policy.auto_summarize do
  summary_directive = Directive.Summarize.new!(%{
    thread_id: thread.id,
    from_seq: 0,
    to_seq: find_summarization_boundary(thread),
    model: policy.summarization_model
  })
  {agent, [summary_directive | other_directives]}
end

9. Implementation Specification

9.1 Module Structure

projects/jido_ai/lib/jido_ai/
├── thread/
│   ├── context_policy.ex          # ContextPolicy struct
│   ├── projection_input.ex        # ProjectionInput struct
│   ├── projection_result.ex       # ProjectionResult struct
│   ├── projector.ex               # Projector behaviour
│   ├── segments.ex                # Segment builders
│   └── projectors/
│       └── req_llm.ex             # Default ReqLLM projector

9.2 Core Structs

defmodule Jido.AI.Thread.ProjectionInput do
  @schema Zoi.struct(__MODULE__, %{
    thread: Zoi.any(description: "Jido.Thread.t()"),
    policy: Zoi.any(description: "ContextPolicy.t()"),
    pending: Zoi.list(Zoi.map()) |> Zoi.default([]),
    system: Zoi.map() |> Zoi.default(%{}),
    tools: Zoi.list(Zoi.any()) |> Zoi.default([]),
    ids: Zoi.map() |> Zoi.default(%{})
  }, coerce: true)
  
  @type t :: unquote(Zoi.type_spec(@schema))
  defstruct Zoi.Struct.struct_fields(@schema)
end

defmodule Jido.AI.Thread.ProjectionResult do
  @schema Zoi.struct(__MODULE__, %{
    messages: Zoi.list(Zoi.map()),
    tool_spec: Zoi.list(Zoi.any()) |> Zoi.default([]),
    meta: Zoi.map()
  }, coerce: true)
  
  @type t :: unquote(Zoi.type_spec(@schema))
  defstruct Zoi.Struct.struct_fields(@schema)
end

9.3 Projector Behaviour

defmodule Jido.AI.Thread.Projector do
  alias Jido.AI.Thread.{ProjectionInput, ProjectionResult}
  
  @callback project(ProjectionInput.t()) :: {:ok, ProjectionResult.t()} | {:error, term()}
  
  @doc "Default projection using ReqLLM format"
  def project(%ProjectionInput{} = input) do
    Jido.AI.Thread.Projectors.ReqLLM.project(input)
  end
end

9.4 Strategy Integration

defmodule Jido.AI.Strategies.ReAct do
  # ... existing code ...
  
  defp process_start(agent, %{query: query} = params) do
    agent = ensure_thread(agent)
    
    # 1. Pending phase: query not yet committed
    pending = [%{kind: :message, payload: %{role: "user", content: query}}]
    
    # 2. Commit phase: append to Thread
    refs = %{request_id: params[:request_id], call_id: generate_call_id()}
    agent = append_entry(agent, :message, %{role: "user", content: query}, refs)
    
    # 3. Project phase: compute LLM context
    input = build_projection_input(agent, [], refs)
    {:ok, projection} = Projector.project(input)
    
    # 4. Directive phase: emit LLM call
    directive = build_llm_directive(projection, refs.call_id, config)
    
    {agent, [directive]}
  end
  
  defp build_projection_input(agent, pending, ids) do
    config = get_config(agent)
    
    %ProjectionInput{
      thread: get_thread(agent),
      policy: resolve_policy(agent),
      pending: pending,
      system: %{prompt: config.system_prompt},
      tools: config.reqllm_tools,
      ids: ids
    }
  end
end

Appendix: Diagrams

A.1 The Projection Pipeline

                    ┌─────────────────────────────────────────┐
                    │           ProjectionInput                │
                    │  ┌─────────┐ ┌────────┐ ┌─────────┐     │
                    │  │ Thread  │ │ Policy │ │ Pending │     │
                    │  └────┬────┘ └────┬───┘ └────┬────┘     │
                    │       │           │          │          │
                    └───────┼───────────┼──────────┼──────────┘
                            │           │          │
                            ▼           ▼          ▼
┌───────────────────────────────────────────────────────────────────────┐
│                         PROJECTOR (Pure)                               │
│                                                                        │
│  ┌────────────────┐   ┌────────────────┐   ┌────────────────┐         │
│  │ System Segment │   │ History Segment│   │ Pending Segment│         │
│  │                │   │                │   │                │         │
│  │ [system_msg]   │   │ [summary_msg,  │   │ [user_msg]     │         │
│  │                │   │  history_msgs] │   │                │         │
│  └───────┬────────┘   └───────┬────────┘   └───────┬────────┘         │
│          │                    │                    │                   │
│          └────────────────────┼────────────────────┘                   │
│                               │                                        │
│                               ▼                                        │
│                    ┌─────────────────────┐                             │
│                    │    CONCATENATE      │                             │
│                    │    (⊕ monoid)       │                             │
│                    └──────────┬──────────┘                             │
│                               │                                        │
└───────────────────────────────┼────────────────────────────────────────┘
                                │
                                ▼
                    ┌─────────────────────────────────────────┐
                    │          ProjectionResult                │
                    │  messages: [sys, sum, hist..., pending]  │
                    │  meta: %{tokens: N, truncated?: bool}   │
                    └─────────────────────────────────────────┘

A.2 Thread as Append-Only Log

Time ──────────────────────────────────────────────────────────────────▶

Thread.entries:

seq: 0        1           2              3           4          5
     │        │           │              │           │          │
     ▼        ▼           ▼              ▼           ▼          ▼
┌─────────┬─────────┬───────────┬────────────┬──────────┬─────────────┐
│ :message│:tool_cal│:tool_resul│  :message  │ :summary │  :message   │
│ user    │ get_wea │ {:ok,...} │ assistant  │ 0→4      │  user       │
│ "What's │ ther    │           │ "It's 72°" │ "Talked  │  "What      │
│ weather"│ NYC     │           │            │  weather"│  about LA?" │
└─────────┴─────────┴───────────┴────────────┴──────────┴─────────────┘
                                                   │
                                                   │
                              Projection boundary ─┘
                              (entries 0-4 summarized,
                               only 5+ included raw)

A.3 Multi-Turn Flow

Turn 1:
  User: "What's 2+2?"
  Thread: [user_msg_0]
  Project: [system, user_0]
  LLM: "4"
  Thread: [user_msg_0, asst_msg_1]

Turn 2:
  User: "Multiply by 3"
  Thread: [user_0, asst_1, user_2]
  Project: [system, user_0, asst_1, user_2]
  LLM: tool_call(calculator, 4*3)
  Thread: [user_0, asst_1, user_2, tool_call_3]
  
  Tool result: 12
  Thread: [user_0, asst_1, user_2, tool_call_3, tool_result_4]
  Project: [system, user_0, asst_1, user_2, asst(tool_calls), tool_result]
  LLM: "The result is 12"
  Thread: [user_0, asst_1, user_2, tool_call_3, tool_result_4, asst_5]

Turn 3 (with summary):
  Thread entries: 0-99 (long session)
  Summary created: covers 0-90
  Thread: [...entries..., summary_100, ...]
  
  User: "Remind me what we discussed"
  Thread: [..., summary_100, ..., user_101]
  Project: [system, summary_100, entries_91-100, user_101]  # Only 12 messages, not 102!

Summary

The Context Projection model provides:

  1. Clean separation: Thread (facts) vs Projection (view) vs Response (transient)
  2. Pure functions: project(input) → result with no side effects
  3. Explicit composition: Segments combine as a monoid
  4. Bounded context: Policy enforces token limits
  5. Auditability: Thread is canonical; projections are reproducible from Thread + Policy
  6. Scalability: Summary checkpoints enable arbitrarily long sessions

This model aligns with functional programming principles while providing the sophisticated memory management needed for production LLM agents.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment