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
ptc_runner lib ptc_runner sub_agent system_prompt.ex
Raw

lib/ptc_runner/sub_agent/system_prompt.ex

defmodule PtcRunner.SubAgent.SystemPrompt do
@moduledoc """
System prompt generation for SubAgent LLM interactions.
Orchestrates prompt generation by combining sections from:
- `Namespace` modules - Compact Lisp-style format for tools and data
- `SystemPrompt.Output` - Expected return format from signature
## Prompt Caching Architecture
To enable efficient prompt caching (e.g., Anthropic's cache_control), the prompt
is split into **static** and **dynamic** sections:
- **Static (system prompt)**: `generate_system/2` returns language reference and output
format - these rarely change and benefit from caching across different questions.
- **Dynamic (user message)**: `generate_context/2` returns data inventory, tool schemas,
and expected output - these vary per agent configuration but not per question.
The mission is placed only in the user message (not duplicated in system prompt).
## Customization
The `system_prompt` field on `SubAgent` accepts:
- **Map** - `:prefix`, `:suffix`, `:language_spec`, `:output_format`
- **Function** - `fn default_prompt -> modified_prompt end`
- **String** - Complete override
## Language Spec
The `:language_spec` option can be:
- **Atom** - Resolved via `PtcRunner.Lisp.LanguageSpec.get!/1`
- **String** - Used as-is
- **Callback** - `fn ctx -> "prompt" end`
Default: `:single_shot` for `max_turns: 1`, `:explicit_return` otherwise.
## Examples
iex> agent = PtcRunner.SubAgent.new(prompt: "Add {{x}} and {{y}}")
iex> context = %{x: 5, y: 3}
iex> prompt = PtcRunner.SubAgent.SystemPrompt.generate(agent, context: context)
iex> prompt =~ "<return_rules>"
true
iex> prompt =~ "data/x"
true
"""
require Logger
alias PtcRunner.Lisp.LanguageSpec
alias PtcRunner.Lisp.Prelude.PromptInventory
alias PtcRunner.Lisp.PromptRegistry
alias PtcRunner.SubAgent.BuiltinTools
alias PtcRunner.SubAgent.Exposure
alias PtcRunner.SubAgent.Namespace
alias PtcRunner.SubAgent.PromptExpander
alias PtcRunner.SubAgent.Signature
alias PtcRunner.SubAgent.SystemPrompt.Output
alias PtcRunner.Tool
@output_format """
<output_format>
Respond with EXACTLY ONE ```clojure code block — no text before or after the block. Put reasoning in `;; comments` inside the code.
```clojure
(your-program-here)
```
Do NOT include multiple code blocks or text outside the ```clojure block.
</output_format>
"""
@output_format_thinking """
<output_format>
For complex tasks, think through the problem first, then respond with EXACTLY ONE ```clojure code block:
thinking:
[your reasoning here]
```clojure
(your-program-here)
```
Do NOT include multiple code blocks or code outside the ```clojure block.
</output_format>
"""
# Tool-call transport output format (ptc_transport: :tool_call).
#
# The model invokes the native `lisp_eval` tool for any
# deterministic computation or app-tool orchestration; app tools must
# be called as `(tool/name ...)` from inside the program, not as
# native provider tool calls. When the answer is ready, the model
# returns it directly in the requested signature shape — no fenced
# code block, no `lisp_eval` call.
@output_format_tool_call """
<output_format>
Two ways to respond, depending on what you need to do:
1. **Run a program.** Call the native `lisp_eval` tool with a `program` argument containing PTC-Lisp source. Use this for any deterministic computation, data transformation, or app-tool orchestration. Call app tools as `(tool/name ...)` from inside the program — never as native function calls. The runtime returns the program's result so you can decide what to do next.
2. **Return the final answer.** When you are ready to answer the mission, return it directly in the requested signature shape as the assistant message content. No tool call. No ```clojure fences. Just the answer.
Do NOT return ```clojure (or any other) fenced code blocks. Do NOT attempt to call app tools as native function calls — only `lisp_eval` is available natively.
</output_format>
"""
@output_format_tool_call_thinking """
<output_format>
For complex tasks, think through the problem first, then respond in one of two ways:
thinking:
[your reasoning here]
1. **Run a program.** Call the native `lisp_eval` tool with a `program` argument containing PTC-Lisp source. Use this for any deterministic computation, data transformation, or app-tool orchestration. Call app tools as `(tool/name ...)` from inside the program — never as native function calls.
2. **Return the final answer.** When you are ready, return the answer directly in the requested signature shape as the assistant message content. No tool call. No ```clojure fences.
Do NOT return ```clojure (or any other) fenced code blocks. Do NOT attempt to call app tools as native function calls — only `lisp_eval` is available natively.
</output_format>
"""
@doc """
Generate a complete system prompt for a SubAgent.
Options: `context` (map), `error_context` (map for recovery prompts).
## Examples
iex> agent = PtcRunner.SubAgent.new(prompt: "Process data")
iex> prompt = PtcRunner.SubAgent.SystemPrompt.generate(agent, context: %{user: "Alice"})
iex> prompt =~ "<return_rules>" and prompt =~ "<output_format>"
true
"""
@spec generate(PtcRunner.SubAgent.Definition.t(), keyword()) :: String.t()
def generate(agent, opts \\ []) do
context = Keyword.get(opts, :context, %{})
error_context = Keyword.get(opts, :error_context)
resolution_context = Keyword.get(opts, :resolution_context, %{})
# Field descriptions received from upstream agent in a chain
received_field_descriptions = Keyword.get(opts, :received_field_descriptions)
# Generate base prompt with resolution context for language_spec callbacks
base_prompt =
generate_base_prompt(agent, context, resolution_context, received_field_descriptions)
# Add error recovery prompt if needed
base_prompt_with_error =
if error_context do
base_prompt <> "\n\n" <> generate_error_recovery_prompt(error_context)
else
base_prompt
end
# Apply customization
customized_prompt = apply_customization(base_prompt_with_error, agent.system_prompt)
# Apply truncation if prompt_limit is set
truncate_if_needed(customized_prompt, agent.prompt_limit)
end
@doc """
Generate static system prompt sections (cacheable).
Returns only the language reference and output format - these sections rarely
change across different questions and benefit from prompt caching.
This function has an alias `generate_static/2` for semantic clarity.
## Options
- `:resolution_context` - Map with turn/model/memory/messages for language_spec callbacks
## Examples
iex> agent = PtcRunner.SubAgent.new(prompt: "Test")
iex> system = PtcRunner.SubAgent.SystemPrompt.generate_system(agent)
iex> system =~ "<return_rules>" and system =~ "<output_format>"
true
iex> system =~ "# Data Inventory"
false
"""
@spec generate_system(PtcRunner.SubAgent.Definition.t(), keyword()) :: String.t()
def generate_system(agent, opts \\ []) do
resolution_context = Keyword.get(opts, :resolution_context, %{})
{language_ref, output_fmt} = resolve_static_sections(agent, resolution_context)
ptc_reference_card = combined_mode_reference_card(agent)
base_prompt =
[language_ref, output_fmt, ptc_reference_card]
|> Enum.reject(&(is_nil(&1) or &1 == ""))
|> Enum.join("\n\n")
# Apply customization (prefix/suffix) but not string/function overrides
# since those replace the entire prompt
customized_prompt = apply_customization(base_prompt, agent.system_prompt)
# Apply truncation if prompt_limit is set
truncate_if_needed(customized_prompt, agent.prompt_limit)
end
@doc """
Alias for `generate_system/2` for semantic clarity.
See `generate_system/2` for documentation.
"""
@spec generate_static(PtcRunner.SubAgent.Definition.t(), keyword()) :: String.t()
def generate_static(agent, opts \\ []), do: generate_system(agent, opts)
@doc """
Generate dynamic context sections (prepended to user message).
Returns data inventory, tool schemas, and expected output - these sections vary
per agent configuration but not per individual question.
Note: The mission is NOT included here - it's already in the user message.
## Options
- `:context` - Map of context variables for the data inventory
- `:received_field_descriptions` - Field descriptions from upstream agent
## Examples
iex> agent = PtcRunner.SubAgent.new(prompt: "Test", tools: %{"search" => fn _ -> [] end})
iex> context_prompt = PtcRunner.SubAgent.SystemPrompt.generate_context(agent, context: %{x: 1})
iex> context_prompt =~ ";; === data/ ===" and context_prompt =~ ";; === tools ==="
true
iex> context_prompt =~ "<mission>"
false
"""
@spec generate_context(PtcRunner.SubAgent.Definition.t(), keyword()) :: String.t()
def generate_context(agent, opts \\ []) do
context = Keyword.get(opts, :context, %{})
received_field_descriptions = Keyword.get(opts, :received_field_descriptions)
memory = Keyword.get(opts, :memory, %{})
# Parse signature if present
context_signature = parse_signature(agent.signature)
# Merge agent context_descriptions into received_field_descriptions
# Chained (received) descriptions take precedence (upstream agent knows better)
all_field_descriptions =
Map.merge(agent.context_descriptions || %{}, received_field_descriptions || %{})
tools = BuiltinTools.effective_tools(agent)
# Use compact Namespace format (same as Turn 2+)
namespace_content =
Namespace.render(%{
tools: tools,
data: context,
field_descriptions: all_field_descriptions,
context_signature: context_signature,
memory: memory,
has_println: false
})
# Capability Prelude V1 (plan §9): dynamic prompt inventory inserted AFTER
# the tools/data section. Domain-specific content comes from the attached
# prelude's export records, NOT from static core prompt templates. `nil`
# when no prelude is attached or it has no prompt-visible exports.
prelude_inventory =
PromptInventory.render(Map.get(agent, :runtime_prelude),
ledger: Keyword.get(opts, :ledger)
)
# Expected output stays as markdown (shared with Turn 2+)
expected_output = Output.generate(context_signature, agent.field_descriptions)
llm_query_instructions = if agent.llm_query, do: llm_query_reference(), else: nil
tool_call_limit_note =
if agent.max_tool_calls,
do: "Constraint: maximum #{agent.max_tool_calls} tool calls per turn. Plan efficiently.",
else: nil
[
namespace_content,
prelude_inventory,
expected_output,
llm_query_instructions,
tool_call_limit_note
]
|> Enum.reject(&(is_nil(&1) or &1 == ""))
|> Enum.join("\n\n")
end
@doc """
Resolve a language_spec value to a string.
## Examples
iex> PtcRunner.SubAgent.SystemPrompt.resolve_language_spec("custom prompt", %{})
"custom prompt"
iex> spec = PtcRunner.SubAgent.SystemPrompt.resolve_language_spec(:single_shot, %{})
iex> is_binary(spec) and String.contains?(spec, "<single_shot>")
true
iex> callback = fn ctx -> if ctx.turn > 1, do: "multi", else: "single" end
iex> PtcRunner.SubAgent.SystemPrompt.resolve_language_spec(callback, %{turn: 1})
"single"
"""
@spec resolve_language_spec(
String.t()
| atom()
| {:profile, atom()}
| {:profile, atom(), keyword()}
| (map() -> String.t()),
map()
) :: String.t()
def resolve_language_spec(spec, context)
def resolve_language_spec(spec, _context) when is_binary(spec), do: spec
def resolve_language_spec(spec, _context) when is_atom(spec) do
LanguageSpec.get!(spec)
end
def resolve_language_spec({:profile, _} = spec, _context) do
LanguageSpec.resolve_profile(spec)
end
def resolve_language_spec({:profile, _, _} = spec, _context) do
LanguageSpec.resolve_profile(spec)
end
def resolve_language_spec(spec, context) when is_function(spec, 1) do
spec.(context)
end
@doc """
Apply system prompt customization (string override, function, or map with prefix/suffix).
## Examples
iex> PtcRunner.SubAgent.SystemPrompt.apply_customization("base", nil)
"base"
iex> PtcRunner.SubAgent.SystemPrompt.apply_customization("base", "override")
"override"
iex> PtcRunner.SubAgent.SystemPrompt.apply_customization("base", fn p -> "PREFIX\\n" <> p end)
"PREFIX\\nbase"
"""
@spec apply_customization(String.t(), PtcRunner.SubAgent.Definition.system_prompt_opts() | nil) ::
String.t()
def apply_customization(base_prompt, nil), do: base_prompt
# String override - use as-is
def apply_customization(_base_prompt, override) when is_binary(override), do: override
# Function transformer
def apply_customization(base_prompt, transformer) when is_function(transformer, 1) do
transformer.(base_prompt)
end
# Map customization - prefix and suffix (language_spec/output_format already applied)
def apply_customization(base_prompt, opts) when is_map(opts) do
# Apply prefix and suffix
prefix = Map.get(opts, :prefix, "")
suffix = Map.get(opts, :suffix, "")
[prefix, base_prompt, suffix]
|> Enum.reject(&(&1 == ""))
|> Enum.join("\n\n")
end
@doc """
Generate error recovery prompt for parse failures.
## Examples
iex> error = %{type: :parse_error, message: "Unexpected token"}
iex> PtcRunner.SubAgent.SystemPrompt.generate_error_recovery_prompt(error) =~ "<previous_error>"
true
"""
@spec generate_error_recovery_prompt(map()) :: String.t()
def generate_error_recovery_prompt(error_context) do
error_type = Map.get(error_context, :type, :unknown_error)
error_message = Map.get(error_context, :message, "Unknown error")
"""
<previous_error>
Your previous program failed with:
- Error: #{error_type}
- Message: #{error_message}
Please ensure your response:
1. Contains a ```clojure code block
2. Uses valid s-expression syntax
3. Calls tools with (tool/tool-name args)
Please fix the issue and try again.
</previous_error>
"""
end
@doc """
Truncate prompt if it exceeds the configured character limit.
## Examples
iex> PtcRunner.SubAgent.SystemPrompt.truncate_if_needed("short", nil)
"short"
iex> result = PtcRunner.SubAgent.SystemPrompt.truncate_if_needed(String.duplicate("x", 1000), %{max_chars: 100})
iex> result =~ "truncated"
true
"""
@spec truncate_if_needed(String.t(), map() | nil) :: String.t()
def truncate_if_needed(prompt, nil), do: prompt
def truncate_if_needed(prompt, limit_config) when is_map(limit_config) do
max_chars = Map.get(limit_config, :max_chars)
if max_chars && String.length(prompt) > max_chars do
Logger.warning(
"System prompt exceeds limit (#{String.length(prompt)} > #{max_chars} chars), truncating"
)
# Simple truncation strategy: keep first part, add warning
truncated = String.slice(prompt, 0, max_chars)
truncated <>
"\n\n[... truncated due to length limit ...]\n\nNote: Some content was removed to fit within the character limit."
else
prompt
end
end
# ============================================================
# Private Helpers
# ============================================================
defp generate_base_prompt(
agent,
context,
resolution_context,
received_field_descriptions
) do
# Parse signature if present
context_signature = parse_signature(agent.signature)
# Get static sections (language_ref, output_fmt)
{language_ref, output_fmt} = resolve_static_sections(agent, resolution_context)
# Merge agent context_descriptions into received_field_descriptions
# Chained (received) descriptions take precedence (upstream agent knows better)
all_field_descriptions =
Map.merge(agent.context_descriptions || %{}, received_field_descriptions || %{})
# Inject builtin llm-query tool if enabled
tools = BuiltinTools.effective_tools(agent)
# Use compact Namespace format (same as Turn 2+)
namespace_content =
Namespace.render(%{
tools: tools,
data: context,
field_descriptions: all_field_descriptions,
context_signature: context_signature,
memory: %{},
has_println: false
})
expected_output = Output.generate(context_signature, agent.field_descriptions)
llm_query_instructions = if agent.llm_query, do: llm_query_reference(), else: nil
mission = expand_prompt(agent.prompt, context)
ptc_reference_card = combined_mode_reference_card(agent)
# Combine all sections
# language_ref contains role, rules, and language reference from priv/prompts/
[
language_ref,
namespace_content,
expected_output,
llm_query_instructions,
output_fmt,
ptc_reference_card,
"<mission>\n#{mission}\n</mission>"
]
|> Enum.reject(&(is_nil(&1) or &1 == ""))
|> Enum.join("\n\n")
end
@doc """
Render a Mission Log section from a journal map.
Shows completed tasks with truncated values (~200 chars).
Used to inject journal state into the system prompt so the LLM
knows which tasks have already been completed.
"""
@spec render_mission_log(map()) :: String.t()
def render_mission_log(journal) when is_map(journal) and map_size(journal) > 0 do
entries =
Enum.map_join(journal, "\n", fn {id, value} ->
truncated = truncate_value(value, 200)
"- [done] #{id}: #{truncated}"
end)
"<mission_log>\n#{entries}\n</mission_log>"
end
def render_mission_log(_), do: ""
defp truncate_value(value, max_len) do
str = inspect(value, limit: 10, printable_limit: max_len)
if String.length(str) > max_len do
String.slice(str, 0, max_len) <> "..."
else
str
end
end
defp expand_prompt(prompt, context) do
case PromptExpander.expand(prompt, context) do
{:ok, expanded} -> expanded
{:error, {:missing_keys, _keys}} -> prompt
end
end
# Resolve language_ref and output_fmt from agent config
defp resolve_static_sections(agent, resolution_context) do
# Default language_spec selection based on agent config.
# Journaling adds journal capability docs (task/step-done) when enabled.
default_spec =
cond do
agent.max_turns <= 1 ->
:single_shot
agent.journaling ->
:explicit_journal
true ->
:explicit_return
end
default_output = default_output_format(agent)
case agent.system_prompt do
opts when is_map(opts) ->
# Resolve language_spec (can be string, atom, or callback)
raw_spec = Map.get(opts, :language_spec, default_spec)
resolved_spec = resolve_language_spec(raw_spec, resolution_context)
{
resolved_spec,
Map.get(opts, :output_format, default_output)
}
_ ->
{LanguageSpec.get(default_spec), default_output}
end
end
# Pick the output-format constant based on ptc_transport and thinking.
#
# `:content` (default) keeps the existing markdown-fenced contract so
# all current behavior is unchanged. `:tool_call` selects the
# tool-call variant which instructs the model to call
# `lisp_eval` for computation and return final answers
# directly in signature shape (no fenced code blocks).
defp default_output_format(%{ptc_transport: :tool_call, thinking: true}),
do: @output_format_tool_call_thinking
defp default_output_format(%{ptc_transport: :tool_call}),
do: @output_format_tool_call
defp default_output_format(%{thinking: true}), do: @output_format_thinking
defp default_output_format(_agent), do: @output_format
# Instructions injected when llm_query: true
defp llm_query_reference do
"""
# tool/llm-query — Ad-hoc LLM Calls
Call `tool/llm-query` to make runtime LLM queries for classification, judgment, or extraction.
```clojure
;; Minimal (returns string)
(tool/llm-query {:prompt "Is this urgent? {{text}}" :text "Help me!"})
;; With signature
(tool/llm-query {:prompt "Classify: {{text}}"
:signature "{category :string, score :float}"
:text "Some input"})
```
**Control keys:** `:prompt` (required), `:signature` (default `":string"`), `:llm`, `:response_template`.
All other keys are template args (available as `{{key}}` in prompt).
Use `pmap` for parallel queries (batched LLM calls):
```clojure
(pmap (fn [item] (tool/llm-query {:prompt "Rate: {{x}}" :x item})) items)
```
**Signature types:** `:string`, `:int`, `:float`, `:bool`, `:keyword`, `:any`.
Lists: `[:type]`. Maps: `{field :type}`. Optional: `{field :type?}`.
Example signatures: `":string"`, `"{score :int, reason :string}"`, `"{is_urgent :bool, reason :string}"`, `"[{id :int, label :string}]"`.
"""
end
# Parse signature string to struct, returning nil on failure
defp parse_signature(nil), do: nil
defp parse_signature(sig_str) do
case Signature.parse(sig_str) do
{:ok, sig} -> sig
{:error, _reason} -> nil
end
end
@doc """
Combined-mode (`output: :text, ptc_transport: :tool_call`) PTC-Lisp
reference card.
Returns the static compact card from `priv/prompts/` plus a dynamically
rendered tool-inventory section for tools whose effective `expose:` is
`:ptc_lisp` or `:both`.
Per Addendum #19: included even when zero PTC-callable tools exist —
the static portion documents `lisp_eval` itself, which is
always callable.
Returns `nil` for non-combined-mode agents (used by callers to filter
the section out of their prompt assembly).
Tier 3.5 Fix 2: exported as a public helper so `Loop.TextMode` can
append it to the tool-calling system prompt at runtime — the runtime
text-mode path uses `Prompts.tool_calling_system()` rather than
`generate_system/2`, so this is the integration point.
"""
@spec combined_mode_reference_card(PtcRunner.SubAgent.Definition.t()) :: String.t() | nil
def combined_mode_reference_card(%{output: :text, ptc_transport: :tool_call} = agent) do
case agent.ptc_reference do
:compact ->
static = PromptRegistry.render(:ptc_text_mode_compact_reference)
inventory = combined_mode_tool_inventory(agent)
case inventory do
"" -> static
rendered -> static <> "\n\n" <> rendered
end
_ ->
nil
end
end
def combined_mode_reference_card(_agent), do: nil
# Render a one-line PTC entry per tool exposed to PTC-Lisp (effective
# expose ∈ {:ptc_lisp, :both}). Per the plan's "Tool-inventory rule",
# this section MUST NOT duplicate native tool schemas (those live in
# the LLM request's `tools` field for `:both` tools); it carries only
# the in-program call shape so the LLM knows the tool is reachable
# via `(tool/name ...)`.
defp combined_mode_tool_inventory(agent) do
tools = BuiltinTools.effective_tools(agent)
tool_structs =
tools
|> Enum.flat_map(fn {name, format} ->
case Tool.new(to_string(name), format) do
{:ok, tool} -> [tool]
{:error, _} -> []
end
end)
ptc_callable =
Exposure.filter_by_expose(tool_structs, agent, [:ptc_lisp, :both])
|> Enum.reject(&Tool.private?/1)
|> Enum.sort_by(& &1.name)
case ptc_callable do
[] ->
""
ts ->
entries = Enum.map_join(ts, "\n", &render_tool_entry/1)
"<ptc_tools>\n" <>
"Tools callable from inside `lisp_eval` as `(tool/name {...})`:\n" <>
entries <> "\n</ptc_tools>"
end
end
defp render_tool_entry(%Tool{name: name, signature: sig, description: desc}) do
base = "- `(tool/#{name} {...})`"
base = if sig in [nil, ""], do: base, else: base <> " — `#{sig}`"
if desc in [nil, ""], do: base, else: base <> " — #{desc}"
end
end