Wire-format source of truth for the lisp_eval tool surface.
Owns the canonical tool description (per capability profile) and the
shared response-payload renderers (render_success/2,
render_error/3) used across:
- In-process v1 PTC
:tool_call—output: :ptc_lisp, ptc_transport: :tool_callagents that exposelisp_evalas the only provider-native tool. Profile::in_process_with_app_tools. - In-process text-mode (combined mode) —
output: :text, ptc_transport: :tool_callagents that exposelisp_evalalongside:both-tagged app tools. Profile::in_process_text_mode. - MCP server — standalone JSON-RPC server that advertises
lisp_evalwith no app tools available inside programs. Profile::mcp_no_tools.
The three feature plans share four conventions; this module is where
all four are pinned (see "Coupling Points" in
Plans/text-mode-ptc-compute-tool.md):
- Profile-string convention. Each profile is one canonical
string constant.
tool_description/1returns it directly — no runtime concatenation of base + capability note. If the representation ever changes (e.g., to a structured map), all three profiles change together. error_reason()is a closed union. Adding a new reason requires updating the@typeandrender_error/3's reason handling in lockstep.render_error/3MUST handle every member without crashing.- Renderer signatures are keyword-driven.
render_success/2andrender_error/3take a keyword list for any non-essential parameter so future additions are non-breaking. Unknown opts are silently ignored, not rejected. tool_description/1carries capability statements only. Cache-reuse guidance, prompt cards, and other workflow guidance live in plan-specific surfaces (system prompt,cache_hint, MCP server documentation), not here.
See Plans/text-mode-ptc-compute-tool.md § "Prerequisite: Shared
Protocol Module" and Plans/ptc-runner-mcp-server.md § "Tool
Description Capability Profiles" for the spec.
Summary
Types
Closed union of error reasons surfaced through render_error/3.
Functions
Delegates to PtcRunner.Lisp.run/2.
Parse a PTC signature string for use by out-of-tree callers.
Render an error lisp_eval response as JSON.
Render a successful lisp_eval invocation as JSON.
Render a successful lisp_eval invocation directly from a Lisp.run/2 result.
Convert a typed Elixir term into a JSON-encodable value.
Capability profile for the lisp_eval tool description.
Validate the program argument of a lisp_eval invocation.
Types
@type error_reason() ::
:parse_error
| :runtime_error
| :timeout
| :memory_limit
| :args_error
| :fail
| :validation_error
Closed union of error reasons surfaced through render_error/3.
Members:
:parse_error— PTC-Lisp source failed to parse.:runtime_error— runtime evaluation error inside a program (also covers return-validation errors against the agent's signature).:timeout— sandbox timeout exceeded.:memory_limit— sandbox memory cap exceeded.:args_error— tool arguments malformed (missingprogram, non-string, wrong shape, oversized). Emitted only by MCP v1; in-process surfaces never construct it (Addendum #25).:fail— program called(fail v)to terminate with an error value. Only reason carrying aresultpayload (Addendum #4).:validation_error— return-value signature mismatch. Reserved for MCP v1; no in-process surface emits it today (Tier 0 scope expansion).
Functions
Delegates to PtcRunner.SubAgent.Loop.JsonHandler.atomize_value/2.
Used by surfaces that need to coerce a raw JSON value into the shape implied by a parsed signature before validation.
@spec lisp_run( String.t(), keyword() ) :: {:ok, PtcRunner.Step.t()} | {:error, PtcRunner.Step.t()}
Delegates to PtcRunner.Lisp.run/2.
Re-exported here so non-v1 callers (text-mode combined loop, MCP server) can drive PTC-Lisp programs through the protocol module without depending on the v1 loop's transitive aliases.
@spec parse_signature(String.t()) :: {:ok, PtcRunner.SubAgent.Signature.signature()} | {:error, String.t()}
Parse a PTC signature string for use by out-of-tree callers.
Thin wrapper over PtcRunner.SubAgent.Signature.parse/1. Per § 13.1
of Plans/ptc-runner-mcp-server.md, :ptc_runner_mcp consumes
signatures exclusively through this function so the parser can
later move out of the SubAgent namespace without breaking the MCP
package.
Examples
iex> PtcRunner.PtcToolProtocol.parse_signature("() -> {count :int}")
{:ok, {:signature, [], {:map, [{"count", :int}]}}}
iex> {:error, _reason} = PtcRunner.PtcToolProtocol.parse_signature("not a signature")
@spec render_error(error_reason(), String.t(), keyword()) :: String.t()
Render an error lisp_eval response as JSON.
Every member of error_reason() is handled. Output payload keys:
"status"— always"error"."reason"—Atom.to_string/1of the reason atom."message"— the supplied human-readable message."feedback"— defaults tomessage; overridden viafeedback:opt."result"— present only whenreason == :fail. The value is taken from theresult:opt (Addendum #4::failis the only reason that carries a value).
Recognized opts
:result— only meaningful forreason: :fail. Encoded as the top-level"result"field (typically a string preview of the failed value). Ignored for any other reason.:feedback— string. Defaults tomessagewhen not provided.
Unknown opts are ignored (Addendum #12).
Render a successful lisp_eval invocation as JSON.
Success payload shape: status, optional result, prints,
feedback, top-level truncated, and (when include_memory: true,
the default) memory.{changed,stored_keys,truncated}. One-shot
callers omit memory by passing include_memory: false or by going
through render_success_from_step/2 which sets it for them.
Required input shape
lisp_step is the Step.t() result of Lisp.run/2. Only the
:return field is consulted directly (to decide whether to drop the
"result" field when nil).
Recognized opts
:execution— required for v1 callers. ATurnFeedback.execution_feedback/3result map carrying:result,:prints,:feedback,:memory.{changed,stored_keys,truncated}, and:truncated. The renderer reads these directly. Future callers (MCP server, text-mode) MAY pass their own equivalent map.:validated— JSON-encodable value. When present, included as a top-level"validated"field. Used by MCP v1 to surface schema-validated return values.:include_memory— boolean (defaulttrue). Whenfalse, the"memory"key is omitted entirely from the payload. One-shot callers (MCP server) set this tofalsebecause state never persists across calls (issue #879). Multi-turnSubAgentloops keep the default sincedefn'd names DO persist across turns.
Unknown opts are ignored (Addendum #12).
Result-field elision
Matches the v1 invariant: when both execution.result and
lisp_step.return are nil, the "result" field is dropped from
the JSON. Any other combination keeps the field (even when the
rendered value is null).
Render a successful lisp_eval invocation directly from a Lisp.run/2 result.
High-level convenience wrapper over render_success/2. Builds the
:execution map internally via
PtcRunner.SubAgent.Loop.TurnFeedback.execution_feedback/3 so callers
outside :ptc_runner (e.g. :ptc_runner_mcp) never have to reach
into TurnFeedback themselves. Per § 13.1 of
Plans/ptc-runner-mcp-server.md, this is the only canonical way for
out-of-tree callers to render an R22 success payload.
Required input shape
lisp_step is a PtcRunner.Step.t() (the success-branch result of
PtcRunner.Lisp.run/2). Only the structured fields the renderer
consults — :return, :prints, :memory — need to be populated.
Recognized opts
:validated— JSON-encodable value forwarded intorender_success/2and surfaced as the top-level"validated"field. Only meaningful when the caller validated the program's return value against a signature.
Unknown opts are silently ignored, matching render_success/2.
One-shot semantics — no memory field
This wrapper is the canonical path for one-shot callers (MCP server,
text-mode rendering of single programs). One-shot calls never see
state across invocations, so the response omits the memory field
entirely (issue #879). Multi-turn callers — SubAgent loops where
defn'd names persist across turns — should call render_success/2
directly with the default include_memory: true.
Example
iex> {:ok, step} = PtcRunner.Lisp.run("(+ 1 2)")
iex> json = PtcRunner.PtcToolProtocol.render_success_from_step(step)
iex> %{"status" => "ok", "result" => "user=> 3"} = Jason.decode!(json)
iex> json |> Jason.decode!() |> Map.fetch!("status")
"ok"
Convert a typed Elixir term into a JSON-encodable value.
Used by surfaces that surface signature-validated return values as
structured JSON (currently only the MCP server's validated field;
see § 13 of Plans/ptc-runner-mcp-server.md). This is the inverse
direction of atomize_value/2, which goes JSON → typed Elixir.
Conversion rules
| Elixir term | JSON form |
|---|---|
| Integer | number |
| Float | number |
| Binary (string) | string |
| Boolean | boolean |
nil | null |
| Map with binary or atom keys | object with string keys |
| List | array |
| Atom (non-key) | string (:foo → "foo", no leading colon) |
| Tuple | array |
%DateTime{} | ISO-8601 string |
%Date{}, %Time{} | ISO-8601 string |
| Anything else | {:error, "non-JSON-encodable value at <path>"} |
Errors propagate the path to the offending sub-value. Map-key path
segments are dot-joined; list/tuple indices use [<index>].
Examples
iex> PtcRunner.PtcToolProtocol.to_json_value(42)
{:ok, 42}
iex> PtcRunner.PtcToolProtocol.to_json_value(1.5)
{:ok, 1.5}
iex> PtcRunner.PtcToolProtocol.to_json_value(:foo)
{:ok, "foo"}
iex> PtcRunner.PtcToolProtocol.to_json_value({1, :ok, "a"})
{:ok, [1, "ok", "a"]}
iex> {:ok, dt, _} = DateTime.from_iso8601("2026-05-07T12:00:00Z")
iex> PtcRunner.PtcToolProtocol.to_json_value(dt)
{:ok, "2026-05-07T12:00:00Z"}
iex> PtcRunner.PtcToolProtocol.to_json_value(%{count: 2, items: [:a, :b]})
{:ok, %{"count" => 2, "items" => ["a", "b"]}}
iex> PtcRunner.PtcToolProtocol.to_json_value(%{rows: [%{ts: make_ref()}]})
{:error, "non-JSON-encodable value at rows[0].ts"}
@spec tool_description( :in_process_with_app_tools | :in_process_text_mode | :mcp_no_tools ) :: String.t()
Capability profile for the lisp_eval tool description.
Returns the canonical description string for the requested profile.
Per Addendum #11, each profile is one constant returned directly —
no runtime concatenation. The :in_process_with_app_tools string is
byte-for-byte locked to the existing v1 wording (Addendum #10).
Validate the program argument of a lisp_eval invocation.
Shared across the in-process :tool_call and text-mode loop branches
so the wire-format error wording for an absent, mistyped, or empty
program stays in lockstep with the rest of the protocol surface.
Returns {:ok, program} for a non-empty binary, or
{:error, :args_error, message} describing the violation.
Examples
iex> PtcRunner.PtcToolProtocol.validate_program("(+ 1 2)")
{:ok, "(+ 1 2)"}
iex> PtcRunner.PtcToolProtocol.validate_program(nil)
{:error, :args_error, "lisp_eval requires a non-empty `program` string argument."}
iex> PtcRunner.PtcToolProtocol.validate_program(42)
{:error, :args_error, "lisp_eval `program` must be a string, got 42."}
iex> PtcRunner.PtcToolProtocol.validate_program(" ")
{:error, :args_error, "lisp_eval `program` must be a non-empty string."}
Delegates to PtcRunner.SubAgent.Loop.JsonHandler.validate_return/2.
Used by surfaces that need to validate a return value against an agent's parsed signature.