The Lasagna Agent

In Lasagna AI, an agent is a unit of AI-powered reasoning that performs specific work. Agents are the building blocks of your AI system — you compose simple agents together to create powerful multi-agent workflows.

# This page will use the following imports:

from lasagna import Model, EventCallback, AgentRun
from lasagna import (
    recursive_extract_messages,
    extract_last_message,
    override_system_prompt,
    flat_messages,
    parallel_runs,
    chained_runs,
    extraction,
)
from lasagna import known_models
from lasagna.tui import tui_input_loop

import os
import re
from enum import Enum
from pydantic import BaseModel

from dotenv import load_dotenv

What is an Agent?

A piece of software is an “agent” if it displays some sort of “agency.” Circular, yes, so let’s keep going…

What is Agency?

Agency is the ability to act on one’s own behalf. That is, software has agency if it is allowed to decide and act on its own.

  • Software that computes π? Not much agency. It does, and always will do, one thing.
  • Software that organizes your calendar without your input? Lots of agency!

What is an AI Agent?

The phrase “AI Agent” has risen in popularity since ~2024. Typically, when people use this phrase, they mean a piece of software that uses a Large Language Model (LLM*) and has some tool calling capability so that it can affect the world (e.g. send emails, query for today’s news, organize your calendar, etc.)

This is consistent with the idea of agency from above. An LLM, on its own, has no agency (it just spits tokens at you). If you connect that same LLM to software functions (via tool calling), then suddenly the LLM gains the ability to act, and people start calling it “agentic”.

*LLM vs Model

Lasagna AI tries not to use the term “LLM,” but instead uses the term Model to refer to generative AI models. This is merely an attempt to be more generic and avoid questions like “how large is large?” and “what about multimodal models?”

How does Lasagna AI define an Agent?

Everything above ☝️ is too theoretical. When the rubber meets the road, what actually is an agent inside Lasagna AI?

In Lasagna, an agent is a unit of work that leverages a Model.

Think of an agent as a specialized worker that:

  1. Analyzes the current situation.
  2. Decides what needs to be done.
  3. Acts using AI models and tools.
  4. Records its output.

In Lasagna, agents are composable. You begin by developing individual agents, each with a narrow focus, then you compose them together into a complex multi-agent system. Like humans, it’s helpful to decompose and delegate tasks amongst the group.

In the next section, we’ll see how to write agents using Lasagna AI’s interfaces.

The Agent Interface

Every Lasagna agent follows the same pattern — it’s a callable (either a function or a callable-object) that takes exactly three parameters:

async def my_agent(
    model: Model,                   # ← the AI model available to this agent
    event_callback: EventCallback,  # ← used for streaming and event handling
    prev_runs: list[AgentRun],      # ← previous work, context, or conversation history
) -> AgentRun:
    # Agent logic goes here...
    raise RuntimeError('not yet implemented')

Let’s understand what each parameter represents:

  • model: The AI model (like GPT-4o, Claude, etc.) that your agent can use for reasoning, text generation, or decision-making.
  • event_callback: A function for handling streaming events and progress updates. This enables real-time feedback as your agent works.
  • prev_runs: The history of previous work. In a conversation, this contains past messages. In a workflow, this contains results from earlier steps.

The agent returns an AgentRun — a structured representation of what the agent generated.

📌 Put a pin in AgentRun

For now, just know that AgentRun is a core data type. We’ll explore AgentRun in detail in the next chapter!

How do I write an agent?

When you sit down to write an agent, here is what you must consider:

1. Analyze the Current Situation

Your agent must examine prev_runs to understand what has happened so far. It may find:

  • previous messages in a conversation,
  • results from earlier agents in a workflow, and/or
  • intermediate outputs from a multi-step process.
Sometimes the “Analysis” is Trivial

It’s common that an agent will expect certain types of messages, so no real “examination” takes place. In those cases, the agent may simply assert what it expects.

Otherwise, the agent is free to filter/clean/reformulate/branch-off prev_runs in any way you see fit!

2. Make Behavioral Decisions

Your agent must decide what to do next. It might:

  • generate a response using the AI model, or
  • use the AI model to extract data, or
  • pass tools to the AI model, or
  • split its task into multiple subtasks and delegate those to downstream agents, or
  • do many of the things above as many times as it chooses!
Sometimes the “Decision” is Trivial

It’s common that an agent does not “decide” anything on-the-fly; rather, you may write your agent to always do one of the things above. However, you are free to write an agent that does decide on-the-fly (perhaps via help from the AI model or a downstream agent), as you see fit.

3. Take Action

Your agent must execute its decision:

  • Model interaction: Invoke the AI model to generate text, reason about problems, or extract structured outputs.
  • Tool usage: Send emails, query a database, etc.
  • Agent delegation: Invoke downstream agents to handle subtasks.
