Multi-Agent Workflows with Context Firewall
repo_root = Path.expand("..", __DIR__)
deps =
if File.exists?(Path.join(repo_root, "mix.exs")) do
[{:ptc_runner, path: repo_root}, {:llm_client, path: Path.join(repo_root, "llm_client")}]
else
[{:ptc_runner, "~> 0.5.0"}]
end
Mix.install(deps ++ [{:kino, "~> 0.14"}], consolidate_protocols: false)
Setup
# For testing and loading local ptc_runner library
# IEx.Helpers.recompile()
# Load LLM setup: local file if available, otherwise fetch from GitHub
local_path = Path.join(__DIR__, "llm_setup.exs")
if File.exists?(local_path) do
Code.require_file(local_path)
else
%{body: code} = Req.get!("https://raw.githubusercontent.com/andreasronge/ptc_runner/main/livebooks/llm_setup.exs")
Code.eval_string(code)
end
"LLM Setup loaded"
Choose your provider:
provider_input = LLMSetup.provider_input()
provider = Kino.Input.read(provider_input)
LLMSetup.configure_provider(provider)
model_input = LLMSetup.model_input(provider)
model = Kino.Input.read(model_input)
my_llm = LLMSetup.create_llm(model)
"Ready: #{model}"
Sample Data
Articles with hidden _body field - the body stays in BEAM memory but is hidden from LLM prompts.
alias PtcRunner.SubAgent
alias PtcRunner.SubAgent.Debug
articles = [
%{
id: 1,
title: "Quantum Coffee Roasters",
keywords: ["quantum", "coffee roasters"],
_body: "The Quantum Coffee Roasters launched a new dark blend emphasizing speed, energy, and bold flavor, targeting early-morning commuters and fitness enthusiasts."
},
%{
id: 2,
title: "Qubits Beyond Decoherence",
keywords: ["quantum", "qubits", "decoherence"],
_body: "Researchers at Helios Quantum Lab announced a breakthrough suggesting qubits can retain partial information after decoherence. The team claims 'echo-state persistence' allows quantum systems to reconstruct fragments of previous states without external error correction. The phenomenon was observed using room-temperature carbon lattice hardware. Early simulations indicate up to 30% error rate reduction in small quantum circuits."
},
%{
id: 3,
title: "Questioning Quantum Persistence",
keywords: ["quantum", "physics", "skepticism"],
_body: "Several physicists expressed skepticism about Helios Quantum Lab's claims, arguing 'echo-state persistence' conflicts with established decoherence models. Critics note the experiments rely on indirect simulations rather than reproducible measurements. The carbon lattice hardware may introduce noise misinterpreted as retained quantum information. Until independently replicated, many consider the claims speculative."
}
]
Kino.DataTable.new(Enum.map(articles, &Map.drop(&1, [:_body])), name: "Articles (without _body)")
JSON Mode Agents
Two specialized agents that will be used as tools:
# Agent 1: Filter articles by relevance
filter_agent = SubAgent.new(
prompt: "Return IDs of articles whose keywords are relevant to {{topic}}. Exclude unrelated articles.",
signature: "(topic :string, articles [{id :int, keywords [:string]}]) -> [:int]",
description: "Filter articles by topic relevance, returns list of relevant article IDs",
output: :json,
timeout: 13_000,
max_turns: 1
)
# Agent 2: Summarize text
summary_agent = SubAgent.new(
prompt: "Summarize this text in 1-2 sentences: {{text}}",
signature: "(text :string) -> {summary :string}",
description: "Summarize text into a short summary",
output: :json,
timeout: 11_000,
max_turns: 1
)
"Agents created"
Test Filter Agent
filter_context = %{
topic: "quantum computing",
articles: Enum.map(articles, &Map.take(&1, [:id, :keywords]))
}
{:ok, filter_step} = SubAgent.run(filter_agent, llm: my_llm, context: filter_context)
IO.puts("=== Filter Agent Result ===")
IO.puts("Relevant IDs: #{inspect(filter_step.return)}")
schema_tokens = div(filter_step.usage.schema_bytes, 4)
IO.puts("Schema: ~#{schema_tokens} tokens (#{round(schema_tokens / filter_step.usage.input_tokens * 100)}% of input)")
Test Summary Agent
article = Enum.at(articles, 1)
{:ok, summary_step} = SubAgent.run(summary_agent, llm: my_llm, context: %{text: article._body})
IO.puts("=== Summary Agent Result ===")
IO.puts("Summary: #{summary_step.return.summary}")
Orchestrator Agent (PTC-Lisp)
A Lisp agent that orchestrates the workflow using the two JSON agents as tools.
# Convert agents to tools
filter_tool = SubAgent.as_tool(filter_agent, llm: my_llm)
summary_tool = SubAgent.as_tool(summary_agent, llm: my_llm)
# Orchestrator uses Lisp to coordinate the tools
orchestrator = SubAgent.new(
prompt: """
Research {{topic}} using the provided articles.
1. Use filter_articles to find relevant article IDs
2. For each relevant article, use summarize to create a summary of its body
3. Combine all summaries and use summarize again to create a final summary
Return the final summary.
""",
signature: "(topic :string, articles [{id :int, keywords [:string], _body :string}]) -> :string",
tools: %{
"filter_articles" => filter_tool,
"summarize" => summary_tool
},
max_turns: 1,
timeout: 10_000,
debug: true
)
# "Orchestrator created with tools: filter_articles, summarize"
preview = SubAgent.preview_prompt(orchestrator,
context: %{
topic: "quantum computing breakthroughs",
articles: articles
}
)
# IO.inspect(preview.system, label: "system: ") # Full system prompt
#IO.inspect(preview.user, label: "user") # Expanded user prompt
# preview.user
IO.puts(preview.user)
Run the Orchestrator
{result, step} = SubAgent.run(
orchestrator,
llm: my_llm,
timeout: 12_000,
context: %{
topic: "quantum computing breakthroughs",
articles: articles
}
)
IO.puts("=== Orchestrator Result ===")
case result do
:ok -> IO.puts("Final Summary:\n#{step.return}")
:error -> IO.puts("Error: #{step.fail.message}")
end
Inspect the Execution
Debug.print_trace(step, raw: true, usage: true)
How It Works
IO.puts("""
Multi-Agent Orchestration Flow:
===============================
[Orchestrator Agent - PTC-Lisp]
|
| Receives: topic, articles (with hidden _body)
| Writes Lisp program to coordinate tools
|
+---> [filter_articles tool]
| |
| | JSON mode SubAgent
| | Input: topic, [{id, keywords}]
| | Output: [relevant_ids]
| |
+---> [summarize tool] (called per article)
| |
| | JSON mode SubAgent
| | Input: article._body (via Lisp get)
| | Output: {summary: "..."}
| |
+---> [summarize tool] (final)
|
| Combines all summaries
| Output: final summary
Key Points:
- _body stays hidden from orchestrator's prompt
- Lisp code accesses _body via (get article :_body)
- Each tool call is a separate LLM request
- JSON schema ensures structured responses
""")
Compiling Agents, TODO - not working yet
{_, compiled_agent } = SubAgent.compile(orchestrator, llm: my_llm)
compiled_agent
Interactive Query
topic_input = Kino.Input.text("Topic", default: "quantum physics research")
topic = Kino.Input.read(topic_input)
{result, step} = SubAgent.run(
orchestrator,
llm: my_llm,
context: %{topic: topic, articles: articles}
)
case result do
:ok ->
IO.puts("Topic: #{topic}")
IO.puts("Summary: #{step.return}")
:error ->
IO.puts("Failed: #{step.fail.message}")
Debug.print_trace(step, raw: true)
end
Learn More
- SubAgent Concepts - Context firewall details
- SubAgent Patterns - Composition patterns
- LLM Agent Livebook - Basic SubAgent usage