PtcRunner.Lisp (PtcRunner v0.11.0)

Copy Markdown View Source

Execute PTC programs written in Lisp DSL (Clojure subset).

PTC-Lisp enables LLMs to write safe programs that orchestrate tools and transform data. Unlike raw code execution (Python, JavaScript), PTC-Lisp provides safety by design: no filesystem/network access, no unbounded recursion, and deterministic execution in isolated BEAM processes with resource limits.

See the PTC-Lisp Specification for the complete language reference.

Tool Registration

Tools are functions that receive a map of arguments and return results. Note: tool names use kebab-case in Lisp (e.g., "get-user" not "get_user"):

tools = %{
  "get-user" => fn %{"id" => id} -> MyApp.Users.get(id) end,
  "search" => fn %{"query" => q} -> MyApp.Search.run(q) end
}

PtcRunner.Lisp.run(~S|(tool/get-user {:id 123})|, tools: tools)

Contract:

  • Receives: map() of arguments (may be empty %{})
  • Returns: Any Elixir term (maps, lists, primitives)
  • Should not raise (return {:error, reason} for errors)

Summary

Functions

Format an error tuple into a human-readable string.

Run a PTC-Lisp program.

Validate PTC-Lisp source code without executing it.

Functions

format_error(other)

@spec format_error(term()) :: String.t()

Format an error tuple into a human-readable string.

Useful for displaying errors to users or feeding back to LLMs for retry.

Examples

iex> PtcRunner.Lisp.format_error({:parse_error, "unexpected token"})
"Parse error: unexpected token"

iex> PtcRunner.Lisp.format_error({:eval_error, "undefined variable: x"})
"Eval error: undefined variable: x"

run(source, opts \\ [])

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

Run a PTC-Lisp program.

Parameters

  • source: PTC-Lisp source code as a string
  • opts: Keyword list of options
    • :context - Initial context map (default: %{})
    • :memory - Initial memory map (default: %{})
    • :tools - Map of tool names to functions (default: %{})
    • :signature - Optional signature string for return value validation
    • :float_precision - Number of decimal places for floats in result (default: nil = full precision)
    • :timeout - Timeout in milliseconds for entire sandbox execution (default: 1000)
    • :compile_timeout - Timeout in milliseconds for the compile phase (parse + analyze) (default: 5000)
    • :pmap_timeout - Timeout in milliseconds per pmap/pcalls task (default: 5000). Increase for LLM-backed tools.
    • :pmap_max_concurrency - Local pmap/pcalls scheduling window — max tasks one call keeps in flight (default: System.schedulers_online() * 2). Reduce to avoid overflowing connection pools. The HARD aggregate cap is :max_parallel_workers.
    • :max_heap - Sandbox-process max heap size in words (default: 1_250_000)
    • :worker_max_heap - Fixed max_heap_size (words) for every pmap/pcalls worker, top-level and nested (default: the :max_heap value)
    • :max_parallel_workers - Global cap on pmap/pcalls worker processes alive at once across the whole run, at any nesting depth (default: 8). Aggregate live parallel heap ≈ max_parallel_workers * worker_max_heap. A pmap/pcalls that cannot get a slot fails with :parallel_capacity_exceeded.
    • :max_symbols - Max unique symbols/keywords allowed (default: 10_000)
    • :max_program_bytes - Max source code size in bytes (default: 1_000_000)
    • :max_print_length - Max characters per println call (default: 2000)
    • :filter_context - Filter context to only include accessed data keys (default: true)
    • :budget - Budget info map for (budget/remaining) introspection (default: nil)
    • :trace_context - Trace context for nested agent tracing (default: nil)
    • :caller - Closed-set tag for telemetry. One of :in_process_v1, :text_mode, or :mcp (default: :in_process_v1). Pure instrumentation: attached to [:ptc_runner, :lisp, :execute, *] events and otherwise discarded. Out-of-set values raise ArgumentError.
    • :profile - Closed-set telemetry tag for the calling profile. One of :mcp_no_tools, :mcp_aggregator, :in_process_v1, or :text_mode, or nil (default). Pure instrumentation: attached to [:ptc_runner, :lisp, :execute, *] events and otherwise discarded. Out-of-set values raise ArgumentError. The MCP v1 handler passes :mcp_no_tools; the aggregator (Phase 1a) flips it to :mcp_aggregator. See Plans/ptc-runner-mcp-aggregator.md §11.5.

