PtcRunner.SubAgent (PtcRunner v0.4.1)

View Source

SubAgent definition for PtcRunner.

A SubAgent is an isolated worker that executes tasks using PTC-Lisp programs generated by an LLM. SubAgents are defined as structs via new/1, separating definition from execution.

Execution Modes

SubAgent supports two execution modes, determined by configuration:

ModeConditionBehavior
Single-shotmax_turns == 1 and tools == %{}One LLM call, expression evaluated, result returned
LoopOtherwiseFull loop with tools, memory, and multi-turn

Fields

  • prompt - String.t(), required, template with {{placeholder}} support
  • signature - String.t() | nil, optional, contract for inputs/outputs

  • tools - map(), callable tools (default: %{})
  • max_turns - pos_integer(), maximum LLM calls (default: 5)
  • tool_catalog - map() | nil, schemas for planning (not callable)

  • prompt_limit - map() | nil, truncation config for LLM view

  • mission_timeout - pos_integer() | nil, max ms for entire execution

  • llm_retry - map() | nil, infrastructure retry config

  • llm - atom() | function() | nil, optional LLM override

  • system_prompt - system_prompt_opts() | nil, system prompt customization

  • memory_limit - pos_integer() | nil, max bytes for memory map (default: 1MB)

  • max_depth - pos_integer(), max nesting depth for SubAgents (default: 3)
  • turn_budget - pos_integer(), total turns across all nested agents (default: 20)
  • description - String.t() | nil, human-readable description for external documentation

  • field_descriptions - map() | nil, descriptions for signature fields (keys are field names)

  • context_descriptions - map() | nil, descriptions for context variables (keys are field names)

  • format_options - keyword list controlling output truncation (see format_options/0)
  • float_precision - non_neg_integer(), decimal places for floats in results and context (default: 2)

Tool Resolution

Tools in the tools map can be:

FormatDescription
fn args -> result endSimple function
{fn, meta}Function with metadata (signature, description)
%SubAgentTool{}Wrapped SubAgent (via as_tool/2)
%LLMTool{}LLM-powered tool (via LLMTool.new/1)

See PtcRunner.Tool for normalization details.

LLM Resolution

When using atom LLMs (like :haiku or :sonnet), provide an llm_registry map at the top level. The registry is automatically inherited by all nested agents.

Resolution order:

  1. agent.llm - Set in SubAgent struct
  2. as_tool(..., llm:) - Bound at tool creation
  3. Parent's LLM - Inherited from calling agent
  4. llm option - Required at top level

Examples

Minimal SubAgent with just a prompt:

iex> agent = PtcRunner.SubAgent.new(prompt: "Analyze the data")
iex> agent.prompt
"Analyze the data"
iex> agent.max_turns
5
iex> agent.tools
%{}

SubAgent with all options:

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.prompt
"Find urgent emails for {{user}}"
iex> agent.max_turns
10

Summary

Types

Output format options for truncation and display.

Language spec for system prompts.

LLM response format.

t()

Functions

Wraps a SubAgent as a tool callable by other agents.

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.

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

Unwraps internal sentinel values from a search result.

Types

format_options()

@type format_options() :: [
  feedback_limit: pos_integer(),
  feedback_max_chars: pos_integer(),
  history_max_bytes: pos_integer(),
  result_limit: pos_integer(),
  result_max_chars: pos_integer()
]

Output format options for truncation and display.

