Module

Module

The Module class is the foundation of acorn. It encapsulates everything needed to build an LLM agent: model configuration, input/output schemas, tools, and execution logic.

What is Module?

A Module is a reusable component that wraps an LLM with structured inputs and outputs. Think of it as a function that:

  • Takes typed arguments (validated with Pydantic)
  • Calls an LLM with those arguments
  • Returns typed results (also validated with Pydantic)

Unlike raw LLM calls that work with strings, Modules give you type safety and validation at both ends.

Single-Turn vs Multi-Turn

Modules can run in two modes:

Single-turn (default): One LLM call that immediately produces output. The LLM must call the __finish__ tool with your output schema.

class Summarizer(Module):
    # max_steps not set = single-turn
    initial_input = Input
    final_output = Output

Multi-turn (agentic loop): The LLM can call tools multiple times across multiple steps before finishing.

class ResearchAgent(Module):
    max_steps = 5  # Up to 5 iterations
    initial_input = Input
    final_output = Output
    tools = [search, analyze]

When max_steps is set, the agent runs in a loop:

  1. LLM decides which tool to call
  2. Tool executes and returns result
  3. Result goes back to LLM
  4. Repeat until LLM calls __finish__ or max steps reached

Basic Usage

Define a Module by subclassing and setting class attributes:

from pydantic import BaseModel, Field
from acorn import Module

class Input(BaseModel):
    text: str = Field(description="Text to analyze")

class Output(BaseModel):
    sentiment: str = Field(description="positive, negative, or neutral")
    confidence: float = Field(description="Confidence score 0-1")

class SentimentAnalyzer(Module):
    """Analyze sentiment of text."""
    
    initial_input = Input
    final_output = Output
    temperature = 0.3

# Use it
analyzer = SentimentAnalyzer()
result = analyzer(text="I love this product!")
print(result.sentiment)  # "positive"
print(result.confidence)  # 0.95

Model Configuration

Control which LLM to use and how it behaves:

model

The LLM to use. Defaults to Claude Sonnet 4.5.

class MyModule(Module):
    model = "anthropic/claude-sonnet-4-5-20250514"  # Default
    model = "openai/gpt-4o"
    model = "ollama/llama3"

For advanced configuration, use a dict:

model = {
    "id": "anthropic/claude-sonnet-4-5-20250514",
    "vertex_location": "us-central1",
    "vertex_credentials": "/path/to/credentials.json",
    "reasoning": True,  # Can be: True, 'low', 'medium' or 'high'
    "api_key": "your-api-key",
    "api_base": "https://custom.endpoint.com/v1"
}

Dict keys:

  • id (required): Model identifier
  • vertex_location (optional): Vertex AI location for Google Cloud models
  • vertex_credentials (optional): Path to Vertex AI credentials file
  • reasoning (optional): Enable extended thinking (True or "low"/"medium"/"high")
  • api_key (optional): Override default API key for this module
  • api_base (optional): Override default API endpoint for this module

Use api_key and api_base when you need per-module API configuration:

# Module using a custom OpenAI-compatible endpoint
class CustomModule(Module):
    model = {
        "id": "custom/model-name",
        "api_key": "sk-custom-key",
        "api_base": "https://my-llm-proxy.example.com/v1"
    }

This is useful for:

  • Multi-tenant scenarios with different API keys per module
  • Custom LLM deployments with non-standard endpoints
  • Testing with different providers without changing environment variables
  • Using different authentication for different modules

temperature

Controls randomness in responses (0.0-1.0). Lower = more deterministic.

temperature = 0.3  # Precise, consistent outputs
temperature = 0.7  # Default, balanced
temperature = 1.0  # Creative, varied outputs

max_tokens

Maximum tokens the LLM can generate in a single response.

max_tokens = 4096  # Default
max_tokens = 1000  # Shorter responses

max_steps

Maximum iterations in the agentic loop. None means single-turn.

max_steps = None  # Single-turn (default)
max_steps = 5     # Up to 5 iterations
max_steps = 20    # Longer tasks

cache

Enable provider-level prompt caching to reduce latency and cost:

class MyModule(Module):
    cache = True  # Cache system + first user message

