skip to content

In autogen-core, how does RoutedAgent decide which @message_handler runs?

level: middleimportance: should knowfreq 48%

answer

  1. Dispatch is by type, not by name
  2. Second parameter carries delivery context
  3. One decorator per delivery direction
  4. Predicate breaks ties within a type
  5. Unmatched types are logged, not raised

basics

~20 s

RoutedAgent dispatches on the type annotation of each handler's message parameter, so one handler per message type. An optional match predicate disambiguates several handlers of the same type, and an unhandled type falls through to on_unhandled_message.

solid answer

~50 s

`RoutedAgent` inspects the decorated handlers at class definition time and builds a routing table keyed by the **type annotation of the handler's first message parameter**. When a message arrives, the runtime calls the agent's dispatch entry point, which looks up the concrete message type and invokes the matching coroutine with `(message, ctx: MessageContext)`. If several handlers declare the same type, each can carry a `match` predicate over the message and context, evaluated in definition order. Nothing is routed on method name or declaration order alone. Direction matters too: `@message_handler` accepts both direct sends and published events, `@rpc` accepts only direct sends, and `@event` accepts only published messages and must return `None`. A message whose type has no handler goes to `on_unhandled_message`, whose default implementation logs it rather than crashing the runtime — which is why a mistyped message class is a silent no-op instead of an error.

code

python · 30 lines
python
from dataclasses import dataclass

from autogen_core import MessageContext, RoutedAgent, event, message_handler, rpc


@dataclass
class TaskRequest:
    payload: str


@dataclass
class TaskDone:
    payload: str


class Worker(RoutedAgent):
    def __init__(self) -> None:
        super().__init__("a worker")

    @rpc
    async def handle_request(self, message: TaskRequest, ctx: MessageContext) -> str:
        return f"done: {message.payload}"

    @event
    async def handle_done(self, message: TaskDone, ctx: MessageContext) -> None:
        print(f"observed on topic {ctx.topic_id}: {message.payload}")

    @message_handler(match=lambda msg, ctx: msg.startswith("ping"))
    async def handle_ping(self, message: str, ctx: MessageContext) -> str:
        return "pong"

go deeper

for a junior

Recall that a RoutedAgent handler is an async method decorated with @message_handler, taking the message plus a context object, and that the message's type annotation is what wires it up.

for a middle

Explain the annotation-driven routing table, the match predicate for several handlers of one type, and the difference between @message_handler, @rpc and @event including the None-return rule for events.

for a senior

Demonstrate the debugging angle: unhandled messages log silently by default, so you override on_unhandled_message, and you keep handlers non-blocking because the local runtime shares one event loop.

for a principal

Argue about message-type design — when a match predicate is a smell that the type should be split or the work moved to another agent type, and how the direction-specific decorators serve as an enforced contract across a team.

## The problem RoutedAgent solves The raw contract in `autogen-core` is `BaseAgent.on_message`, a single coroutine that receives every message addressed to the agent. Written by hand, that becomes an `isinstance` ladder — verbose, easy to get wrong, and impossible to reason about statically. `RoutedAgent` is the ergonomic subclass that turns the ladder into declarative dispatch. ## How the routing table is built When you decorate methods with `@message_handler`, the decorator records metadata on the function and `RoutedAgent` collects it, building a map from **message type to handler**. The type comes from the annotation on the handler's message parameter, so this signature async def handle_task(self, message: TaskRequest, ctx: MessageContext) -> TaskResult registers `handle_task` as the handler for `TaskRequest`. The method name is irrelevant; the annotation is the contract. This is the single most important thing to say in an interview, because the common wrong answer is "by method name" or "whichever handler is defined first". Every handler takes the same two parameters: the message, and a `MessageContext` carrying the surrounding delivery facts — the sender's `AgentId` when there is one, the `topic_id` when the message arrived by publication, whether the delivery is RPC-shaped, and a cancellation token. ## Disambiguating several handlers of one type Sometimes one message type carries several logical cases — a task envelope whose payload decides who should act. Rather than branching inside a single handler, `@message_handler` accepts a `match` predicate evaluated against the message and context. Handlers sharing a type are checked in definition order and the first whose predicate returns true wins. It is deliberately a small feature: heavy routing logic usually signals that the message type should be split, or that the work belongs on a different agent type. ## Direction-specific decorators Core distinguishes two delivery shapes, and the decorators let a handler opt into one of them: - `@message_handler` — both direct sends and published messages. - `@rpc` — direct sends only. Suitable when the handler's return value is the point, because a direct send awaits a reply. - `@event` — published messages only, and it must return `None`, because a broadcast has no single caller to return to. Choosing `@rpc` or `@event` over the generic decorator is a documentation act as much as a functional one: it states in the signature whether this behaviour is request/response or fire-and-forget, and it prevents a message arriving by the path you did not design for. ## Return values A handler's return value is delivered back to the sender for a direct send. For a published message, there is no caller to receive it — this is precisely why `@event` handlers are constrained to return `None`, and why relying on a return value from a broadcast is a design error rather than an intermittent bug. ## Unhandled messages If a message type has no matching handler, `RoutedAgent` calls `on_unhandled_message`, whose default implementation logs the message. The runtime does not tear down the agent. That default is friendly for incremental development and hostile during debugging: an agent that appears to "do nothing" is very often an agent receiving a message type it has no handler for — perhaps because a refactor renamed the class, or because a distributed deployment delivered a differently-serialized type. Overriding `on_unhandled_message` to raise, or at least to log loudly with the sender and topic from the context, is a cheap production improvement. ## Where handlers fit in the agent lifecycle Agent instances are created lazily by the factory registered for the agent type, on first delivery to a given `AgentId`. Handlers therefore run on a per-instance object that may hold state across messages, and for `SingleThreadedAgentRuntime` they run on one asyncio event loop. Blocking work inside a handler stalls the whole runtime — always `await`, and push CPU-bound work off the loop. ## What a strong answer sounds like Name annotation-based dispatch first, then the `match` predicate, then the three decorators and what each means about direction and return value, then the silent `on_unhandled_message` fallback and why you usually make it noisy. That progression shows you have not only read the API but debugged an agent that quietly ignored its input.

  • What is in MessageContext, and when have you actually needed it?
    It carries the delivery facts around the message: the sender's AgentId when the message was sent directly, the topic_id when it arrived by publication, whether the delivery is RPC-shaped, and a cancellation token. It matters when a handler serves both paths and must reply only to direct senders, when you want to publish a follow-up onto the topic you came from, and when long work has to honour cancellation.
  • Why would you choose @event over the generic @message_handler?
    @event restricts the handler to published messages and forces a None return, which encodes the fire-and-forget contract in the signature. It stops a direct send from reaching logic that assumed broadcast semantics, and it prevents someone writing a return value that would be silently discarded. The generic decorator is fine, but it leaves the intended delivery path undocumented.
  • An agent seems to ignore some messages entirely. How do you diagnose it?
    Suspect dispatch first. Unhandled types go to on_unhandled_message, which by default only logs — so check that a handler is annotated with exactly the concrete type being sent, not a base class or a same-named class from another module. Override on_unhandled_message to raise or log the type, sender and topic, and confirm the agent is actually subscribed if the message was published rather than sent.

saying these in an interview costs you the question

  • Claiming handlers are matched by method name
  • Assuming declaration order decides dispatch
  • Expecting a return value from a published message
  • Thinking an unhandled type raises an error
  • Blocking inside a handler on the single-threaded runtime

context