In autogen-core, when do you publish_message to a topic instead of send_message?
answer
- One named recipient versus a channel
- Reply expected, or nothing at all
- Subscriptions are the wiring, not the publish
- Topic source becomes the agent key
- Silent no-op when nobody subscribes
basics
~20 sUse send_message for request/response to one known AgentId — it awaits a reply. Use publish_message when any number of subscribed agents should react and you want no reply and no knowledge of who is listening; the publisher does not receive its own message.
solid answer
~50 s`send_message(message, recipient=AgentId(type, key))` is point-to-point and RPC-shaped: you name exactly one agent instance, you await the handler's return value, and errors propagate to the caller. `publish_message(message, topic_id=TopicId(type, source))` is fire-and-forget broadcast: the runtime delivers to every agent whose subscription matches the topic, returns nothing to the publisher, and does not deliver the message back to the publishing agent itself. Subscriptions are what create the wiring — `TypeSubscription(topic_type=..., agent_type=...)` routes a topic to an agent type, with the topic's `source` selecting the agent instance key, and the `@type_subscription` / `@default_subscription` class decorators are shorthand for the same thing. Choose direct send when you need a result or a specific worker; choose publish when the set of reactors should be able to change without editing the sender. The cost of publishing is that failures are invisible to the sender, so anything you must know succeeded should be a send.
code
python · 42 linesimport asyncio
from dataclasses import dataclass
from autogen_core import (
AgentId,
MessageContext,
RoutedAgent,
SingleThreadedAgentRuntime,
TopicId,
TypeSubscription,
message_handler,
)
@dataclass
class TaskCompleted:
job: str
class Auditor(RoutedAgent):
def __init__(self) -> None:
super().__init__("audit log")
@message_handler
async def on_completed(self, message: TaskCompleted, ctx: MessageContext) -> None:
print(f"audited {message.job} from {ctx.topic_id}")
async def main() -> None:
runtime = SingleThreadedAgentRuntime()
await Auditor.register(runtime, "auditor", lambda: Auditor())
await runtime.add_subscription(
TypeSubscription(topic_type="jobs", agent_type="auditor")
)
runtime.start()
await runtime.publish_message(
TaskCompleted(job="job-7"), TopicId(type="jobs", source="default")
)
await runtime.stop_when_idle()
asyncio.run(main())go deeper
Know the two calls by name: send_message goes to one agent and gives you a reply, publish_message broadcasts to subscribers and gives you nothing back.
Explain the subscription layer — TypeSubscription mapping topic type to agent type with source becoming the agent key — and why publishing without a matching subscription silently does nothing.
Show the operational judgment: broadcast trades away error visibility, so confirmations and fan-in need explicit modelling, and anything that must be known to have succeeded is a direct send.
Own the coupling argument — which parts of a system should be able to gain a new reactor without editing the producer, and where that decoupling costs more in traceability than it returns.
## Two delivery primitives, two different contracts `autogen-core` gives the runtime exactly two ways to move a message, and the choice between them is an architectural decision, not a stylistic one. **Direct send.** `await runtime.send_message(msg, AgentId("worker", "job-7"))` addresses one agent instance by type and key. The runtime creates that instance lazily from its registered factory if it does not exist, dispatches the message, and returns the handler's return value to the caller. Exceptions raised in the handler surface at the call site. This is a remote procedure call in the classic sense: the caller knows the callee, waits, and gets an answer or an error. **Publish.** `await runtime.publish_message(msg, TopicId("task", "job-7"))` addresses a *channel*. Every agent whose subscription matches the topic receives a copy. There is no return value, no aggregation of responses, and no error path back to the publisher. Notably, the runtime does not deliver a published message back to the agent that published it, which prevents the trivial self-loop when an agent both publishes and subscribes to the same topic type. ## How subscriptions wire topics to agents Publishing to a topic nobody subscribes to is legal and silently does nothing — this is the single most common confusion. The binding is explicit: - `TypeSubscription(topic_type="task", agent_type="worker")` says: a message published on topic type `task` with source `S` goes to `AgentId("worker", S)`. The topic's *source* becomes the agent's *key*, which is how per-conversation or per-tenant isolation falls out naturally — publish with `source="tenant-a"` and only that tenant's worker instance is involved. - `@type_subscription(topic_type="task")` on the agent class is shorthand for registering that subscription when the agent type is registered. - `@default_subscription` plus `DefaultTopicId()` cover the common single-channel case without you inventing names. - Subscriptions can also be added at runtime with `runtime.add_subscription(...)`. ## When each one is right Reach for **send** when: the answer is the point (you need a value back), one specific instance owns the work (a session, a tenant, a job id), you want failures to surface immediately, or you are implementing a step in a sequence where the next step depends on this result. Reach for **publish** when: the number of reactors is not the sender's business, you want to add an auditor, logger or metrics agent without editing the producer, several agents legitimately act on the same fact, or you are modelling a *notification* rather than a *request*. Event-shaped names help: `TaskCompleted` published, `ComputeTask` sent. ## What publishing costs you Broadcast buys decoupling and pays for it in observability. The publisher cannot tell whether anyone handled the message, whether a subscriber raised, or whether a subscription was even registered — a typo in a topic type is a no-op, not an error. Two mitigations matter in practice: make handlers on subscribed agents publish their own completion or failure events so the flow remains traceable, and be explicit that anything you need a guarantee about should be a direct send. It is also worth remembering that a published message is delivered to each matching agent independently; there is no built-in "wait for all subscribers" join, so fan-out/fan-in has to be modelled with a collector agent that counts the responses it expects. ## Interaction with distribution The same two primitives survive when you move from the local runtime to a cross-process deployment, which is why designing in terms of topics pays off: a publisher does not know or care whether its subscribers are in-process or in another worker. What changes is that the messages must be serializable and delivery crosses a network — a reason to prefer publish for notifications, whose loss is tolerable, and to think carefully about a send whose reply now depends on a remote hop. ## The strong answer State the contract difference first (one known recipient with a reply, versus a channel with no reply), then the subscription mechanism that makes publishing work at all, then the operational tradeoff: decoupling in exchange for silent failure. Mentioning that the publisher does not receive its own published message, and that publishing to an unsubscribed topic is a silent no-op, is what distinguishes someone who has debugged this from someone who has read the API reference.
- An agent publishes to a topic it is subscribed to. Does it receive its own message?No. The runtime skips delivery back to the publishing agent, which prevents an immediate self-loop when a class both publishes and subscribes to the same topic type. If you genuinely need an agent to process its own emission — a retry or a staged pipeline, say — model it as a separate message type or a distinct agent instance rather than relying on self-delivery, because the runtime will not provide it.
- You publish a message and nothing happens. Where do you look?First at subscriptions: publishing to a topic type no agent subscribes to is a legal no-op with no error. Confirm the subscription is registered for that exact topic type and that the agent type was registered with the runtime. Then check the topic source against the expected agent key, since a TypeSubscription maps source to key. Finally check the handler's message-type annotation, because an unhandled type is only logged.
- How do you do fan-out then wait for all the results with these primitives?Core gives you no built-in join. The usual shape is a collector agent that knows how many responses to expect: it publishes the work with a correlation id, subscribes to the completion topic, counts arrivals, and emits a single aggregate event when the count is met or a deadline passes. If the number of workers is fixed and small, awaiting several direct sends concurrently is simpler and gives you errors for free.
- How does the topic source affect which agent instance handles a published message?With TypeSubscription, the topic's source becomes the recipient's agent key, so publishing on the same topic type with different sources reaches different instances of the same agent type. That is the idiomatic way to isolate per-session, per-job or per-tenant state: one agent type, many keys, each holding only its own conversation, with the runtime instantiating each lazily from the registered factory.
saying these in an interview costs you the question
- Expecting publish_message to return subscriber responses
- Assuming publish reaches every agent regardless of subscriptions
- Believing a publisher also receives its own event
- Treating an unsubscribed topic as an error
- Using publish for work that must be confirmed done