LLM Integration

Integrate LLM-powered adaptive intelligence into your behavior trees with the BYOM (Bring Your Own Model) architecture.


BYOM Philosophy

Igris BTree is LLM-agnostic. You choose your model, inference backend, and costs. The system provides the integration points - you bring the intelligence.

Why BYOM?

  • No vendor lock-in: Use any LLM provider
  • Full control: Choose model, costs, privacy policy
  • Flexibility: Local models, cloud APIs, or both
  • Testability: Mock providers for unit tests
  • Future-proof: Swap models without code changes

LlmProvider Trait

The LlmProvider trait is the core abstraction for LLM integration.

Trait Definition

#[async_trait]
pub trait LlmProvider: Send + Sync {
    /// Generate text from prompt
    async fn generate(&self, prompt: &str) -> anyhow::Result<String>;

    /// Provider name for logging
    fn name(&self) -> &str;

    /// Optional: streaming generation
    async fn generate_stream(
        &self,
        prompt: &str,
    ) -> anyhow::Result<tokio::sync::mpsc::Receiver<String>> {
        anyhow::bail!("Streaming not supported by this provider")
    }
}

Required Methods

generate()

Synchronous (non-streaming) text generation:

async fn generate(&self, prompt: &str) -> anyhow::Result<String>
  • Input: Text prompt
  • Output: Generated text
  • Errors: Network errors, API errors, timeout, etc.

name()

Provider identifier for logging:

fn name(&self) -> &str

Returns a human-readable name (e.g., "openai", "ollama", "mock").

Optional Methods

generate_stream()

Streaming generation (token-by-token):

async fn generate_stream(
    &self,
    prompt: &str,
) -> anyhow::Result<tokio::sync::mpsc::Receiver<String>>

Default implementation returns an error. Override to support streaming.


MockLlmProvider

Built-in mock provider for testing and development.

Basic Usage

use igris_btree::MockLlmProvider;
use std::sync::Arc;

// Custom responses
let provider = Arc::new(MockLlmProvider::new(vec![
    r#"{"type": "Sequence", "name": "plan1", "children": []}"#.to_string(),
    r#"{"type": "Sequence", "name": "plan2", "children": []}"#.to_string(),
]));

// Use in context
let context = BTreeContext::new().with_llm(provider);

Built-in Scenarios

Navigation Plan

let provider = Arc::new(MockLlmProvider::with_navigation_plan());

Returns a pre-built navigation sequence:

{
  "type": "Sequence",
  "name": "Navigate to warehouse",
  "children": [
    {
      "type": "Action",
      "name": "Move forward",
      "tool": "navigate",
      "args": {"direction": "forward", "distance": 10}
    },
    {
      "type": "Action",
      "name": "Turn right",
      "tool": "navigate",
      "args": {"direction": "right", "angle": 90}
    }
  ]
}

Recovery Plan

let provider = Arc::new(MockLlmProvider::with_recovery_plan());

Returns two plans (for testing ReplanOnFailure):

  1. Primary plan (simulates failure)
  2. Recovery plan (fallback)

Custom Mock

use igris_btree::MockLlmProvider;

let provider = MockLlmProvider::new(vec![
    // First call returns this
    r#"{"type": "Action", "name": "step1", "tool": "tool1", "args": {}}"#.to_string(),

    // Second call returns this
    r#"{"type": "Action", "name": "step2", "tool": "tool2", "args": {}}"#.to_string(),
]);

// Cycles through responses

Implementing Custom Providers

Example: Ollama Provider

use igris_btree::LlmProvider;
use async_trait::async_trait;
use reqwest::Client;
use serde_json::json;

pub struct OllamaProvider {
    client: Client,
    base_url: String,
    model: String,
}

impl OllamaProvider {
    pub fn new(base_url: impl Into<String>, model: impl Into<String>) -> Self {
        Self {
            client: Client::new(),
            base_url: base_url.into(),
            model: model.into(),
        }
    }
}

