PtcRunner.Lisp (PtcRunner v0.14.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)

Public tools declared with cache: true reuse successful results only within the current evaluation; private tools never cache. Every run/2 starts with an empty evaluator-owned cache, and cache entries are neither accepted from callers nor returned in PtcRunner.Lisp.Result.

Summary

Functions

Converts a PTC-Lisp memory map into the public memory representation.

Converts native PTC-Lisp runtime values into an inert public Elixir representation.

Format an error tuple into a human-readable string.

Format a PTC-Lisp value as Clojure-style syntax for display.

Run a PTC-Lisp program.

Validate PTC-Lisp source code without executing it.

Functions

externalize_memory(memory)

@spec externalize_memory(map()) ::
  map()
  | {:error,
     {:java_projection_error
      | :lisp_value_projection_error
      | :symbol_ref_projection_error, term()}}

Converts a PTC-Lisp memory map into the public memory representation.

Unlike externalize_value/1, this also normalizes top-level def binding keys through the bounded source vocabulary.

externalize_value(value)

@spec externalize_value(term()) ::
  term()
  | {:error,
     {:java_projection_error
      | :lisp_value_projection_error
      | :symbol_ref_projection_error, term()}}

Converts native PTC-Lisp runtime values into an inert public Elixir representation.

Stateful hosts may keep native values internally between turns, but should use this at public observation boundaries such as final steps and trace events. Executable closures, builtins, composed callables, runtime callables, and plain BEAM functions are replaced recursively with deterministic display labels that cannot retain callable state, captured environments, or metadata. If distinct map keys or set members collapse to the same inert representation, every entry is retained with an opaque inert collision wrapper. Strict Kernel boundaries use the same projection and reject those collisions instead.

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"

format_value(value, opts \\ [])

@spec format_value(
  term(),
  keyword()
) :: {String.t(), boolean()}

Format a PTC-Lisp value as Clojure-style syntax for display.

This is the public wrapper around PtcRunner.Lisp.Format.to_clojure/2 used by LLM-facing renderers and embedding applications.

Returns {formatted_string, truncated?}.

Examples

iex> PtcRunner.Lisp.format_value(%{count: 2, ids: [1, 2]})
{"{:count 2 :ids [1 2]}", false}

iex> PtcRunner.Lisp.format_value([1, 2, 3], limit: 2)
{"[1 2 ...] (2/3)", true}

run(source, opts \\ [])

@spec run(
  String.t(),
  keyword()
) :: {:ok, PtcRunner.Lisp.Result.t()} | {:error, PtcRunner.Lisp.Result.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: %{})
    • :turn_history - Prior turn return values, oldest first, used by *1, *2, and *3 (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, configurable via config :ptc_runner, :default_timeout)
    • :compile_timeout - Timeout in milliseconds for the compile phase (parse + analyze) (default: 5000)
    • :compile_max_heap - Compile-worker heap ceiling in words (default: the :max_heap value). No ambient application default is consulted, so a sealed run's compile ceiling comes only from its own options.
    • :pmap_timeout - Shared absolute deadline in milliseconds for each pmap/pcalls operation, including nested parallel calls (default: 5000). Increase for LLM-backed tools.
    • :parallel_deadline_cap - Absolute monotonic-time ceiling in milliseconds clamping every parallel deadline regardless of when the operation starts (default: the :run_deadline_ms value). A parallel operation started late in a run therefore cannot outlive the run.
    • :pmap_max_concurrency - Local pmap/pcalls scheduling window — max tasks one call keeps in flight (default: the build-time System.schedulers_online() * 2, frozen into the semantic revision). Reduce to avoid overflowing connection pools. The HARD aggregate cap is :max_parallel_workers.
    • :max_heap - Program heap budget in words ABOVE the measured environment baseline (default: 1_250_000, configurable via config :ptc_runner, :default_max_heap). Host-provided data (context, :memory, tool closures, the parsed program) is measured after spawn and excluded from this budget — see PtcRunner.Sandbox for the re-baseline semantics.
    • :setup_max_heap - Hard heap ceiling in words while the host environment is copied into the sandbox, before the re-baseline (default: 4 × max_heap). Callers granting large tools/memory must raise this explicitly; exceeding it fails with a setup-phase :memory_exceeded error.
    • :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)
    • :strict_data - When true, a missing data/<name> is a runtime error instead of nil (default: false). The Kernel enables this at every one of its boundaries -- the workflow entry, a mission evaluation, and both REPL session kinds; run/2 stays permissive.
    • :data_grants - Optional sorted data/<name> forms included in the missing-grant diagnostic under :strict_data. The Kernel passes the list PtcRunner.Lisp.DataKeys.source_referenceable_forms/1 derives from the granted data, the same one the mission inventory publishes, rather than deriving it from context keys. When omitted, the diagnostic names the missing key without a grant list.
    • :missing_data_params_message - Optional diagnostic used when data/params is missing under :strict_data. The Kernel sets this so a no-params evaluation is not reported as a missing grant.
    • :prelude - A compiled %PtcRunner.Lisp.Prelude{} artifact, a prelude SOURCE string, or a list of source-bearing selection maps accepted by PtcRunner.Lisp.Prelude.Bundle.compile/1 to attach before user code. Source selections are concatenated and compiled once in explicit order after duplicate namespace rejection. The attached prelude's protected namespaces and public export table are consulted by the analyzer/evaluator so qualified prelude calls (e.g. crm/get-user) resolve, while private helpers stay user-invisible. Compile/attach failures return {:error, Step}. Attach-time tool:<name> requirements are checked against the granted :tools map. (default: nil)
    • :caller - Closed-set tag for telemetry. One of :direct, :kernel, or :repl (default: :direct). Pure instrumentation: attached to [:ptc_runner, :lisp, :execute, *] events and otherwise discarded. Out-of-set values raise ArgumentError.

