Powered by AppSignal & Oban Pro

PTC-Lisp SubAgent

livebooks/ptc_runner_llm_agent.livemd

PTC-Lisp SubAgent

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.4.0"}]
  end

Mix.install(deps ++ [{:req_llm, "~> 1.0"}, {:kino, "~> 0.14"}], consolidate_protocols: false)

Setup

Add your API key in the Secrets panel (ss) for cloud models. Ollama works without a key.

api_key = System.get_env("LB_OPENROUTER_API_KEY") || System.get_env("OPENROUTER_API_KEY")
if api_key, do: System.put_env("OPENROUTER_API_KEY", api_key)
if(api_key, do: "API key configured", else: "No API key - Ollama only")
model_options =
  if Code.ensure_loaded?(LLMClient) do
    LLMClient.list_models()
    |> Enum.filter(& &1.available)
    |> Enum.map(&{&1.model_id, "#{&1.alias} - #{&1.description}"})
    |> Enum.sort_by(&elem(&1, 1))
  else
    [
      {"openrouter:anthropic/claude-haiku-4.5", "haiku - Claude Haiku 4.5"},
      {"openrouter:google/gemini-2.5-flash", "gemini - Gemini 2.5 Flash"},
      {"openrouter:deepseek/deepseek-chat-v3-0324", "deepseek - DeepSeek V3"}
    ]
  end

model_input = Kino.Input.select("Model", model_options)
model = Kino.Input.read(model_input)

my_llm =
  if Code.ensure_loaded?(LLMClient) do
    fn %{system: system, messages: messages} ->
      case LLMClient.generate_text(model, [%{role: :system, content: system} | messages], receive_timeout: 60_000) do
        {:ok, r} -> {:ok, r.content}
        error -> error
      end
    end
  else
    fn %{system: system, messages: messages} ->
      case ReqLLM.generate_text(model, [%{role: :system, content: system} | messages], receive_timeout: 30_000) do
        {:ok, r} -> {:ok, ReqLLM.Response.text(r)}
        error -> error
      end
    end
  end

"Ready: #{model}"

Basic Example

alias PtcRunner.SubAgent.Debug

{_, step} = PtcRunner.SubAgent.run(
  "What is the sum of the first 5 prime numbers?",
  llm: my_llm,
  debug: true
)

Debug.print_trace(step)
step.return

The Raspberry Problem

LLMs often fail at simple counting tasks. Using PTC-Lisp, the agent can solve this accurately:

{_, step} = PtcRunner.SubAgent.run(
  "How many r's are in raspberry?",
  llm: my_llm,
  debug: true
)

Debug.print_trace(step)
step.return

With Signatures

Use context to pass data. Signatures validate the output structure:

{_, step} = PtcRunner.SubAgent.run(
  "Classify as positive/negative/neutral with confidence 0.0-1.0: {{review}}",
  context: %{review: "Great product, fast shipping! Would buy again."},
  signature: "(review :string) -> {sentiment :string, confidence :float}",
  llm: my_llm,
  debug: true
)
Debug.print_trace(step, messages: false)
step.return

With Tools

expenses = [
  %{"id" => 1, "category" => "travel", "amount" => 450.00, "vendor" => "Airlines Inc"},
  %{"id" => 2, "category" => "food", "amount" => 32.50, "vendor" => "Cafe Luna"},
  %{"id" => 3, "category" => "travel", "amount" => 189.00, "vendor" => "Hotel Central"},
  %{"id" => 4, "category" => "office", "amount" => 299.99, "vendor" => "Tech Store"},
  %{"id" => 5, "category" => "food", "amount" => 28.00, "vendor" => "Deli Express"}
]

tools = %{
  "list-expenses" => {fn _ -> expenses end,
    signature: "() -> [{id :int, category :string, amount :float, vendor :string}]",
    description: "Returns all expense records"
  }
}

Kino.DataTable.new(expenses)
{:ok, step} = PtcRunner.SubAgent.run(
  "What is the total travel expense?",
  tools: tools,
  signature: "{total :float}",
  llm: my_llm,
  debug: true
)

Debug.print_trace(step)
step.return

Interactive Query

question_input = Kino.Input.textarea("Question", default: "Show spending by category")
question = Kino.Input.read(question_input)

case PtcRunner.SubAgent.run(question, tools: tools, llm: my_llm, debug: true) do
  {:ok, step} ->
    Debug.print_trace(step)
    step.return

  {:error, step} ->
    "Failed: #{step.fail.message}"
end

Learn More