#[async_trait]
impl LlmProvider for OllamaProvider {
    async fn generate(&self, prompt: &str) -> anyhow::Result<String> {
        let response = self.client
            .post(format!("{}/api/generate", self.base_url))
            .json(&json!({
                "model": self.model,
                "prompt": prompt,
                "stream": false
            }))
            .send()
            .await?;

        let data: serde_json::Value = response.json().await?;
        let text = data["response"]
            .as_str()
            .ok_or_else(|| anyhow::anyhow!("No response field"))?;

        Ok(text.to_string())
    }

    fn name(&self) -> &str {
        "ollama"
    }
}

// Usage
let provider = Arc::new(OllamaProvider::new("http://localhost:11434", "phi3"));
let context = BTreeContext::new().with_llm(provider);

Example: OpenAI Provider

use igris_btree::LlmProvider;
use async_trait::async_trait;
use reqwest::Client;
use serde_json::json;

pub struct OpenAIProvider {
    client: Client,
    api_key: String,
    model: String,
}

impl OpenAIProvider {
    pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
        Self {
            client: Client::new(),
            api_key: api_key.into(),
            model: model.into(),
        }
    }
}

#[async_trait]
impl LlmProvider for OpenAIProvider {
    async fn generate(&self, prompt: &str) -> anyhow::Result<String> {
        let response = self.client
            .post("https://api.openai.com/v1/chat/completions")
            .header("Authorization", format!("Bearer {}", self.api_key))
            .json(&json!({
                "model": self.model,
                "messages": [{
                    "role": "user",
                    "content": prompt
                }],
                "temperature": 0.7
            }))
            .send()
            .await?;

        let data: serde_json::Value = response.json().await?;
        let text = data["choices"][0]["message"]["content"]
            .as_str()
            .ok_or_else(|| anyhow::anyhow!("No content in response"))?;

        Ok(text.to_string())
    }

    fn name(&self) -> &str {
        "openai"
    }
}

// Usage
let provider = Arc::new(OpenAIProvider::new(
    std::env::var("OPENAI_API_KEY")?,
    "gpt-4"
));
let context = BTreeContext::new().with_llm(provider);

Provider Best Practices

  1. Error Handling: Return descriptive errors
  2. Timeouts: Set reasonable timeouts (igris-rt provides 5s default)
  3. Retries: Implement retry logic for transient failures
  4. Logging: Log requests and responses for debugging
  5. Costs: Track token usage and costs
  6. Caching: Cache responses when appropriate
  7. Rate Limiting: Respect API rate limits

LLMPlannerNode

Generates behavior tree plans using LLM.

How It Works

1. Read task description from blackboard
2. Construct prompt with task and available tools
3. Call LLM provider
4. Parse response as JSON
5. Validate tree structure
6. Store plan in blackboard

Prompt Template

The LLMPlannerNode constructs prompts like this:

Generate a behavior tree plan for the following task:

Task: {task_description}

Available tools:
- navigate(destination, speed): Navigate to location
- pick(object_id): Pick up object
- place(location): Place held object

Generate a JSON behavior tree with nodes:
- Sequence: Execute children in order
- Selector: Try children until one succeeds
- Action: Execute a tool with parameters

Example format:
{
  "type": "Sequence",
  "name": "Complete task",
  "children": [
    {
      "type": "Action",
      "name": "Navigate to target",
      "tool": "navigate",
      "args": {"destination": "warehouse", "speed": 1.0}
    }
  ]
}

Return ONLY the JSON, no explanation.

Usage Example

use igris_btree::prelude::*;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // Setup LLM provider
    let llm = Arc::new(MockLlmProvider::with_navigation_plan());
    let mut context = BTreeContext::new().with_llm(llm);

    // Set task in blackboard
    context.blackboard.set(
        "mission_task",
        "Navigate to warehouse and pick up box #42"
    ).await;

    // Create planner node
    let mut planner = LLMPlannerNode::new(
        "mission_planner",
        "mission_task",  // Read task from here
        "execution_plan" // Write plan here
    );

    // Generate plan
    let status = planner.tick(&mut context).await?;

    if status == NodeStatus::Success {
        let plan: String = context.blackboard.get("execution_plan").await?;
        println!("Generated plan: {}", plan);
    }

    Ok(())
}

