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.
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
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 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}
@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 stringopts: 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):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- Fixedmax_heap_size(words) for every pmap/pcalls worker, top-level and nested (default: the:max_heapvalue):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 perprintlncall (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 raiseArgumentError.:profile- Closed-set telemetry tag for the calling profile. One of:mcp_no_tools,:mcp_aggregator,:in_process_v1, or:text_mode, ornil(default). Pure instrumentation: attached to[:ptc_runner, :lisp, :execute, *]events and otherwise discarded. Out-of-set values raiseArgumentError. The MCP v1 handler passes:mcp_no_tools; the aggregator (Phase 1a) flips it to:mcp_aggregator. SeePlans/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]— measurementsmonotonic_time,system_time; metadatacaller,profile,program_bytes,signature_supplied?.[:ptc_runner, :lisp, :execute, :stop]— measurementsduration,monotonic_time,result_bytes,prints_count; metadatacaller,profile,program_bytes,signature_supplied?.[:ptc_runner, :lisp, :execute, :exception]— measurementsduration,monotonic_time; metadatacaller,profile,program_bytes,signature_supplied?,kind,reason,stacktrace.
Return Value
On success, returns:
{:ok, Step.t()}with:step.return: The value returned to the callerstep.memory: Complete memory state after executionstep.usage: Execution metrics (duration_ms,memory_bytes,eval_reductions)
On error, returns:
{:error, Step.t()}with:step.fail.reason: Error reason atomstep.fail.message: Human-readable error descriptionstep.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.
: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.Session implements this contract with a default
depth of 3.
Related modules:
PtcRunner.SubAgent.Loop- Uses this contract to persist memory across turnsPtcRunner.Session- Public stateful embedding wrapper for memory and turn historyPtcRunner.Lisp.Eval- Evaluates programs with user_ns (memory) symbol resolution
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.33Resource 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 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