Or specify custom cache points:

cache = [
    {"location": "message", "role": "system"},
    {"location": "message", "index": 0}
]

model_fallbacks

Specify fallback models for automatic provider failover. If the primary model fails, acorn automatically retries with fallback models in order.

class MyModule(Module):
    model = "anthropic/claude-sonnet-4-5-20250514"
    model_fallbacks = [
        "openai/gpt-4o",
        "vertex_ai/gemini-pro"
    ]

Each fallback can be a string (model name) or a dict with the same keys as model:

model_fallbacks = [
    "openai/gpt-4o",  # Simple string
    {
        "id": "vertex_ai/gemini-pro",
        "vertex_location": "us-central1",
        "vertex_credentials": "/path/to/creds.json"
    }
]

Dict keys (same as model):

  • id (required): Model identifier
  • vertex_location (optional): Vertex AI location
  • vertex_credentials (optional): Path to Vertex AI credentials
  • reasoning (optional): Enable extended thinking (True or "low"/"medium"/"high")
  • api_key (optional): Override API key for this fallback
  • api_base (optional): Override API endpoint for this fallback

Mix strings and dicts freely:

model_fallbacks = [
    "openai/gpt-4o",
    {"id": "vertex_ai/gemini-pro", "vertex_location": "us-central1"},
    "anthropic/claude-3-5-sonnet-20241022"
]

Use cases:

  • High availability: Ensure your application continues working if one provider has an outage
  • Cost optimization: Use cheaper fallback models when primary model is unavailable
  • Rate limiting: Automatically switch providers when you hit rate limits
  • Multi-region deployments: Configure region-specific fallbacks for lower latency

How it works:

Acorn integrates with LiteLLM’s automatic fallback mechanism. When the primary model fails (rate limit, timeout, service outage), LiteLLM automatically tries each fallback in order until one succeeds. The fallback is transparent - your code receives the same response structure regardless of which model was used.

Check step.response or LiteLLM metadata to identify which model actually handled the request.

Schemas

Define what goes in and what comes out using Pydantic models.

initial_input

Validates arguments passed when calling the module.

class Input(BaseModel):
    question: str = Field(description="Question to answer")
    context: str | None = Field(default=None, description="Optional context")

class MyModule(Module):
    initial_input = Input

# Call with validated arguments
module = MyModule()
result = module(question="What is Python?", context="Programming")

If initial_input is not set, the module accepts any keyword arguments.

final_output

Defines the structure of the result. Required for single-turn, optional for multi-turn.

class Output(BaseModel):
    answer: str = Field(description="The answer")
    confidence: float = Field(description="Confidence score")
    sources: list[str] = Field(description="Sources used")

class MyModule(Module):
    final_output = Output

When the LLM calls __finish__, the arguments are validated against this schema. If validation fails, acorn automatically retries with an error message.

For multi-turn modules without structured output, set final_output = None:

class TaskExecutor(Module):
    max_steps = 10
    final_output = None  # No structured output
    tools = [do_task_a, do_task_b]

executor = TaskExecutor()
result = executor()  # Returns None after executing tools

System Prompt

Instructions for the LLM. Five ways to set it:

1. Docstring (recommended)

class MyModule(Module):
    """You are a helpful assistant.
    
    Be concise and cite sources.
    """

2. String attribute

class MyModule(Module):
    system_prompt = "You are a helpful assistant."

3. File path

from acorn import Path

class MyModule(Module):
    system_prompt = Path("prompts/assistant.md")

4. Method (dynamic)

from datetime import date

class MyModule(Module):
    def system_prompt(self):
        return f"You are a helpful assistant. Today is {date.today()}."

5. Template (Jinja2)

Use Template for dynamic prompts with variable substitution and Jinja2 features.

from acorn import Template

class MyModule(Module):
    system_prompt = Template(
        template="You are a  assistant. Today is .",
        args={"role": "helpful", "date": "2024-01-15"}
    )

When to Use Template

Template is a Jinja2-based prompt builder that separates prompt logic from code. Use it when you need:

  • Dynamic prompts: Inject runtime values (dates, user names, configuration)
  • Reusability: Share templates across modules with different variables
  • Complex logic: Use loops, conditionals, and filters for sophisticated prompt construction