Configuration

// Planner uses these from context:
// - llm_provider: LLM for generation
// - tool_registry: Available tools for prompt
// - rt_executor: Bounds LLM call to 5s (optional)

Error Handling

The planner handles errors gracefully:

  • No LLM provider: Returns Failure with error log
  • LLM timeout: Retries up to 3 times
  • Invalid JSON: Attempts to extract JSON from markdown
  • Parse errors: Returns Failure with error details

SubtreeLoader

Loads and executes dynamically generated subtrees.

How It Works

1. Read JSON plan from blackboard
2. Parse JSON into BTreeNode instances
3. Tick loaded subtree
4. Return subtree status

Supported Node Types

Currently parses:

  • Sequence: Sequential execution
  • Selector: Fallback logic
  • Action: ToolAction nodes
  • Condition: CheckBlackboard nodes

Future support planned for decorators and parallel nodes.

Usage Example

use igris_btree::prelude::*;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let llm = Arc::new(MockLlmProvider::with_navigation_plan());
    let mut context = BTreeContext::new().with_llm(llm);

    // Complete pipeline: Task → Plan → Execute
    let mut tree = Sequence::new("llm_pipeline")
        .add_child(Box::new(SetBlackboard::new(
            "set_task",
            "task",
            "Navigate to warehouse"
        )))
        .add_child(Box::new(LLMPlannerNode::new(
            "planner",
            "task",
            "plan"
        )))
        .add_child(Box::new(SubtreeLoader::new(
            "executor",
            "plan"
        )));

    // Execute complete pipeline
    let executor = BTreeExecutor::new();
    let result = executor.execute(&mut tree, &mut context).await?;

    println!("Pipeline status: {:?}", result.status);

    Ok(())
}

JSON Plan Format

{
  "type": "Sequence",
  "name": "Generated plan",
  "children": [
    {
      "type": "Action",
      "name": "Step 1",
      "tool": "tool_name",
      "args": {
        "param1": "value1",
        "param2": 42
      }
    },
    {
      "type": "Sequence",
      "name": "Nested sequence",
      "children": [
        {
          "type": "Action",
          "name": "Step 2",
          "tool": "another_tool",
          "args": {}
        }
      ]
    }
  ]
}

ReplanOnFailure Decorator

Automatically replans when child fails using LLM.

How It Works

1. Tick child normally
2. If child succeeds → Return Success
3. If child fails:
   a. Read task from blackboard
   b. Ask LLM for recovery plan
   c. Load and execute recovery plan
   d. If recovery succeeds → Return Success
   e. If recovery fails → Increment counter, repeat
4. If max replans reached → Return Failure

Usage Example

use igris_btree::prelude::*;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let llm = Arc::new(MockLlmProvider::with_recovery_plan());
    let mut context = BTreeContext::new().with_llm(llm);

    // Set task description
    context.blackboard.set(
        "navigation_task",
        "Navigate to warehouse avoiding obstacles"
    ).await;

    // Wrap action with adaptive recovery
    let mut adaptive = ReplanOnFailure::new(
        "adaptive_navigation",
        Box::new(ToolAction::new("navigate", "nav_to_warehouse", params)),
        "navigation_task",  // Task description key
        "recovery_plan",    // Recovery plan key
        3                   // Max 3 replans
    );

    // If primary action fails, LLM generates recovery
    let status = adaptive.tick(&mut context).await?;

    Ok(())
}

Replan Prompt

When child fails, the decorator generates a prompt like:

The following action failed:
Action: navigate
Parameters: {"destination": "warehouse", "speed": 1.0}

Task description: Navigate to warehouse avoiding obstacles

Generate a recovery plan as a JSON behavior tree to complete the task.
Consider alternative approaches and error recovery strategies.

Return ONLY the JSON tree, no explanation.

Configuration

