LLM Agents

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: true

Execution 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 vLLM

Cloud 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:

KeyLabelPurpose
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_response

Validating 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

  1. Tool schemas are included in the LLM request
  2. LLM generates a response that may include tool calls
  3. LeafMesh executes the tool calls
  4. Tool results are fed back to the LLM for a follow-up response
  5. This loop continues until the LLM responds without tool calls, or max_tool_calls_per_message is 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
FieldDefaultRangeMeaning
network.allow[]host:port entriesThe only hosts the code may reach. Empty: no network at all.
timeout_seconds301–600Wall-clock limit for one run.
cpu_seconds51–300CPU-time limit for one run.
memory_mb2561–4096Memory limit, in MiB.
workspace_mb641–1024Most 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:

StrengthWhat happens
fullEvery protection is active. The tool is offered and network.allow is honoured.
partialSome protections are missing. The tool is offered, but network is refused.
unavailableThe 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

OperatorExample
Comparisons==, !=, <, <=, >, >=
Booleanand, or, not (also &&, `\\`)
Arithmetic+, -, *, /, %
String equalitycategory == 'billing'
Nested accessqualification.match_score >= 30
Cross-agent accesscalling_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_response

Execution order:

  1. context_processor — assemble business context
  2. input_processor — clean and sanitize input
  3. others_processor — supplementary data
  4. Prompt is assembled from the prepared data
  5. LLM call executes
  6. 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 response

Three 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_yield can 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 final chunk's data dict.

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

FieldTypeDefaultDescription
namestringrequiredUnique agent identifier
modelstringrequiredLLM model identifier
promptstringrequiredSystem prompt
temperaturefloat0.1Sampling temperature (0.0–2.0)
max_tokensint1000Maximum response tokens
toolsarray[]Available tools
tool_choicestring/object"auto"Tool calling mode
max_tool_calls_per_messageint5Max tool calls per execution (range 0-20)
yieldsobjectrequiredStructured output schema
can_callarray[]Downstream routing rules
communication_typestring"dual""dual", "chain", or "execute"
stream_yieldstringnullYield key to stream when caller passes stream=True (dual-mode only)
optimization_strategystringnoneAdaptive model selection strategy (see below)
skillsarray/object[]Attached skills — shortform [name1, name2] or longform {sourceName, names, enabled}. See [Skill-Augmented Agents](skill-augmented-agents).
thinkingboolfalseSDK reasoning tools: chain_of_thought, metacognitive_reflection, apply_edits. Text only — never runs code.
sandboxbool/objectoffRun code in a contained sandbox through sandbox_exec. See [Running Code](#running-code-sandbox).
super_agentbool/objectfalseOpt-in plan-and-execute mode. See [Super-Agent](super-agent).
super_agent_cost_ceilingint50000Token budget for one super-agent dispatch.
super_agent_wall_clockint600Wall-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 keyBefore v2.2.24 (old)v2.2.24+ (new)
reasoning: trueSDK chain-of-thought toolsNative model reasoning (provider API)
thinking: trueNative model reasoningSDK chain-of-thought tools (text only; code runs in the [sandbox](#running-code-sandbox))
thinking_budget: NMax native reasoning tokensDeprecated 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 configIntentv2.2.24+ replacement
reasoning: true (with non-reasoning model like gpt-4o)SDK chain-of-thought tools on any modelChange to thinking: true
thinking: true + thinking_budget: N (on o3-mini / claude-opus-4-5 / etc.)Native model reasoningChange to reasoning: true + reasoning_budget: N
Both reasoning: true AND thinking: true(rare) both SDK + nativeSwap 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.yaml

The 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

StrategyWhat it picks
null (default)Always uses the configured model. No adaptive routing.
performanceHighest-performance model that handles the request complexity.
costCheapest model that still meets the request complexity.
speedFastest 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


LeafMesh — LLM agents with deterministic control

Last updated: 11/10/2026