For simple static prompts, use docstrings or string attributes. For prompts that need occasional dynamic values, use methods. For prompts requiring complex logic or shared templates, use Template.

Template Options

Inline string template:

from acorn import Template

class ChatBot(Module):
    model = "anthropic/claude-sonnet-4-5-20250514"
    
    system_prompt = Template(
        template="You are , a  assistant specialized in .",
        args={
            "name": "Alex",
            "personality": "friendly",
            "domain": "Python programming"
        }
    )

File-based template:

from acorn import Template

class ChatBot(Module):
    model = "anthropic/claude-sonnet-4-5-20250514"
    
    system_prompt = Template(
        path="prompts/chatbot.md",
        args={
            "name": "Alex",
            "personality": "friendly",
            "domain": "Python programming"
        }
    )

The path is relative to the module’s file. For example, if your module is in agents/chatbot.py, then path="prompts/chatbot.md" resolves to agents/prompts/chatbot.md.

Template Variables

Pass variables via the args parameter. Access them in templates with `` syntax:

system_prompt = Template(
    template="You are . Your expertise: .",
    args={"role": "advisor", "expertise": "data science"}
)

You can update args dynamically before rendering:

class MyModule(Module):
    model = "anthropic/claude-sonnet-4-5-20250514"
    
    system_prompt = Template(
        template="Current mode: ",
        args={"mode": "default"}
    )
    
    def __init__(self, mode="default"):
        self.system_prompt.args["mode"] = mode
        super().__init__()

Jinja2 Features

Templates support full Jinja2 syntax:

Conditionals:

template = """
You are a  assistant.

Use your best judgment when applying these guidelines.

"""

system_prompt = Template(template=template, args={"role": "helpful", "strict_mode": True})

Loops:

template = """
Available tools:

"""

args = {
    "tools": [
        {"name": "search", "description": "Search the web"},
        {"name": "calculate", "description": "Perform calculations"}
    ]
}

system_prompt = Template(template=template, args=args)

Filters:

template = """
User: 
Role: 
Summary: 
"""

args = {
    "username": "john_doe",
    "role": "developer",
    "description": "A very long description that will be truncated..."
}

system_prompt = Template(template=template, args=args)

Complete Example

from acorn import Module, Template
from pydantic import BaseModel, Field
from datetime import datetime

class CodeReviewerModule(Module):
    """Code review assistant with dynamic configuration."""
    
    model = "anthropic/claude-sonnet-4-5-20250514"
    
    system_prompt = Template(
        path="prompts/code_reviewer.md",
        args={
            "reviewer_name": "CodeBot",
            "date": datetime.now().strftime("%Y-%m-%d"),
            "languages": ["Python", "JavaScript", "Go"],
            "strict_mode": True
        }
    )
    
    class Input(BaseModel):
        code: str = Field(description="Code to review")
        language: str = Field(description="Programming language")
    
    class Output(BaseModel):
        issues: list[str] = Field(description="List of issues found")
        suggestions: list[str] = Field(description="List of suggestions")
        rating: int = Field(description="Code quality rating 1-10")
    
    initial_input = Input
    final_output = Output

prompts/code_reviewer.md:

You are , a code review assistant.

Today's date: 

Supported languages:



Focus on critical issues. Style suggestions are optional.

Benefits

  • Separation of concerns: Keep prompt text separate from Python code
  • Version control: Track prompt changes independently
  • Reusability: Share templates across multiple modules with different variables
  • Maintainability: Update prompts without touching code
  • Type safety: Variables are passed as Python dicts, not string concatenation

Tools

Functions the LLM can call to gather information or take actions.

Defining Tools

Use the @tool decorator on functions or methods:

from acorn import tool

@tool
def search_web(query: str, max_results: int = 5) -> list[dict]:
    """Search the web for information.
    
    Args:
        query: The search query
        max_results: Maximum number of results
    """
    # Your implementation
    return results

Acorn generates the tool schema from:

  • Function name → tool name
  • First line of docstring → tool description
  • Type hints → parameter types
  • Args section → parameter descriptions
  • Default values → optional parameters