ReplanOnFailure::new(
    name,
    child,                // Primary action
    task_key,             // Blackboard key for task description
    plan_key,             // Blackboard key for recovery plans
    max_replans           // Maximum replan attempts (1-3 recommended)
)

Best Practices

  1. Set clear task descriptions: More context = better recovery plans
  2. Limit replans: 1-3 replans is usually sufficient
  3. Monitor costs: Each replan is an LLM call
  4. Log replan events: Track when and why replanning occurs
  5. Fallback safety: Combine with Selector for non-LLM fallback

Bounded Execution

LLM calls are bounded by igris-rt for real-time guarantees.

Default Timeout

LLM calls are limited to 5 seconds by default:

// In LLMPlannerNode and ReplanOnFailure:
// - Uses igris-rt executor if available
// - Falls back to unbounded tokio execution
// - Retries up to 3 times on timeout

Custom Timeout

Provide custom rt_executor in context:

// Custom bounded execution
let rt_executor = Arc::new(MyRtExecutor::new(
    Duration::from_secs(10)  // 10 second timeout
));

let context = BTreeContext::new()
    .with_llm(llm)
    .with_rt_executor(rt_executor);

Why Bounded?

  • Predictability: No indefinite waits
  • Real-time safety: Critical systems can't wait forever
  • Cost control: Prevents runaway LLM usage
  • Debugging: Easier to identify hanging calls

Testing with LLMs

Unit Testing

Use MockLlmProvider for deterministic tests:

#[tokio::test]
async fn test_llm_planning() {
    let llm = Arc::new(MockLlmProvider::new(vec![
        r#"{"type": "Sequence", "name": "test_plan", "children": []}"#.to_string()
    ]));

    let mut context = BTreeContext::new().with_llm(llm);
    context.blackboard.set("task", "Test task").await;

    let mut planner = LLMPlannerNode::new("planner", "task", "plan");
    let status = planner.tick(&mut context).await.unwrap();

    assert_eq!(status, NodeStatus::Success);
    assert!(context.blackboard.contains("plan").await);
}

Integration Testing

Test with real LLM but mock external tools:

#[tokio::test]
#[ignore]  // Run with --ignored for integration tests
async fn test_real_llm_planning() {
    let llm = Arc::new(OllamaProvider::new("http://localhost:11434", "phi3"));

    // Test real LLM generation
    let result = llm.generate("Generate a simple plan").await;
    assert!(result.is_ok());
}

Testing Patterns

  1. Happy Path: Use MockLlmProvider with valid JSON
  2. Error Cases: Test invalid JSON, missing provider, timeouts
  3. Recovery: Test ReplanOnFailure with multiple plans
  4. Performance: Measure LLM call latency
  5. Integration: Test with real LLM in staging environment

Performance Optimization

Caching Strategies

use std::collections::HashMap;
use std::sync::RwLock;

pub struct CachedLlmProvider {
    inner: Arc<dyn LlmProvider>,
    cache: RwLock<HashMap<String, String>>,
}

impl CachedLlmProvider {
    pub fn new(inner: Arc<dyn LlmProvider>) -> Self {
        Self {
            inner,
            cache: RwLock::new(HashMap::new()),
        }
    }
}

#[async_trait]
impl LlmProvider for CachedLlmProvider {
    async fn generate(&self, prompt: &str) -> anyhow::Result<String> {
        // Check cache
        {
            let cache = self.cache.read().unwrap();
            if let Some(cached) = cache.get(prompt) {
                return Ok(cached.clone());
            }
        }

        // Generate and cache
        let response = self.inner.generate(prompt).await?;

        {
            let mut cache = self.cache.write().unwrap();
            cache.insert(prompt.to_string(), response.clone());
        }

        Ok(response)
    }

    fn name(&self) -> &str {
        "cached"
    }
}

Batch Planning

Generate multiple plans at once:

// Instead of multiple single plans:
LLMPlannerNode::new("plan1", "task1", "plan1")
LLMPlannerNode::new("plan2", "task2", "plan2")

