This guide covers combined mode — text agents that opt into the
internal lisp_eval tool so the LLM can escalate to deterministic
PTC-Lisp computation when a result is too large to feed into the chat
context. It is the output: :text, ptc_transport: :tool_call shape.
For the pure transports, see Text Mode and PTC-Lisp Transport. Combined mode is orthogonal to both: text agents that want optional escalation paths.
What is combined mode?
Combined mode is a normal text agent with one extra provider-native tool
exposed: lisp_eval. The LLM answers chat-shaped turns directly
when it can; when it needs deterministic compute, multi-tool
orchestration, or filtering over a large result, it calls
lisp_eval with a small PTC-Lisp program that runs in PtcRunner's
sandbox.
agent =
PtcRunner.SubAgent.new(
prompt: "You are a support assistant.",
output: :text,
ptc_transport: :tool_call,
tools: %{
"search_logs" =>
{&MyApp.Logs.search/1,
signature: "(query :string) -> [:any]",
description: "Search log events.",
expose: :both,
cache: true,
native_result: [preview: :metadata]}
},
max_turns: 6
)The provider sees search_logs (because expose: :both) and
lisp_eval. PTC-Lisp programs see search_logs because the same
:both setting puts it on the program-callable side. Whichever layer
calls the tool first seeds a shared cache; the other layer reuses the
result.
Combined mode is a strict superset of pure text mode in feature surface,
not a replacement. Pure output: :text (without ptc_transport) stays
unchanged.
When to use it
Reach for combined mode when:
- A native tool returns large results (logs, query rows, scrape dumps) that would otherwise blow the chat context.
- The LLM may need to filter, aggregate, or join across multiple tool results within one user turn.
- You want a chat-shaped agent UX but with an escape hatch for deterministic compute on demand.
- You want to share results between a native tool call and a follow-up PTC-Lisp program without re-running the upstream call.
It is not the right fit when:
- The agent is pure chat with small tool results — overhead from the
compact reference card and
lisp_evalschema isn't worth it. - You already need structured output from a single program — use
output: :ptc_lisp, ptc_transport: :tool_callinstead. That mode short-circuits on(return v)matching the signature; combined mode does not (see "Final-output semantics" below).
Tool exposure policy
Each tool declares which layer can call it via the expose: option.
| Value | Provider-native? | Inside lisp_eval programs? |
|---|---|---|
:native | yes | no — (tool/name ...) rejected at parse time |
:ptc_lisp | no | yes — only as (tool/name ...) |
:both | yes | yes |
Per-mode defaults when expose: is omitted:
output: | ptc_transport: | Default expose: |
|---|---|---|
:text | not :tool_call | :native |
:text | :tool_call (combined) | :native |
:ptc_lisp | any | :ptc_lisp |
The intentional gotcha. Combined mode defaults to :native. An
agent that opts into combined mode but tags zero tools as :both or
:ptc_lisp still gets a working lisp_eval — useful for pure
deterministic computation, math, or transforming data passed via
context. But (tool/foo ...) calls inside programs will be rejected
at parse time with a clear error. This is by design: combined mode
forces deliberate exposure decisions rather than auto-promoting every
tool. Tag tools :both (or :ptc_lisp) explicitly to make them
program-callable.
The compact PTC-Lisp reference card is appended to the system prompt
even when zero tools are exposed to programs (see "ptc_reference:
option" below) — lisp_eval itself is still useful, and omitting
the card produces agents that don't know how to use it.
The cache bridge
When a tool is expose: :both, cache: true, native and PTC-Lisp layers
share one cache entry per (tool_name, canonical_args) pair. The
canonical end-to-end transcript:
;; Turn 1 — User question
USER: "How many errors with code 42 last hour?"
;; Turn 2 — LLM calls native search_logs
ASSISTANT (tool_calls): [
{id: "call_1", name: "search_logs", args: {"query": "error code 42"}}
]
;; Runtime executes search_logs/1 (1842 rows). Stores the full result
;; in tool_cache under canonical key {"search_logs", %{"query" => ...}}.
;; Returns a metadata-only preview to the LLM:
TOOL (tool_call_id: "call_1"): {
"status": "ok",
"result_count": 1842,
"schema": {"type": "array", "items": {"type": "object",
"properties": {"id": "integer",
"timestamp": "string",
"message": "string"}}},
"sample_keys": ["id", "message", "timestamp"],
"full_result_cached": true,
"cache_hint": "Call lisp_eval and then call (tool/search_logs {:query \"error code 42\"}) to process the full cached result."
}
;; Turn 3 — LLM escalates to lisp_eval
ASSISTANT (tool_calls): [
{id: "call_2",
name: "lisp_eval",
args: {"program": "(def rows (tool/search_logs {:query \"error code 42\"}))\n(return {:total (count rows)})"}}
]
;; Runtime hits the same canonical cache key — search_logs/1 is NOT
;; re-executed. Program runs over the cached rows. (return ...) produces
;; a successful tool result via PtcToolProtocol.render_success/2.
TOOL (tool_call_id: "call_2"): {
"status": "ok",
"result": "user=> {:total 1842}",
"prints": [],
...
}
;; Turn 4 — LLM composes the final answer
ASSISTANT (content): "There were 1842 errors with code 42 in the queried window."Two things make this work:
cache: trueon the tool definition tells PtcRunner that results are safe to reuse for identical canonical args. This is the samecache:field PTC-Lisp tools have always had — combined mode reuses it rather than introducing a parallelcacheable:flag.- Canonical cache key.
PtcRunner.SubAgent.KeyNormalizer.canonical_cache_key/2normalizes args before hashing: atom and string keys converge, nested map ordering is stabilized, and integer-equal floats collapse to integers. Native and PTC-Lisp callers always agree on the key regardless of how args arrived.
Cache-key migration wart (deliberate). In v1, the existing PTC-Lisp
cache path was migrated to canonical_cache_key/2. Most callers see no
difference, but a few previously-distinct keys now converge:
| Before | After |
|---|---|
%{"a" => 1} and %{a: 1} were distinct entries | Same entry |
%{a: 1, b: 2} and %{b: 2, a: 1} could miss each other | Same entry |
1 and 1.0 were distinct entries | Same entry (collapses to 1) |
This is widening, not narrowing — fewer cache misses, never more. If a
test previously relied on a miss between (say) 1 and 1.0, update it.
See the CHANGELOG for the migration note.
Final-output semantics
Inside combined-mode lisp_eval, the program's terminating
expression — whether (return v), (fail v), or a normal final
expression — produces a tool result, not the run's final answer.
The LLM gets one more turn to compose the final answer (which is then
the agent's final answer).
output: | signature: | Final answer source |
|---|---|---|
:text | none | LLM's final text response (raw) |
:text | :string / :any | LLM's final text response (raw) |
:text | {:map, ...} / {:list, ...} | LLM's final text response, parsed as JSON, validated against the signature |
:text | :int / :float / :bool / :datetime | LLM's final text response, coerced to the scalar type |
(return v) does not short-circuit. The program terminates with v
as its final value, the runtime emits a success tool-result, and the LLM
gets one more turn to respond (budget permitting; see below). This is
identical to how every other tool call works in :text mode.
(fail v) does not abort the run. The program terminates with an
error tool-result (reason: "fail", result field carrying v). The
LLM gets one more turn to react — apologize, retry with different args,
fall back to a textual answer.
If you want short-circuit semantics where (return v) matching the
signature is the final answer, use output: :ptc_lisp, ptc_transport: :tool_call instead. That mode exists for exactly this
purpose. Combined mode is deliberately the more permissive shape.
Turn budget guidance
lisp_eval consumes one max_turns slot like any other tool
call. The "LLM gets one more turn to respond" guarantee above is
conditional on turn budget remaining — it is not a reserved slot.
Size max_turns with at least one slot of headroom beyond your
worst-case lisp_eval count. If max_turns is exhausted by the
program call (so the paired role: :tool message is the last thing the
loop emits), the run terminates via TextMode's existing
max_turns_exceeded path. The tool_call_id is paired before
termination (universal pairing rule), but no follow-up text turn
happens, and step.return carries whatever max-turns handling produces
— not the program's v.
lisp_eval itself is exempt from max_tool_calls. Only
native app-tool calls count against the tool-call budget. An agent with
max_tool_calls: 1 may still invoke lisp_eval repeatedly,
bounded only by max_turns.
native_result options
For expose: :both, cache: true tools, native_result: controls the
preview shape returned to the LLM (the full result lives in the cache
regardless).
preview: | What the LLM sees |
|---|---|
:metadata (default) | result_count, JSON-Schema-ish schema, sample_keys. No row values. |
:rows (with limit:, default 20) | First limit rows verbatim, plus result_count and schema. |
| 1-arity function | Whatever the function returns, merged with the universal cache fields. |
:metadata is safe-by-default for compliance-sensitive workloads:
nothing from the result body crosses the LLM boundary, only its shape.
# Verbatim row preview, capped at 5
native_result: [preview: :rows, limit: 5]
# Custom preview
native_result: [preview: fn full_result ->
%{"top_scores" => Enum.take(full_result, 3) |> Enum.map(& &1.score)}
end]Custom preview function contract. The function receives only
full_result (not args, not tool name — capture them in a closure if
needed). It MUST return a map that Jason.encode!/1 accepts. If the
function raises, returns a non-map, or returns a non-encodable value,
the runtime falls back to the metadata preview and emits a
Logger.warning/1 tagged with the tool name and failure category
(raised, non_map, non_encodable). The tool's actual return value
is unaffected — only the preview is replaced.
The validator rejects native_result: unless the tool also has
expose: :both and cache: true. The combination is meaningful only
when both layers can see the tool and a cache exists for the layers
to share.
ptc_reference: option
Combined-mode agents need at least a compact PTC-Lisp reference in the
system prompt so the LLM knows how to use lisp_eval. The
ptc_reference: option pins this:
PtcRunner.SubAgent.new(
prompt: "...",
output: :text,
ptc_transport: :tool_call,
ptc_reference: :compact # default; only valid value in v1
)Only :compact is accepted in v1. The compact card is appended to the
combined-mode system prompt at runtime — roughly 270 tokens of static
content (forms, cache-reuse paragraph, one example) plus a dynamic
inventory of :both and :ptc_lisp-exposed tools.
The card source lives at
priv/prompts/ptc_text_mode_compact_reference.md.
Setting ptc_reference: :full raises ArgumentError — it is deferred
to a follow-up. Setting false or any other value also raises. Users
who don't want the prompt overhead should not opt into combined mode.
chat/3 interaction
Combined mode is supported in PtcRunner.SubAgent.chat/3. Each
chat/3 call behaves like a fresh combined-mode run over the provided
messages. The validator does not reject combined mode at the chat/3
boundary.
Cross-call state does NOT persist. tool_cache, journal,
turn_history, and retained child-execution state do not survive
across chat/3 turns. Cross-turn threading is fully deferred to a
future ChatState API.
Known wart (accepted, not fixed in v1). A previous turn's
full_result_cached: true + cache_hint references a cache key that
no longer exists on the next chat/3 call. The LLM following the hint
causes a tool re-run — correct behavior (the upstream tool fires
again), but wasteful. The native preview renderer does not branch on
chat-vs-run mode in v1; this is documented honestly rather than
papered over. Workaround: keep cache-sensitive workflows inside one
PtcRunner.SubAgent.run/2 invocation.
Telemetry
Every tool-call telemetry event in combined mode carries an
exposure_layer field:
exposure_layer: :native— the call came in via the provider's native tool calling.exposure_layer: :ptc_lisp— the call came from inside alisp_evalprogram (a(tool/name ...)invocation).
Use exposure_layer to debug cache reuse and budget consumption — it
is the field that tells "tool was called from chat" apart from "tool
was called from inside a program." Other useful fields on the same
events: cached, result_preview_truncated, full_result_cached,
cache_key_hash, retained_bytes.
See Observability for the broader telemetry surface.
Resource policy and memory risk
Concrete v1 invariants for retained native results:
tool_cachelives for the duration of onePtcRunner.SubAgent.run/2call.- No cross-run persistence.
tool_cachedoes not survive acrosschat/3turns in v1. - No eviction during a run. Full results stay in
tool_cacheuntil the run terminates. There is no LRU, no size-based eviction, no TTL.
Very large retained results consume runtime memory for the entire run. Mitigation:
- Filter eagerly inside
lisp_evaland return only the projection your program needs. The full result remains cached for later programs in the same run, but the program's own variables are scoped — keep them small. - Don't cache tools that return arbitrarily large blobs unless callers will actually reuse the result.
- Use
preview: :metadata(the default) so previews don't carry rows themselves.
Configurable resource limits (tool_cache_limit, per-tool
max_cached_bytes, eviction strategies, memory accounting) are
deferred to follow-up work — see "Known limitations" below.
Known limitations / deferred
The following behaviors are deferred from v1 and documented for honesty:
- No
*1/*2/*3integration with native tool results. Only successful non-terminallisp_evalprograms advanceturn_history. Native call results don't feed*1references. - No richer
ChatStateAPI.tool_cache,journal,turn_history, retained child-execution state do not thread acrosschat/3turns. - No cross-
chat/3-turn compaction. - No automatic journal breadcrumbs for native large-result tool calls. The cache hint in the tool-result content is the only cross-turn signal.
- No configurable resource limits (
tool_cache_limit, per-toolmax_cached_bytes, eviction). - No short-circuit on
(return v)matching a signature — combined mode is deliberately the permissive shape; useoutput: :ptc_lisp, ptc_transport: :tool_callfor short-circuit semantics. ptc_reference: :fullraisesArgumentErrorin v1.
Edge cases pinned for transparency (most users won't hit these):
-0.0collapses to0in canonical cache keys. Harmless for cache identity, diverges from JSON's strict equality.- NaN / Infinity floats pass through cache keys unchanged. BEAM arithmetic doesn't produce them; foreign NIFs could.
- Charlist (
'abc') vs binary ("abc") cache identity is not unified — they hash to different keys. - Atom + string key collision in the same map silently overwrites during preview rendering. Avoid mixing key types in tool results.
- Decimal/DateTime values inside cache key args are not fully canonicalized; they pass through the catch-all clause.
falsevalues in metadata previews can collapse tonullvia a||short-circuit in the preview builder. Affects display only — cache identity is unaffected.
Migration / breaking changes
Combined mode is opt-in. No defaults flipped. Pure output: :text
behavior is unchanged. Pure output: :ptc_lisp (any ptc_transport)
is unchanged. The validator continues to reject
output: :text, ptc_transport: :content (nonsensical: text mode has
no fenced PTC-Lisp parsing path).
The one thing existing PTC-Lisp users may notice is the deliberate
cache-key widening from the KeyNormalizer.canonical_cache_key/2
migration — see "The cache bridge → Cache-key migration wart" above
and the CHANGELOG.
See also
- Getting Started — basic SubAgent usage
- Text Mode — pure
output: :text(no combined mode) - PTC-Lisp Transport —
output: :ptc_lispwith:contentvs:tool_call - Observability — telemetry surface
including
exposure_layer - PTC-Lisp Specification — language
reference for programs run inside
lisp_eval PtcRunner.SubAgent.new/1— full agent options referencePtcRunner.SubAgent.run/2— runtime optionsPtcRunner.PtcToolProtocol—lisp_evaldescription and response renderersPtcRunner.SubAgent.Exposure— exposure resolution and filtering helpersPtcRunner.SubAgent.Loop.NativePreview— native result preview builder