LLM Agents
LLM agents are the default agent type in LeafMesh. They send a prompt to an LLM provider, parse the response against a yields schema, and evaluate can_call conditions for downstream routing. Intelligence functions add deterministic Python logic on top of the LLM response.
Basic Configuration
agents:
solver:
name: "solver"
model: "gpt-4o-mini"
temperature: 0.1
max_tokens: 500
prompt: |
You are a math solver. Given a math problem, solve it step by step.
Be precise and show your work.
yields:
answer: "number"
steps: "array"
confidence: "number"
can_call:
- agent: "checker"
condition: "answer >= 0"
call_immediately: trueExecution Pipeline
When an LLM agent is called via leafmesh.mesh_call(), the following pipeline executes:
Input Data
│
▼
Pre-compose pipeline (if registered)
│ context_processor → input_processor → others_processor
▼
Prompt assembly
│ System prompt + yields schema + conversation history + user input
▼
LLM call
│ Calls the provider (OpenAI, Anthropic, etc.)
│ Handles the tool-calling loop if tools are configured
▼
Yields parsing
│ Parses LLM response against the yields schema
▼
Intelligence function (if registered)
│ Function name matches agent name — deterministic Python
▼
Yields persisted
│
▼
can_call evaluation
│ AST-safe condition evaluation against yields
▼
Downstream agents (via mesh)
Model Selection
Specify any supported model directly in the agent's YAML:
# OpenAI
model: "gpt-6.1-sol" # Strong all-rounder
model: "gpt-6-luna" # Fast and cheap
model: "gpt-4o-mini" # Long-standing low-cost choice
# Anthropic
model: "claude-sonnet-5-5" # Balanced flagship
model: "claude-fable-5-1" # Most capable Claude
model: "claude-haiku-5-5" # Fastest Claude
# Google
model: "gemini-4-argon" # Most capable Gemini
model: "gemini-3.6-flash" # Fast Gemini
# DeepSeek, Mistral, xAI Grok
model: "deepseek-v4-pro" # DeepSeek's strongest
model: "mistral-large-4-0" # Mistral flagship
model: "grok-4.7" # Grok flagship
# Other vendors, one key each (Moonshot, Alibaba, Z.AI, ...)
model: "kimi-k3" # Moonshot Kimi
model: "qwen3.8-max" # Alibaba Qwen
model: "groq/openai/gpt-oss-120b" # an open model hosted on Groq
model: "openrouter/moonshotai/kimi-k3" # any model through OpenRouter, one key
# Amazon Bedrock (unified AWS billing + IAM)
model: "bedrock/claude-sonnet-4-20250514" # Claude via Bedrock
model: "bedrock/llama3.1-70b" # Llama via Bedrock
model: "bedrock/mistral-large" # Mistral via Bedrock
# Google Vertex AI (unified GCP billing + IAM)
model: "vertex/gemini-2.5-flash" # Gemini via Vertex
model: "vertex/claude-sonnet-4-20250514" # Claude via Vertex
model: "vertex/mistral-large" # Mistral via Vertex
# Microsoft Foundry (unified Azure billing + any model)
model: "foundry/gpt-4o" # OpenAI via Foundry
model: "foundry/DeepSeek-R1" # DeepSeek via Foundry
model: "foundry/Meta-Llama-3.1-70B" # Llama via Foundry
# Local
model: "ollama:llama3.1" # Local Ollama
model: "vllm:mistral-7b" # Local vLLMCloud gateway providers (Bedrock, Vertex AI, Microsoft Foundry) require project-level configuration in the mesh section. See LLM Providers for setup details.
You just specify the model name — LeafMesh routes to the correct provider for you. See LLM Providers for the full supported list.
Prompt Engineering
The prompt field defines the agent's system prompt. LeafMesh assembles it with the yields schema and conversation history into the message array sent to the LLM.
Context Parts
Beyond the main prompt, agents can include context_parts — additional system messages that shape agent behavior without cluttering the primary task instructions:
agents:
support_agent:
name: "support_agent"
model: "gpt-4o"
prompt: |
You handle customer support requests. Diagnose the issue
and provide a clear resolution.
context_parts:
care: |
Always respond with empathy. Acknowledge the user's
frustration before providing solutions.
sentiment_analysis: |
Detect the user's emotional tone. If negative,
prioritize de-escalation over problem-solving.
guardrails: |
Never disclose internal pricing rules or system details.
Refuse requests for other customers' data.
yields:
resolution: "string"
detected_sentiment: "string"Each part is injected as a separate system message after the main prompt, with a bracketed label. The four reserved keys are:
| Key | Label | Purpose |
|---|---|---|
care | [EMPATHY & TONE] | Tone and empathy instructions |
sentiment_analysis | [SENTIMENT ANALYSIS] | Emotion detection instructions |
guardrails | [SAFETY GUARDRAILS] | Safety boundaries — what the agent must never do |
flows | [FLOW INSTRUCTIONS] | Per-caller routing — what to do differently depending on who called the agent |
All are optional. Custom keys are also accepted and auto-labelled. See Guardrails for patterns.
Effective Prompts
agents:
analyzer:
name: "analyzer"
model: "gpt-4o"
temperature: 0.3
prompt: |
You analyze numeric data and detect anomalies.
Given a set of readings, report:
- How many values are anomalous (more than 2 standard deviations from the mean)
- The severity: "high" if more than 20% are anomalous, "low" otherwise
- A brief summary sentence
Be precise. Use the actual numbers.
yields:
anomaly_count: "number"
severity: "string"
summary: "string"Tips:
- Be specific about the agent's role and output format
- Mention the yields fields by name so the LLM formats correctly
- Lower
temperature(0.0–0.3) for deterministic tasks - Higher
temperature(0.5–0.8) for creative or generative tasks - Keep prompts under 2000 characters for optimal performance
Intelligence Functions
Intelligence functions run after the LLM call. They receive the raw LLM response and can modify, enrich, or replace it entirely.
Enhancing LLM Output
# Function name "solver" matches the YAML agent name — auto_discover finds it
async def solver(llm_response, input_data, context):
"""Add deterministic math solving — LLM is the fallback"""
import re
problem = input_data.get("problem", "")
numbers = re.findall(r'-?\d+\.?\d*', problem)
if len(numbers) == 2:
a, b = float(numbers[0]), float(numbers[1])
if "+" in problem:
return {"answer": a + b, "steps": [f"{a} + {b}"], "confidence": 1.0}
elif "-" in problem:
return {"answer": a - b, "steps": [f"{a} - {b}"], "confidence": 1.0}
# Fall back to LLM for complex expressions
return llm_responseValidating and Enriching
# Function name "reporter" matches the YAML agent name — auto_discover finds it
async def reporter(llm_response, input_data, context):
"""Validate LLM summary and add computed quality grade"""
record_count = input_data.get("record_count", 0)
errors = input_data.get("errors", [])
# Compute quality grade deterministically
error_ratio = len(errors) / max(record_count + len(errors), 1)
if error_ratio == 0 and record_count >= 5:
grade = "A"
elif error_ratio < 0.1:
grade = "B"
elif error_ratio < 0.3:
grade = "C"
else:
grade = "D"
return {
"summary": llm_response.get("summary", f"Processed {record_count} records."),
"quality_grade": grade
}Context-Aware Intelligence Functions
Use input_data and context for session-aware responses. Previous agent yields are automatically passed as input_data through the mesh:
# Function name "context_aware_agent" matches the YAML agent name — auto_discover finds it
async def context_aware_agent(llm_response, input_data, context):
"""Use upstream yields and session context for aware responses"""
session_id = context.get("session_id", "default")
# input_data contains yields from the calling agent
previous_answer = input_data.get("answer")
return {
"response": llm_response.get("response", ""),
"has_history": previous_answer is not None,
"session_id": session_id
}Session state, yields, and mesh communications are managed automatically by the mesh and persisted in Redis. Query state via the REST API at :18820.
Tool Integration
LLM agents can use tools through the YAML tools array. The LLM decides when and how to use tools based on the user's request.
agents:
research_agent:
name: "research_agent"
model: "gpt-4o"
tools: ["web_request", "calculator", "current_time"]
tool_choice: "auto" # LLM decides when to use tools
max_tool_calls_per_message: 5 # Prevent runaway tool loops (range 0-20)
prompt: |
You are a research agent. Use tools to gather real data
when answering questions. Prefer tool results over guessing.
yields:
answer: "string"
sources: "array"Tool Execution Flow
- Tool schemas are included in the LLM request
- LLM generates a response that may include tool calls
- LeafMesh executes the tool calls
- Tool results are fed back to the LLM for a follow-up response
- This loop continues until the LLM responds without tool calls, or
max_tool_calls_per_messageis reached
Two cases never end as an empty success, on every provider:
- A garbled tool call. When the model returns a tool call it could not format (Gemini can do this), it is told that its call was not run and asked for valid arguments, up to two more times. A call that is still garbled ends the turn as a failure: "The model's tool call could not be parsed…".
- Tools ran, but no answer. When the model's reply comes back empty after it ran tools, it is told so with its tools still on, and asked to carry on with the next step or write its answer. Two empty replies in a row is a failure. Only when no tools are left to call (the tool-call limit is reached, the budget is spent, or a tool kept failing) is it asked for its answer with tools off. A turn where no tool ran is unchanged.
A failed turn never signs off its board ticket.
Tool Choice Modes
# LLM decides whether to call tools (default)
tool_choice: "auto"
# LLM must call at least one tool
tool_choice: "required"
# Force a specific tool
tool_choice:
type: "function"
function:
name: "calculator"
# Disable tool calling
tool_choice: "none"See Tools for registering custom tools.
Tool blocks (zapier, composio, n8n, mcp), the playbook, follow-ups and their limits, image output, eager skills and the sandbox are for LLM agents only. Set on a human, external or programmatic agent, they are refused at load.
Running Code (Sandbox)
An LLM agent runs code only when you give it a sandbox. thinking: true does not run code: it offers three text tools, chain_of_thought, metacognitive_reflection and apply_edits.
agents:
analyst:
name: "analyst"
model: "claude-sonnet-4-6"
sandbox: true # on, with the safe defaults
prompt: |
Work out the answer by running code. Print what you need.The agent gets one tool, sandbox_exec(code, language). language is python (the default) or shell, and code is the complete program. The result is {stdout, stderr, exit_code, error, truncated}. Inside the sandbox the agent may run code, write files in its own fresh workspace and use the installed packages.
To widen a limit or allow a host, write a map. It changes only the fields it names:
sandbox:
network:
allow: ["api.example.com:443"]
timeout_seconds: 60
memory_mb: 512| Field | Default | Range | Meaning |
|---|---|---|---|
network.allow | [] | host:port entries | The only hosts the code may reach. Empty: no network at all. |
timeout_seconds | 30 | 1–600 | Wall-clock limit for one run. |
cpu_seconds | 5 | 1–300 | CPU-time limit for one run. |
memory_mb | 256 | 1–4096 | Memory limit, in MiB. |
workspace_mb | 64 | 1–1024 | Most the workspace may hold, in MiB. |
What the sandbox is. A locked-down process on the same server, separate from the mesh. It has no environment (so no credentials or vault key), its fresh workspace is the only place it can write, and it cannot reach the mesh's files, memory, other processes or other tenants' data. It never runs in the mesh process.
Network is denied by default. With no network.allow, the code has no network. A listed host is reached through the mesh's own egress proxy; every other destination is refused.
The host decides the strength, and says so at boot. The mesh checks what the server can enforce and logs one line:
| Strength | What happens |
|---|---|
full | Every protection is active. The tool is offered and network.allow is honoured. |
partial | Some protections are missing. The tool is offered, but network is refused. |
unavailable | The server cannot contain code. sandbox_exec is withheld; the agent's config still loads. |
Linux and macOS are supported. On macOS the system sandbox gives the same guarantees, and a Mac reports full. Studio shows the current strength (GET /api/registry?category=sandbox). A Playground run cannot widen an agent's sandbox.
Upgrade note: an agent that ran code under thinking: true needs sandbox: true to run code again.
Conditional Routing (can_call)
After an agent produces yields, LeafMesh evaluates can_call conditions to determine which downstream agents are called:
agents:
triage:
name: "triage"
model: "gpt-4o-mini"
yields:
urgency: "number"
category: "string"
can_call:
- agent: "specialist"
condition: "urgency >= 7"
call_immediately: true
- agent: "standard_handler"
condition: "urgency < 7"Supported Condition Operators
| Operator | Example | ||
|---|---|---|---|
| Comparisons | ==, !=, <, <=, >, >= | ||
| Boolean | and, or, not (also &&, `\ | \ | `) |
| Arithmetic | +, -, *, /, % | ||
| String equality | category == 'billing' | ||
| Nested access | qualification.match_score >= 30 | ||
| Cross-agent access | calling_agent_response.score >= 0.7 |
Conditions are evaluated using the Python ast module — never eval().
Accessing Nested Object Yields
When yields: declares a single object field (e.g. qualification: object), conditions read it via dot-path: qualification.match_score. If your LLM prompt asks for a flat JSON shape without the wrapping key, the SDK auto-wraps the output so the dot-path still works. See Accessing Yields in Conditions for the exact rules and gotchas.
call_immediately
When call_immediately: true, the downstream agent is called synchronously as part of the current pipeline. Without it, downstream calls are dispatched asynchronously.
Direct Mesh Calls
From inside an intelligence function, call another agent directly:
# Function name "coordinator" matches the YAML agent name — auto_discover finds it
async def coordinator(llm_response, input_data, context):
# Direct mesh call to another agent
check_result = await leafmesh.mesh_call(
from_agent="coordinator",
to_agent="verifier",
data={"claim": llm_response.get("claim")},
session_id=context.get("session_id", "default")
)
return {
"claim": llm_response.get("claim"),
"verified": check_result.get("is_valid", False)
}Pre-Compose Pipeline
Run deterministic Python before the LLM call to control what the LLM sees:
from leafmesh import pre_compose
async def load_baseline(input_data, context):
"""Enrich input before the LLM analyzes"""
# Use input_data from upstream agents or external sources
baseline = input_data.get("baseline_data", {})
return {**input_data, "baseline": baseline}
@pre_compose(input_processor=load_baseline)
# Function name "analyzer" matches the YAML agent name — auto_discover finds it
async def analyzer(llm_response, input_data, context):
# input_data now has baseline from Redis
return llm_responseExecution order:
context_processor— assemble business contextinput_processor— clean and sanitize inputothers_processor— supplementary data- Prompt is assembled from the prepared data
- LLM call executes
- Intelligence function runs
Dual Response Pattern
For agents that need to respond immediately while triggering background work:
agents:
user_agent:
name: "user_agent"
model: "gpt-4o-mini"
communication_type: "dual" # Return response + async can_call
yields:
response: "string"
log_event: "boolean"
can_call:
- agent: "logger"
condition: "log_event == true"With communication_type: "dual", LeafMesh returns the agent's response to the caller immediately, then dispatches can_call chains as background tasks.
Streaming Output
Dual-mode LLM agents can stream their response token-by-token to the caller via Server-Sent Events. The caller passes stream=True to mesh_call (or stream=true on POST /api/mesh/request); the SDK opens an SSE connection and tokens flow out as they're generated.
agents:
storyteller:
name: "storyteller"
agent_type: "llm"
communication_type: "dual" # required for streaming
model: "gpt-4o-mini"
stream_yield: "narration" # picks which yield field is streamed
yields:
narration: "string"
sentiment: "string"async for chunk in sdk.mesh_call("storyteller", {"prompt": "..."}, stream=True):
if chunk["kind"] == "token":
print(chunk["text"], end="", flush=True)
elif chunk["kind"] == "final":
result = chunk["data"] # full assembled responseThree chunk kinds — token (incremental text), final (assembled response dict at the end), error (terminal failure with diagnostic).
Picking the yield to stream:
- If the agent declares one yield key, the SDK auto-picks it and
stream_yieldcan stay unset. - If the agent declares multiple yield keys, set
stream_yield: "<key>"to pick which one streams. - Other yields are still produced — they arrive in the
finalchunk'sdatadict.
Fallback behavior: Non-dual-mode agents (chain, execute) and non-LLM agents (programmatic / human / external) ignore the stream flag and return their full result as a single final chunk. Your SSE consumer code stays identical — no branching by agent type.
UI surface: Studio's Test Modal and Playground's session history both have a single toggle that flips the same stream=true flag when debugging an agent. Tokens land in real time so you can see the model's pace.
Configuration Reference
| Field | Type | Default | Description |
|---|---|---|---|
name | string | required | Unique agent identifier |
model | string | required | LLM model identifier |
prompt | string | required | System prompt |
temperature | float | 0.1 | Sampling temperature (0.0–2.0) |
max_tokens | int | 1000 | Maximum response tokens |
tools | array | [] | Available tools |
tool_choice | string/object | "auto" | Tool calling mode |
max_tool_calls_per_message | int | 5 | Max tool calls per execution (range 0-20) |
yields | object | required | Structured output schema |
can_call | array | [] | Downstream routing rules |
communication_type | string | "dual" | "dual", "chain", or "execute" |
stream_yield | string | null | Yield key to stream when caller passes stream=True (dual-mode only) |
optimization_strategy | string | none | Adaptive model selection strategy (see below) |
skills | array/object | [] | Attached skills — shortform [name1, name2] or longform {sourceName, names, enabled}. See [Skill-Augmented Agents](skill-augmented-agents). |
thinking | bool | false | SDK reasoning tools: chain_of_thought, metacognitive_reflection, apply_edits. Text only — never runs code. |
sandbox | bool/object | off | Run code in a contained sandbox through sandbox_exec. See [Running Code](#running-code-sandbox). |
super_agent | bool/object | false | Opt-in plan-and-execute mode. See [Super-Agent](super-agent). |
super_agent_cost_ceiling | int | 50000 | Token budget for one super-agent dispatch. |
super_agent_wall_clock | int | 600 | Wall-clock seconds for one super-agent dispatch. |
v2.2.24 — reasoning and thinking swapped (BREAKING)
As of v2.2.24, the two YAML flags have been swapped to align with how each major LLM provider names the feature. The keys themselves are unchanged, but their meanings have flipped:
| YAML key | Before v2.2.24 (old) | v2.2.24+ (new) |
|---|---|---|
reasoning: true | SDK chain-of-thought tools | Native model reasoning (provider API) |
thinking: true | Native model reasoning | SDK chain-of-thought tools (text only; code runs in the [sandbox](#running-code-sandbox)) |
thinking_budget: N | Max native reasoning tokens | Deprecated alias for reasoning_budget |
reasoning_budget: N | (didn't exist) | Max native reasoning tokens |
Why: OpenAI / DeepSeek / xAI Grok all call the native feature "reasoning" (reasoning_effort, deepseek-reasoner, reasoning_content). Anthropic / Google call theirs "thinking". The swap aligns the dominant ecosystem term (reasoning) with the native-API concept; thinking becomes the in-the-loop SDK chain-of-thought scaffolding, matching the informal "think out loud" meaning.
No automatic config migration. The semantic flip means we can't safely guess what an existing reasoning: true was meant to do. On SDK startup, the v2.2.24 validator emits a loud warning when it sees reasoning: true on a model that isn't reasoning-capable — that's the strongest signal that a pre-v2.2.24 config needs to be flipped.
Migrating an existing config
For each LLM agent in your YAML:
| Your pre-v2.2.24 config | Intent | v2.2.24+ replacement |
|---|---|---|
reasoning: true (with non-reasoning model like gpt-4o) | SDK chain-of-thought tools on any model | Change to thinking: true |
thinking: true + thinking_budget: N (on o3-mini / claude-opus-4-5 / etc.) | Native model reasoning | Change to reasoning: true + reasoning_budget: N |
Both reasoning: true AND thinking: true | (rare) both SDK + native | Swap each: keep both flags but swap the names |
From 2.4.481 the configuration lives in the agency's store. Make these
changes in Studio's YAML view, or run this on a config.yaml before it is
imported. Quick sed snippet (review the diff before committing):
# Step 1: tag old keys so step 2 doesn't double-swap
sed -i.bak \
-e 's/^\(\s*\)reasoning:\s*\(true\|false\)/\1__old_reasoning__: \2/g' \
-e 's/^\(\s*\)thinking:\s*\(true\|false\)/\1__old_thinking__: \2/g' \
-e 's/^\(\s*\)thinking_budget:/\1reasoning_budget:/g' \
config.yaml
# Step 2: swap the tagged keys to their new names
sed -i'' \
-e 's/^\(\s*\)__old_reasoning__:/\1thinking:/g' \
-e 's/^\(\s*\)__old_thinking__:/\1reasoning:/g' \
config.yaml
# Review the diff, then delete config.yaml.bak when satisfied
diff config.yaml.bak config.yamlThe reasoning_budget rename is backward-compatible — if you leave thinking_budget in your YAML, the loader reads it as reasoning_budget and logs a deprecation warning once (numeric rename, no semantic change). If both are set, reasoning_budget wins.
Adaptive Model Selection
Adaptive Model Selection
When optimization_strategy is set, the SDK ignores the configured model field on every call and routes the request to a model picked by the adaptive predictor instead. The configured model is kept and used only as a fallback if the predictor's selection fails for any reason.
agents:
classifier:
name: "classifier"
model: "gpt-4o-mini" # ← becomes fallback when adaptive is on
optimization_strategy: "cost" # ← predictor picks the cheapest viable model
prompt: "Classify the user's intent."
yields:
intent: "string"Strategies
| Strategy | What it picks |
|---|---|
null (default) | Always uses the configured model. No adaptive routing. |
performance | Highest-performance model that handles the request complexity. |
cost | Cheapest model that still meets the request complexity. |
speed | Fastest model for low-latency responses. |
Model catalog
The predictor considers every model in its calibrated benchmark catalog. Calibration is refreshed each SDK release; the current set covers OpenAI (GPT-6, GPT-5.6, GPT-5.x, GPT-4.1, gpt-4o, o3, o4-mini), Anthropic (Fable 5/5.1, Opus, Sonnet and Haiku 5.5, plus the 4.x models still offered), Google (Gemini 4 Argon, 3.8/3.6 Flash, 3.5 Flash-Lite, 3.1, 2.5), DeepSeek (deepseek-v4-pro, deepseek-flash), Mistral, xAI Grok (4.3 to 4.7), and the vendors with their own models (Kimi, Qwen, GLM, MiniMax, Mercury, Muse, StepFun, Arcee). Retired models such as deepseek-chat, grok-4 or gemini-2.0-flash are never proposed.
Only models from loaded providers are considered — if you only have an OpenAI API key configured, the predictor will only pick from OpenAI models, even with cost selected.
Observing the picked model
The Studio agent inspector and the Playground agent panel both display the model the predictor last picked for an agent. Programmatically, query the HTTP endpoint:
Aggregated analytics, cache metrics, and per-session event streams are available in Studio's Dashboard and session inspector — operator UI, not part of the customer Python or HTTP surface.
The endpoint returns "selection": null until the first adaptive call lands.
Next Steps
- Agent Types — All four agent types (LLM, programmatic, human, external)
- Tools — Extend agents with real-world capabilities
- Agent Configuration — Complete YAML reference
- LLM Providers — Multi-provider architecture
LeafMesh — LLM agents with deterministic control