This guide covers the foundational concepts for library users: context management, memory, and how agents complete their work.

How SubAgents Work

When you call SubAgent.run/2, the library:

  1. Sends your prompt and context to the LLM
  2. The LLM generates a PTC-Lisp program (a Clojure subset)
  3. The program executes in a sandboxed environment
  4. Results are validated against your signature
  5. On success, {:ok, step} returns with step.return containing the result

You don't write PTC-Lisp - the LLM does. You configure the agent with Elixir.

Alternative: Text Mode. For classification, extraction, or tool-based tasks without PTC-Lisp, use output: :text. The behavior auto-detects based on whether tools are provided and the return type. See Text Mode Guide.

SubAgent Context Boundaries

SubAgents solve a fundamental problem: LLMs need information to make decisions, but context windows are expensive and limited. SubAgents let agents work with large datasets through tools and return compact, validated summaries to the parent.

                      
 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.

Chaining Return Data

# Step 1: Find emails
{: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]

# Step 2: Chain to next agent
{:ok, step2} = PtcRunner.SubAgent.run(
  "Draft replies for these {{count}} urgent emails",
  context: step1,  # Auto-chains return data
  tools: drafting_tools,
  llm: llm
)

In Step 2, chained return data is ordinary context. Do not put secrets or sensitive identifiers into SubAgent return data unless it is acceptable for generated programs and prompt/context renderers to see them.

Context

Values passed to context: become available to the LLM's generated programs:

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

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"

Temporal values

Pass DateTime, NaiveDateTime, Date, and Time values directly. PtcRunner normalizes them to ISO 8601 strings at every LLM-facing boundary — Mustache template substitution, data inventory rendering, tool result encoding, :string coercion, and PTC-Lisp (str ...). The LLM never sees Elixir's ~U[...] sigil form.

prompt: "Event happened at {{when}}"
context: %{when: ~U[2026-05-03 09:14:00Z]}
# Expands to: "Event happened at 2026-05-03T09:14:00Z"

In :ptc_lisp mode, generated programs can pass tool-returned temporal values straight to date primitives:

;; tool/get_ticket returns a map with :opened_at (a %DateTime{} on the Elixir side)
(.getTime (java.util.Date. (:opened_at (tool/get_ticket {:id 123}))))

Chaining Context

When passing a previous Step to context:, the return data is automatically extracted:

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

How Agents Complete

Agents complete their work in one of two ways:

Single-turn (Expression Result)

For simple tasks with max_turns: 1, the LLM's expression result is returned directly:

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

step.return  #=> %{category: "positive", confidence: 0.95}

Multi-turn (Explicit Return)

For agentic tasks with tools, the LLM must explicitly signal completion. It does this by calling return or fail in its generated program:

{: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
)

The agent loops until the LLM's program calls return with valid data, or fail to abort.

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 in the next turn.

2. Mission Failures (Explicit)

When the LLM determines it cannot complete the task, it calls fail. Your code receives:

{:error, step} = SubAgent.run(...)
step.fail  #=> %{reason: :not_found, message: "User does not exist"}

3. System Crashes

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

Multi-turn State

In multi-turn agents, the LLM can store values that persist across turns. This happens automatically - values defined in one turn are available in subsequent turns.

From your perspective as a library user:

  • You see the final result in step.return
  • You see execution history in step.turns
  • You don't need to manage intermediate state

The LLM handles state internally to cache tool results, track progress, and avoid redundant work.

For progress visibility, use the plan: option to define step labels. A progress checklist is rendered between turns. Optionally, the LLM can mark steps complete with (step-done "id" "summary"). The rendering can be customized via progress_fn:. See Navigator Pattern.

Defaults

OptionDefaultDescription
max_turns5Maximum LLM turns before timeout
timeout5000Per-turn sandbox timeout (ms)
max_heapnilPer-turn sandbox heap limit (words, nil = app config or ~10MB)
mission_timeoutnilTotal mission timeout (ms, nil = no limit)
memory_limit1_048_576Max bytes for memory map (1MB)
memory_strategy:strict:strict (fatal) or :rollback (recover) on memory limit exceeded
float_precision2Decimal places for floats in results
compactionfalseEnable pressure-triggered context compaction (multi-turn only)
pmap_timeout5000Timeout (ms) for parallel pmap operations
max_depth3Maximum recursion depth for nested agents
turn_budget20Total turn budget across retries
retry_turns0Retry budget after return validation failures
output:ptc_lispOutput mode (:ptc_lisp or :text)

See Also