Telemetry

run/2 emits the following events:

  • [:ptc_runner, :lisp, :execute, :start] — measurements monotonic_time, system_time; metadata caller, program_bytes, signature_supplied?.

  • [:ptc_runner, :lisp, :execute, :stop] — measurements duration, monotonic_time, result_bytes, prints_count, memory_bytes, eval_reductions; metadata caller, program_bytes, signature_supplied?, and semantic outcome.

    duration is in the emitter's native time unit, result_bytes is the external size of the returned value, and prints_count the number of prints. memory_bytes and eval_reductions are the sandbox child's own figures, the same pair step.usage carries: memory_bytes is Process.info(child, :memory) read the instant the result was ready — a heap reading at return, not a peak and not RSS — and eval_reductions is that child's reduction count. Both are 0 when a run failed before the child reported, which is not the same as a run that used nothing, so read outcome before charting either.

  • [:ptc_runner, :lisp, :execute, :exception] — measurements duration, monotonic_time; metadata caller, program_bytes, signature_supplied?, and exception_class. Exception reasons, stacktraces, source, arguments, and results are never attached.

Tool Cache Lifetime

A public tool configured with cache: true reuses successful results during one evaluation. The evaluator derives cache identity from the arguments prepared for the callback, including Java projection. Sequential and higher-order calls observe earlier entries. Parallel workers start from the same pre-parallel cache and do not observe sibling writes; their entries merge in input order and become reusable after the parallel operation. Every top-level call starts empty, and private tools never cache. Passing a :tool_cache option raises ArgumentError, and cache entries are not included in the returned Result.

Return Value

println writes only to an evaluation-local buffer. Successful and failed results expose any retained entries through step.prints; a caller must explicitly render or store them. The evaluator itself does not write those entries to stdout or a canonical trace.

On success, returns:

  • {:ok, PtcRunner.Lisp.Result.t()} with:
    • step.return: An inert public projection of the evaluated value. Executable values become typed display wrappers. Distinct map keys and set members that share one display form remain distinct through inert collision wrappers.
    • step.memory: Data memory after execution. Direct Lisp callers can pass it back through the :memory option on a later eval; use externalize_memory/1 when you need a purely public observation shape. The run/2 result preserves supported closures and composed callable forms, including embedded builtin/tool runtime-callable references. It deliberately omits top-level runtime callables such as directly bound builtin/tool aliases and renders runtime callables nested in ordinary collection data as labels. Java callables are also rendered as labels, including when captured by a closure, so such a closure is observation-only on the public continuation path. Do not serialize step.memory (JSON, ETF-to-disk, database) between evals — serialization silently converts native values such as keywords into plain strings. For a bounded persistent REPL continuation, use PtcRunner.Kernel.ReplSession from one stable owner process. It is process-affine; a separate supervised abstraction must serialize any multi-process frontend.
    • step.usage: Execution metrics (duration_ms, memory_bytes, eval_reductions)

On error, returns:

  • {:error, PtcRunner.Lisp.Result.t()} with:
    • step.fail.reason: Error reason atom
    • step.fail.message: Human-readable error description
    • step.memory: Native continuation memory at the time of error, with the same callable caveats as successful run/2 results. Setup-phase heap kills return empty memory because the oversized grant cannot safely be projected outside the bounded worker. Do not serialize continuation memory; use externalize_memory/1 for an observation-only projection.

Memory Contract

The top-level program value determines step.return without an implicit map merge or special :return key handling. run/2 then projects that value to its inert public representation. Storage is explicit: (def x v) persists native v in memory (step.memory["x"]), and that memory can survive across explicitly managed evaluations.

There are two deliberate host memory projections. Ordinary embedders may use run/2 to continue data definitions, closures that do not capture Java callables, and supported composed callable forms by threading step.memory directly into the next call's :memory option. Top-level direct runtime-callable aliases and Java callables are not preserved on this public path. Hosts that must preserve every runtime callable, including direct builtin/tool aliases and Java callables captured by closures, use the internal run_native/2 continuation path with preserve_runtime_callables: true, as PtcRunner.Kernel does. A host must not substitute the JSON/public memory projection for either continuation path: that projection is observation-only and cannot retain executable values.

:turn_history is separate from memory. Hosts pass a list of prior successful step.return values in chronological order; *1 reads the most recent value, *2 the previous value, and *3 the third-most-recent value. Missing history reads return nil. run/2 does not mutate the supplied history; callers that want REPL semantics should append step.return only after a successful run and keep their chosen bounded depth. For direct embedding use, PtcRunner.Kernel.ReplSession implements this contract with a default depth of 3.

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 a limit returns an error result whose Step.fail.reason is :timeout or :memory_exceeded. Step.fail.details.phase identifies :setup (bounded grant preparation) or :eval (program execution). Heap diagnostics also include :limit_bytes, :baseline_bytes, and :budget_bytes.

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)

Filtering performs direct lookups for the statically referenced keys; it does not enumerate or copy unrelated collection or scalar grants into the sandbox.

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