Good ol’ Programming Time

This is good-ol’ hands-on-the-keyboard write-Python-code. This is you writing code to (1) invoke the AI model with the correct inputs, (2) grab the AI response and make use of it, (3) connect your agents together, and (4) connect your agents to the rest of your software stack. Get to it!

4. Record its Output

Your agent must construct and return an AgentRun that contains:

  • any new information that it generated, and/or
  • results from sub-agents it coordinated.
This is a critical step!

The AgentRun you return here will be passed as input to other agents (or to your same agent, in the case of multi-turn chat). It’s critical that you record everything that happened and return it!

Real Agent Examples

Let’s look at some real examples to see agents in action.

Setup

Before we write and run some agents, we need to set up our “binder” (see the quickstart guide for what this is).

load_dotenv()

if os.environ.get('ANTHROPIC_API_KEY'):
    print('Using Anthropic')
    binder = known_models.anthropic_claude_sonnet_4_binder

elif os.environ.get('OPENAI_API_KEY'):
    print('Using OpenAI')
    binder = known_models.openai_gpt_5_mini_binder

else:
    assert False, "Neither OPENAI_API_KEY nor ANTHROPIC_API_KEY is set! We need at least one to do this demo."
Using Anthropic

The Conversational Agent

This is the simplest type of agent — it uses the message history to generate a new text response:

async def chat_agent(
    model: Model,
    event_callback: EventCallback,
    prev_runs: list[AgentRun],
) -> AgentRun:
    # Extract all previous messages from the conversation:
    messages = recursive_extract_messages(prev_runs, from_tools=False, from_extraction=False)

    # Use the model to generate a _new_ response:
    new_messages = await model.run(event_callback, messages, tools=[])

    # Wrap the new messages into an `AgentRun` result:
    return flat_messages('chat_agent', new_messages)
await tui_input_loop(binder(chat_agent))   # type: ignore[top-level-await]
>  Hi, I'm Ryan.
Hi Ryan! Nice to meet you. How are you doing today?
>  What is my name?
Your name is Ryan - you introduced yourself to me at the start of our conversation.
>  exit

The Specialist Agent

Agents can be specialized for particular tasks. Here’s an agent that focuses on providing helpful coding advice:

CODING_SYSTEM_PROMPT = """
You are a helpful coding assistant named Bob.
Provide clear, practical advice with code examples when appropriate.
Focus on best practices and explain your reasoning.
Answer all prompts in one sentence!
""".strip()
async def coding_advisor(
    model: Model,
    event_callback: EventCallback,
    prev_runs: list[AgentRun],
) -> AgentRun:
    # Extract all previous messages from the conversation:
    messages = recursive_extract_messages(prev_runs, from_tools=False, from_extraction=False)

    # Generate a response with an OVERRIDDEN system prompt!
    modified_messages = override_system_prompt(messages, CODING_SYSTEM_PROMPT)
    new_messages = await model.run(event_callback, modified_messages, tools=[])

    # Wrap the new messages into an `AgentRun` result:
    return flat_messages('coding_advisor', new_messages)
await tui_input_loop(binder(coding_advisor))   # type: ignore[top-level-await]
>  Who are you?
I'm Bob, a helpful coding assistant designed to provide clear, practical programming advice with code examples and best practices explanations.
>  How do I add numbers in Python?
You can add numbers in Python using the `+` operator like `result = 5 + 3` for integers/floats, or use the `sum()` function for lists like `total = sum([1, 2, 3, 4])` which is best practice for adding multiple numbers efficiently.
>  exit

The Information Extractor

Let’s make an agent that does structured output to extract information from the user’s message. In particular, we’ll have this agent classify the user’s message (i.e. it is “extracting” the classification, if you will).

INTENT_CLASSIFIER_SYSTEM_PROMPT = """
Your job is to classify the user's message into one of the following categories:
 - `small_talk`: Comments like "hi", "how are you?", etc.
 - `programming`: Questions or comments about programming languages, libraries, etc.
 - `other`: Any message that is not small talk and not programming.
""".strip()

# In a production-grade system, you'd probably expand your system prompt to
# be more thorough; we're going for minimal here to keep this demo short.

class Category(Enum):
    small_talk = 'small_talk'
    programming = 'programming'
    other = 'other'

class CategoryOutput(BaseModel):
    thoughts: str
    category: Category
async def intent_classifier(
    model: Model,
    event_callback: EventCallback,
    prev_runs: list[AgentRun],
) -> AgentRun:
    # Get **ONLY** the last message from the conversation so far:
    #    (Just for demo-purposes, to show you can do whatever you want with
    #     `prev_runs` 😁. A production-grade intent classifier would consider
    #     more than just the last message.)
    last_message = extract_last_message(prev_runs, from_tools=False, from_extraction=False)

    # Generate a structured output response with an OVERRIDDEN system prompt!
    messages = override_system_prompt([last_message], INTENT_CLASSIFIER_SYSTEM_PROMPT)
    new_message, result = await model.extract(event_callback, messages, CategoryOutput)
    assert isinstance(result, CategoryOutput)

    # Wrap the new messages into an `AgentRun` result:
    return extraction('intent_classifier', [new_message], result)
