Layered (multi-agent) Systems

Agents for your agents!

There are two main ways to layer agents together into a multi-agent system:

  1. Agents as Tools
  2. Agent Routing

We’ll discuss each in their own sections below.

# This page will use the following imports:

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

from pydantic import BaseModel, Field
from enum import Enum

import os

from dotenv import load_dotenv

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

Agents as Tools

The simplest way to combine agents is to pass one (or more) agents as “tools” to another agent.

Here is a simple example:

async def joke_specialist(
    model: Model,
    event_callback: EventCallback,
    prev_runs: list[AgentRun],
) -> AgentRun:
    """
    Use this agent when the user seems discouraged and needs to feel better.
    This tool will return the perfect joke for you to use to cheer the user up.
    """
    messages = recursive_extract_messages(prev_runs, from_tools=False, from_extraction=False)
    messages = override_system_prompt(messages, "You are a joke-telling specialist. You always tell a joke related to the user's most recent message. Your response must contain **only** the joke.")
    new_messages = await model.run(event_callback, messages, tools=[])
    return flat_messages('joke_specialist', new_messages)


async def root_agent(
    model: Model,
    event_callback: EventCallback,
    prev_runs: list[AgentRun],
) -> AgentRun:
    messages = recursive_extract_messages(prev_runs, from_tools=False, from_extraction=False)
    messages = override_system_prompt(messages, 'You are a generic assistant. Answer all prompts briefly. Use your tools when necessary.')

    new_messages = await model.run(
        event_callback,
        messages,
        tools=[
            joke_specialist,   # <-- 🔨 downstream agent as a tool
        ],
    )

    return flat_messages('root_agent', new_messages)
await tui_input_loop(binder(root_agent))   # type: ignore[top-level-await]
>  Hi!
Hello! How can I help you today?
>  Oh, I'm sick. :(
I'm sorry to hear you're not feeling well! Being sick is never fun. Let me try to cheer you up a little bit.joke_specialist()

Why don't sick people ever win at poker?



Because they always fold! 🤧 -> Why don't sick people ever win at poker?



Because they always fold! 🤧

Here's a little joke to hopefully bring a smile to your face: Why don't sick people ever win at poker? Because they always fold! 🤧



I hope you feel better soon! Make sure to get plenty of rest and stay hydrated. Take care of yourself! 💙
>  exit

Why Two Agents?

While this example is simple (and a bit contrived), it still demonstrates the correct idea: Split up responsibilities between agents.

How are responsibilities split in the example above?

  • root_agent(): Identifies when the user needs to be cheered up.
  • joke_specialist(): Identifies how to cheer the user up.

It’s best to separate responsibilities for at least two reasons:

  1. Better performance: Invoking an AI model twice, each with a narrow goal, should boost performance (at the expense of more tokens).
  2. Safer modification: If you decide to modify one of the agents, you can without too much fear of breaking the other agents! Whereas, if this was all in a single agent, you might break the “when” by trying to improve the “how” (or vice versa). Yikes.
Bound vs Unbound Agents

You are free to pass either bound or unbound agents as tools.

If bound, it will use the bound model (of course).

If unbound, it will use the model of the calling agent.

See the Agents as Tools recipe for another example.

Agent Routing

The most flexible way to combine agents is to have agents delegate to one another (“routing”). The router agent’s job is to delegate. It might delegate wholesale, or it might transform the prompt before delegating. It might delegate to a single downstream agent, or to several downstream agents. This is what makes it so flexible!

The recipe for routing is to combine structured output with good ol’ programming.

Here is an example, extending the example above to be more flexible:

class Mood(Enum):
    happy = 'happy'
    sad = 'sad'
    neutral = 'neutral'


class MessageClassification(BaseModel):
    thoughts: str = Field(description="Your free-form thoughts about the user's most recent message, and what mood the user may be in.")
    mood: Mood = Field(description="Your determination of the user's mood based on their most recent message. If it is not clear, output 'neutral'.")


class RouterAgent:
    def __init__(
        self,
        cheer_up_agent: BoundAgentCallable,
        default_agent: BoundAgentCallable,
    ) -> None:
        self.cheer_up_agent = cheer_up_agent
        self.default_agent = default_agent

    async def __call__(
        self,
        model: Model,
        event_callback: EventCallback,
        prev_runs: list[AgentRun],
    ) -> AgentRun:
        messages = recursive_extract_messages(prev_runs, from_tools=False, from_extraction=False)
        messages = override_system_prompt(messages, "You classify the user's mood.")

        message, result = await model.extract(
            event_callback,
            messages = messages,
            extraction_type = MessageClassification,
        )

        extraction_run = extraction('router_agent', [message], result)

        downstream_agent = (self.cheer_up_agent if result.mood == Mood.sad else self.default_agent)

        downstream_run = await downstream_agent(event_callback, prev_runs)

        return chained_runs('router_agent', [extraction_run, downstream_run])
async def joke_telling_agent(
    model: Model,
    event_callback: EventCallback,
    prev_runs: list[AgentRun],
) -> AgentRun:
    messages = recursive_extract_messages(prev_runs, from_tools=False, from_extraction=False)
    messages = override_system_prompt(messages, "You are a joke-telling specialist. You always tell a joke related to the user's most recent message. Cheer the user up by telling a joke!")
    new_messages = await model.run(event_callback, messages, tools=[])
    return flat_messages('joke_telling_agent', new_messages)


async def generic_agent(
    model: Model,
    event_callback: EventCallback,
    prev_runs: list[AgentRun],
) -> AgentRun:
    messages = recursive_extract_messages(prev_runs, from_tools=False, from_extraction=False)
    messages = override_system_prompt(messages, "You are a helpful assistant.")
    new_messages = await model.run(event_callback, messages, tools=[])
    return flat_messages('generic_agent', new_messages)


my_agent = RouterAgent(
    cheer_up_agent = binder(joke_telling_agent),
    default_agent = binder(generic_agent),
)


await tui_input_loop(binder(my_agent))   # type: ignore[top-level-await]
>  Hi!
MessageClassification({"thoughts": "The user sent a simple greeting \"Hi!\" which is a friendly and positive way to start a conversation. There's nothing in the message that suggests sadness or negativity, and the exclamation point adds a bit of enthusiasm or cheerfulness to the greeting. This seems like a neutral to slightly positive interaction.", "mood": "happy"})

Hello! How are you doing today? Is there anything I can help you with?
>  Oh, I'm sick. :(
MessageClassification({"thoughts": "The user mentioned they are sick and used a sad face emoticon \":(\" which clearly indicates they're not feeling well both physically and emotionally. Being sick typically makes people feel down, uncomfortable, and unhappy. The combination of stating they're sick and the sad emoticon strongly suggests a sad mood.", "mood": "sad"})

Oh no, I'm sorry you're feeling under the weather! Here's a joke to hopefully brighten your day:



Why don't sick people ever win races?



Because they're always running a fever! 🤒



I hope you feel better soon! Rest up and take care of yourself! 😊
>  Thanks, bye.
MessageClassification({"thoughts": "The user is saying goodbye with a simple \"Thanks, bye.\" This appears to be a polite but brief farewell. While they thanked me, which could indicate some appreciation, the brevity and context (they mentioned being sick earlier) suggests they might still not be feeling great. However, the \"thanks\" does show some gratitude for the joke I shared. Overall, this seems like a neutral farewell - not particularly happy or sad, just a standard goodbye.", "mood": "neutral"})

You're welcome! Take care and get well soon! 🌟



Bye! 👋
>  exit

See the Agent Routing recipe for a working example.