A deep dive into how ADK Elixir compiles agent definitions into LLM requests — and why Elixir is the ideal language for it.
Why "Context Engineering" > "Prompt Engineering"
Prompt engineering is writing text and hoping the model behaves. Context engineering is building structured data pipelines that compile agent definitions, session state, conversation history, and runtime context into precisely shaped LLM requests.
In ADK Elixir, you never write a raw prompt. Instead, you declare agents:
LlmAgent.new(
name: "analyst",
model: "gemini-flash-latest",
instruction: "Analyze data for {company_name}. Current quarter: {quarter}.",
identity: "You are a senior data analyst.",
global_instruction: "Always respond in JSON.",
output_schema: %{"type" => "object", "properties" => %{"summary" => %{"type" => "string"}}},
sub_agents: [researcher(), writer()],
tools: [search_tool(), calculate_tool()]
)That declaration is compiled at runtime into a full LLM request: system instruction, messages, tool definitions, schemas, transfer instructions — all assembled automatically. The agent definition is source code. The LLM request is the compiled output.
This is what makes agents maintainable. You change the declaration. The compilation pipeline handles the rest.
Elixir's Pattern Matching Advantage
Python ADK implements context engineering through a chain of 12 request
processor classes, each with a run_async() method:
# Python ADK — class-based processors
class InstructionRequestProcessor(BaseRequestProcessor):
async def run_async(self, request, ctx):
request.system_instruction = self._compile(ctx)
return request
class ContentsRequestProcessor(BaseRequestProcessor):
async def run_async(self, request, ctx):
request.contents = self._build_contents(ctx)
return requestIn Elixir, the same logic fits naturally into pattern matching and pipes:
# Elixir ADK — pipeline with pattern matching
defp build_request(ctx, agent) do
messages = build_messages(ctx)
all_tools = effective_tools(agent)
messages = ADK.Context.Compressor.maybe_compress(messages, compressor_opts(agent, ctx))
{static_instruction, dynamic_instruction} =
ADK.InstructionCompiler.compile_split(agent, ctx)
%{
model: agent.model,
instruction: compile_instruction(ctx, agent),
static_system_instruction: static_instruction,
dynamic_system_instruction: dynamic_instruction,
messages: messages,
tools: Enum.map(all_tools, &ADK.Tool.declaration/1)
}
|> maybe_add_generate_config(agent, ctx)
|> maybe_add_output_schema(agent)
endThe build_messages/1 function uses pattern matching on event structs to
transform conversation history:
defp build_messages(ctx) do
ADK.Session.get_events(ctx.session_pid)
|> Enum.filter(&ADK.Event.on_branch?(&1, ctx.branch))
|> Enum.map(fn e ->
cond do
ADK.Event.compaction?(e) ->
%{role: :user, parts: (e.content || %{})[:parts] || []}
e.author == "user" ->
%{role: :user, parts: (e.content || %{})[:parts] || []}
e.author == current_agent ->
%{role: :model, parts: (e.content || %{})[:parts] || []}
true ->
reformat_other_agent_message(e)
end
end)
endThree things stand out:
Pattern matching on structs —
ADK.Event.compaction?(e)checks theauthorfield.on_branch?/2matches on branch paths. No isinstance checks, no type coercion.Pipe-based transformation —
get_events |> filter |> mapreads top-to-bottom. The data flows through the pipeline like water through pipes.Immutable context threading —
ctxis passed through every function unchanged. No mutation, no side effects, no "did someone modify the request object upstream?" bugs.
The Compilation Pipeline
Here's the full data flow from agent definition to LLM HTTP request:
┌──────────────────────────────────────────────────────────────┐
│ Agent Definition │
│ name, model, instruction, identity, tools, sub_agents │
└──────────────────┬───────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ InstructionCompiler.compile_split/2 │
│ │
│ ┌─────────────────────┐ ┌──────────────────────────┐ │
│ │ Static Parts │ │ Dynamic Parts │ │
│ │ • global_instruction │ │ • agent instruction │ │
│ │ • identity │ │ (with {var} substitution)│ │
│ │ • transfer targets │ │ • output_schema instruction│ │
│ └─────────────────────┘ └──────────────────────────┘ │
│ │
│ Dynamic providers resolved: fn ctx -> ... end │
│ Template vars substituted from session state │
└──────────────────┬───────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ build_messages/1 │
│ │
│ Session Events ──► Branch Filter ──► Role Assignment │
│ │
│ • Events filtered by branch path (on_branch?/2) │
│ • User events → role: :user │
│ • Current agent events → role: :model │
│ • Other agent events → reformatted as "[name] said: ..." │
│ • Compaction events → role: :user (summaries) │
└──────────────────┬───────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ Context Compression (optional) │
│ │
│ Truncate │ SlidingWindow │ Summarize │ TokenBudget │
│ │
│ Applied via maybe_compress/2 based on agent config │
└──────────────────┬───────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ LLM Request Assembly │
│ │
│ %{ │
│ model: "gemini-flash-latest", │
│ instruction: "...", # full compiled text │
│ static_system_instruction: "...", # cacheable portion │
│ dynamic_system_instruction: "...",# per-request portion │
│ messages: [...], # compressed history │
│ tools: [...], # tool declarations │
│ generate_content_config: %{...}, # temperature, etc. │
│ output_schema: %{...} # if structured output │
│ } │
└──────────────────┬───────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ LLM Backend (Gemini / Anthropic / OpenAI) │
│ │
│ Translates generic request → provider-specific HTTP call │
└──────────────────────────────────────────────────────────────┘Each stage is a pure function. The pipeline is easy to test, easy to debug, and impossible to corrupt through mutation.
Branch-Aware History
Multi-agent conversations create a tree of events. Without branch filtering, agent B would see agent C's internal tool calls — confusing both the model and the conversation flow.
ADK Elixir uses dot-delimited branch paths to keep conversations clean.
How Branches Work
root ← top-level agent
root.router ← router agent
root.router.weather ← weather specialist
root.router.news ← news specialistWhen the weather agent runs, it sees:
- ✅ Events with
branch: nil(universal — user messages) - ✅ Events with
branch: "root"(ancestor) - ✅ Events with
branch: "root.router"(ancestor) - ✅ Events with
branch: "root.router.weather"(own branch) - ❌ Events with
branch: "root.router.news"(sibling — filtered out)
The Implementation
Event.on_branch?/2 is elegant in its simplicity:
def on_branch?(%Event{branch: nil}, _current_branch), do: true
def on_branch?(%Event{}, nil), do: true
def on_branch?(%Event{branch: event_branch}, current_branch)
when is_binary(event_branch) and is_binary(current_branch) do
event_branch == current_branch or
String.starts_with?(current_branch, event_branch <> ".")
endThree clauses. No loops, no tree traversal, no parent lookups. Just string prefix matching on dot-delimited paths.
Three-Agent Scenario
Let's trace a conversation with a router, weather agent, and news agent:
# Define the agents
weather = LlmAgent.new(
name: "weather",
model: "gemini-flash-latest",
instruction: "Provide weather forecasts.",
tools: [weather_tool()]
)
news = LlmAgent.new(
name: "news",
model: "gemini-flash-latest",
instruction: "Summarize current news.",
tools: [news_tool()]
)
router = LlmAgent.new(
name: "router",
model: "gemini-flash-latest",
instruction: "Route requests to weather or news agents.",
sub_agents: [weather, news]
)When user asks "What's the weather in NYC?" followed by "Any tech news?":
Event 1: author=user, branch=nil, text="Weather in NYC?"
└─ Visible to: everyone (branch: nil)
Event 2: author=router, branch="root.router", text="Delegating to weather..."
└─ Visible to: router, weather, news (ancestor path)
Event 3: author=weather, branch="root.router.weather", tool_call=get_weather(NYC)
└─ Visible to: weather only (own branch)
Event 4: author=weather, branch="root.router.weather", text="NYC: 72°F, sunny"
└─ Visible to: weather only (own branch)
Event 5: author=user, branch=nil, text="Any tech news?"
└─ Visible to: everyone
Event 6: author=router, branch="root.router", text="Delegating to news..."
└─ Visible to: router, weather, news
Event 7: author=news, branch="root.router.news", tool_call=get_news(tech)
└─ Visible to: news onlyWhen the news agent builds its messages, it sees Events 1, 2, 5, 6, 7 — but never Events 3 and 4. The weather agent's tool calls are invisible to the news agent. Clean context, no cross-contamination.
Branching in Context
Context branching happens automatically when agents delegate:
# ADK.Context.for_child/2 extends the branch path
def for_child(%__MODULE__{} = ctx, agent_spec) do
child_name = ADK.Agent.name(agent_spec)
child_branch = if ctx.branch, do: "#{ctx.branch}.#{child_name}", else: child_name
%{ctx | agent: agent_spec, branch: child_branch, temp_state: %{}}
end
# ADK.Context.fork_branch/2 for parallel agents
def fork_branch(%__MODULE__{branch: parent} = ctx, child_name) do
branch = if parent, do: "#{parent}.#{child_name}", else: child_name
%{ctx | branch: branch, temp_state: %{}}
endYou never manage branches manually. The framework handles it.
Dynamic Instructions
Static instructions are fine for simple agents. Real-world agents need instructions that change based on context — time of day, user preferences, conversation state, external conditions.
ADK Elixir supports three forms of instruction providers:
Anonymous Functions
LlmAgent.new(
name: "assistant",
model: "gemini-flash-latest",
instruction: fn ctx ->
hour = DateTime.utc_now().hour
base = "You are a helpful assistant."
time_context = cond do
hour < 6 -> "The user is likely working late. Be concise."
hour < 12 -> "Good morning energy. Be upbeat."
hour < 18 -> "Afternoon focus. Be efficient."
true -> "Evening wind-down. Be relaxed."
end
# Access session state for user preferences
state = ADK.Session.get_all_state(ctx.session_pid)
lang_pref = Map.get(state, "preferred_language", "English")
"#{base}\n#{time_context}\nRespond in #{lang_pref}."
end
)MFA (Module-Function-Args) Providers
For testable, reusable instruction logic:
defmodule MyApp.Instructions do
def for_user(ctx) do
state = ADK.Session.get_all_state(ctx.session_pid)
tier = Map.get(state, "user_tier", "free")
case tier do
"premium" -> "You have access to all features. Be thorough."
"enterprise" -> "You are in enterprise mode. Follow compliance guidelines."
_ -> "You are in free mode. Suggest upgrades when relevant."
end
end
def with_expertise(ctx, domain) do
"You are an expert in #{domain}. " <> for_user(ctx)
end
end
# Usage
LlmAgent.new(
name: "analyst",
model: "gemini-flash-latest",
instruction: {MyApp.Instructions, :for_user}
)
# With extra args
LlmAgent.new(
name: "legal_analyst",
model: "gemini-flash-latest",
instruction: {MyApp.Instructions, :with_expertise, ["contract law"]}
)How Resolution Works
InstructionCompiler.resolve_provider/2 handles all three forms:
def resolve_provider(instruction, _ctx) when is_binary(instruction), do: instruction
def resolve_provider(fun, ctx) when is_function(fun, 1), do: safe_call(fn -> fun.(ctx) end)
def resolve_provider({mod, fun_name}, ctx), do: safe_call(fn -> apply(mod, fun_name, [ctx]) end)
def resolve_provider({mod, fun_name, args}, ctx), do: safe_call(fn -> apply(mod, fun_name, [ctx | args]) end)Pattern matching dispatches to the right resolution strategy. If a provider raises, the error is logged and an empty string is used — the agent still responds rather than crashing.
Template Variable Substitution
Instructions support {key} template variables that are filled from session
state at compile time:
LlmAgent.new(
name: "support",
model: "gemini-flash-latest",
instruction: """
You are helping {user_name} with their {plan_type} account.
Their account ID is {account_id}.
Priority level: {priority}.
"""
)Before an LLM call, the runner populates session state:
# Set state before or during the session
ADK.Session.set_state(session_pid, "user_name", "Alice")
ADK.Session.set_state(session_pid, "plan_type", "enterprise")
ADK.Session.set_state(session_pid, "account_id", "ENT-12345")
ADK.Session.set_state(session_pid, "priority", "high")The substitution engine replaces {key} patterns:
def substitute_vars(instruction, state) when is_binary(instruction) and is_map(state) do
Regex.replace(~r/\{(\w+)\}/, instruction, fn full_match, key ->
case Map.get(state, key) || Map.get(state, String.to_existing_atom(key)) do
nil -> full_match # Leave unresolved vars as-is
value -> to_string(value)
end
end)
endThe flow: Session State → substitute_vars/2 → Resolved Instruction → LLM
Unresolved variables are left as literal text (e.g., {unknown_key}) rather
than raising — this is defensive by design, so missing state doesn't crash
the agent.
Context Compression Strategies
Long conversations exceed token limits. ADK Elixir provides four compression strategies, each suited to different scenarios.
Strategy Overview
| Strategy | Approach | Best For |
|---|---|---|
Truncate | Keep last N messages | Simple chatbots, prototypes |
SlidingWindow | Keep last N invocations (preserves tool call pairs) | Tool-heavy agents |
Summarize | LLM-summarize old messages, keep recent verbatim | Long conversations needing full context |
TokenBudget | Fill a token budget newest-first | Production agents with token cost limits |
Decision Tree
Need to stay within a specific token budget?
├── Yes → TokenBudget
└── No
├── Agent uses many tools?
│ ├── Yes → SlidingWindow (invocation-aware, won't orphan tool responses)
│ └── No
│ ├── Context matters a lot? (e.g., multi-session support agent)
│ │ ├── Yes → Summarize (preserves context in compressed form)
│ │ └── No → Truncate (simplest, cheapest)
└── Combine: TokenBudget as outer limit + any inner strategyConfiguration Examples
# Simple truncation — last 20 messages
LlmAgent.new(
name: "chatbot",
model: "gemini-flash-latest",
instruction: "You are a helpful chatbot.",
context_compressor: [
strategy: {ADK.Context.Compressor.Truncate, [max_messages: 20]},
threshold: 25 # Only compress when > 25 messages
]
)
# Sliding window — last 5 user invocations with all their tool calls
LlmAgent.new(
name: "tool_agent",
model: "gemini-flash-latest",
instruction: "You have many tools.",
tools: many_tools(),
context_compressor: [
strategy: {ADK.Context.Compressor.SlidingWindow, [invocations: 5]},
threshold: 0 # Always apply
]
)
# LLM summarization — keep last 5 verbatim, summarize everything older
LlmAgent.new(
name: "support",
model: "gemini-flash-latest",
instruction: "You are a support agent.",
context_compressor: [
strategy: {ADK.Context.Compressor.Summarize, [keep_recent: 5]},
threshold: 10,
context: %{model: "gemini-flash-latest"}
]
)
# Token budget — stay within 4000 tokens
LlmAgent.new(
name: "efficient",
model: "gemini-flash-latest",
instruction: "Be concise.",
context_compressor: [
strategy: {ADK.Context.Compressor.TokenBudget, [
token_budget: 4000,
chars_per_token: 4, # Rough estimate (same as Python ADK)
keep_recent: 3
]},
threshold: 0 # Token budget handles its own thresholding
]
)How TokenBudget Works
The token budget strategy mirrors Python ADK's _estimate_prompt_token_count:
- System messages and the N most recent messages are always kept
- Their token cost is deducted from the budget
- Remaining older messages are added newest-first until the budget is exhausted
- Messages that don't fit are dropped
Token estimation uses total_chars ÷ chars_per_token (default: 4 chars per
token). This is a rough heuristic — good enough for budget management without
requiring an external tokenizer.
Static vs Dynamic Instruction Separation
InstructionCompiler.compile_split/2 separates instructions into two buckets:
def compile_split(agent, ctx) do
static_parts = [
global_instruction(agent, ctx), # Rarely changes
identity_instruction(agent), # Never changes
transfer_instruction(agent) # Changes only with agent topology
]
dynamic_parts = [
agent_instruction(agent, ctx), # Has {var} substitution, may use fn/MFA
output_schema_instruction(agent) # May change per-request
]
{join(static_parts), join(dynamic_parts)}
endWhy This Matters: Gemini Context Caching
Gemini's API supports context caching — you send static content once, get a cache token back, and reference it in subsequent requests. This saves both latency and cost for large system instructions.
The split enables this pattern:
# First request: send static instruction, get cache handle
{static, dynamic} = InstructionCompiler.compile_split(agent, ctx)
# The LLM backend can cache `static` across requests
# and only send `dynamic` fresh each time
request = %{
static_system_instruction: static, # Cached by Gemini
dynamic_system_instruction: dynamic, # Sent fresh each call
messages: messages
}For agents with large global instructions (compliance rules, knowledge bases, few-shot examples), this can reduce token costs by 50-80% on subsequent calls.
Design Guideline
Put stable content in global_instruction and identity. Put per-request
content in instruction with {var} templates or fn ctx -> providers:
LlmAgent.new(
name: "compliance_bot",
model: "gemini-flash-latest",
# Static — cached, sent once
global_instruction: """
COMPLIANCE RULES (version 2024.3):
1. Never recommend specific financial products...
2. Always include risk disclaimers...
... (500 lines of regulations) ...
""",
identity: "You are a licensed financial advisor assistant.",
# Dynamic — fresh each request
instruction: fn ctx ->
state = ADK.Session.get_all_state(ctx.session_pid)
region = Map.get(state, "user_region", "US")
"Apply #{region}-specific regulations. Current session risk tolerance: {risk_level}."
end
)Other-Agent Message Reformatting
When agent B reads messages from agent A, the raw events could be confusing — whose tool calls are whose? ADK Elixir reformats other-agent messages to maintain clarity:
defp reformat_other_agent_message(event) do
agent_name = event.author || "unknown"
parts = (event.content || %{})[:parts] || []
reformatted_parts =
Enum.flat_map(parts, fn
%{text: text} when is_binary(text) ->
[%{text: "[#{agent_name}] said: #{text}"}]
%{function_call: %{name: fname, args: args}} ->
args_str = if is_map(args), do: Jason.encode!(args), else: inspect(args)
[%{text: "[#{agent_name}] called tool `#{fname}` with parameters: #{args_str}"}]
%{function_response: %{name: fname, response: resp}} ->
resp_str = if is_binary(resp), do: resp, else: inspect(resp)
[%{text: "[#{agent_name}] tool `#{fname}` returned: #{resp_str}"}]
other -> [other]
end)
%{role: :user, parts: reformatted_parts}
endThis produces clear, attributable context:
[weather] said: The temperature in NYC is 72°F and sunny.
[weather] called tool `get_forecast` with parameters: {"city":"NYC","days":3}
[weather] tool `get_forecast` returned: {"forecast":"Sunny through Thursday"}The router agent sees exactly what happened without confusion about whose tool calls belong to whom.
Note: Reformatted messages become role: :user messages. This is
intentional — the model only "owns" messages with role: :model. Everything
else is context from the outside world.
Recipes
Recipe 1: Memory-Aware Agent
An agent that remembers past sessions and adjusts its behavior:
defmodule MyApp.MemoryAgent do
def build do
LlmAgent.new(
name: "memory_agent",
model: "gemini-flash-latest",
instruction: fn ctx ->
# Fetch memories from previous sessions
memories = case ctx.app_name do
nil -> []
app ->
{:ok, entries} = ADK.Memory.InMemory.search(
ADK.Memory.InMemory,
app,
ctx.user_id,
"recent context"
)
entries
end
memory_context = case memories do
[] -> "This is a new user. Be welcoming."
entries ->
summary = Enum.map_join(entries, "\n", & &1.text)
"Previous context:\n#{summary}\n\nUse this to personalize your responses."
end
"""
You are a personal assistant with memory across sessions.
#{memory_context}
The user's name is {user_name}.
"""
end
)
end
endRecipe 2: Time-Sensitive Instructions
An agent whose capabilities change based on business hours:
defmodule MyApp.SupportAgent do
def build do
LlmAgent.new(
name: "support",
model: "gemini-flash-latest",
instruction: fn _ctx ->
now = DateTime.utc_now()
hour = now.hour
day = Date.day_of_week(now)
cond do
day in [6, 7] ->
"""
Weekend mode. You can help with general questions but cannot
process refunds or account changes. Direct urgent issues to
Monday support.
"""
hour >= 9 and hour < 17 ->
"""
Business hours. Full capabilities available. You can process
refunds up to $100 and make account changes.
"""
true ->
"""
After hours. You can help with general questions and create
tickets for the morning team. No account modifications.
"""
end
end,
tools: fn _ctx ->
# Could also make tools dynamic based on the same conditions
[general_faq(), create_ticket()]
end
)
end
endRecipe 3: Progressive Disclosure
An agent that reveals more capabilities as the conversation deepens:
defmodule MyApp.ProgressiveAgent do
def build do
LlmAgent.new(
name: "guide",
model: "gemini-flash-latest",
instruction: fn ctx ->
event_count = if ctx.session_pid do
ADK.Session.get_events(ctx.session_pid) |> length()
else
0
end
base = "You are a coding tutor."
case event_count do
n when n < 4 ->
base <> """
The student just started. Keep things simple. Use basic examples.
Don't mention advanced topics yet.
"""
n when n < 12 ->
base <> """
The student is warming up. You can introduce intermediate concepts.
Start mentioning patterns and best practices.
"""
_ ->
base <> """
Extended conversation. The student is engaged. Feel free to discuss
advanced topics, trade-offs, and architectural decisions.
"""
end
end
)
end
endRecipe 4: Context-Efficient Multi-Agent Pipeline
A pipeline that uses TokenBudget compression and branch isolation together:
defmodule MyApp.Pipeline do
def build do
researcher = LlmAgent.new(
name: "researcher",
model: "gemini-flash-latest",
instruction: "Research the topic thoroughly. Output key findings.",
tools: [search_tool()],
context_compressor: [
strategy: {ADK.Context.Compressor.TokenBudget, [token_budget: 8000]},
threshold: 0
]
)
writer = LlmAgent.new(
name: "writer",
model: "gemini-flash-latest",
instruction: "Write a clear article from the research findings.",
context_compressor: [
strategy: {ADK.Context.Compressor.SlidingWindow, [invocations: 3]},
threshold: 0
]
)
editor = LlmAgent.new(
name: "editor",
model: "gemini-flash-latest",
instruction: "Edit for clarity, grammar, and tone. Output the final version."
)
# Sequential pipeline — each agent sees only its branch
ADK.Agent.SequentialAgent.new(
name: "content_pipeline",
agents: [researcher, writer, editor]
)
end
endEach agent in the pipeline gets its own branch. The writer sees the researcher's output (via branch ancestry) but the editor doesn't see the researcher's raw tool calls — only the writer's polished output.
Recipe 5: State-Driven Agent Personality
An agent that adapts its personality based on accumulated session state:
defmodule MyApp.AdaptiveAgent do
def build do
LlmAgent.new(
name: "adaptive",
model: "gemini-flash-latest",
instruction: """
You are a helpful assistant.
Communication style: {communication_style}
Expertise level to target: {expertise_level}
Topics the user is interested in: {interests}
""",
# State is set by the agent itself via tool calls
tools: [
ADK.Tool.FunctionTool.new(
name: "set_preference",
description: "Update user preference after learning about them",
function: fn args, ctx ->
key = args["key"]
value = args["value"]
ADK.Session.set_state(ctx.session_pid, key, value)
"Preference #{key} set to #{value}"
end
)
]
)
end
endThe agent calls set_preference to update state like communication_style
or expertise_level. On the next turn, the instruction template
automatically picks up the new values.
Recipe 6: Compaction-Aware Long Session
An agent designed for very long sessions (100+ turns) with smart compaction:
defmodule MyApp.LongSession do
def build do
LlmAgent.new(
name: "marathon",
model: "gemini-flash-latest",
global_instruction: """
IMPORTANT: This is a long-running session. You may see summarized
history from earlier in the conversation. Trust the summaries —
they were generated by an LLM from the actual conversation.
""",
instruction: """
You are helping {user_name} with project {project_name}.
Current phase: {project_phase}.
""",
context_compressor: [
strategy: {ADK.Context.Compressor.Summarize, [
keep_recent: 8,
summary_instruction: """
Summarize this conversation segment. Preserve:
- Key decisions made
- Action items agreed upon
- Important facts and numbers
- Current project status
Be concise but complete.
"""
]},
threshold: 15,
context: %{model: "gemini-flash-latest"}
]
)
end
endWhy Elixir is a Better Home for Context Engineering
The patterns above showcase several Elixir advantages:
Pattern matching on events —
condwith struct matching is clearer than Python's if/elif chains or isinstance checks. Theon_branch?/2implementation is three clauses of pure logic.Pipe-based transformations —
events |> filter |> map |> compressreads as a data pipeline. Python requires intermediate variables or nested function calls.Immutable context threading —
ctxflows through the pipeline unchanged. No mutation means no "who modified my context?" debugging sessions.MFA tuples for providers —
{Module, :function, [args]}is a first-class concept in Elixir. In Python, you'd use lambdas or callable classes — less inspectable, less testable.GenServer sessions — Session state lives in a GenServer process, accessed via
get_all_state/1. This is naturally concurrent — multiple agents can read session state simultaneously without locks.OTP supervision — If a context compilation step fails, the agent process crashes and its supervisor restarts it. In Python, you need try/except chains and manual recovery logic.
Compile-time guarantees — Elixir's compiler catches misspelled module names, missing functions, and struct field errors before runtime. Python discovers these in production.
Context engineering is fundamentally about data transformation — taking structured declarations and compiling them into LLM requests. Elixir was built for exactly this kind of work.
Further Reading
- Context Compilation — The compilation pipeline reference
- Agent Patterns — 25 agent design patterns with code
- Intentional Differences — Why ADK Elixir diverges from Python