# Text Mode SubAgents

Text mode (`output: :text`) lets the LLM respond directly without generating PTC-Lisp code. It covers four variants, auto-detected based on whether tools are provided and the return type:

| Variant | Tools | Signature / Return Type | Behavior |
|---------|-------|------------------------|----------|
| Plain text | No | None or `:string` | Raw text response |
| JSON | No | Complex type (map, list, float, int) | Structured JSON response |
| Tool + text | Yes | None or `:string` | Tool loop, then text answer |
| Tool + JSON | Yes | Complex type (map, list, float, int) | Tool loop, then JSON answer |

## When to Use Text Mode

| Task Type | Mode | Why |
|-----------|------|-----|
| Free-form question answering | Text (plain text) | No structure needed |
| Classification | Text (JSON) | Direct structured response |
| Entity extraction | Text (JSON) | No computation needed |
| Summarization with structure | Text (JSON) | Simple output mapping |
| Tools with small/fast LLMs | Text (tool + JSON) | Native tool calling, no PTC-Lisp needed |
| Tools with free-form answer | Text (tool + text) | Tool use without structured output |
| Multi-step reasoning | PTC-Lisp | Needs tool calls + computation |
| Data transformation | PTC-Lisp | Needs computation |
| External API calls | PTC-Lisp | Needs tools + orchestration |

**Choose text mode over PTC-Lisp when:**
- Using smaller models (Haiku, GPT-4.1 Mini, Gemma, Llama) that struggle with PTC-Lisp syntax
- You want the LLM provider to handle tool schema formatting
- You don't need memory persistence between turns
- You need a plain text or simple structured response

## Basic Usage

### Plain Text (No Signature)

```elixir
{:ok, step} = SubAgent.run(
  "Summarize this article: {{text}}",
  context: %{text: "Long article..."},
  output: :text,
  llm: my_llm
)

step.return  #=> "The article discusses..."  (raw string)
```

### JSON (Complex Return Type)

```elixir
{:ok, step} = SubAgent.run(
  "Classify the sentiment of: {{text}}",
  context: %{text: "I love this product!"},
  output: :text,
  signature: "(text :string) -> {sentiment :string, score :float}",
  llm: my_llm
)

step.return  #=> %{"sentiment" => "positive", "score" => 0.95}
```

### Tool + Text (Tools with String Return)

```elixir
{:ok, step} = SubAgent.run(
  "Use the search tool to find info about Elixir, then summarize.",
  output: :text,
  tools: %{
    "search" => {&MyApp.search/1,
                 signature: "(query :string) -> [{title :string, snippet :string}]",
                 description: "Search the web"}
  },
  llm: my_llm
)

step.return  #=> "Elixir is a dynamic, functional language..."  (raw string)
```

### Tool + JSON (Tools with Complex Return Type)

```elixir
{:ok, step} = SubAgent.run(
  "What is 17 + 25? Use the add tool.",
  output: :text,
  signature: "() -> {result :int}",
  tools: %{
    "add" => {fn args -> args["a"] + args["b"] end,
              signature: "(a :int, b :int) -> :int",
              description: "Add two numbers"}
  },
  llm: my_llm
)

step.return["result"]  #=> 42
```

**Constraints:** Signature is optional. Tools are optional. When no signature or a `:string` return type is used, text mode returns a raw string. When a complex return type (map, list, float, int) is used, text mode returns JSON. Compaction is not supported.

## How It Works

### Without Tools

1. The prompt and context are sent to the LLM
2. The LLM responds with text
3. If a complex return type is specified, the response is parsed as JSON and validated against the signature
4. If no signature or `:string` return type, the raw text is returned

### With Tools

The execution flow uses the LLM provider's native tool calling API:

1. Tool signatures are converted to JSON Schema and sent to the LLM provider
2. The LLM uses its native tool calling API to request tool executions
3. ptc_runner executes the tools and feeds results back
4. The loop continues until the LLM returns a final answer
5. If a complex return type is specified, the answer is validated against the signature

```
LLM ──tool_call──> ptc_runner executes tool ──result──> LLM
LLM ──tool_call──> ptc_runner executes tool ──result──> LLM
LLM ──final answer──> validate (if complex type) ──> Step
```

## Mustache Templates

Text mode embeds data directly in the prompt using Mustache syntax. When a signature with input parameters is provided, all parameters must appear in the prompt.

### Simple Variables

Reference context values with `{{variable}}`:

```elixir
SubAgent.new(
  prompt: "Analyze the sentiment of: {{text}}",
  output: :text,
  signature: "(text :string) -> {sentiment :string}"
)
```

