PtcRunner.SubAgent.Loop (PtcRunner v0.4.1)

View Source

Core agentic loop that manages LLM↔tool cycles.

The loop repeatedly calls the LLM, parses PTC-Lisp from the response, executes it, and continues until return/fail is called or max_turns is exceeded.

Flow

  1. Build LLM input with system prompt, messages, and tool names
  2. Call LLM to get response (resolving atoms via llm_registry if needed)
  3. Parse PTC-Lisp code from response (code blocks or raw s-expressions)
  4. Execute code via Lisp.run/2
  5. Check for return/fail or continue to next turn
  6. Build trace entry and update message history
  7. Merge execution results into context for next turn

Termination Conditions

The loop terminates when any of these occur:

ConditionResultReason
(return value) called{:ok, step}Normal completion
(fail error) called{:error, step}Explicit failure
max_turns exceeded{:error, step}:max_turns_exceeded
max_depth exceeded{:error, step}:max_depth_exceeded
turn_budget exhausted{:error, step}:turn_budget_exhausted
mission_timeout exceeded{:error, step}:mission_timeout
LLM error after retries{:error, step}:llm_error

Memory Handling

Memory persists across turns within a single run/2 call. After each successful Lisp execution:

  1. Lisp.run/2 applies the memory contract (see PtcRunner.Lisp for details)
  2. step.memory contains the updated memory state
  3. Loop updates state.memory for the next turn
  4. Memory is merged into context via state.context

The memory contract determines how return values affect memory:

  • Non-map returns: no memory update
  • Map without :return: merged into memory
  • Map with :return: rest merged, :return value returned

See PtcRunner.Lisp.run/2 for the authoritative memory contract documentation.

LLM Inheritance

Child SubAgents inherit the llm_registry from their parent, enabling atom-based LLM references (like :haiku or :sonnet) to work throughout the agent hierarchy. The registry only needs to be provided once at the top-level SubAgent.run/2 call.

Resolution order for LLM selection:

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

This is an internal module called by SubAgent.run/2.

Summary

Functions

Execute a SubAgent in loop mode (multi-turn with tools).

Functions

run(agent, opts)

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

Execute a SubAgent in loop mode (multi-turn with tools).

Parameters

  • agent - A %SubAgent{} struct
  • opts - Keyword list with:
    • llm - Required. LLM callback function
    • context - Initial context map (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 filtering: true (always), false (never), :on_error (only on failure) (default: true)
    • llm_retry - Optional retry configuration map with:
      • max_attempts - Maximum number of 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])

Returns

  • {:ok, Step.t()} on success (when return is called)
  • {:error, Step.t()} on failure (when fail is called or max_turns exceeded)

Examples

iex> agent = PtcRunner.SubAgent.new(prompt: "Add {{x}} and {{y}}", tools: %{}, max_turns: 2)
iex> llm = fn %{messages: _} -> {:ok, "```clojure\n(return {:result (+ ctx/x ctx/y)})\n```"} end
iex> {:ok, step} = PtcRunner.SubAgent.Loop.run(agent, llm: llm, context: %{x: 5, y: 3})
iex> step.return
%{result: 8}