PtcRunner.SubAgent (PtcRunner v0.10.1)

Copy Markdown View Source

Agentic loop for LLM-driven PTC-Lisp execution.

A SubAgent prompts an LLM to write programs, executes them in a sandbox, and loops until completion. Define agents with new/1, execute with run/2.

Execution Modes

ModeConditionBehavior
Single-shotmax_turns == 1 and tools == %{}One LLM call, expression returned
LoopOtherwiseMulti-turn with tools until return or fail

Examples

# Simple single-shot
{:ok, step} = SubAgent.run("What's 2 + 2?", llm: my_llm, max_turns: 1)
step.return  #=> 4

# With tools and signature
agent = SubAgent.new(
  prompt: "Find expensive products",
  signature: "{name :string, price :float}",
  tools: %{"list_products" => &MyApp.list/0}
)
{:ok, step} = SubAgent.run(agent, llm: my_llm)

See Also

Summary

Functions

Wraps a SubAgent as a tool callable by other agents.

Multi-turn chat with conversation history threading.

Returns the default format options.

Creates a SubAgent struct from keyword options.

Preview the system and user prompts that would be sent to the LLM.

Executes a SubAgent with the given options.

Bang variant of run/2 that raises on failure.

Returns true if the agent's return type is plain text (:string or no signature).

Chains SubAgent/CompiledAgent executions with error propagation.

Chains agents in a pipeline, passing the previous step as context.

Unwraps internal sentinel values from a search result.

Types

compression_opts()

@type compression_opts() :: PtcRunner.SubAgent.Definition.compression_opts()

format_options()

@type format_options() :: PtcRunner.SubAgent.Definition.format_options()

language_spec()

@type language_spec() :: PtcRunner.SubAgent.Definition.language_spec()

llm_callback()

@type llm_callback() :: PtcRunner.SubAgent.Definition.llm_callback()

llm_registry()

@type llm_registry() :: PtcRunner.SubAgent.Definition.llm_registry()

llm_response()

@type llm_response() :: PtcRunner.SubAgent.Definition.llm_response()

output_mode()

plan_step()

system_prompt_opts()

@type system_prompt_opts() :: PtcRunner.SubAgent.Definition.system_prompt_opts()

t()

Functions

as_tool(agent, opts \\ [])

@spec as_tool(
  t(),
  keyword()
) :: PtcRunner.SubAgent.SubAgentTool.t()

Wraps a SubAgent as a tool callable by other agents.

Returns a SubAgentTool struct that parent agents can include in their tools map. When called, the wrapped agent inherits LLM and registry from the parent unless overridden.

Options

  • :llm - Bind specific LLM (atom or function). Overrides parent inheritance.
  • :description - Override agent's description (falls back to agent.description)
  • :name - Suggested tool name (informational, not enforced by the struct)
  • :cache - Cache results by input args (default: false). Only use for deterministic agents where same inputs always produce same outputs.

Description Requirement

A description is required for tools. It can be provided either:

  • On the SubAgent via new(description: "..."), or
  • Via the :description option when calling as_tool/2

Raises ArgumentError if neither is provided.

LLM Resolution

When the tool is called, the LLM is resolved in priority order:

  1. agent.llm - The agent's own LLM override (highest priority)
  2. bound_llm - LLM bound via the :llm option
  3. Parent's llm - Inherited from the calling agent (lowest priority)

Examples

iex> child = PtcRunner.SubAgent.new(
...>   prompt: "Double {{n}}",
...>   signature: "(n :int) -> {result :int}",
...>   description: "Doubles a number"
...> )
iex> tool = PtcRunner.SubAgent.as_tool(child)
iex> tool.signature
"(n :int) -> {result :int}"
iex> tool.description
"Doubles a number"

iex> child = PtcRunner.SubAgent.new(prompt: "Process data", description: "Default desc")
iex> tool = PtcRunner.SubAgent.as_tool(child, llm: :haiku, description: "Processes data")
iex> tool.bound_llm
:haiku
iex> tool.description
"Processes data"

iex> child = PtcRunner.SubAgent.new(prompt: "Analyze {{text}}", signature: "(text :string) -> :string", description: "Analyzes text")
iex> tool = PtcRunner.SubAgent.as_tool(child, name: "analyzer")
iex> tool.signature
"(text :string) -> :string"

