Advanced Topics

View Source

This 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 answer

Example: 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 TypeLisp ContextLLM Prompt
Normal fieldsFull valueVisible
Firewalled (_)Full value<Firewalled>
Large listsFull listSample (first N)
Large stringsFull stringTruncated
MemoryFull valueHidden

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 prompt

Telemetry, debug mode, and trace inspection: See Observability.

Output Truncation

Large results are automatically truncated at different stages to manage context size and memory:

OptionDefaultUsed For
feedback_limit10Max collection items shown to LLM in turn feedback
feedback_max_chars512Max chars in turn feedback message
history_max_bytes512Truncation limit for *1/*2/*3 history access
result_limit50Inspect :limit for final result formatting
result_max_chars500Max chars in final result string
max_print_length2000Max 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 TypeCompilable?Why
Pure Elixir functionsYesDeterministic
LLMToolNoNeeds LLM
SubAgent as toolNoNeeds 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)}")
end

3. 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

MessageContentCaching
SYSTEMRole, language reference, return/fail usage, output formatStatic (cacheable)
USERMission + namespaces + execution history + turns leftPartial (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}
NamespaceMeaningMutable?
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:

  1. Loop records the reasoning
  2. Appends: "Your mission is still active. Provide a PTC-Lisp program or call 'return'."
  3. 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 function

Control 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 function

Collections

(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)           ; Group

Maps

(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)              ; Extract

Keywords as Functions

(:id item)                     ; Same as (get item :id)
(mapv :id items)               ; Extract :id from each

Type Conversion

(parse-long "42")              ; String to int (nil on failure)
(parse-double "3.14")          ; String to float

Glossary

TermDefinition
SignatureContract defining inputs and outputs
StepResult struct with return, fail, memory, trace
Firewall_ prefix hiding data from LLM prompts
Data InventoryType info section in system prompt
TurnOne LLM generation + execution cycle
MissionComplete SubAgent execution until return/fail

See Also