Adding Tools to Module

Option 1: Class attribute

class MyModule(Module):
    tools = [search_web, calculate, read_file]

Option 2: Decorated methods

class MyModule(Module):
    @tool
    def search_web(self, query: str) -> list:
        """Search the web."""
        # Has access to self
        return self._search_api(query)
    
    @tool
    def analyze(self, data: str) -> str:
        """Analyze data."""
        return self._analyze(data)

Option 3: Both

class MyModule(Module):
    tools = [external_tool]  # External functions
    
    @tool
    def internal_tool(self, data: str) -> str:
        """Tool with access to module state."""
        return self.process(data)

Both lists are combined. If there’s a name conflict, acorn raises ToolConflictError.

The finish Tool

Every module automatically gets a __finish__ tool. When the LLM calls it, the module terminates and returns the result.

The tool’s parameters match your final_output schema:

class Output(BaseModel):
    answer: str
    confidence: float

# LLM sees this tool:
# __finish__(answer="Paris", confidence=0.95)

If final_output = None, __finish__ takes no parameters:

# LLM sees:
# __finish__()

Branching

Branching lets you spawn sub-agents (branches) that inherit parent context and extend capabilities with additional tools or specialized logic. Use branching for parallel analysis, map-reduce patterns, verification workflows, and deep dives into subtasks.

When to Use Branching

Good use cases:

  • Map-reduce operations: Analyze each dependency, file, or data item in parallel (see examples/dependency_scanner.py)
  • Verification workflows: Fact-check claims or validate outputs
  • Deep analysis: Spawn a specialist agent for detailed investigation
  • Parallel subtasks: Execute independent tasks simultaneously

Not recommended:

  • Simple sequential tool calls (use regular tools instead)
  • Sharing state between parent and branch (branches are isolated)

Defining a Branch Module

A branch is a regular Module subclass with initial_input and final_output:

class FactCheckBranch(Module):
    """Verify factual claims using external sources."""
    
    model = "anthropic/claude-sonnet-4-5-20250514"
    initial_input = ClaimInput  # Branch-specific input
    final_output = VerificationOutput  # Branch returns structured result
    tools = [search_external_sources]  # Branch can have its own tools
    max_steps = 5

Registering Branches

Add branch classes to the parent’s branches list:

class Parent(Module):
    model = "anthropic/claude-haiku-4-5"
    branches = [FactCheckBranch, DeepAnalysisBranch]
    max_steps = 10
    final_output = ParentOutput

This auto-generates a branch() tool the LLM can call.

The branch() Tool

When branches are registered, the parent module automatically gets a branch() tool:

Discovery mode (no arguments):

# LLM calls: branch()
# Returns XML listing available branches and their input schemas

Execution mode (with branch name and inputs):

# LLM calls: branch(name="FactCheckBranch", claim="The sky is blue", merge="end_result")
# Spawns FactCheckBranch with claim input, returns merged result

Branch Inheritance

Branches inherit from parent:

Inherited:

  • Full conversation history (excluding system message)
  • Parent tools (accessible via call_parent_tool())
  • Execution context

Not inherited:

  • System prompt (branch uses its own)
  • Parent’s final_output schema

This inheritance model lets branches understand what the parent discussed without duplicating context.

Merge Strategies

Control how branch results flow back to the parent:

end_result (default): Return only the branch’s final_output as XML

branch(name="FactCheckBranch", claim="...", merge="end_result")
# Returns: <branch_result><verified>true</verified>...</branch_result>

summarize: LLM-generated summary of branch history + final result

branch(name="FactCheckBranch", claim="...", merge="summarize")
# Returns: "Branch summary: The branch verified... Final result: {...}"

Use summarize when you need context about how the branch arrived at its result, or when the branch has final_output = None.

Calling Parent Tools

Branches can access parent tools via the auto-generated call_parent_tool() tool:

Discovery mode (list parent tools):

# Branch LLM calls: call_parent_tool()
# Returns JSON list of available parent tools with schemas

Execution mode (call a parent tool):