Fields:

  • feedback_limit - Max collection items in turn feedback (default: 10)
  • feedback_max_chars - Max chars in turn feedback (default: 512)
  • history_max_bytes - Truncation limit for *1/*2/*3 history (default: 512)
  • result_limit - Inspect :limit for final result (default: 50)
  • result_max_chars - Final string truncation (default: 500)

language_spec()

@type language_spec() :: String.t() | atom() | (map() -> String.t())

Language spec for system prompts.

Can be:

  • String: used as-is
  • Atom: resolved via PtcRunner.Lisp.Prompts.get!/1 (e.g., :minimal, :default)
  • Function: callback receiving context map with :turn, :model, :memory, :messages

llm_callback()

@type llm_callback() :: (map() -> {:ok, llm_response()} | {:error, term()})

llm_registry()

@type llm_registry() :: %{required(atom()) => llm_callback()}

llm_response()

@type llm_response() ::
  String.t()
  | %{
      :content => String.t(),
      optional(:tokens) => %{
        optional(:input) => pos_integer(),
        optional(:output) => pos_integer()
      }
    }

LLM response format.

Can be either a plain string (backward compatible) or a map with content and optional tokens. When tokens are provided, they are included in telemetry measurements and accumulated in Step.usage.

system_prompt_opts()

@type system_prompt_opts() ::
  %{
    optional(:prefix) => String.t(),
    optional(:suffix) => String.t(),
    optional(:language_spec) => language_spec(),
    optional(:output_format) => String.t()
  }
  | (String.t() -> String.t())
  | String.t()

t()

@type t() :: %PtcRunner.SubAgent{
  context_descriptions: map() | nil,
  description: String.t() | nil,
  field_descriptions: map() | nil,
  float_precision: non_neg_integer(),
  format_options: format_options(),
  llm: atom() | (map() -> {:ok, llm_response()} | {:error, term()}) | nil,
  llm_retry: map() | nil,
  max_depth: pos_integer(),
  max_turns: pos_integer(),
  memory_limit: pos_integer() | nil,
  mission_timeout: pos_integer() | nil,
  parsed_signature: {:signature, list(), term()} | nil,
  prompt: String.t(),
  prompt_limit: map() | nil,
  signature: String.t() | nil,
  system_prompt: system_prompt_opts() | nil,
  tool_catalog: map() | nil,
  tools: map(),
  turn_budget: pos_integer()
}

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)

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

compile(agent, opts)

See PtcRunner.SubAgent.Compiler.compile/2.

default_format_options()

@spec default_format_options() :: format_options()

Returns the default format options.

new(opts)

@spec new(keyword()) :: t()

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)
  • tool_catalog - Map of schemas for planning (not callable)
  • prompt_limit - Map with truncation config for LLM view
  • 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)
  • 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)

Returns

A %SubAgent{} 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()]}

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{} struct
  • opts - Keyword list with:
    • context - Context map for template expansion (default: %{})

Returns

A map with:

  • :system - The complete system prompt string
  • :user - The expanded user message (mission prompt)
  • :tool_schemas - List of tool schema maps with name, signature, and description fields

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"
iex> preview.system =~ "PTC-Lisp"
true
iex> is_binary(preview.system)
true

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{} 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 - Enable debug mode (default: false). When enabled, trace entries store exact message contents:
    • llm_response - The assistant message (LLM output, stored as-is)
    • llm_feedback - The user message (execution feedback, after truncation) Use SubAgent.Debug.print_trace(step, messages: true) to view the conversation.
  • 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])
  • 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(+ ctx/x ctx/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}

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

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

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

Equivalent to run!(agent, Keyword.put(opts, :context, step)). Enables pipeline-style composition where each agent receives the previous agent's return value as input.

Examples

iex> doubler = PtcRunner.SubAgent.new(
...>   prompt: "Double {{n}}",
...>   signature: "(n :int) -> {result :int}",
...>   max_turns: 1
...> )
iex> adder = PtcRunner.SubAgent.new(
...>   prompt: "Add 10 to {{result}}",
...>   signature: "(result :int) -> {final :int}",
...>   max_turns: 1
...> )
iex> mock_llm = fn %{messages: msgs} ->
...>   content = msgs |> List.last() |> Map.get(:content)
...>   cond do
...>     content =~ "Double" -> {:ok, "```clojure\n{:result (* 2 ctx/n)}\n```"}
...>     content =~ "Add 10" -> {:ok, "```clojure\n{:final (+ ctx/result 10)}\n```"}
...>   end
...> end
iex> result = PtcRunner.SubAgent.run!(doubler, llm: mock_llm, context: %{n: 5})
...> |> PtcRunner.SubAgent.then!(adder, llm: mock_llm)
iex> result.return.final
20

unwrap_sentinels(step)

@spec unwrap_sentinels(PtcRunner.Step.t()) ::
  {:ok, PtcRunner.Step.t()} | {:error, PtcRunner.Step.t()}

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.