microsoft/semantic-kernel · error · InvalidOperationException

Message must have a topic to be published.

Error message

Message must have a topic to be published.

What it means

PublishMessageServicerAsync (the internal servicer wired by ForPublish) throws InvalidOperationException if envelope.Topic has no value. The public PublishMessageAsync always sets the topic via ForPublish, so reaching this throw requires a MessageEnvelope constructed without a topic being routed through the publish servicer — typically custom/internal misuse rather than normal API usage.

Source

Thrown at dotnet/src/Agents/Runtime/InProcess/InProcessRuntime.cs:356

                continue;
            }

            Task actualTask = processTask.AsTask();
            pendingTasks.Add(taskId, actualTask.ContinueWith(t => pendingTasks.Remove(taskId), TaskScheduler.Current));
        }

        // The pending task dictionary may contain null values when a race condition is experienced during
        // the prior "ContinueWith" call.  This could be solved with a ConcurrentDictionary, but locking
        // is entirely undesirable in this context.
        await Task.WhenAll([.. pendingTasks.Values.Where(task => task is not null)]).ConfigureAwait(false);
        await this.FinishAsync(this._finishSource?.Token ?? CancellationToken.None).ConfigureAwait(false);
    }

    private async ValueTask PublishMessageServicerAsync(MessageEnvelope envelope, CancellationToken deliveryToken)
    {
        if (!envelope.Topic.HasValue)
        {
            throw new InvalidOperationException("Message must have a topic to be published.");
        }

        List<Task>? tasks = null;
        TopicId topic = envelope.Topic.Value;
        foreach (ISubscriptionDefinition subscription in this._subscriptions.Values.Where(subscription => subscription.Matches(topic)))
        {
            (tasks ??= []).Add(ProcessSubscriptionAsync(envelope, topic, subscription, deliveryToken));
        }

        if (tasks is not null)
        {
            await Task.WhenAll(tasks).ConfigureAwait(false);
        }

        async Task ProcessSubscriptionAsync(MessageEnvelope envelope, TopicId topic, ISubscriptionDefinition subscription, CancellationToken deliveryToken)
        {
            deliveryToken.ThrowIfCancellationRequested();

View on GitHub (pinned to c028a0c7dc)

Solutions

  1. Always publish via the public PublishMessageAsync(message, topic, ...) which guarantees the topic is set.
  2. If building a MessageEnvelope manually for publish, always call WithSender/ForPublish with a valid TopicId.
  3. Do not reuse the send servicer for publish envelopes or vice versa.

Example fix

// before (custom plumbing)
var env = new MessageEnvelope(msg, id, ct); // no topic
env.ForPublish(default(TopicId), publishServicer); // topic never set

// after
await runtime.PublishMessageAsync(msg, new TopicId("myTopic"));
Defensive patterns

Strategy: validation

Validate before calling

// Always publish through the public API which sets the topic.
await runtime.PublishMessageAsync(message, new TopicId("myTopic"));
// If building an envelope manually, ensure a TopicId is supplied to ForPublish.

Type guard

bool HasTopic(MessageEnvelope env) => env.Topic.HasValue;

Prevention

When it happens

Trigger: Constructing a MessageEnvelope and calling ForPublish without a topic; a custom runtime/servicer reusing PublishMessageServicerAsync on an envelope built for send; internal code path that drops the topic before servicing.

Common situations: Subclassing or extending the runtime and reusing the servicer incorrectly; bugs in custom message-delivery plumbing; envelope mutation between enqueue and service.

Related errors


AI-assisted analysis of microsoft/semantic-kernel@c028a0c7dc (2026-08-13). Data as JSON: /api/errors/626f87392ecd37d9. Report an issue: GitHub.