Powered by AppSignal & Oban Pro

Multi-Agent Workflows with Context Firewall

livebooks/multi_agent_firewall.livemd

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