Nested access uses dot notation: `{{user.name}}`, `{{order.items.count}}`.

### Sections for Lists

Iterate over lists with `{{#section}}...{{/section}}`:

```elixir
SubAgent.new(
  prompt: """
  Categorize these products:
  {{#products}}
  - {{name}}: ${{price}}
  {{/products}}
  """,
  output: :text,
  signature: "(products [{name :string, price :float}]) -> {categories [{name :string, category :string}]}"
)
```

With context `%{products: [%{name: "Widget", price: 9.99}, %{name: "Gadget", price: 19.99}]}`, the prompt expands to:

```
Categorize these products:
- Widget: $9.99
- Gadget: $19.99
```

### Scalar Lists with Dot Notation

For lists of primitives, use `{{.}}` to reference the current element:

```elixir
SubAgent.new(
  prompt: "Classify these tags: {{#tags}}{{.}}, {{/tags}}",
  output: :text,
  signature: "(tags [:string]) -> {primary_tag :string}"
)
```

### Inverted Sections

Use `{{^section}}` to render content when a value is falsy or empty:

```elixir
SubAgent.new(
  prompt: """
  {{#items}}Process items...{{/items}}
  {{^items}}No items to process.{{/items}}
  """,
  output: :text,
  signature: "(items [:string]) -> {status :string}"
)
```

## Validation Rules

When a signature with input parameters is provided, text mode enforces strict validation at agent construction time.

### All Parameters Must Be Used

Every signature parameter must appear in the prompt (as a variable or section):

```elixir
# Valid - both params used
SubAgent.new(
  prompt: "Analyze {{text}} for {{user}}",
  output: :text,
  signature: "(text :string, user :string) -> {result :string}"
)

# Invalid - 'user' not used
SubAgent.new(
  prompt: "Analyze {{text}}",
  output: :text,
  signature: "(text :string, user :string) -> {result :string}"
)
# => ArgumentError: Text mode requires all signature params in prompt. Unused: ["user"]
```

### Section Fields Must Match Signature

Fields inside sections are validated against the element type:

```elixir
# Valid - 'name' exists in element type
SubAgent.new(
  prompt: "{{#items}}{{name}}{{/items}}",
  output: :text,
  signature: "(items [{name :string, price :float}]) -> {count :int}"
)

# Invalid - 'unknown' not in element type
SubAgent.new(
  prompt: "{{#items}}{{unknown}}{{/items}}",
  output: :text,
  signature: "(items [{name :string}]) -> {count :int}"
)
# => ArgumentError: {{unknown}} inside {{#items}} not found in element type
```

### Dot Notation Requires Scalar Lists

Use `{{.}}` only for lists of primitives, not lists of maps:

```elixir
# Valid - tags is [:string]
SubAgent.new(
  prompt: "{{#tags}}{{.}}{{/tags}}",
  output: :text,
  signature: "(tags [:string]) -> {count :int}"
)

# Invalid - items is [{name :string}], use {{name}} instead
SubAgent.new(
  prompt: "{{#items}}{{.}}{{/items}}",
  output: :text,
  signature: "(items [{name :string}]) -> {count :int}"
)
# => ArgumentError: {{.}} inside {{#items}} - use {{field}} instead (list contains maps)
```

## Multiple Tools

Provide multiple tools and the LLM decides which to call:

```elixir
tools = %{
  "multiply" => {fn args -> args["a"] * args["b"] end,
                 signature: "(a :int, b :int) -> :int",
                 description: "Multiply two numbers"},
  "subtract" => {fn args -> args["a"] - args["b"] end,
                 signature: "(a :int, b :int) -> :int",
                 description: "Subtract b from a"}
}

{:ok, step} = SubAgent.run(
  "Calculate (6 * 7) - 10",
  output: :text,
  signature: "() -> {result :int}",
  tools: tools,
  max_turns: 5,
  llm: my_llm
)

step.return["result"]  #=> 32
```

The LLM may call multiple tools per turn or across multiple turns.

## Tool Signatures

Tool signatures define the JSON Schema sent to the LLM provider. Use the same signature syntax as PTC-Lisp tools:

```elixir
# Full tool definition with signature and description
"search" => {fn args -> do_search(args["query"]) end,
             signature: "(query :string, limit :int?) -> [{id :int, title :string}]",
             description: "Search the database"}

# Bare function (no schema sent to LLM — not recommended)
"ping" => fn _args -> "pong" end
```

Optional parameters (`:int?`) are excluded from the `required` list in the generated JSON Schema.

## Limits and Error Handling

### max_turns

Controls total LLM round-trips. Each tool call response and each final answer attempt counts as a turn:

```elixir
SubAgent.new(
  prompt: "Find and analyze data",
  output: :text,
  signature: "() -> {analysis :string}",
  tools: tools,
  max_turns: 10  # Allow up to 10 LLM calls
)
```

If exhausted, returns `{:error, step}` with `step.fail.reason == :max_turns_exceeded`.

### max_tool_calls

Limits total tool executions across all turns:

```elixir
SubAgent.new(
  prompt: "Search for info",
  output: :text,
  signature: "() -> {answer :string}",
  tools: tools,
  max_tool_calls: 5  # No more than 5 total tool calls
)
```

When the limit is reached, remaining tool calls in the current turn receive an error message, and the LLM is informed.

### Tool Errors

Tool failures don't crash the agent. If a tool raises an exception or isn't found, the error is fed back to the LLM as a tool result, giving it a chance to recover:

```elixir
# Tool that may fail
"risky" => {fn _args -> raise "service unavailable" end,
            signature: "() -> :string",
            description: "Call external service"}
```

The LLM receives `{"error": "service unavailable"}` as the tool result and can adapt its approach.

## Text Mode vs PTC-Lisp Mode

| Aspect | Text Mode (no tools) | Text Mode (with tools) | PTC-Lisp Mode |
|--------|---------------------|----------------------|---------------|
| Data in prompt | Embedded via Mustache | Embedded via Mustache | Shown in Data Inventory |
| Template syntax | Full Mustache (sections) | Full Mustache (sections) | Simple `{{var}}` only |
| LLM output | Text or JSON | Tool calls + text/JSON | PTC-Lisp code |
| Tools | Not supported | Provider-native API | Supported |
| Computation | None | None (tools only) | Full Lisp runtime |
| Memory | N/A | Always `%{}` | Accumulated across turns |
| System prompt | Minimal | Minimal | Full PTC-Lisp spec |
| Compaction | Not supported | Not supported | Supported |
| Sandbox | N/A | Direct function calls | Isolated BEAM process |
| Best for | Classification, extraction | Small/fast LLMs with tools | Capable LLMs |

## Piping Between Modes

Text mode returns the standard `Step` struct, enabling seamless piping:

```elixir
# Text mode extracts data
extract_agent = SubAgent.new(
  prompt: "Extract entities from: {{text}}",
  output: :text,
  signature: "(text :string) -> {entities [:string], topic :string}"
)

# PTC-Lisp mode processes with tools
process_agent = SubAgent.new(
  prompt: "Look up details for {{topic}}",
  signature: "(entities [:string], topic :string) -> {details [:map]}",
  tools: %{lookup: &MyApp.lookup/1}
)

{:ok, step1} = SubAgent.run(extract_agent, context: %{text: "..."}, llm: llm)
{:ok, step2} = SubAgent.run(process_agent, context: step1, llm: llm)
```

Text mode with tools can also pipe to other modes:

```elixir
# Text mode with tools gathers data
gather = SubAgent.new(
  prompt: "Look up the population of {{city}}",
  output: :text,
  signature: "(city :string) -> {population :int, country :string}",
  tools: %{"lookup" => {&MyApp.lookup/1,
                        signature: "(city :string) -> {population :int, country :string}",
                        description: "Look up city data"}}
)

# Text mode without tools summarizes
summarize = SubAgent.new(
  prompt: "Write a one-sentence summary about {{city}} (pop: {{population}}, in {{country}})",
  output: :text,
  signature: "(city :string, population :int, country :string) -> {summary :string}"
)

{:ok, step1} = SubAgent.run(gather, context: %{city: "Tokyo"}, llm: llm)
{:ok, step2} = SubAgent.run(summarize, context: step1, llm: llm)
```

## See Also

- [Getting Started](subagent-getting-started.md) - Basic SubAgent usage
- [Output Modes in an App Loop](../../livebooks/output_modes_in_app_loops.livemd) - Runnable livebook contrasting `:text` plain, `:text` structured, and `:ptc_lisp` over one scenario
- [Core Concepts](subagent-concepts.md) - Context, memory, and data flow
- [Patterns](subagent-patterns.md) - Chaining and composition patterns
- [Signature Syntax](../signature-syntax.md) - Full type syntax reference
- `PtcRunner.SubAgent.run/2` - API reference
- `PtcRunner.SubAgent.Loop.TextMode.run/3` - Text mode execution loop
- `PtcRunner.SubAgent.JsonParser.parse/1` - JSON extraction from LLM responses