Telemetry

run/2 is wrapped in :telemetry.span/3 and emits the following events:

  • [:ptc_runner, :lisp, :execute, :start] — measurements monotonic_time, system_time; metadata caller, profile, program_bytes, signature_supplied?.
  • [:ptc_runner, :lisp, :execute, :stop] — measurements duration, monotonic_time, result_bytes, prints_count; metadata caller, profile, program_bytes, signature_supplied?.
  • [:ptc_runner, :lisp, :execute, :exception] — measurements duration, monotonic_time; metadata caller, profile, program_bytes, signature_supplied?, kind, reason, stacktrace.

Return Value

On success, returns:

  • {:ok, Step.t()} with:
    • step.return: The value returned to the caller
    • step.memory: Complete memory state after execution
    • step.usage: Execution metrics (duration_ms, memory_bytes)

On error, returns:

  • {:error, Step.t()} with:
    • step.fail.reason: Error reason atom
    • step.fail.message: Human-readable error description
    • step.memory: Memory state at time of error

Memory Contract

The top-level program value passes through to step.return unchanged — there is no implicit map merge and no special :return key handling. Storage is explicit: (def x v) persists v in memory (step.memory["x"]), and that memory survives across turns within a single SubAgent run.

Related modules:

Float Precision

When :float_precision is set, all floats in the result are rounded to that many decimal places. This is useful for LLM-facing applications where excessive precision wastes tokens.

# Full precision (default)
{:ok, step} = PtcRunner.Lisp.run("(/ 10 3)")
step.return
#=> 3.3333333333333335

# Rounded to 2 decimals
{:ok, step} = PtcRunner.Lisp.run("(/ 10 3)", float_precision: 2)
step.return
#=> 3.33

Resource Limits

Lisp programs execute with configurable timeout and memory limits:

PtcRunner.Lisp.run(source, timeout: 5000, max_heap: 5_000_000)

Exceeding limits returns an error:

  • {:error, {:timeout, ms}} - execution exceeded timeout
  • {:error, {:memory_exceeded, bytes}} - heap limit exceeded

Context Filtering

By default, PTC-Lisp performs static analysis to identify which data/xxx keys are accessed by a program, then filters the context to only include those datasets. This significantly reduces memory pressure when the context contains large datasets that aren't used.

# Only products is loaded into the sandbox, orders/employees are filtered out
ctx = %{"products" => large_list, "orders" => large_list, "employees" => large_list}
PtcRunner.Lisp.run("(count data/products)", context: ctx)

Scalar context values (strings, numbers, nil) are always preserved as they typically represent metadata like prompts or configuration.

Disable filtering if you need all context available (e.g., for dynamic data access):

PtcRunner.Lisp.run(source, context: ctx, filter_context: false)

See PtcRunner.Lisp.DataKeys for the static analysis implementation.

validate(source, opts \\ [])

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

Validate PTC-Lisp source code without executing it.

Parses and analyzes the source, then checks for undefined variables. Returns :ok if valid, or {:error, messages} with a list of error strings.

Accepts optional keyword options to configure compile-phase limits:

  • :compile_timeout - Timeout in ms for bounded compile (default: 5000)
  • :max_heap - Max heap words for bounded compile (default: 1_250_000)
  • :max_program_bytes - Max source size in bytes (default: 1_000_000)

Examples

iex> PtcRunner.Lisp.validate("(and (map? data/result) (> (count data/result) 0))")
:ok

iex> PtcRunner.Lisp.validate("(and (map? foo) true)")
{:error, ["foo"]}

iex> PtcRunner.Lisp.validate("(let [x 1] (> x 0))")
:ok