Core Concepts

View Source

This guide covers the foundational concepts of SubAgents: context management, the firewall convention, memory, and error handling.

Quick Example

A typical SubAgent program calls a tool and returns the result:

(let [data (ctx/fetch-items {:category ctx/category})]
  (return (filter (where :price < 100) data)))

Key points:

  • ctx/category: Accesses the input context.
  • ctx/fetch-items: Invokes a tool with an argument map.
  • return: Completes the mission with the final value.

The Context Firewall

SubAgents solve a fundamental problem: LLMs need information to make decisions, but context windows are expensive and limited. The Context Firewall lets agents work with large datasets while keeping the parent context lean.

                      
 Main Agent    "Find urgent  ──► │  SubAgent   │
│ (strategic) │     emails"           (isolated)  
                                                
  Context:         CONTRACT:         Has tools: 
  ~100 tokens   {summary, _ids}      - list     
                                     - search   
               validated                
                  data only          Processes  
                                     50KB data  
                      

The parent only sees what the signature exposes. Heavy data stays inside the SubAgent.

The Firewall Convention (_ prefix)

Fields prefixed with _ are firewalled:

signature: "{summary :string, count :int, _email_ids [:int]}"

Visibility rules:

LocationNormal FieldsFirewalled (_)
Lisp context (ctx/)Full valueFull value
LLM prompt historyVisibleHidden (<Firewalled>)
Parent LLM schemaVisibleOmitted
Elixir step.returnIncludedIncluded

The firewall protects LLM context windows, not your Elixir code. Your application always has full access.

Example: Email Processing

# Step 1: Find emails (returns firewalled IDs)
{:ok, step1} = PtcRunner.SubAgent.run(
  "Find all urgent emails",
  signature: "{summary :string, count :int, _email_ids [:int]}",
  tools: email_tools,
  llm: llm
)

step1.return.summary     #=> "Found 3 urgent emails"
step1.return.count       #=> 3
step1.return._email_ids  #=> [101, 102, 103]  # Available to Elixir!

# Step 2: Process those emails
# The _email_ids are available in ctx/ even though the LLM can't "see" them
{:ok, step2} = PtcRunner.SubAgent.run(
  "Draft replies for these {{count}} urgent emails",
  context: step1,  # Auto-chains return + signature
  tools: drafting_tools,
  llm: llm
)

In Step 2, the LLM:

  • Knows there are 3 emails (public data)
  • Cannot see the actual IDs in its prompt
  • Can use ctx/_email_ids in its generated programs

Context (ctx/)

Values passed to context: are available via the ctx/ prefix in PTC-Lisp:

{:ok, step} = PtcRunner.SubAgent.run(
  "Get details for order {{order_id}}",
  context: %{order_id: "ORD-123", customer_tier: "gold"},
  tools: order_tools,
  llm: llm
)

The LLM can reference these in its programs:

(ctx/get-order {:id ctx/order_id})
(if (= ctx/customer_tier "gold")
  (ctx/apply-discount {:rate 0.1})
  nil)

Template Expansion

The {{placeholder}} syntax in prompts expands from context:

prompt: "Find emails for {{user.name}} about {{topic}}"
context: %{user: %{name: "Alice"}, topic: "billing"}
# Expands to: "Find emails for Alice about billing"

Every placeholder must have a matching context key or signature parameter.

Chaining Context

When passing a previous Step to context:, both the return data and signature are extracted:

# These are equivalent:
run(prompt, context: step1.return, context_signature: step1.signature)
run(prompt, context: step1)  # Auto-extraction

State Persistence (def/defn)

Each agent has private state persisting across turns within a single run. Use def to store values and defn to define functions:

;; Store intermediate results
(def processed-ids [1 2 3])

;; Access as plain symbol
processed-ids

;; Define reusable functions
(defn suspicious? [expense]
  (> (:amount expense) 5000))

State is:

  • Scoped per-agent - SubAgents don't share state with parents or siblings
  • Turn-persistent - Survives across turns within one run call
  • Hidden from prompts - Not shown in LLM conversation history

Use state for:

  • Caching expensive computations
  • Tracking state across turns
  • Storing data too large for context

Result Feedback

The program's return value determines what the LLM sees in subsequent turns. Use def to store large data while showing only a summary as feedback:

Store and summarize:

(def all-users (ctx/fetch-users {}))
(str "Stored " (count all-users) " users")
;; LLM sees: "Stored 500 users"
;; all-users = full dataset (accessible via programs)

This is the core value of PTC: large datasets stay in BEAM memory via def, LLM only sees compact summaries as the expression result. The _ prefix firewalls input data; explicit def storage and expression results control output data.

See also: PtcRunner.Lisp module docs for the full state specification.

Error Handling

SubAgents handle errors at three levels:

1. Turn Errors (Recoverable)

Syntax errors, tool failures, and validation errors are fed back to the LLM. It sees the error and can adapt:

;; Check if previous turn failed
(if ctx/fail
  (ctx/cleanup {:failed_op (:op ctx/fail)})
  (ctx/proceed ctx/items))

The ctx/fail structure:

%{
  reason: :parse_error | :tool_error | :validation_error,
  message: "Human-readable description",
  op: "tool_name",      # If tool-related
  details: %{}          # Additional context
}

2. Mission Failures (Explicit)

When the agent determines it cannot complete the mission, it calls fail:

(let [user (ctx/get-user {:id 123})]
  (if (nil? user)
    (fail {:reason :not_found
           :message "User 123 does not exist"})
    (ctx/process user)))

Result: {:error, step} where step.fail contains the error.

3. System Crashes

Programming bugs in your tool functions follow "let it crash" - they're returned as internal errors for developer investigation.

Built-in Special Forms

Every SubAgent has two built-in special forms for termination:

return - Mission Success

(return {:name "Widget" :price 99.99})
  • Validates data against the signature
  • If invalid, the LLM sees the error and can retry
  • On success, the loop ends and run/2 returns {:ok, step}

fail - Mission Failure

(fail {:reason :not_found :message "No matching items"})
  • Terminates the loop immediately
  • run/2 returns {:error, step} with step.fail populated

Execution Behavior

SubAgent behavior is determined explicitly by max_turns and tools:

Single-turn Execution

For classification or mapping tasks with one LLM call:

# max_turns: 1, no tools
{:ok, step} = PtcRunner.SubAgent.run(
  "Classify this text: {{text}}",
  signature: "{category :string, confidence :float}",
  context: %{text: "..."},
  max_turns: 1,
  llm: llm
)

The LLM provides one or more expressions; no return call needed.

Agentic Loop

For multi-turn investigation with tools:

# max_turns > 1, with tools
{:ok, step} = PtcRunner.SubAgent.run(
  "Find the report with highest anomaly score",
  signature: "{report_id :int, reasoning :string}",
  tools: report_tools,
  max_turns: 5,
  llm: llm
)

Full agentic loop requiring explicit return or fail.

Note: max_turns > 1 without tools enables multi-turn exploration where map results merge into memory for iterative analysis.

Float Precision

Floats are rounded to 2 decimal places by default (e.g., 3.33 instead of 3.3333333333333335). Configure via float_precision: option. See SubAgent.new/1 for details.

Defaults

OptionDefaultDescription
max_turns5Maximum LLM turns before timeout
timeout5000Per-turn timeout (ms)
mission_timeout60000Total mission timeout (ms)
prompt_limit%{list: 5, string: 1000}Truncation limits for prompts
float_precision2Decimal places for floats in results and Data Inventory

See Also