# Branch LLM calls: call_parent_tool(name="search_database", query="...")
# Executes parent's search_database tool, returns result

This lets branches leverage parent capabilities without duplicating tool definitions.

Manual Branching

Spawn branches programmatically from on_step or other callbacks:

class Parent(Module):
    max_steps = 10
    
    def on_step(self, step):
        # Spawn branch manually
        result = self.branch(FactCheckBranch, claim="The sky is blue", merge="end_result")
        
        # Result is injected into parent history automatically
        # Continue parent execution with branch result
        return step

Manual branching doesn’t require the branch class to be in self.branches.

Common Patterns

Pattern 1: Map-reduce for parallel analysis

class PackageAnalyzerBranch(Module):
    """Analyze one package."""
    initial_input = PackageInput
    final_output = PackageProfile
    tools = [fetch_package_info]

class DependencyScanner(Module):
    """Scan all dependencies."""
    branches = [PackageAnalyzerBranch]
    max_steps = 60  # Enough for many parallel branches
    
    # LLM calls branch(name="PackageAnalyzerBranch", package_name="requests")
    # for each dependency, then aggregates results

Pattern 2: Verification workflow

class FactCheckBranch(Module):
    """Verify a single claim."""
    initial_input = ClaimInput
    final_output = VerificationOutput
    tools = [search_sources, cross_reference]

class ArticleReviewer(Module):
    """Review article for accuracy."""
    branches = [FactCheckBranch]
    
    # LLM calls branch() for each claim in the article
    # Collects verification results, writes review

Pattern 3: Deep analysis with parent tools

class DeepDiveBranch(Module):
    """Detailed analysis of a subtopic."""
    initial_input = TopicInput
    final_output = DetailedReport
    
    # Can call call_parent_tool() to access parent's tools
    # Useful for reusing expensive resources (DB connections, APIs)

class ResearchAgent(Module):
    tools = [search_database, query_api]
    branches = [DeepDiveBranch]

Best Practices

Keep branches focused: Each branch should have a clear, single purpose.

Use appropriate merge strategies: Use end_result for structured outputs, summarize when you need execution context.

Set reasonable max_steps: Parent needs enough steps for all branch calls plus its own work. For N branches: max_steps >= N + 5.

Leverage parent tools: Use call_parent_tool() instead of duplicating tools in branches.

Handle branch failures: Branches can fail (API errors, validation failures). Parent should have fallback logic.

Consider cost: Each branch is a separate module execution with its own LLM calls. Use branching when parallelization or specialized logic provides clear value.

Lifecycle Hooks

Customize behavior at key points in execution.

on_step

Called after each step in the agentic loop. Use it to:

  • Track progress
  • Modify tool results
  • Add/remove tools dynamically
  • Terminate early
  • Adjust parameters
class MyModule(Module):
    max_steps = 10
    
    def on_step(self, step):
        print(f"Step {step.counter}")
        print(f"Tools called: {[tc.name for tc in step.tool_calls]}")
        
        # Modify tool results before next LLM call
        for result in step.tool_results:
            if len(str(result.output)) > 5000:
                result.output = str(result.output)[:5000] + "..."
        
        # Dynamic tool management
        if step.counter > 5:
            step.add_tool(advanced_tool)
        
        # Early termination
        if self._is_done():
            step.finish(answer="Done early", confidence=1.0)
        
        return step

The step object has:

  • counter: Current step number (1-indexed)
  • tool_calls: List of ToolCall objects
  • tool_results: List of ToolResult objects (mutable)
  • model, temperature, max_tokens: Current settings (can modify)
  • tools: Current tool list (can modify)

on_stream

Called when streaming is enabled. Receives chunks as they arrive:

class MyModule(Module):
    stream = True
    
    def on_stream(self, chunk):
        # Text content (chain-of-thought)
        if chunk.content:
            print(chunk.content, end="", flush=True)
        
        # Partial structured output
        if chunk.partial:
            if chunk.partial.answer:
                print(f"\nAnswer: {chunk.partial.answer}")

Advanced Configuration

metadata

Arbitrary metadata passed to LiteLLM for tracking:

