# Text Mode + PTC-Lisp Compute (Combined Mode)

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](subagent-text-mode.md) and
[PTC-Lisp Transport](subagent-ptc-transport.md). 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.

```elixir
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_eval` schema isn't worth it.
- You already need structured output from a single program — use
  `output: :ptc_lisp, ptc_transport: :tool_call` instead. 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:

1. **`cache: true`** on the tool definition tells PtcRunner that
   results are safe to reuse for identical canonical args. This is the
   same `cache:` field PTC-Lisp tools have always had — combined mode
   reuses it rather than introducing a parallel `cacheable:` flag.
2. **Canonical cache key.** `PtcRunner.SubAgent.KeyNormalizer.canonical_cache_key/2`
   normalizes 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.

```elixir
# 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:

```elixir
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 a
  `lisp_eval` program (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](subagent-observability.md) for the broader
telemetry surface.

## Resource policy and memory risk

Concrete v1 invariants for retained native results:

- `tool_cache` lives for the duration of one
  `PtcRunner.SubAgent.run/2` call.
- **No cross-run persistence.** `tool_cache` does not survive across
  `chat/3` turns in v1.
- **No eviction during a run.** Full results stay in `tool_cache` until
  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_eval` and 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`/`*3` integration with native tool results.** Only
  successful non-terminal `lisp_eval` programs advance
  `turn_history`. Native call results don't feed `*1` references.
- **No richer `ChatState` API.** `tool_cache`, `journal`,
  `turn_history`, retained child-execution state do not thread across
  `chat/3` turns.
- **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-tool `max_cached_bytes`, eviction).
- **No short-circuit on `(return v)` matching a signature** — combined
  mode is deliberately the permissive shape; use `output: :ptc_lisp,
  ptc_transport: :tool_call` for short-circuit semantics.
- **`ptc_reference: :full`** raises `ArgumentError` in v1.

Edge cases pinned for transparency (most users won't hit these):

- **`-0.0` collapses to `0`** in 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.
- **`false` values in metadata previews** can collapse to `null` via 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](subagent-getting-started.md) — basic SubAgent usage
- [Text Mode](subagent-text-mode.md) — pure `output: :text` (no
  combined mode)
- [PTC-Lisp Transport](subagent-ptc-transport.md) —
  `output: :ptc_lisp` with `:content` vs `:tool_call`
- [Observability](subagent-observability.md) — telemetry surface
  including `exposure_layer`
- [PTC-Lisp Specification](../ptc-lisp-specification.md) — language
  reference for programs run inside `lisp_eval`
- `PtcRunner.SubAgent.new/1` — full agent options reference
- `PtcRunner.SubAgent.run/2` — runtime options
- `PtcRunner.PtcToolProtocol` — `lisp_eval` description and
  response renderers
- `PtcRunner.SubAgent.Exposure` — exposure resolution and filtering
  helpers
- `PtcRunner.SubAgent.Loop.NativePreview` — native result preview
  builder
