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.9.0"}]
end
Mix.install(deps ++ [{:req_llm, "~> 1.6"}, {:kino, "~> 0.14"}], consolidate_protocols: false)Setup
# For testing locally and reloading the 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
setup = LLMSetup.setup()setup = LLMSetup.choose_provider(setup)my_llm = LLMSetup.choose_model(setup)Output Modes
SubAgents support two output modes:
| Mode | Use When | Output |
|---|---|---|
:text | Classification, extraction, summarization | Structured JSON |
:ptc_lisp (default) | Computation, tool orchestration, multi-step reasoning | PTC-Lisp program result |
Text Mode - Direct LLM Tasks
Use output: :text when the LLM can answer directly without computation:
alias PtcRunner.SubAgent
alias PtcRunner.SubAgent.Debug
review = "Great product, fast shipping! Would buy again."
{_, step} = SubAgent.run(
"Classify as positive/negative/neutral with confidence 0.0-1.0: {{review}}",
output: :text,
signature: "(review :string) -> {sentiment :string, confidence :float}",
context: %{review: review},
llm: my_llm
)
# Debug.print_trace(step, raw: true)
step.returnPTC-Lisp Mode - Computational Tasks
The default mode. The LLM writes a program to solve tasks that need accurate computation:
{_, step} = SubAgent.run(
"How many r's are in raspberry?",
llm: my_llm,
max_turns: 1
)
Debug.print_trace(step)
step.returnExecution Modes
max_turns | Mode | Behavior |
|---|---|---|
1 | Single-shot | One LLM call, answer immediately |
> 1 (default: 10) | Multi-turn | Can iterate, fix errors, explore data |
Single-shot is faster and cheaper - use when the task is straightforward.
Multi-turn allows the LLM to inspect results with println, retry on errors, and call return when confident.
Signatures
Signatures define input/output types. They work with both output modes.
Format: (input1 :type, input2 :type) -> output_type
| Type | Examples |
|---|---|
:string, :int, :float, :bool | Primitives |
{field :type, ...} | Object with named fields |
[element_type] | List of elements |
{:optional, :type} | Optional field |
# Input: two strings, Output: object with score and explanation
sig1 = "(text1 :string, text2 :string) -> {similarity :float, explanation :string}"
# Input: list of items, Output: object with categorized lists
sig2 = "(items [{name :string, price :float}]) -> {expensive [{name :string}], cheap [{name :string}]}"
# Output only (no inputs from context)
sig3 = "{count :int, items [:string]}"
:okCompiled SubAgents
Compile an agent once to derive reusable PTC-Lisp logic. Runs without further LLM calls:
agent = SubAgent.new(
prompt: "Count r's in {{word}}",
signature: "(word :string) -> :int",
max_turns: 1
)
{:ok, compiled} = SubAgent.compile(agent, llm: my_llm)
IO.puts("Compiled source:\n#{compiled.source}")# Execute on multiple inputs - no LLM calls
words = ["strawberry", "raspberry", "program", "error"]
for word <- words do
step = compiled.execute.(%{"word" => word}, [])
"#{word}: #{step.return}"
endWorking with Tools
Tools let agents fetch external data or perform actions:
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} = SubAgent.run(
"What is the total travel expense?",
tools: tools,
signature: "{total :float}",
llm: my_llm
)
Debug.print_trace(step, raw: true)
step.returnInteractive Query
question_input = Kino.Input.textarea("Question", default: "Show spending by category")question = Kino.Input.read(question_input)
case SubAgent.run(question, tools: tools, llm: my_llm) do
{:ok, step} ->
# Debug.print_trace(step)
step.return
{:error, step} ->
Debug.print_trace(step)
"Failed: #{step.fail["message"]}"
endAd-Hoc LLM Queries (llm_query)
Enable llm_query: true to let the agent make runtime LLM calls from PTC-Lisp — useful when the task requires LLM judgment combined with programmatic logic.
tickets = [
%{"id" => 1, "text" => "Server is completely down, all customers affected"},
%{"id" => 2, "text" => "Typo in the footer of the about page"},
%{"id" => 3, "text" => "Payment processing failing for all users"},
%{"id" => 4, "text" => "Would be nice to have dark mode"}
]
alias PtcRunner.TraceLog
{:ok, result, trace_path} = TraceLog.with_trace(fn ->
SubAgent.run(
"""
Classify each ticket's urgency using tool/llm-query, then return only the critical ones.
""",
signature: "(tickets [{id :int, text :string}]) -> {critical [{id :int, text :string, reason :string}]}",
llm_query: true,
context: %{tickets: tickets},
llm: my_llm
)
end)
# Show execution trace
case result do
{:ok, step} ->
Debug.print_trace(step, raw: true)
step.return
{:error, step} ->
Debug.print_trace(step, raw: true)
"Failed: #{step.fail["message"]}"
endExample generated program (for those without LLM access):
(def tickets data/tickets)
(def classified
(pmap (fn [ticket]
(assoc ticket
:classification
(tool/llm-query {:prompt "Is this support ticket critical/urgent? Respond with {is_critical :bool, reason :string}\n\nTicket: {{text}}"
:signature "{is_critical :bool, reason :string}"
:text (:text ticket)})))
tickets))
(def critical
(filter (fn [t] (get-in t [:classification :is_critical])) classified))
(return {:critical (map (fn [t] {:id (:id t) :text (:text t) :reason (get-in t [:classification :reason])}) critical)})This uses pmap to classify all tickets in parallel via tool/llm-query, then filters for critical ones.
Analyzing the Trace
TraceLog captures all events (LLM calls, turns) to a JSONL file. Use TraceLog.Analyzer to inspect timing and token usage:
events = TraceLog.Analyzer.load(trace_path)
summary = TraceLog.Analyzer.summary(events)
IO.puts("Duration: #{summary.duration_ms}ms")
IO.puts("LLM calls: #{summary.llm_calls}")
IO.puts("Turns: #{summary.turns}")
IO.puts("Tokens: #{summary.total_tokens}")
# Print timeline of all events
TraceLog.Analyzer.print_timeline(events)Performance tip: Replace
mapvwithpmapto classify all tickets in parallel. TraceLog supports concurrent traces — eachtool/llm-querycall is captured with its own trace ID, so parallel execution is fully observable.
Debug Options
# Preview the prompt before running
agent = SubAgent.new(prompt: "What is 2 + 2?")
SubAgent.preview_prompt(agent).system |> IO.puts()print_trace options:
| Option | Description |
|---|---|
raw: true | Show raw LLM input/output |
messages: true | Show all messages including system prompt |
usage: true | Show token usage |
view: :compressed | Show what LLM sees (compressed format) |
Learn More
- Playground - PTC-Lisp basics
- SubAgent Guide
- PTC-Lisp Spec