// Batch into single call:
BatchPlannerNode::new("batch", vec![
    ("task1", "plan1"),
    ("task2", "plan2"),
])

Parallel LLM Calls

Use Parallel node for concurrent LLM calls:

Parallel::new("parallel_planning", ParallelPolicy::RequireAll)
    .add_child(Box::new(LLMPlannerNode::new("plan_a", "task_a", "result_a")))
    .add_child(Box::new(LLMPlannerNode::new("plan_b", "task_b", "result_b")))

Cost Management

Tracking LLM Calls

use std::sync::atomic::{AtomicU64, Ordering};

pub struct MeteredLlmProvider {
    inner: Arc<dyn LlmProvider>,
    call_count: AtomicU64,
    total_tokens: AtomicU64,
}

impl MeteredLlmProvider {
    pub fn new(inner: Arc<dyn LlmProvider>) -> Self {
        Self {
            inner,
            call_count: AtomicU64::new(0),
            total_tokens: AtomicU64::new(0),
        }
    }

    pub fn stats(&self) -> (u64, u64) {
        (
            self.call_count.load(Ordering::Relaxed),
            self.total_tokens.load(Ordering::Relaxed)
        )
    }
}

#[async_trait]
impl LlmProvider for MeteredLlmProvider {
    async fn generate(&self, prompt: &str) -> anyhow::Result<String> {
        self.call_count.fetch_add(1, Ordering::Relaxed);

        let response = self.inner.generate(prompt).await?;

        // Estimate tokens (rough)
        let tokens = (prompt.len() + response.len()) / 4;
        self.total_tokens.fetch_add(tokens as u64, Ordering::Relaxed);

        Ok(response)
    }

    fn name(&self) -> &str {
        "metered"
    }
}

Cost-Aware Strategies

  1. Minimize calls: Use LLM only when deterministic fails
  2. Cache aggressively: Cache similar prompts
  3. Local first: Use local models for development
  4. Cloud for production: Use cloud only when needed
  5. Monitor usage: Track calls and costs

Advanced Patterns

Hybrid Planning

Combine deterministic and LLM planning:

Selector::new("hybrid_planning")
    // Try deterministic first (fast, free)
    .add_child(Box::new(Sequence::new("deterministic")
        .add_child(Box::new(CheckBlackboard::new("has_plan", "cached_plan", true)))
        .add_child(Box::new(SubtreeLoader::new("exec", "cached_plan")))
    ))
    // Fall back to LLM (slow, costs)
    .add_child(Box::new(Sequence::new("llm_fallback")
        .add_child(Box::new(LLMPlannerNode::new("planner", "task", "plan")))
        .add_child(Box::new(SubtreeLoader::new("exec", "plan")))
    ))

Multi-Stage Planning

Break complex tasks into stages:

Sequence::new("multi_stage")
    .add_child(Box::new(LLMPlannerNode::new("stage1", "task", "plan1")))
    .add_child(Box::new(SubtreeLoader::new("exec1", "plan1")))
    .add_child(Box::new(LLMPlannerNode::new("stage2", "task", "plan2")))
    .add_child(Box::new(SubtreeLoader::new("exec2", "plan2")))

Confidence-Based Planning

Use LLM confidence to decide whether to execute:

Sequence::new("confident_execution")
    .add_child(Box::new(LLMPlannerWithConfidence::new("planner", "task", "plan", "confidence")))
    .add_child(Box::new(CheckBlackboard::new("check_confidence", "confidence", 0.8)))
    .add_child(Box::new(SubtreeLoader::new("exec", "plan")))

Next Steps


Summary

Key takeaways:

  • BYOM: Bring your own model, no vendor lock-in
  • LlmProvider trait: Simple abstraction for any LLM
  • MockLlmProvider: Testing without real LLMs
  • LLMPlannerNode: Generate plans dynamically
  • SubtreeLoader: Execute generated plans
  • ReplanOnFailure: Automatic recovery with LLM
  • Bounded execution: 5s timeout for real-time safety
  • Cost management: Track and optimize LLM usage