class MyModule(Module):
    metadata = {
        "user_id": "user123",
        "session_id": "session456"
    }

xml_input_root / xml_output_root

Customize XML element names for input/output serialization:

class MyModule(Module):
    xml_input_root = "query"     # Default: "input"
    xml_output_root = "response"  # Default: "output"

max_parse_retries

Number of times to retry when output validation fails:

class MyModule(Module):
    max_parse_retries = 3  # Default: 2

Common Patterns

Research Assistant

Multi-turn agent that gathers information before answering:

class ResearchAgent(Module):
    """Research assistant that uses tools to answer questions."""
    
    class Input(BaseModel):
        question: str
    
    class Output(BaseModel):
        answer: str
        sources: list[str]
    
    initial_input = Input
    final_output = Output
    max_steps = 5
    temperature = 0.3
    
    @tool
    def search(self, query: str) -> list:
        """Search for information."""
        return search_api(query)
    
    @tool
    def analyze(self, data: str) -> str:
        """Analyze collected data."""
        return analyze_data(data)
    
    def on_step(self, step):
        print(f"Step {step.counter}: {[tc.name for tc in step.tool_calls]}")
        return step

Data Processor

Single-turn module for data transformation:

class DataProcessor(Module):
    """Transform data from one format to another."""
    
    class Input(BaseModel):
        data: dict
        target_format: str
    
    class Output(BaseModel):
        transformed_data: dict
        validation_errors: list[str]
    
    initial_input = Input
    final_output = Output
    temperature = 0.1  # Deterministic

Task Executor

Multi-turn without structured output:

class TaskExecutor(Module):
    """Execute a series of tasks using tools."""
    
    max_steps = 10
    final_output = None  # No structured output
    
    @tool
    def create_file(self, path: str, content: str) -> str:
        """Create a file."""
        Path(path).write_text(content)
        return f"Created {path}"
    
    @tool
    def send_email(self, to: str, subject: str) -> str:
        """Send an email."""
        send_mail(to, subject)
        return f"Sent email to {to}"

executor = TaskExecutor()
result = executor()  # Returns None after executing tools

History

The conversation history is accessible and mutable via self.history:

def on_step(self, step):
    # Read history
    print(f"History has {len(self.history)} messages")
    
    # Add a message
    self.history.append({
        "role": "user",
        "content": "Remember to be concise."
    })
    
    # Trim old messages to manage context
    if len(self.history) > 50:
        self.history = self.history[:1] + self.history[-40:]
    
    return step

History is a list of message dicts:

[
    {"role": "system", "content": "..."},
    {"role": "user", "content": "..."},
    {"role": "assistant", "content": "...", "tool_calls": [...]},
    {"role": "tool", "tool_call_id": "...", "content": "..."}
]

Error Handling

ParseError

Raised when output validation fails after all retries:

from acorn import ParseError

try:
    result = module(text="...")
except ParseError as e:
    print(f"Failed to parse output: {e}")
    print(f"Raw output: {e.raw_output}")

ToolConflictError

Raised when duplicate tool names are detected:

from acorn import ToolConflictError

class BadModule(Module):
    tools = [search]  # External tool named "search"
    
    @tool
    def search(self, query: str) -> str:  # Conflict!
        pass

# Raises: ToolConflictError: Duplicate tool name: search

AcornError

Base exception for all acorn errors:

from acorn import AcornError

try:
    result = module(text="...")
except AcornError as e:
    print(f"Acorn error: {e}")

Best Practices

Start with single-turn: Use max_steps = None unless you need tool calling across multiple steps.

Keep schemas simple: Complex nested schemas are harder for LLMs to fill correctly. Flatten when possible.

Write clear tool descriptions: The first line of the docstring is what the LLM sees. Make it count.

Use field descriptions: Pydantic field descriptions guide the LLM on what to provide.

Set appropriate temperature: Lower (0.1-0.3) for deterministic tasks, higher (0.7-1.0) for creative tasks.

Monitor with on_step: Track tool usage and results to understand agent behavior.

Validate early: Use Pydantic validators on input/output schemas to catch issues.

Handle large outputs: Truncate tool results in on_step to avoid context limits.

Next Steps