PtcRunner.PtcToolProtocol (PtcRunner v0.12.0)

Copy Markdown View Source

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_calloutput: :ptc_lisp, ptc_transport: :tool_call agents that expose lisp_eval as the only provider-native tool. Profile: :in_process_with_app_tools.
  • In-process text-mode (combined mode)output: :text, ptc_transport: :tool_call agents that expose lisp_eval alongside :both-tagged app tools. Profile: :in_process_text_mode.
  • MCP server — standalone JSON-RPC server that advertises lisp_eval with 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):

  1. Profile-string convention. Each profile is one canonical string constant. tool_description/1 returns 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.
  2. error_reason() is a closed union. Adding a new reason requires updating the @type and render_error/3's reason handling in lockstep. render_error/3 MUST handle every member without crashing.
  3. Renderer signatures are keyword-driven. render_success/2 and render_error/3 take a keyword list for any non-essential parameter so future additions are non-breaking. Unknown opts are silently ignored, not rejected.
  4. tool_description/1 carries 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

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

error_reason()

@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 (missing program, 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 a result payload (Addendum #4).
  • :validation_error — return-value signature mismatch. Reserved for MCP v1; no in-process surface emits it today (Tier 0 scope expansion).

Functions

atomize_value(value, type)

@spec atomize_value(term(), term()) :: term()

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.

lisp_run(source, opts \\ [])

@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.

parse_signature(signature_string)

@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")

render_error(reason, message, opts \\ [])

@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/1 of the reason atom.
  • "message" — the supplied human-readable message.
  • "feedback" — defaults to message; overridden via feedback: opt.
  • "result" — present only when reason == :fail. The value is taken from the result: opt (Addendum #4: :fail is the only reason that carries a value).

Recognized opts

  • :result — only meaningful for reason: :fail. Encoded as the top-level "result" field (typically a string preview of the failed value). Ignored for any other reason.
  • :feedback — string. Defaults to message when not provided.

Unknown opts are ignored (Addendum #12).

render_success(lisp_step, opts \\ [])

@spec render_success(
  map(),
  keyword()
) :: String.t()

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. A TurnFeedback.execution_feedback/3 result 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 (default true). When false, the "memory" key is omitted entirely from the payload. One-shot callers (MCP server) set this to false because state never persists across calls (issue #879). Multi-turn SubAgent loops keep the default since defn'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_success_from_step(lisp_step, opts \\ [])

@spec render_success_from_step(
  map(),
  keyword()
) :: String.t()

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 into render_success/2 and surfaced as the top-level "validated" field. Only meaningful when the caller validated the program's return value against a signature.
  • :include_memory — boolean (default false). When true, the rendered payload includes memory.{changed,stored_keys,truncated}. Use this for stateful embedding loops where Lisp memory persists across invocations.
  • :prior_memory — memory map from before this turn (default %{}). Used only to compute memory.changed when include_memory: true.
  • :format_options — optional format options for result, print, and memory previews.

Unknown opts are silently ignored, matching render_success/2.

One-shot and stateful semantics

By default, this wrapper keeps one-shot behavior: state never persists across invocations, so the response omits the memory field entirely (issue #879). Stateful embedders can pass include_memory: true and prior_memory: previous_memory to get the same LLM-facing execution feedback without reaching into PtcRunner.SubAgent.Loop.TurnFeedback.

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"

to_json_value(value)

@spec to_json_value(term()) :: {:ok, term()} | {:error, String.t()}

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 termJSON form
Integernumber
Floatnumber
Binary (string)string
Booleanboolean
nilnull
Map with binary or atom keysobject with string keys
Listarray
Atom (non-key)string (:foo"foo", no leading colon)
Tuplearray
%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"}

tool_description(atom)

@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_program(program)

@spec validate_program(term()) ::
  {:ok, String.t()} | {:error, :args_error, String.t()}

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."}

validate_return(definition, value)

@spec validate_return(map(), term()) :: :ok | {:error, list()}

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.