Advanced Topics
View SourceThis guide covers advanced SubAgent features: multi-turn ReAct patterns, the compile pattern for batch processing, and system prompt internals.
Multi-Turn Patterns (ReAct)
The SubAgent loop naturally supports ReAct (Reason + Act). Each turn's result merges into the context for the next turn.
Implicit Context Chaining
Turn 1: LLM program -> execute -> result merged to data/
Turn 2: LLM sees data/results, generates next program
Turn 3: LLM calls return with final answerExample: Discovery and Reasoning
{:ok, step} = SubAgent.run(
"Find urgent emails from Acme",
signature: "{summary :string, _ids [:int]}",
tools: %{
"search_emails" => &MyApp.Email.search/1,
"count_results" => &MyApp.Email.count/1
},
max_turns: 5,
llm: llm
)Turn 1: Discovery
;; Store results in user namespace
(def results (tool/search-emails {:query "Acme Corp"}))The LLM sees in its next prompt:
Program Result:
{:results [{id: 101, subject: "Urgent...", _body: <Firewalled>}, ...]}
(8 more items omitted. Full data available in data/results)Turn 2: Filter and Return
;; Process all results from data/results
(let [urgent (filter (fn [e] (includes? (:subject e) "Urgent")) data/results)]
(return {
:summary (str "Found " (count urgent) " urgent emails")
:_ids (mapv :id urgent)
}))Visibility Rules
| Data Type | Lisp Context | LLM Prompt |
|---|---|---|
| Normal fields | Full value | Visible |
Firewalled (_) | Full value | <Firewalled> |
| Large lists | Full list | Sample (first N) |
| Large strings | Full string | Truncated |
| Memory | Full value | Hidden |
When data is truncated, the system appends:
"[98 more items omitted. Full data available in data/results]"
Investigation Agents (Zero Tools)
Sometimes you have all data in context but it's too large for one pass:
data = %{
reports: [...] # thousands of items
}
# max_turns > 1 with tools enables agentic loop
{:ok, step} = SubAgent.run(
"Find the report with highest anomaly score",
signature: "{report_id :int, reasoning :string}",
context: data,
max_turns: 5,
llm: llm
)The LLM can "walk" the data across turns:
;; Turn 1: Extract summaries
(mapv (fn [r] {:id (:id r) :score (:score r)}) data/reports)
;; Turn 2: Find max and get details
(first (filter #(= (:id %) 123) data/reports))
;; Turn 3: Return with reasoning
(return {:report_id 123 :reasoning "..."})Debugging
Prompt Preview
Inspect expanded prompts without executing:
preview = SubAgent.preview_prompt(agent,
context: %{user: "alice", sender: "bob@example.com"}
)
IO.puts(preview.system) # Full system prompt
IO.puts(preview.user) # Expanded user promptTelemetry, debug mode, and trace inspection: See Observability.
Output Truncation
Large results are automatically truncated at different stages to manage context size and memory:
| Option | Default | Used For |
|---|---|---|
feedback_limit | 10 | Max collection items shown to LLM in turn feedback |
feedback_max_chars | 512 | Max chars in turn feedback message |
history_max_bytes | 512 | Truncation limit for *1/*2/*3 history access |
result_limit | 50 | Inspect :limit for final result formatting |
result_max_chars | 500 | Max chars in final result string |
max_print_length | 2000 | Max chars per println call |
Configure via format_options:
SubAgent.new(
prompt: "Analyze large dataset",
format_options: [
feedback_limit: 20, # Show more items to LLM
feedback_max_chars: 1024 # Allow longer feedback
]
)When data is truncated in turn feedback, the system appends:
"... (truncated)"
When lists are truncated in prompts, the system appends:
"[98 more items omitted. Full data available in data/results]"
Compile Pattern
For repetitive batch processing, separate the cognitive step (writing logic) from execution (running at scale).
What Can Be Compiled
| Tool Type | Compilable? | Why |
|---|---|---|
| Pure Elixir functions | Yes | Deterministic |
| LLMTool | No | Needs LLM |
| SubAgent as tool | No | Needs LLM |
1. Derive Phase
LLM analyzes sample data and generates pure PTC-Lisp:
scorer = SubAgent.new(
prompt: "Extract anomaly score and reasoning from report",
signature: "(report :map) -> {score :float, reason :string}",
tools: %{"lookup_threshold" => &MyApp.lookup_threshold/1}
)
{:ok, compiled} = SubAgent.compile(scorer,
llm: llm,
sample: sample_reports
)
IO.puts(compiled.source)
#=> (fn [report] (let [...] {...}))2. Validate (Optional)
Test against known cases:
case SubAgent.validate_compiled(compiled, test_reports) do
:ok -> IO.puts("Verified!")
{:error, failures} -> Logger.warning("Failed: #{length(failures)}")
end3. Apply Phase
Execute at scale with zero LLM cost:
results = Enum.map(all_reports, fn r ->
compiled.execute(%{report: r})
end)Persistence
Save derived logic for later:
File.write!("agents/scorer.lisp", compiled.source)
# Load later
{:ok, compiled} = SubAgent.load(
File.read!("agents/scorer.lisp"),
signature: "(report :map) -> {score :float, reason :string}"
)Prompt Structure
Understanding what the LLM receives helps debug unexpected behavior.
Message Layout
| Message | Content | Caching |
|---|---|---|
| SYSTEM | Role, language reference, return/fail usage, output format | Static (cacheable) |
| USER | Mission + namespaces + execution history + turns left | Partial (tool/data stable) |
Note: With compression enabled, tools and data are in the USER message (not SYSTEM) to leverage prompt caching on the stable content.
Namespace Model
The USER message presents three namespaces:
;; === tool/ ===
(tool/search query) ; query:string -> [:map]
;; === data/ ===
data/products ; list[7], sample: {:name "Laptop"}
;; === user/ (your prelude) ===
cached-results ; = list[5], sample: {:id 1}| Namespace | Meaning | Mutable? |
|---|---|---|
tool/ | Available tools (side effects) | No (external) |
data/ | Input context (read-only) | No (external) |
user/ | Your definitions (prelude) | Yes (grows each turn) |
Viewing the Prompt
# Preview without executing
preview = SubAgent.preview_prompt(agent, context: %{})
IO.puts(preview.system)
IO.puts(preview.user)
# After execution, see compressed view
SubAgent.Debug.print_trace(step, view: :compressed)Strict Termination
If the LLM provides text without a code block or terminal form:
- Loop records the reasoning
- Appends: "Your mission is still active. Provide a PTC-Lisp program or call 'return'."
- LLM must provide a functional result
PTC-Lisp Quick Reference
Core
(tool/tool-name {:arg value}) ; Call tool
data/key ; Access context
(def key value) ; Store value
key ; Access stored value
(defn name [args] body) ; Define functionControl Flow
(do expr1 expr2) ; Sequential, returns last
(let [x 1 y 2] (+ x y)) ; Local bindings
(if cond then else) ; Conditional
(when cond expr) ; Conditional without else
(cond c1 e1 c2 e2 :else e3) ; Multi-branch
(fn [x] (* x 2)) ; Anonymous functionCollections
(map f coll) ; Transform
(mapv f coll) ; Transform to vector
(filter pred coll) ; Keep matching
(reduce f init coll) ; Fold
(first coll) (last coll) ; Access
(count coll) (empty? coll) ; Info
(sort-by :key coll) ; Sort
(group-by :key coll) ; GroupMaps
(get m :key) ; Access
(get-in m [:a :b]) ; Nested access
(assoc m :key val) ; Add/update
(merge m1 m2) ; Combine
(keys m) (vals m) ; ExtractKeywords as Functions
(:id item) ; Same as (get item :id)
(mapv :id items) ; Extract :id from eachType Conversion
(parse-long "42") ; String to int (nil on failure)
(parse-double "3.14") ; String to floatGlossary
| Term | Definition |
|---|---|
| Signature | Contract defining inputs and outputs |
| Step | Result struct with return, fail, memory, trace |
| Firewall | _ prefix hiding data from LLM prompts |
| Data Inventory | Type info section in system prompt |
| Turn | One LLM generation + execution cycle |
| Mission | Complete SubAgent execution until return/fail |
See Also
- Core Concepts - Context, memory, and the firewall
- Observability - Telemetry, debug mode, and tracing
- Prompt Customization - LLM-specific prompts and language specs
- Patterns - Chaining, orchestration, and composition
- Signature Syntax - Type system details
PtcRunner.SubAgent- Full API reference- PTC-Lisp Specification - Language reference