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.
- Design Principles
- Architecture Overview
- ContextPolicy Specification
- Projector Implementation
- Entry Kind Mappings
- Strategy Integration
- Token Budgeting Algorithm
- Migration Path
- API Reference
- Future Enhancements
| 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 |
- Thread is source of truth — Machine's
conversationfield becomes derived, not authoritative - Projection is pure —
project(thread, policy)has no side effects - Append-only history — Summaries are entries, not replacements
- Provider-agnostic storage — Thread payloads use generic role strings, not provider atoms
Signal (data) → Dispatch (runtime)
Agent (data) → AgentServer (process)
Directive (desc) → Runtime (execution)
Thread (history) → Context (projection) ← NEW
┌─────────────────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────────────────────┘
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 │
└─────────────────────────────────┘
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
endThe strategy resolves policy from multiple sources (highest priority first):
- Per-request option:
ask(pid, query, context_policy: policy) - Agent state:
agent.state.__context_policy__ - Strategy config:
strategy: {ReAct, context_policy: policy, ...} - Default:
ContextPolicy.new()
defp resolve_context_policy(agent, params, config) do
params[:context_policy]
|| agent.state[:__context_policy__]
|| config[:context_policy]
|| ContextPolicy.new()
enddefmodule 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()}
enddefmodule 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| 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: ..."} |
# 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"}
}The ReAct strategy changes from:
# OLD: Machine owns conversation
{machine, directives} = Machine.update(machine, {:start, query, call_id}, env)
# machine.conversation is authoritativeTo:
# 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# 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{}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
endavailable_budget = max_input_tokens - reserve_output_tokens
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
endTool 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 resultsInitial 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
endFuture enhancement: provider-specific tokenizers via ReqLLM.Tokenizer behaviour.
- Add
Jido.AI.Thread.ContextPolicymodule - Add
Jido.AI.Thread.Projectorbehaviour - Add
Jido.AI.Thread.Projectors.ReqLLMimplementation - Strategy optionally uses Thread if
agent.state.__thread__exists - Machine's
conversationremains authoritative if Thread missing
- Strategy always ensures Thread exists
- Machine's
conversationfield removed or made ephemeral - All entry appends go through Thread
- Projector generates context for every LLM call
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# 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)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# 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 contextWhen 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
enddefmodule 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# 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 # => 10For 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")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
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.