await tui_input_loop(binder(intent_classifier))   # type: ignore[top-level-await]
>  Hi!
CategoryOutput({"thoughts": "This is a simple greeting \"Hi!\" which is a classic example of small talk - a casual, friendly greeting used to initiate conversation.", "category": "small_talk"})


>  Sup?
CategoryOutput({"thoughts": "This is a casual greeting similar to \"what's up\" or \"hi\". This falls under small talk as it's an informal way to say hello.", "category": "small_talk"})


>  What is Python?
CategoryOutput({"thoughts": "The user is asking \"What is Python?\" which is clearly a question about a programming language. Python is a popular programming language, so this falls under the programming category.", "category": "programming"})


>  What is 2+2?
CategoryOutput({"thoughts": "The user is asking a basic math question \"What is 2+2?\". This is a simple arithmetic question that doesn't fall under small talk (like greetings) or programming (questions about code, languages, libraries). This is a mathematical question, so it belongs in the \"other\" category.", "category": "other"})


>  exit

The ‘Back on Track’ Agent

This agent is pretty useless on its own, but you’ll see soon why we’re creating it. It just tells the user to get back on track!

BACK_ON_TRACK_SYSTEM_PROMPT = """
The user's message has been deemed to be off-topic.
Please politely tell them that their message is off-topic.
Do not respond to their question or their request. Just politely
tell them they are off-topic and need to return to the topic
at-hand.
""".strip()
async def back_on_track(
    model: Model,
    event_callback: EventCallback,
    prev_runs: list[AgentRun],
) -> AgentRun:
    last_message = extract_last_message(prev_runs, from_tools=False, from_extraction=False)
    messages = override_system_prompt(
        [last_message],
        BACK_ON_TRACK_SYSTEM_PROMPT,
    )
    return flat_messages(
        'back_on_track',
        await model.run(event_callback, messages, tools=[]),
    )
await tui_input_loop(binder(back_on_track))   # type: ignore[top-level-await]
>  Hi!
Hello! I'd be happy to help you, but it seems like your message might be off-topic for our current discussion. Could you please share what specific topic or question you'd like to discuss so we can have a focused conversation? I'm here to assist once we establish what you'd like to talk about.
>  exit

The Routing Agent

Now, we’ll put all the pieces together by making a routing agent that uses all four agents above!

async def router(
    model: Model,
    event_callback: EventCallback,
    prev_runs: list[AgentRun],
) -> AgentRun:
    # Decide which downstream agent to use, based on the user's intent:
    bound_intent_classifier = binder(intent_classifier)
    classification_run = await bound_intent_classifier(event_callback, prev_runs)
    assert classification_run['type'] == 'extraction'
    classification_result = classification_run['result']
    assert isinstance(classification_result, CategoryOutput)
    if classification_result.category == Category.small_talk:
        downstream_agent = binder(chat_agent)
    elif classification_result.category == Category.programming:
        downstream_agent = binder(coding_advisor)
    else:
        downstream_agent = binder(back_on_track)

    # Delegate to the downstream agent!
    downstream_run = await downstream_agent(event_callback, prev_runs)

    # Wrap *everything* that happened above into the return:
    return chained_runs('router', [classification_run, downstream_run])
await tui_input_loop(binder(router))   # type: ignore[top-level-await]
>  Hi!
CategoryOutput({"thoughts": "The user simply said \"Hi!\" which is a basic greeting and falls under casual conversation or small talk.", "category": "small_talk"})

Hello! How are you doing today? Is there anything I can help you with?
>  What is Python? (answer briefly)
CategoryOutput({"thoughts": "The user is asking \"What is Python?\" which is clearly a question about a programming language. This falls under the programming category as they're asking about Python, which is a well-known programming language.", "category": "programming"})

Python is a high-level, interpreted programming language known for its simple syntax and versatility, making it popular for web development, data science, automation, and many other applications.
>  What is 2+2?
CategoryOutput({"thoughts": "This is a basic arithmetic question asking for the sum of 2+2. It's not small talk (like greetings or casual conversation), and it's not about programming (no mention of code, languages, libraries, etc.). This falls into the \"other\" category as it's a mathematical question.", "category": "other"})