iex> child = PtcRunner.SubAgent.new(prompt: "No description")
iex> PtcRunner.SubAgent.as_tool(child)
** (ArgumentError) as_tool requires description to be set - pass description: option or set description on the SubAgent

chat(agent, user_message, opts \\ [])

@spec chat(t(), String.t(), keyword()) ::
  {:ok, term(), [map()], map()} | {:error, term()}

Multi-turn chat with conversation history threading.

Wraps run/2 for chat use cases where conversation history must persist across calls. Auto-detects mode based on agent.output:

  • :text — Forces text mode, clears signature. Returns plain text.
  • :ptc_lisp — Keeps PTC-Lisp mode and signature. Returns structured data and memory (variables defined via def).

Parameters

  • agent - A SubAgent.t() struct
  • user_message - The user's message for this turn
  • opts - Runtime options (same as run/2, plus :messages and :memory)

Options

  • :messages - Prior conversation history (default: []). Pass the updated_messages from a previous chat/3 call to continue the conversation.
  • :memory - Prior memory map (default: %{}). For PTC-Lisp mode, pass the memory from a previous chat/3 call so the LLM can access prior variables.
  • All other options are forwarded to run/2 (e.g., :llm, :context)

Returns

  • {:ok, result, updated_messages, memory} — the result (text or structured), the full message history, and the memory map (empty for text mode)
  • {:error, reason} — on failure

Examples

# Text mode
agent = SubAgent.new(
  prompt: "placeholder",
  output: :text,
  system_prompt: "You are a helpful assistant."
)

{:ok, reply, messages, _memory} = SubAgent.chat(agent, "Hello!", llm: my_llm)
{:ok, reply2, messages2, _memory} = SubAgent.chat(
  agent, "Tell me more",
  llm: my_llm, messages: messages
)

# PTC-Lisp mode with memory threading
agent = SubAgent.new(
  prompt: "placeholder",
  output: :ptc_lisp,
  system_prompt: "You are a helpful assistant.",
  tools: my_tools
)

{:ok, result, messages, memory} = SubAgent.chat(agent, "Look up X", llm: my_llm)
{:ok, result2, messages2, memory2} = SubAgent.chat(
  agent, "Now use that result",
  llm: my_llm, messages: messages, memory: memory
)

compile(agent, opts)

See PtcRunner.SubAgent.Compiler.compile/2.

default_format_options()

Returns the default format options.

effective_tools(agent)

See PtcRunner.SubAgent.BuiltinTools.effective_tools/1.

expand_builtin_tools(families)

See PtcRunner.SubAgent.BuiltinTools.expand_builtin_tools/1.

new(opts)

Creates a SubAgent struct from keyword options.

Raises ArgumentError if validation fails (missing required fields or invalid types).

Parameters

  • opts - Keyword list of options

Required Options

  • prompt - String template describing what to accomplish (supports {{placeholder}} expansion)

Optional Options

  • signature - String contract defining expected inputs and outputs
  • tools - Map of callable tools (default: %{})
  • max_turns - Positive integer for maximum LLM calls (default: 5)
  • retry_turns - Non-negative integer for extra turns in must-return mode (default: 0)
  • prompt_limit - Map with truncation config for LLM view
  • timeout - Positive integer for max milliseconds per Lisp execution (default: 5000)
  • max_heap - Positive integer for max heap size in words per Lisp execution (default: app config or 1,250,000 ~10MB)
  • mission_timeout - Positive integer for max milliseconds for entire execution
  • llm_retry - Map with infrastructure retry config
  • llm - Atom or function for optional LLM override
  • system_prompt - System prompt customization (map, function, or string)
  • memory_limit - Positive integer for max bytes for memory map (default: 1MB = 1,048,576 bytes)
  • memory_strategy - How to handle memory limit exceeded: :strict (fatal, default) or :rollback (roll back memory, feed error to LLM)
  • name - Short display name shown in traces and the ptc-viewer (e.g. "meta_agent", "task_agent")
  • description - String describing the agent's purpose (for external docs)
  • field_descriptions - Map of field names to descriptions for signature fields
  • context_descriptions - Map of context variable names to descriptions (shown in Data Inventory)
  • format_options - Keyword list controlling output truncation (merged with defaults)
  • float_precision - Non-negative integer for decimal places in floats (default: 2)
  • compression - Compression strategy for turn history (see compression_opts/0)
  • pmap_timeout - Positive integer for max milliseconds per pmap parallel operation (default: 5000)
  • pmap_max_concurrency - Positive integer for max concurrent tasks in pmap/pcalls (default: System.schedulers_online() * 2). Reduce to avoid overflowing connection pools or API rate limits.
  • max_depth - Positive integer for maximum recursion depth in nested agents (default: 3)
  • turn_budget - Positive integer for total turn budget across retries (default: 20)
  • output - Output mode: :ptc_lisp (default) or :text
  • thinking - Boolean enabling thinking section in output format (default: false)
  • llm_query - Boolean enabling LLM query mode (default: false)
  • builtin_tools - List of builtin tool families to enable (default: []). Available: :grep (adds grep and grep-n tools)
  • plan - List of plan steps (strings, {id, description} tuples, or keyword list)

Returns

A SubAgent.t() struct.

Raises

  • ArgumentError - if prompt is missing or not a string, max_turns is not positive, tools is not a map, any optional field has an invalid type, or prompt placeholders don't match signature parameters (when signature is provided)

Examples

iex> agent = PtcRunner.SubAgent.new(prompt: "Analyze the data")
iex> agent.prompt
"Analyze the data"

iex> email_tools = %{"list_emails" => fn _args -> [] end}
iex> agent = PtcRunner.SubAgent.new(
...>   prompt: "Find urgent emails for {{user}}",
...>   signature: "(user :string) -> {count :int, _ids [:int]}",
...>   tools: email_tools,
...>   max_turns: 10
...> )
iex> agent.max_turns
10

preview_prompt(agent, opts \\ [])

@spec preview_prompt(
  t(),
  keyword()
) :: %{
  system: String.t(),
  user: String.t(),
  tool_schemas: [map()],
  schema: map() | nil
}

Preview the system and user prompts that would be sent to the LLM.

This function generates and returns the prompts without executing the agent, useful for debugging prompt generation, verifying template expansion, and reviewing what the LLM will see.

Parameters

  • agent - A SubAgent.t() struct
  • opts - Keyword list with:
    • context - Context map for template expansion (default: %{})

Returns

A map with:

  • :system - The static system prompt (cacheable - does NOT include mission)
  • :user - The full first user message (context sections + mission)
  • :tool_schemas - List of tool schema maps with name, signature, and description fields
  • :schema - JSON schema for the return type (text mode only, nil for PTC-Lisp)

Examples

iex> agent = PtcRunner.SubAgent.new(
...>   prompt: "Find emails for {{user}}",
...>   signature: "(user :string) -> {count :int}",
...>   tools: %{"list_emails" => fn _ -> [] end}
...> )
iex> preview = PtcRunner.SubAgent.preview_prompt(agent, context: %{user: "alice"})
iex> preview.user =~ "Find emails for alice"
true
iex> preview.user =~ "<mission>"
true
iex> preview.system =~ "<return_rules>"
true
iex> preview.system =~ "<mission>"
false

run(agent_or_prompt, opts \\ [])

@spec run(
  t() | String.t(),
  keyword()
) :: {:ok, PtcRunner.Step.t()} | {:error, PtcRunner.Step.t()}

Executes a SubAgent with the given options.

Returns a Step struct containing the result, metrics, and execution trace.

Parameters

  • agent - A SubAgent.t() struct or a string prompt (for convenience)
  • opts - Keyword list of runtime options

Runtime Options

  • llm - Required. LLM callback function (map() -> {:ok, String.t()} | {:error, term()}) or atom

  • llm_registry - Map of atom to LLM callback for atom-based LLM references (default: %{})
  • context - Map of input data (default: %{})
  • debug - Deprecated, no longer needed. Turn structs always capture raw_response. Use SubAgent.Debug.print_trace(step, raw: true) to view full LLM output.
  • trace - Trace collection mode (default: true):
    • true - Always collect trace in Step
    • false - Never collect trace
    • :on_error - Only include trace when execution fails
  • llm_retry - Optional map to configure retry behavior for transient LLM failures:
    • max_attempts - Maximum retry attempts (default: 1, meaning no retries unless explicitly configured)
    • backoff - Backoff strategy: :exponential, :linear, or :constant (default: :exponential)
    • base_delay - Base delay in milliseconds (default: 1000)
    • retryable_errors - List of error types to retry (default: [:rate_limit, :timeout, :server_error])
  • collect_messages - Capture full conversation history in Step.messages (default: false). When enabled, messages are in OpenAI format: [%{role: :system | :user | :assistant, content: String.t()}]
  • Other options from agent definition can be overridden

LLM Registry

When using atom LLMs (like :haiku or :sonnet), provide an llm_registry map:

registry = %{
  haiku: fn input -> MyApp.LLM.haiku(input) end,
  sonnet: fn input -> MyApp.LLM.sonnet(input) end
}

SubAgent.run(agent, llm: :sonnet, llm_registry: registry)

The registry is automatically inherited by all child SubAgents, so you only need to provide it once at the top level.

Returns

  • {:ok, Step.t()} on success
  • {:error, Step.t()} on failure

Examples

# Using a SubAgent struct
iex> agent = PtcRunner.SubAgent.new(prompt: "Calculate {{x}} + {{y}}", max_turns: 1)
iex> llm = fn %{messages: [%{content: _prompt}]} -> {:ok, "```clojure\n(+ data/x data/y)\n```"} end
iex> {:ok, step} = PtcRunner.SubAgent.run(agent, llm: llm, context: %{x: 5, y: 3})
iex> step.return
8

# Using string convenience form
iex> llm = fn %{messages: [%{content: _prompt}]} -> {:ok, "```clojure\n42\n```"} end
iex> {:ok, step} = PtcRunner.SubAgent.run("Return 42", max_turns: 1, llm: llm)
iex> step.return
42

# Using atom LLM with registry
iex> registry = %{test: fn %{messages: [%{content: _}]} -> {:ok, "```clojure\n100\n```"} end}
iex> {:ok, step} = PtcRunner.SubAgent.run("Test", max_turns: 1, llm: :test, llm_registry: registry)
iex> step.return
100

run!(agent, opts \\ [])

@spec run!(
  t() | String.t(),
  keyword()
) :: PtcRunner.Step.t()

Bang variant of run/2 that raises on failure.

Returns the Step struct directly instead of {:ok, step}. Raises SubAgentError if execution fails.

Examples

iex> agent = PtcRunner.SubAgent.new(prompt: "Say hello", max_turns: 1)
iex> mock_llm = fn _ -> {:ok, "```clojure\n\"Hello!\"\n```"} end
iex> step = PtcRunner.SubAgent.run!(agent, llm: mock_llm)
iex> step.return
"Hello!"

# Failure case (using loop mode)
iex> agent = PtcRunner.SubAgent.new(prompt: "Fail", max_turns: 2)
iex> mock_llm = fn _ -> {:ok, ~S|(fail {:reason :test :message "Error"})|} end
iex> PtcRunner.SubAgent.run!(agent, llm: mock_llm)
** (PtcRunner.SubAgentError) SubAgent failed: failed - %{message: "Error", reason: :test}

text_return?(agent)

Returns true if the agent's return type is plain text (:string or no signature).

Used by TextMode to decide between raw text and JSON response handling.

Examples

iex> agent = PtcRunner.SubAgent.new(prompt: "Hello", output: :text)
iex> PtcRunner.SubAgent.text_return?(agent)
true

iex> agent = PtcRunner.SubAgent.new(prompt: "Get data", signature: "() -> {name :string}", output: :text)
iex> PtcRunner.SubAgent.text_return?(agent)
false

then(result, agent, opts \\ [])

Chains SubAgent/CompiledAgent executions with error propagation.

See PtcRunner.SubAgent.Chaining.then/3 for full documentation.

then!(step, agent, opts \\ [])

Chains agents in a pipeline, passing the previous step as context.

See PtcRunner.SubAgent.Chaining.then!/3 for full documentation.

unwrap_sentinels(step)

Unwraps internal sentinel values from a search result.

Handles:

  • {:__ptc_return__, value} -> {:ok, step_with_raw_value}
  • {:__ptc_fail__, value} -> {:error, error_step}

Used by single-shot mode and compiled agents to provide clean results.