Packages
Secure BEAM sandbox runtime for LLM code mode and MCP aggregation. Run concurrent LLM/tool clients safely while agents orchestrate approved tools, call upstream MCP/OpenAPI servers, and transform data.
Current section
Files
Jump to
Current section
Files
usage-rules/subagent.md
# SubAgent Usage Rules
The `PtcRunner.SubAgent` API drives an LLM-controlled agentic loop. Use it
when you want the LLM to plan, call tools, and produce a typed result.
## Two-step pattern (preferred for reuse)
```elixir
agent = PtcRunner.SubAgent.new(
prompt: "Find the most expensive product",
signature: "{name :string, price :float}",
tools: %{"list_products" => fn _args -> MyApp.Products.list() end},
max_turns: 5
)
{:ok, step} = PtcRunner.SubAgent.run(agent, llm: my_llm, context: %{})
```
Use the one-shot string form `SubAgent.run("prompt", opts)` only for trivial
cases — it builds an ephemeral agent with defaults each call.
## Result shape
`SubAgent.run/2` returns `{:ok, %Step{}}` or `{:error, %Step{}}`. **Never
raises** for normal LLM/program failures (`SubAgent.run!/2` does). Public Step
fields you can rely on:
- `step.return` — value returned by `(return ...)` or the last expression in
single-shot mode.
- `step.fail` — `%{reason: atom | binary, message: binary, details: map}` on failure, otherwise `nil`.
- `step.usage` — duration, token, and memory metrics.
- `step.turns` — list of per-turn structs (LLM output, generated program, eval result).
- `step.tool_calls`, `step.pmap_calls` — tool invocation records.
- `step.memory` — final memory map (PTC-Lisp `def`s).
- `step.prints` — list of `println` outputs (always present, defaults to `[]`).
- `step.summaries` — map of `step-done` summaries (always present, defaults to `%{}`).
- `step.messages` — full LLM conversation if `collect_messages: true`.
- `step.journal` — present when the agent has `journaling: true`, otherwise `nil`.
- `step.child_traces`, `step.child_steps` — for nested SubAgentTool calls.
There is no top-level `:trace` field — use `:turns` for execution history,
`:trace_id` / `:parent_trace_id` for telemetry correlation.
## Execution modes (set or inferred)
| Mode | When | Behavior |
|------|------|----------|
| Single-shot | `output: :ptc_lisp` (default), `max_turns: 1`, no tools, `retry_turns: 0` | One LLM call, expression result is the answer; `(return ...)` not needed. |
| Loop | tools present, `max_turns > 1`, or `retry_turns > 0` | Multi-turn until `(return v)` or `(fail r)`; otherwise stops at `max_turns`. |
| Text | `output: :text` | LLM returns text/JSON directly. Provider's native tool calling is used if `tools:` is set. Add `ptc_transport: :tool_call` for the combined text + PTC-Lisp compute mode. |
If your agent loops to `max_turns_exceeded` despite producing the right value,
it is almost always missing `(return ...)`. Either drop to `max_turns: 1` or
guide the prompt: `"When done, call (return {:name ..., :price ...})"`.
## App loops and chat UIs
`SubAgent.run/2` is mission-oriented: one prompt, one loop, one result. It does
not create a durable chat session or hidden process-level memory for your app.
For chat-shaped products, keep the transcript and routing logic in your
application, then call `run/2` once per user message with the right contract:
```elixir
history = [%{role: :user, content: "Summarize ticket 123"}]
{:ok, step} =
PtcRunner.SubAgent.run(
"Summarize ticket 123",
output: :text,
tools: %{"get_ticket" => fn %{"id" => id} -> MyApp.Support.get_ticket(id) end},
llm: llm,
max_turns: 4
)
history = history ++ [%{role: :assistant, content: step.return}]
```
`step.return` is a plain string here because this call has no `signature:`. With
a `signature:`, `step.return` is a **map** keyed by strings — render it before
putting it into `content:`.
For the next message, decide again: `output: :text` for prose, `output: :text`
plus `signature:` for structured extraction, or default `:ptc_lisp` when the
answer requires deterministic filtering, sorting, aggregation, or date math.
Pass only bounded history through `context:`; don't append the full transcript
forever.
`SubAgent.chat/3` is available when you want PtcRunner to thread `messages` and
PTC-Lisp `memory` between calls:
```elixir
agent = PtcRunner.SubAgent.new(
prompt: "placeholder",
output: :text,
system_prompt: "Answer concisely."
)
{:ok, reply, messages, memory} = PtcRunner.SubAgent.chat(agent, "Hello", llm: llm)
{:ok, reply2, messages2, _memory2} =
PtcRunner.SubAgent.chat(agent, "Tell me more", llm: llm, messages: messages, memory: memory)
```
Even with `chat/3`, there is no `start` / `send_message` / `close` API. Your app
still owns persistence, history trimming, and which tools/signature/mode apply
to each user message.
## Tools — accepted shapes
**Hard contract:** every tool function is **arity-1** and receives a
**string-keyed** argument map (possibly empty `%{}`). Bare references to
zero-arity or multi-arity functions (`&MyApp.list/0`, `&MyApp.search/2`) will
crash at invocation time — wrap them. The signature/spec auto-extraction
documents the function for the LLM but does **not** rewrite arity at runtime.
Tool map keys are **strings**. Values can be any of:
```elixir
tools = %{
# Bare function — must already be arity-1 with a map arg
"list_products" => fn _args -> MyApp.Products.list() end,
# Function + explicit signature
"search" => {fn %{"query" => q, "limit" => l} -> MyApp.search(q, l) end,
"(query :string, limit :int) -> [{id :int}]"},
# Function + keyword opts (preferred for production)
"search" => {fn %{"query" => q, "limit" => l} -> MyApp.search(q, l) end,
signature: "(query :string, limit :int?) -> [{id :int, title :string}]",
description: "Search items. Returns up to limit results.",
cache: true
},
# Module function — wrap to adapt arity
"get_user" => fn %{"id" => id} -> MyApp.Users.get(id) end
}
```
Tool functions return any Elixir term. Don't raise — return `{:error, reason}`.
`cache: true` is safe only for pure, idempotent tools. Don't use it on tools
that read mutable state another tool might change.
`max_tool_calls:` (a `new/1` / `run/2` option) bounds how many app-tool
invocations the program may make across the whole run — a backstop against
runaway loops. Calls to the internal `lisp_eval` tool don't count against it.
## Signatures
Signatures are short type contracts:
```elixir
"{name :string, price :float}" # output only
"(query :string) -> [{id :int, title :string}]" # input + output
"{count :int, _email_ids [:int]}" # ordinary field name
```
Effects:
- Sent to the LLM in the system prompt so it knows what shape to return.
- Validated against `step.return` after `(return ...)`. Mismatch → error.
- Fields prefixed with `_` are ordinary fields. The underscore is part of the
field name and does not hide the value from generated programs, prompt
history, tool arguments, `println`, or `str`.
If the LLM struggles, loosen with `:any` or `?` (optional) before adding retries.
## Validation retries
```elixir
SubAgent.run(agent, llm: llm, retry_turns: 3)
```
`retry_turns` is a separate budget from `max_turns`. After a validation
failure, the agent gets up to `retry_turns` further attempts (with no tools,
just to fix the return shape). Use it only when you've tightened the
signature past what the model reliably hits on the first try.
## LLM callback contract
The `llm:` value is one of:
- **Function** — see input/output below.
- **Model alias string** like `"haiku"`, `"sonnet"`, `"openrouter:..."` —
resolved via `PtcRunner.LLM.callback/2` (requires `req_llm`).
- **Atom** like `:haiku`, resolved via `llm_registry: %{haiku: fn ... end}`.
**Input:** a map with at least `:system` (binary) and `:messages` (a list of
`%{role: :system|:user|:assistant, content: binary}`).
**Output:** `{:ok, response} | {:error, term}` where `response` is one of:
- A raw `String.t()` — wrapped internally as `%{content: string, tokens: nil}`.
- `%{content: String.t(), tokens: map() | nil}` — the canonical normalized form.
- `%{content: String.t() | nil, tokens: ..., tool_calls: [map()]}` — text-mode
with provider-native tool calls.
In PTC-Lisp mode `content` should contain a PTC-Lisp program (typically inside a
```` ```clojure ```` fence; raw s-expressions also parse). In text mode, plain
text or JSON.
## PTC-Lisp transport (`ptc_transport`)
For agents in PTC-Lisp mode (`output: :ptc_lisp`, the default), `ptc_transport`
controls how the LLM delivers programs to PtcRunner. Two values:
| Value | Default? | Wire format |
|-------|----------|-------------|
| `:content` | yes | Markdown-fenced PTC-Lisp in the assistant message body. Existing behavior. |
| `:tool_call` | opt-in | Native tool call to a single internal `lisp_eval` tool. The `program` argument carries the PTC-Lisp source. |
Both transports run programs in the same sandbox and call your app tools the
same way — via `(tool/name ...)` forms inside the program. App tools are
**never** exposed as native provider tools, including in `:tool_call` mode.
Only `lisp_eval` is. This keeps the sandbox boundary, `max_tool_calls`
budget, tool cache, and `pmap`/`pcalls` semantics identical across transports.
### When to use which
- **`:content`** — *one program, one deterministic orchestration.* Lower
latency, lower cost, single LLM turn for the typical "fan out to tools,
filter, aggregate, return" pattern. Stay here unless you have a reason to
switch.
- **`:tool_call`** — providers/models where native tool calling is materially
more reliable than fenced-code parsing, **or** workloads that genuinely need
iterative refinement across multiple program executions (model inspects an
intermediate result before writing the next program).
`:tool_call` turns one PTC-Lisp program into a ReAct-style loop: the model can
call `lisp_eval` zero or more times, then return a final answer
directly. That extra round-tripping is a **tradeoff, not an upgrade** — pay
for it deliberately. On some capable models (e.g., recent Anthropic) it can
also *reduce* pass rate by encouraging the model to fragment one-program work
across turns. The right choice depends on **(model × workload)**, not model
alone — see the [transport guide][transport-guide-empirical] for a small
benchmark.
[transport-guide-empirical]: https://hexdocs.pm/ptc_runner/subagent-ptc-transport.html#empirical-note-one-small-benchmark
### Behavior in `:tool_call` mode
- The LLM may call `lisp_eval` zero or more times, then return a final
answer directly as content. Direct final answers are validated against
`signature:` exactly like `(return v)` in `:content` mode.
- Direct final answers are allowed **before or after** any execution-tool
calls. A simple prompt the model can answer without computing anything stays
one turn.
- `(return v)` and `(fail v)` from inside a program still terminate the loop.
- `max_tool_calls` continues to bound *app tools* invoked from inside the
program. Calls to `lisp_eval` itself do **not** count against that
budget — the loop is bounded by `max_turns` and by the rule "exactly one
`lisp_eval` call per assistant turn."
- Markdown-fenced PTC-Lisp returned as content is *not* parsed in `:tool_call`
mode — the model is told to call `lisp_eval` instead.
### Provider support
`:tool_call` requires a provider/model with native tool calling. Models without
it surface as `:llm_error` with the provider reason; there is no fallback to
`:content`. See the LLM setup usage rules (`usage-rules/llm_setup.md`,
"`ptc_transport: :tool_call` and provider tool calling") for which built-in
providers qualify.
### Examples
```elixir
# :content — default. Same as today, no option needed.
agent = PtcRunner.SubAgent.new(
prompt: "Find the most expensive product",
signature: "{name :string, price :float}",
tools: %{"list_products" => fn _args -> MyApp.Products.list() end}
)
{:ok, step} = PtcRunner.SubAgent.run(agent, llm: my_llm)
```
```elixir
# :tool_call — opt-in. Pass on the agent struct …
agent = PtcRunner.SubAgent.new(
prompt: "Find the most expensive product",
signature: "{name :string, price :float}",
tools: %{"list_products" => fn _args -> MyApp.Products.list() end},
ptc_transport: :tool_call
)
```
### Don't
- Don't pass `ptc_transport: :content` together with `output: :text` — raises
`ArgumentError`. (`ptc_transport: :tool_call` *with* `output: :text` is valid:
it's the combined "text + PTC-Lisp compute" mode — see
[Text Mode + PTC-Lisp Compute](https://hexdocs.pm/ptc_runner/text-mode-ptc-compute.html).)
- Don't define an app tool named `lisp_eval`. The name is reserved
globally; the validator rejects it regardless of `ptc_transport`.
For the full decision guide ("when to switch, when to stay") and a runnable
walkthrough, see [PTC-Lisp transport](https://hexdocs.pm/ptc_runner/subagent-ptc-transport.html).
## Composition
- `SubAgent.as_tool(agent, opts)` turns an agent into a tool another agent can
call — child agents run with isolated state and their own turn budget.
**Requires** a description, either `description:` in `opts` or
`agent.description`; missing both raises `ArgumentError`.
- `SubAgent.compile/2` bakes the LLM-generated PTC-Lisp into a deterministic
Elixir function for zero-LLM-cost orchestration. **Constraints:**
`max_turns: 1` and `output: :ptc_lisp` are required — passing a multi-turn
or text-mode agent raises `ArgumentError` (this is a build-time misuse, not a
runtime failure). Returns `{:ok, %CompiledAgent{}}` on success or
`{:error, %Step{}}` if the compilation run itself fails. Pure-Elixir tools and
`LLMTool`/`SubAgentTool` are supported (the latter still call the LLM at
execute time).
- `SubAgent.chat/3` threads `messages` and (in `:ptc_lisp` mode) `memory`
across calls for chat UIs. Prefer application-owned history plus one
`run/2` call per user message when each turn needs a different output mode,
signature, or tool surface.
## Don't
- Don't construct `%PtcRunner.SubAgent.Definition{}` directly — use `new/1`.
- Don't read or mutate fields under `PtcRunner.SubAgent.Loop.*`,
`Compiler`, or `CompiledAgent` — internal API.
- Don't use `Process.sleep/1` in tools or tests; use monitors or
scripted callbacks.