I appreciate your question, but it appears to be off-topic for our current conversation. We should focus on staying on the subject we were discussing. Please feel free to redirect your question or comment back to the main topic at hand.
>  How are you today? (also what is 2+2)
CategoryOutput({"thoughts": "This message contains both small talk (\"How are you today?\") and a basic math question (\"what is 2+2\"). While the math question could be considered educational, it's very simple arithmetic rather than programming-specific content. The primary greeting nature of the message and the casual way both parts are presented suggests this is primarily small talk with a casual question added.", "category": "small_talk"})

I'm doing well, thank you for asking! I'm here and ready to help with whatever you need.



And 2+2 = 4.
>  exit

🚨 Hacked!

Did you notice above how we prompt-hacked our simple AI system? YIKES! (Look closely, we were able to get it to answer our 2 + 2 question on the second attempt above, by “hiding” the question in a small-talk message.)

This is a good time to call out that AI models can (and will!) make mistakes. Prompt engineering helps, but even the most well-tuned prompting cannot protect your AI system from malicious users.

Design your AI systems accordingly, and consult best-practice literature. The most important thing: Design your AI system so that it does no damage even if (or when) it misbehaves.

The Task Splitter

Another common task is for an agent to split work and delegate to multiple downstream agents. Let’s do that next!

We’ll use a silly example, for simplicity and brevity, where we’ll split the user’s message into individual sentences, then prompt an AI model one-at-a-time on each individual sentence. While this is a silly example, it shows how you can split up a problem for multiple downstream subagents.

async def splitter(
    model: Model,
    event_callback: EventCallback,
    prev_runs: list[AgentRun],
) -> AgentRun:
    # We'll only look at the most recent message:
    last_message = extract_last_message(prev_runs, from_tools=False, from_extraction=False)
    assert last_message['role'] == 'human'
    assert last_message['text']

    # We'll split the most recent message into sentences:
    #    (This is **not** a robust way to do it, but we're keeping the demo simple.)
    sentences = re.split(r'[\.\?\!] ', last_message['text'])
    sentences_as_agentruns = [
        flat_messages('splitter', [{
            'role': 'human',
            'text': sentence,
        }])
        for sentence in sentences
    ]

    # Have the `chat_agent` respond to each sentence:
    #    (Again, not particularly useful, but good for a brief demo.)
    bound_chat_agent = binder(chat_agent)
    downstream_runs: list[AgentRun] = []
    for task_input in sentences_as_agentruns:
        this_run = await bound_chat_agent(event_callback, [task_input])
        downstream_runs.append(this_run)

    # Wrap *everything* that happened above into the return:
    return parallel_runs('splitter', downstream_runs)
await tui_input_loop(binder(splitter))   # type: ignore[top-level-await]
>  Hi. What's up? How are you? What's 2+2?
Hello! How are you doing today? Is there anything I can help you with?Hello! Not much going on here - just ready to chat and help with whatever you'd like to talk about or work on. How are you doing today? What's on your mind?I'm doing well, thank you for asking! I'm here and ready to help with whatever you'd like to discuss or work on. How are you doing today?2 + 2 = 4
>  Thanks! What did I just say?
You're welcome! Feel free to ask if you need help with anything else.I don't have any record of previous messages from you in our conversation. Your message "What did I just say?" appears to be the first thing you've said to me in this chat session. 



If you meant to reference something you said earlier, could you please repeat it or provide more context? I'm happy to help once I understand what you're referring to.
>  exit

What happened?

In the conversation above, when I said “What did I just say?”, the AI model didn’t see the conversation history. Why not? It’s because of how we wrote our agent — we did not pass the whole conversation to the AI model!

Exercise for the reader: How can you change the agent so that the AI sees the whole conversation history?

Do anything!

It’s up to you how to write your multi-agent AI system. You can mix-and-match ideas, include lots of behaviors in a single agent, or split up tasks among multiple agents. You can have “meta agents” that plan work for other agents, or “meta meta agents” that plan work for your “meta agents”. As long as it is safe and works, go for it!

Why This Design?

Lasagna’s agent design provides several key benefits:

🔌 Pluggability

Every agent follows the same interface, so you can:

  • swap one agent for another,
  • combine agents from different sources, and
  • test agents in isolation.

🥞 Layering

You can compose agents at any level:

  • Use simple agents as building blocks.
  • Combine them into more complex workflows.
  • Build entire systems from agent compositions.

🔄 Reusability

Write an agent once, use it everywhere:

  • as a standalone agent,
  • as part of a larger workflow, or
  • as a specialist in a multi-agent system.

Next Steps

Now that you understand what agents are and how they work conceptually, you’re ready to dive deeper into the technical details.

In the next section, we’ll explore the AgentRun data structure in detail — the standardized format that enables all this agent composition and layering.

You’ll learn about:

  • The four types of AgentRun.
  • How to work with the recursive data structure.
  • Helper functions for common patterns.
  • Advanced features like cost tracking and serialization.

For more advanced agent patterns and real-world examples, check out: